feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe

T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
This commit is contained in:
Yaojia Wang
2026-07-04 21:53:41 +02:00
parent 2ab93c9682
commit 95438cdc12
32 changed files with 3772 additions and 4 deletions

View File

@@ -0,0 +1,205 @@
import Foundation
import Testing
import WireProtocol
@testable import SessionCore
/// T-iOS-6 · AwayDigest "what happened while I was away" reducer (plan §3.2).
/// Input is the `GET /live-sessions/:id/events` timeline (src/types.ts:428-433);
/// class vocabulary is the server's `TimelineClass` union (src/types.ts:423,
/// produced by src/session/timeline.ts CLASS_MAP): tool/waiting/done/stuck/user.
@Suite("AwayDigest")
struct AwayDigestTests {
// MARK: - helpers
/// Chronology anchor: all test events sit at `leftAt + offset` ms.
private static let leftAt = Date(timeIntervalSince1970: 1_000)
private static let leftAtMs = 1_000_000
private static let defaultLimit = 10
private static func event(
atOffsetMs offset: Int,
class className: String,
toolName: String? = nil,
label: String = "activity"
) -> TimelineEvent {
TimelineEvent(at: leftAtMs + offset, class: className, toolName: toolName, label: label)
}
// MARK: - counting
@Test("counts tool runs, waiting events, and done across the event list")
func countsToolWaitingAndDoneAcrossEvents() {
// Arrange: 3 tool + 1 waiting + 1 done (task spec's canonical example).
let events = [
Self.event(atOffsetMs: 1, class: "tool", toolName: "Bash", label: "ran Bash"),
Self.event(atOffsetMs: 2, class: "tool", toolName: "Edit", label: "edited with Edit"),
Self.event(atOffsetMs: 3, class: "waiting", label: "waiting for approval"),
Self.event(atOffsetMs: 4, class: "tool", toolName: "Write", label: "edited with Write"),
Self.event(atOffsetMs: 5, class: "done", label: "done"),
]
// Act
let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: Self.defaultLimit)
// Assert
#expect(digest.toolRuns == 3)
#expect(digest.waitingCount == 1)
#expect(digest.sawDone == true)
#expect(digest.sawStuck == false)
}
@Test("a stuck event sets sawStuck")
func stuckEventSetsSawStuck() {
// Arrange
let events = [Self.event(atOffsetMs: 1, class: "stuck", label: "stuck")]
// Act
let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: Self.defaultLimit)
// Assert
#expect(digest.sawStuck == true)
#expect(digest.sawDone == false)
}
@Test("user events appear in recent but count toward no metric")
func userEventsAppearInRecentButCountNothing() {
// Arrange
let userEvent = Self.event(atOffsetMs: 1, class: "user", label: "user input")
// Act
let digest = AwayDigest.reduce(events: [userEvent], since: Self.leftAt, limit: Self.defaultLimit)
// Assert
#expect(digest.toolRuns == 0)
#expect(digest.waitingCount == 0)
#expect(digest.sawDone == false)
#expect(digest.sawStuck == false)
#expect(digest.recent == [userEvent])
}
// MARK: - since filter
@Test("events strictly earlier than since are excluded from counts and recent")
func eventsBeforeSinceAreExcluded() {
// Arrange: one stale tool run before leaving, one fresh after.
let stale = Self.event(atOffsetMs: -1, class: "tool", label: "ran Bash")
let boundary = Self.event(atOffsetMs: 0, class: "waiting", label: "waiting for approval")
let fresh = Self.event(atOffsetMs: 1, class: "tool", label: "ran Edit")
// Act
let digest = AwayDigest.reduce(
events: [stale, boundary, fresh], since: Self.leftAt, limit: Self.defaultLimit)
// Assert: strictly-earlier excluded; the exact leave instant still counts.
#expect(digest.toolRuns == 1)
#expect(digest.waitingCount == 1)
#expect(digest.recent == [boundary, fresh])
}
@Test("all events before since yield the all-zero digest")
func allEventsBeforeSinceYieldAllZeroDigest() {
// Arrange
let events = [
Self.event(atOffsetMs: -2, class: "tool", label: "ran Bash"),
Self.event(atOffsetMs: -1, class: "done", label: "done"),
]
// Act
let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: Self.defaultLimit)
// Assert: UI can skip rendering entirely.
#expect(digest == .empty)
#expect(digest.isEmpty == true)
}
// MARK: - limit / recent
@Test("limit truncates recent to the most recent events in chronological order")
func limitTruncatesRecentToMostRecentEvents() {
// Arrange
let events = (1...5).map { Self.event(atOffsetMs: $0, class: "tool", label: "ran \($0)") }
// Act
let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: 2)
// Assert: newest two, still oldestnewest.
#expect(digest.recent == [events[3], events[4]])
#expect(digest.toolRuns == 5)
}
@Test("out-of-order server input is ordered by timestamp before truncating recent")
func outOfOrderEventsAreOrderedByTimestampForRecent() {
// Arrange: the server is an untrusted input source order is not a given.
let oldest = Self.event(atOffsetMs: 1, class: "tool", label: "ran 1")
let middle = Self.event(atOffsetMs: 2, class: "waiting", label: "waiting")
let newest = Self.event(atOffsetMs: 3, class: "done", label: "done")
// Act
let digest = AwayDigest.reduce(
events: [newest, oldest, middle], since: Self.leftAt, limit: 2)
// Assert
#expect(digest.recent == [middle, newest])
}
@Test("zero or negative limit yields empty recent while counts still compute")
func zeroOrNegativeLimitYieldsEmptyRecentButCountsStillComputed() {
// Arrange
let events = [Self.event(atOffsetMs: 1, class: "tool", label: "ran Bash")]
// Act
let zero = AwayDigest.reduce(events: events, since: Self.leftAt, limit: 0)
let negative = AwayDigest.reduce(events: events, since: Self.leftAt, limit: -3)
// Assert
#expect(zero.recent.isEmpty)
#expect(zero.toolRuns == 1)
#expect(negative.recent.isEmpty)
#expect(negative.toolRuns == 1)
}
// MARK: - empty / extreme inputs
@Test("empty events yield the all-zero digest")
func emptyEventsYieldAllZeroDigest() {
// Arrange / Act
let digest = AwayDigest.reduce(events: [], since: Self.leftAt, limit: Self.defaultLimit)
// Assert
#expect(digest == AwayDigest(
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false, recent: []))
#expect(digest.isEmpty == true)
}
@Test("extreme since dates never crash the ms conversion")
func extremeSinceDatesDoNotCrash() {
// Arrange
let events = [Self.event(atOffsetMs: 1, class: "tool", label: "ran Bash")]
// Act
let future = AwayDigest.reduce(events: events, since: .distantFuture, limit: 5)
let past = AwayDigest.reduce(events: events, since: .distantPast, limit: 5)
let overflow = AwayDigest.reduce(
events: events, since: Date(timeIntervalSince1970: .greatestFiniteMagnitude), limit: 5)
// Assert
#expect(future.isEmpty == true)
#expect(past.toolRuns == 1)
#expect(overflow.isEmpty == true)
}
// MARK: - vocabulary sync
@Test("digest class vocabulary stays in sync with WireProtocol's known classes")
func classVocabularyStaysInSyncWithWireProtocol() {
// Arrange: the local names must be exactly the server's TimelineClass
// union as frozen in WireProtocol (src/types.ts:423).
let local: Set<String> = [
TimelineClassName.tool, TimelineClassName.waiting, TimelineClassName.done,
TimelineClassName.stuck, TimelineClassName.user,
]
// Act / Assert
#expect(local == TimelineEvent.knownClasses)
}
}

