diff --git a/bitchatTests/Simulation/SimulatedMesh.swift b/bitchatTests/Simulation/SimulatedMesh.swift index 6caf379d..c4bf6312 100644 --- a/bitchatTests/Simulation/SimulatedMesh.swift +++ b/bitchatTests/Simulation/SimulatedMesh.swift @@ -29,6 +29,29 @@ final class SimulatedMesh { private var pendingDeliveries: [(from: Int, packet: BitchatPacket)] = [] /// Total (packet, receiving-node) deliveries pumped — the storm bound. private(set) var deliveredFrameCount = 0 + /// The same deliveries split by packet type. A whole-mesh frame budget + /// silently includes announce traffic, and announce *admission* is the one + /// decision the manual scheduler does not own: `sendAnnounceNow` asks + /// `announceThrottle.shouldSend(force:now: Date())` (BLEService), so + /// whether a scheduled re-announce reaches the wire depends on real + /// elapsed seconds, not on `advanceTime`. Burn 0.2s of wall clock in the + /// middle of a test — the forced-announce window is 0.15s — and announce + /// deliveries rise (measured: 22 → 36) while every other type is + /// unchanged. A test that drains the mesh to quiet before it measures + /// absorbs that into its baseline; one that cannot has a budget moving + /// with runner load. Counting per type lets a test bound the traffic it + /// is actually about either way. + private var deliveredFrameCountsByType: [UInt8: Int] = [:] + + /// Deliveries of one packet type — the storm bound for a specific kind of + /// traffic, immune to unrelated announce frames. + /// Unlocked deliberately, matching `deliveredFrameCount` above: both + /// counters are written only by `pump` on the test thread (the outbound + /// tap touches `pendingDeliveries`/`emitted`, never these). Taking the + /// lock here would advertise a protection the writer does not hold. + func deliveredFrameCount(ofType type: MessageType) -> Int { + deliveredFrameCountsByType[type.rawValue] ?? 0 + } private(set) var nodes: [Node] = [] private var neighbors: [Set] = [] @@ -168,6 +191,7 @@ final class SimulatedMesh { for receiver in neighbors[from] { for link in links(from: from, at: receiver) { deliveredFrameCount += 1 + deliveredFrameCountsByType[packet.type, default: 0] += 1 nodes[receiver].service._test_ingestFrame(packet, link: link) } } diff --git a/bitchatTests/Simulation/SimulatedMeshTests.swift b/bitchatTests/Simulation/SimulatedMeshTests.swift index b4630076..512165d8 100644 --- a/bitchatTests/Simulation/SimulatedMeshTests.swift +++ b/bitchatTests/Simulation/SimulatedMeshTests.swift @@ -63,7 +63,8 @@ struct SimulatedMeshTests { // baseline snapshot depends on the draw — the budget assertion below // flaked on CI at 14 and 18 frames for exactly that reason. Advance // until the mesh goes a full window with no new frames, so the - // baseline only ever measures the message under test. + // baseline only ever measures the message under test. Quiescence is + // judged on every frame; the baseline itself is taken per type. var settled = mesh.deliveredFrameCount for _ in 0..<20 { mesh.advanceTime(by: 2) @@ -71,7 +72,7 @@ struct SimulatedMeshTests { if now == settled { break } settled = now } - let baseline = mesh.deliveredFrameCount + let baseline = mesh.deliveredFrameCount(ofType: .message) let capture = TransportEventCapture() c.service.eventDelegate = capture @@ -84,9 +85,18 @@ struct SimulatedMeshTests { let arrived = await capture.drainedPublicMessageCount(content: "hello line") == 1 #expect(arrived) - // Storm bound: a single public message across one relay hop must - // not multiply into more than a handful of frames. - #expect(mesh.deliveredFrameCount - baseline <= 12) + // Storm bound: a single public message across one relay hop must not + // multiply into more than a handful of frames. Counted per type so + // the budget measures relay fan-out and nothing else. The whole-mesh + // total also carries announces, and announce admission is the one + // decision the manual scheduler does not own — `sendAnnounceNow` asks + // `announceThrottle.shouldSend(force:now: Date())` — so announce + // volume tracks real elapsed seconds. The settle loop above keeps + // that traffic in the baseline rather than in the delta: burning 0.2s + // of wall clock before it moves the baseline 66 → 80 frames and + // leaves this delta at 4. Per-type counting is what makes the + // assertion say which traffic it bounds. + #expect(mesh.deliveredFrameCount(ofType: .message) - baseline <= 8) } @Test