View File

@@ -0,0 +1,175 @@
import Testing
import WireProtocol
@testable import SessionCore
/// T-iOS-6 · GateState + GateTracker permission-gate reducer (plan §3.2).
/// Mirrors the web client's stale-gate guard (public/terminal-session.ts:94-311):
/// epoch increments ONLY on a pending falsetrue rising edge; a decision carrying
/// a stale epoch is dropped so a slow tap can never approve the NEXT gate.
@Suite("GateState + GateTracker")
struct GateStateTests {
// MARK: - epoch semantics (rising edge only)
@Test("pending false→true rising edge creates a gate with epoch +1")
func risingEdgeCreatesGateWithIncrementedEpoch() {
// Arrange
let tracker = GateTracker.initial
// Act
let next = tracker.reduce(pending: true, gate: .tool, detail: "Bash")
// Assert
#expect(tracker.current == nil)
#expect(next.current == GateState(kind: .tool, detail: "Bash", epoch: 1))
}
@Test("sustained pending frames keep the same epoch")
func sustainedPendingKeepsSameEpoch() {
// Arrange
let held = GateTracker.initial.reduce(pending: true, gate: .tool, detail: "Bash")
// Act: two continuation frames while the same gate stays held.
let afterOne = held.reduce(pending: true, gate: .tool, detail: "Bash")
let afterTwo = afterOne.reduce(pending: true, gate: .tool, detail: "Bash")
// Assert
#expect(afterOne.current?.epoch == 1)
#expect(afterTwo.current?.epoch == 1)
}
@Test("continuation frames refresh detail from the latest frame without bumping epoch")
func sustainedPendingRefreshesDetailFromLatestFrame() {
// Arrange (mirrors terminal-session.ts:310 pendingTool follows msg.detail)
let held = GateTracker.initial.reduce(pending: true, gate: .tool, detail: "Bash")
// Act
let refreshed = held.reduce(pending: true, gate: .tool, detail: "Edit")
// Assert
#expect(refreshed.current == GateState(kind: .tool, detail: "Edit", epoch: 1))
}
@Test("pending true→false falling edge clears the gate")
func fallingEdgeClearsGate() {
// Arrange
let held = GateTracker.initial.reduce(pending: true, gate: .tool, detail: "Bash")
// Act
let cleared = held.reduce(pending: false, gate: nil, detail: nil)
// Assert
#expect(cleared.current == nil)
}
@Test("a second rising edge increments the epoch again")
func secondRisingEdgeIncrementsEpochAgain() {
// Arrange: gate 1 held, then resolved.
let cleared = GateTracker.initial
.reduce(pending: true, gate: .tool, detail: "Bash")
.reduce(pending: false, gate: nil, detail: nil)
// Act: gate 2 arrives.
let second = cleared.reduce(pending: true, gate: .plan, detail: nil)
// Assert
#expect(second.current == GateState(kind: .plan, detail: nil, epoch: 2))
}
// MARK: - stale-epoch decisions are dropped ( gate)
@Test("a decision carrying a stale epoch is dropped")
func staleEpochDecisionIsDropped() {
// Arrange: gate 1 resolved, gate 2 now held (epoch 2).
let tracker = GateTracker.initial
.reduce(pending: true, gate: .tool, detail: "Bash")
.reduce(pending: false, gate: nil, detail: nil)
.reduce(pending: true, gate: .tool, detail: "Write")
// Act / Assert: the user's slow tap against gate 1 must NOT approve gate 2.
#expect(tracker.canDecide(epoch: 1) == false)
}
@Test("a decision carrying the current epoch is accepted")
func currentEpochDecisionIsAccepted() {
// Arrange
let tracker = GateTracker.initial
.reduce(pending: true, gate: .tool, detail: "Bash")
.reduce(pending: false, gate: nil, detail: nil)
.reduce(pending: true, gate: .tool, detail: "Write")
// Act / Assert
#expect(tracker.canDecide(epoch: 2) == true)
}
@Test("a decision when no gate is held is dropped even with the last-issued epoch")
func decisionWithNoGateHeldIsDropped() {
// Arrange: gate 1 was resolved server-side before the tap landed.
let cleared = GateTracker.initial
.reduce(pending: true, gate: .tool, detail: "Bash")
.reduce(pending: false, gate: nil, detail: nil)
// Act / Assert
#expect(cleared.canDecide(epoch: 1) == false)
}
// MARK: - affordances (plan three-way, tool two-way)
@Test("plan gate offers the three-way affordance set")
func planGateOffersThreeWayAffordances() {
// Arrange
let gate = GateState(kind: .plan, detail: nil, epoch: 1)
// Act / Assert (mirrors public/tabs.ts:343-350)
#expect(gate.affordances == [.approveAuto, .approveReview, .keepPlanning])
}
@Test("tool gate offers the two-way affordance set")
func toolGateOffersTwoWayAffordances() {
// Arrange
let gate = GateState(kind: .tool, detail: "Bash", epoch: 1)
// Act / Assert (mirrors public/tabs.ts:334-339)
#expect(gate.affordances == [.approve, .reject])
}
@Test("pending frame without a gate kind defaults to a tool gate")
func pendingWithoutGateKindDefaultsToToolGate() {
// Arrange: an unrecognized/absent gate decodes as nil (ServerMessage)
// the pending signal must never be lost (web: gate ?? null tool buttons).
let tracker = GateTracker.initial
// Act
let held = tracker.reduce(pending: true, gate: nil, detail: "Bash")
// Assert
#expect(held.current?.kind == .tool)
#expect(held.current?.affordances == [.approve, .reject])
}
@Test("affordances map to the exact wire messages the web client sends")
func affordancesMapToWireMessagesMirroringWebClient() {
// Arrange / Act / Assert (public/tabs.ts:345-347: acceptEdits / default / reject)
#expect(GateState.Affordance.approveAuto.clientMessage == .approve(mode: .acceptEdits))
#expect(GateState.Affordance.approveReview.clientMessage == .approve(mode: .default))
#expect(GateState.Affordance.keepPlanning.clientMessage == .reject)
#expect(GateState.Affordance.approve.clientMessage == .approve(mode: nil))
#expect(GateState.Affordance.reject.clientMessage == .reject)
}
// MARK: - purity
@Test("reduce is pure: same input gives same output and never mutates the receiver")
func reduceIsPureAndDoesNotMutateReceiver() {
// Arrange
let tracker = GateTracker.initial
let snapshotBefore = tracker
// Act: reduce twice with the identical input on the SAME value.
let first = tracker.reduce(pending: true, gate: .plan, detail: "d")
let second = tracker.reduce(pending: true, gate: .plan, detail: "d")
// Assert
#expect(first == second)
#expect(tracker == snapshotBefore)
}
}

View File

@@ -0,0 +1,186 @@
import Testing
import TestSupport
import WireProtocol
@testable import SessionCore
/// T-iOS-5 · PingScheduler keep-alive pacer (plan §3.2 / §3.2.1).
/// 25s ping cadence (`Tunables.pingInterval`); 1 missed pong tolerated,
/// `Tunables.pongMissLimit` (2) CONSECUTIVE misses connection declared lost.
/// All timing on FakeClock zero real waits.
@Suite("PingScheduler")
struct PingSchedulerTests {
/// Scripted pong ledger: pops one result per ping, records the call count.
/// Runs out of script answers `true` (pong received).
private actor PongScript {
private var results: [Bool]
private(set) var pingCount = 0
init(_ results: [Bool]) {
self.results = results
}
func nextResult() -> Bool {
pingCount += 1
guard !results.isEmpty else { return true }
return results.removeFirst()
}
}
/// Starts `run` on a FakeClock and parks it at its first sleep.
private static func startScheduler(
clock: FakeClock,
script: PongScript,
interval: Duration = Tunables.pingInterval
) async -> Task<PingScheduler.Outcome, Never> {
let scheduler = PingScheduler(interval: interval)
let task = Task {
await scheduler.run(clock: clock) { await script.nextResult() }
}
await clock.waitForSleepers(count: 1)
return task
}
/// Fires one ping tick: advance past the interval, then wait until the
/// scheduler is parked in its NEXT sleep (proof the tick fully processed).
private static func fireTickAndAwaitNextSleep(clock: FakeClock) async {
clock.advance(by: Tunables.pingInterval)
await clock.waitForSleepers(count: 1)
}
@Test("default interval is Tunables.pingInterval (25s)")
func defaultIntervalIsTunablesPingInterval() {
// Arrange / Act
let scheduler = PingScheduler()
// Assert
#expect(scheduler.interval == Tunables.pingInterval)
#expect(scheduler.interval == .seconds(25))
}
@Test("sends one ping per interval tick while pongs keep arriving")
func sendsOnePingPerIntervalTickWhenPongsArrive() async {
// Arrange
let clock = FakeClock()
let script = PongScript([true, true, true])
let task = await Self.startScheduler(clock: clock, script: script)
// Act: three full interval ticks.
await Self.fireTickAndAwaitNextSleep(clock: clock)
await Self.fireTickAndAwaitNextSleep(clock: clock)
await Self.fireTickAndAwaitNextSleep(clock: clock)
// Assert: exactly one ping per tick, none before the first interval.
#expect(await script.pingCount == 3)
task.cancel()
#expect(await task.value == .cancelled)
}
@Test("does not ping before the first interval has elapsed")
func doesNotPingBeforeFirstIntervalElapses() async {
// Arrange
let clock = FakeClock()
let script = PongScript([])
let task = await Self.startScheduler(clock: clock, script: script)
// Act: advance just short of the interval.
clock.advance(by: Tunables.pingInterval - .seconds(1))
// Assert: still parked, zero pings sent.
#expect(clock.pendingSleeperCount == 1)
#expect(await script.pingCount == 0)
task.cancel()
#expect(await task.value == .cancelled)
}
@Test("tolerates a single missed pong and keeps the connection alive")
func toleratesSingleMissedPongAndKeepsPinging() async {
// Arrange: miss one pong, then recover.
let clock = FakeClock()
let script = PongScript([false, true])
let task = await Self.startScheduler(clock: clock, script: script)
// Act: tick #1 misses scheduler must survive and park again;
// tick #2 gets its pong.
await Self.fireTickAndAwaitNextSleep(clock: clock)
await Self.fireTickAndAwaitNextSleep(clock: clock)
// Assert: both pings sent, run still alive (it re-parked after each).
#expect(await script.pingCount == 2)
task.cancel()
#expect(await task.value == .cancelled)
}
@Test("two consecutive missed pongs signal connectionLost")
func twoConsecutiveMissedPongsSignalConnectionLost() async {
// Arrange
let clock = FakeClock()
let script = PongScript([false, false])
let task = await Self.startScheduler(clock: clock, script: script)
// Act: miss #1 (tolerated)
await Self.fireTickAndAwaitNextSleep(clock: clock)
// miss #2 == Tunables.pongMissLimit run must finish by itself.
clock.advance(by: Tunables.pingInterval)
// Assert
#expect(await task.value == .connectionLost)
#expect(await script.pingCount == Tunables.pongMissLimit)
// Assert: a dead scheduler never pings again.
clock.advance(by: Tunables.pingInterval)
#expect(await script.pingCount == Tunables.pongMissLimit)
}
@Test("a pong between misses resets the counter so non-consecutive misses never disconnect")
func pongBetweenMissesResetsConsecutiveMissCounter() async {
// Arrange: miss, pong, miss, pong never two misses in a row.
let clock = FakeClock()
let script = PongScript([false, true, false, true])
let task = await Self.startScheduler(clock: clock, script: script)
// Act
for _ in 0..<4 {
await Self.fireTickAndAwaitNextSleep(clock: clock)
}
// Assert: still alive after 4 ticks (it re-parked after the last one).
#expect(await script.pingCount == 4)
task.cancel()
#expect(await task.value == .cancelled)
}
@Test("cancellation while sleeping ends run as cancelled, not connectionLost")
func cancellationWhileSleepingEndsRunAsCancelled() async {
// Arrange
let clock = FakeClock()
let script = PongScript([])
let task = await Self.startScheduler(clock: clock, script: script)
// Act: tear down mid-sleep (normal close path).
task.cancel()
// Assert
#expect(await task.value == .cancelled)
#expect(await script.pingCount == 0)
}
@Test("honors a custom interval instead of the default")
func honorsCustomIntervalInsteadOfDefault() async {
// Arrange
let customInterval: Duration = .seconds(5)
let clock = FakeClock()
let script = PongScript([true])
let task = await Self.startScheduler(
clock: clock, script: script, interval: customInterval
)
// Act: one custom-interval tick.
clock.advance(by: customInterval)
await clock.waitForSleepers(count: 1)
// Assert
#expect(await script.pingCount == 1)
task.cancel()
#expect(await task.value == .cancelled)
}
}

View File

@@ -0,0 +1,148 @@
import Testing
@testable import SessionCore
/// T-iOS-5 · ReconnectMachine pure back-off reducer (plan §3.2).
/// Mirrors public/terminal-session.ts:106,248,352-361: retry delays
/// 1s 2s 4s 8s 16s 30s (capped), reset to 1s on `connected`.
@Suite("ReconnectMachine")
struct ReconnectMachineTests {
/// Delay ladder the web client produces (public/terminal-session.ts:356).
private static let expectedBackoffLadder: [Duration] = [
.seconds(1), .seconds(2), .seconds(4), .seconds(8),
.seconds(16), .seconds(30), .seconds(30),
]
/// Drives `count` disconnecttimer-fired cycles from `.initial` and
/// collects every `.scheduleRetry(after:)` delay along the way.
private static func scheduledDelays(disconnectCount: Int) -> [Duration] {
var machine = ReconnectMachine.initial
var delays: [Duration] = []
for _ in 0..<disconnectCount {
let (afterDisconnect, effect) = machine.reduce(.disconnected)
if case .scheduleRetry(let after) = effect {
delays.append(after)
}
let (afterFire, _) = afterDisconnect.reduce(.retryTimerFired)
machine = afterFire
}
return delays
}
/// Returns the machine state after `count` disconnecttimer-fired cycles.
private static func machineAfterDisconnects(_ count: Int) -> ReconnectMachine {
var machine = ReconnectMachine.initial
for _ in 0..<count {
let (afterDisconnect, _) = machine.reduce(.disconnected)
let (afterFire, _) = afterDisconnect.reduce(.retryTimerFired)
machine = afterFire
}
return machine
}
@Test("disconnect sequence schedules 1s,2s,4s,8s,16s then caps at 30s")
func disconnectSequenceFollowsDoublingLadderCappedAt30s() {
// Arrange / Act
let delays = Self.scheduledDelays(disconnectCount: Self.expectedBackoffLadder.count)
// Assert
#expect(delays == Self.expectedBackoffLadder)
}
@Test("every disconnected input produces a scheduleRetry effect")
func disconnectedAlwaysSchedulesARetry() {
// Arrange
let disconnectCount = Self.expectedBackoffLadder.count
// Act
let delays = Self.scheduledDelays(disconnectCount: disconnectCount)
// Assert: no cycle dropped its retry (delays collected == cycles run).
#expect(delays.count == disconnectCount)
}
@Test("connected resets backoff so the next disconnect starts at 1s again")
func connectedResetsBackoffToOneSecond() {
// Arrange: escalate well into the ladder first.
let escalated = Self.machineAfterDisconnects(4)
// Act
let (afterConnected, connectEffect) = escalated.reduce(.connected)
let (_, retryEffect) = afterConnected.reduce(.disconnected)
// Assert
#expect(connectEffect == .none)
#expect(afterConnected == .initial)
#expect(retryEffect == .scheduleRetry(after: .seconds(1)))
}
@Test("connected while already at initial state is a no-op")
func connectedOnInitialStateIsNoOp() {
// Arrange
let machine = ReconnectMachine.initial
// Act
let (next, effect) = machine.reduce(.connected)
// Assert
#expect(next == .initial)
#expect(effect == .none)
}
@Test("retryTimerFired produces connectNow")
func retryTimerFiredProducesConnectNow() {
// Arrange
let (waiting, _) = ReconnectMachine.initial.reduce(.disconnected)
// Act
let (_, effect) = waiting.reduce(.retryTimerFired)
// Assert
#expect(effect == .connectNow)
}
@Test("foregrounded produces immediate connectNow without waiting for the timer")
func foregroundedProducesImmediateConnectNow() {
// Arrange: mid-backoff (next disconnect would schedule 4s).
let waiting = Self.machineAfterDisconnects(2)
// Act
let (afterForeground, effect) = waiting.reduce(.foregrounded)
// Assert: connect right now, and the backoff ladder is NOT reset
// only a successful `connected` resets it.
#expect(effect == .connectNow)
#expect(afterForeground == waiting)
let (_, retryEffect) = afterForeground.reduce(.disconnected)
#expect(retryEffect == .scheduleRetry(after: .seconds(4)))
}
@Test("userRetry produces immediate connectNow without waiting for the timer")
func userRetryProducesImmediateConnectNow() {
// Arrange
let waiting = Self.machineAfterDisconnects(3)
// Act
let (afterRetry, effect) = waiting.reduce(.userRetry)
// Assert
#expect(effect == .connectNow)
#expect(afterRetry == waiting)
}
@Test("reduce is pure: same input gives same output and never mutates the receiver")
func reduceIsPureAndDoesNotMutateOriginalSnapshot() {
// Arrange
let machine = ReconnectMachine.initial
let snapshotBefore = machine
// Act: reduce twice with the identical input on the SAME value.
let (firstNext, firstEffect) = machine.reduce(.disconnected)
let (secondNext, secondEffect) = machine.reduce(.disconnected)
// Assert: deterministic output, untouched original.
#expect(firstNext == secondNext)
#expect(firstEffect == secondEffect)
#expect(machine == snapshotBefore)
#expect(machine == .initial)
}
}