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:
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// The server's `TimelineClass` vocabulary (src/types.ts:423, produced by
|
||||
/// src/session/timeline.ts CLASS_MAP). Kept in lockstep with
|
||||
/// `TimelineEvent.knownClasses` (WireProtocol) — a sync test asserts equality.
|
||||
/// Internal (not public API): consumers see counts, not class strings.
|
||||
enum TimelineClassName {
|
||||
static let tool = "tool"
|
||||
static let waiting = "waiting"
|
||||
static let done = "done"
|
||||
static let stuck = "stuck"
|
||||
static let user = "user"
|
||||
}
|
||||
|
||||
/// "What happened while I was away" summary (T-iOS-6, plan §3.2 — frozen
|
||||
/// shape). Reduced once after a reconnect from the session's activity timeline
|
||||
/// (`GET /live-sessions/:id/events`, src/types.ts:428-433) and rendered above
|
||||
/// the terminal (T-iOS-14).
|
||||
public struct AwayDigest: Sendable, Equatable {
|
||||
/// Events with class `tool` at/after `since` (PreToolUse + PostToolUse).
|
||||
public let toolRuns: Int
|
||||
/// Events with class `waiting` (approval requests / notifications).
|
||||
public let waitingCount: Int
|
||||
/// Any `done` event seen (Stop / SessionEnd).
|
||||
public let sawDone: Bool
|
||||
/// Any `stuck` event seen (A5 silent-too-long derivation).
|
||||
public let sawStuck: Bool
|
||||
/// The newest events (post-`since`), oldest→newest, truncated to `limit`.
|
||||
public let recent: [TimelineEvent]
|
||||
|
||||
/// The all-zero digest: nothing happened → UI skips rendering entirely.
|
||||
public static let empty = AwayDigest(
|
||||
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false, recent: [])
|
||||
|
||||
public init(
|
||||
toolRuns: Int, waitingCount: Int, sawDone: Bool, sawStuck: Bool,
|
||||
recent: [TimelineEvent]
|
||||
) {
|
||||
self.toolRuns = toolRuns
|
||||
self.waitingCount = waitingCount
|
||||
self.sawDone = sawDone
|
||||
self.sawStuck = sawStuck
|
||||
self.recent = recent
|
||||
}
|
||||
|
||||
/// True when there is nothing to show (the UI's "don't render" signal).
|
||||
public var isEmpty: Bool { self == .empty }
|
||||
|
||||
/// Folds a timeline into a digest. PURE function — no I/O, no clock.
|
||||
///
|
||||
/// - `events`: `/events` entries (untrusted server input; order not assumed
|
||||
/// — entries are stably re-ordered by `at` before truncation).
|
||||
/// - `since`: the moment the user left; strictly-earlier events are
|
||||
/// excluded, an event AT the instant still counts.
|
||||
/// - `limit`: max `recent` entries kept (newest win); `<= 0` keeps none.
|
||||
public static func reduce(events: [TimelineEvent], since: Date, limit: Int) -> AwayDigest {
|
||||
let sinceMs = clampedEpochMs(since)
|
||||
let away = events
|
||||
.filter { $0.at >= sinceMs }
|
||||
.sorted { $0.at < $1.at }
|
||||
guard !away.isEmpty else { return .empty }
|
||||
|
||||
return AwayDigest(
|
||||
toolRuns: away.filter { $0.class == TimelineClassName.tool }.count,
|
||||
waitingCount: away.filter { $0.class == TimelineClassName.waiting }.count,
|
||||
sawDone: away.contains { $0.class == TimelineClassName.done },
|
||||
sawStuck: away.contains { $0.class == TimelineClassName.stuck },
|
||||
recent: Array(away.suffix(max(0, limit))))
|
||||
}
|
||||
|
||||
/// `Date` → epoch-ms `Int`, clamped: `Int(_: Double)` TRAPS on overflow, and
|
||||
/// `since` is caller input (e.g. `.distantFuture`) — clamp instead of crash.
|
||||
private static func clampedEpochMs(_ date: Date) -> Int {
|
||||
let ms = date.timeIntervalSince1970 * 1_000
|
||||
guard ms < Double(Int.max) else { return .max }
|
||||
guard ms > Double(Int.min) else { return .min }
|
||||
return Int(ms)
|
||||
}
|
||||
}
|
||||
119
ios/Packages/SessionCore/Sources/SessionCore/GateState.swift
Normal file
119
ios/Packages/SessionCore/Sources/SessionCore/GateState.swift
Normal file
@@ -0,0 +1,119 @@
|
||||
import WireProtocol
|
||||
|
||||
/// One held permission gate as the UI should render it (T-iOS-6, plan §3.2 —
|
||||
/// frozen shape). Produced by `GateTracker` from `status` frames; `nil` gate
|
||||
/// (via `SessionEvent.gate(nil)`) means "gate lifted".
|
||||
///
|
||||
/// `epoch` is the stale-decision guard (mirrors the web client's
|
||||
/// `pendingEpochValue`, public/terminal-session.ts:98-102): it increments only
|
||||
/// on a pending false→true rising edge, and every approve/reject carries the
|
||||
/// epoch it was tapped against — a stale epoch is dropped so a slow tap can
|
||||
/// never approve the NEXT gate (防误批新 gate).
|
||||
public struct GateState: Sendable, Equatable {
|
||||
/// `plan` → three-way affordances; `tool` → two-way (public/tabs.ts:334-350).
|
||||
public let kind: GateKind
|
||||
/// Server-supplied context (tool name for tool gates); untrusted display text.
|
||||
public let detail: String?
|
||||
/// Rising-edge counter; see type doc.
|
||||
public let epoch: Int
|
||||
|
||||
public init(kind: GateKind, detail: String?, epoch: Int) {
|
||||
self.kind = kind
|
||||
self.detail = detail
|
||||
self.epoch = epoch
|
||||
}
|
||||
}
|
||||
|
||||
extension GateState {
|
||||
/// One user-facing gate action. Pure affordance DATA — display strings live
|
||||
/// in the App layer; the wire mapping mirrors public/tabs.ts:334-350.
|
||||
public enum Affordance: Sendable, Equatable {
|
||||
/// Plan gate: "Approve + Auto" → `approve(mode: .acceptEdits)`.
|
||||
case approveAuto
|
||||
/// Plan gate: "Approve + Review" → `approve(mode: .default)`.
|
||||
case approveReview
|
||||
/// Plan gate: "Keep Planning" → `reject` (stays in plan mode).
|
||||
case keepPlanning
|
||||
/// Tool gate: "Approve" → `approve(mode: nil)`.
|
||||
case approve
|
||||
/// Tool gate: "Reject" → `reject`.
|
||||
case reject
|
||||
|
||||
/// The exact wire message the web client sends for this affordance
|
||||
/// (public/tabs.ts:345-347: plan three-way only ever sends
|
||||
/// acceptEdits / default / reject — never raw `auto`, plan §3.1 note).
|
||||
public var clientMessage: ClientMessage {
|
||||
switch self {
|
||||
case .approveAuto: return .approve(mode: .acceptEdits)
|
||||
case .approveReview: return .approve(mode: .default)
|
||||
case .keepPlanning: return .reject
|
||||
case .approve: return .approve(mode: nil)
|
||||
case .reject: return .reject
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The action set for this gate: plan → three-way, tool → two-way.
|
||||
public var affordances: [Affordance] {
|
||||
switch kind {
|
||||
case .plan: return [.approveAuto, .approveReview, .keepPlanning]
|
||||
case .tool: return [.approve, .reject]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure reducer that folds `status` frames' `(pending, gate, detail)` into the
|
||||
/// current `GateState?` (T-iOS-6). Mirrors public/terminal-session.ts:306-311:
|
||||
///
|
||||
/// - pending false→true rising edge → epoch +1, new gate held;
|
||||
/// - sustained pending → SAME epoch, kind/detail refresh from the latest frame
|
||||
/// (the server is the source of truth; reattach re-sync stays correct);
|
||||
/// - pending true→false falling edge → gate cleared, epoch counter retained;
|
||||
/// - `gate == nil` while pending → treated as a tool gate, because an
|
||||
/// unrecognized gate value decodes as nil (ServerMessage) and the pending
|
||||
/// signal must never be lost (web: `gate ?? null` → tool buttons).
|
||||
///
|
||||
/// PURE state machine: `reduce` never mutates the receiver — it returns a new
|
||||
/// immutable snapshot. No I/O, no clock; the caller (SessionEngine, T-iOS-10)
|
||||
/// feeds frames in and emits `SessionEvent.gate(current)` on change.
|
||||
public struct GateTracker: Sendable, Equatable {
|
||||
public static let initial = GateTracker(current: nil, lastEpoch: 0)
|
||||
|
||||
/// The gate currently held server-side, or `nil` when none.
|
||||
public let current: GateState?
|
||||
|
||||
/// Highest epoch ever issued; NOT reset on falling edges so decisions
|
||||
/// against a resolved gate can never match a later one.
|
||||
private let lastEpoch: Int
|
||||
|
||||
private init(current: GateState?, lastEpoch: Int) {
|
||||
self.current = current
|
||||
self.lastEpoch = lastEpoch
|
||||
}
|
||||
|
||||
/// Feeds one `status` frame's gate-relevant fields; returns the next snapshot.
|
||||
public func reduce(pending: Bool, gate: GateKind?, detail: String?) -> GateTracker {
|
||||
guard pending else {
|
||||
// Falling edge (or still idle): gate lifted, epoch counter retained.
|
||||
return GateTracker(current: nil, lastEpoch: lastEpoch)
|
||||
}
|
||||
let kind = gate ?? .tool
|
||||
if let held = current {
|
||||
// Sustained pending: same epoch, refresh kind/detail from the frame.
|
||||
let refreshed = GateState(kind: kind, detail: detail, epoch: held.epoch)
|
||||
return GateTracker(current: refreshed, lastEpoch: lastEpoch)
|
||||
}
|
||||
// Rising edge: mint the next epoch (the ONLY place it increments).
|
||||
let nextEpoch = lastEpoch + 1
|
||||
let risen = GateState(kind: kind, detail: detail, epoch: nextEpoch)
|
||||
return GateTracker(current: risen, lastEpoch: nextEpoch)
|
||||
}
|
||||
|
||||
/// True iff a decision tapped against `epoch` may be sent NOW: a gate must
|
||||
/// be held and its epoch must match. Stale epoch or no gate → drop the
|
||||
/// decision (防误批新 gate).
|
||||
public func canDecide(epoch: Int) -> Bool {
|
||||
guard let held = current else { return false }
|
||||
return held.epoch == epoch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import WireProtocol
|
||||
|
||||
/// WS keep-alive pacer (T-iOS-5, plan §3.2 / §3.2.1).
|
||||
///
|
||||
/// `URLSessionWebSocketTask` has NO automatic ping (plan §1), so without this
|
||||
/// a half-dead connection "looks connected" forever. `run` sends an explicit
|
||||
/// ping every `interval` (default `Tunables.pingInterval` = 25 s) on the
|
||||
/// injected clock.
|
||||
///
|
||||
/// Miss policy (`Tunables.pongMissLimit` = 2): one missed pong is tolerated;
|
||||
/// the moment `pongMissLimit` CONSECUTIVE pings go unanswered the connection
|
||||
/// is declared dead and `run` returns `.connectionLost` — the caller
|
||||
/// (SessionEngine, T-iOS-10) then feeds `.disconnected` into
|
||||
/// `ReconnectMachine`. Any answered ping resets the consecutive-miss counter.
|
||||
///
|
||||
/// Zero real timers: the clock is injected (`any Clock<Duration>`), so tests
|
||||
/// drive a `FakeClock` and never wait wall-clock time.
|
||||
public struct PingScheduler: Sendable {
|
||||
/// How `run` ended.
|
||||
public enum Outcome: Sendable, Equatable {
|
||||
/// `Tunables.pongMissLimit` consecutive pings went unanswered — treat
|
||||
/// the transport as disconnected.
|
||||
case connectionLost
|
||||
/// The surrounding task was cancelled (normal teardown, e.g. explicit
|
||||
/// close or transport swap) — NOT a connectivity verdict.
|
||||
case cancelled
|
||||
}
|
||||
|
||||
/// Ping period. Frozen default: `Tunables.pingInterval` (25 s).
|
||||
public let interval: Duration
|
||||
|
||||
public init(interval: Duration = Tunables.pingInterval) {
|
||||
self.interval = interval
|
||||
}
|
||||
|
||||
/// Loops until the connection dies or the task is cancelled: sleep one
|
||||
/// `interval` on `clock`, then await `sendPing`.
|
||||
///
|
||||
/// `sendPing` returns `true` iff the pong arrived — the transport adapter
|
||||
/// owns ping/pong resolution (e.g. racing `URLSessionWebSocketTask`'s
|
||||
/// `pongReceiveHandler` against its own deadline, T-iOS-9); `false` = miss.
|
||||
public func run(
|
||||
clock: any Clock<Duration>,
|
||||
sendPing: @Sendable () async -> Bool
|
||||
) async -> Outcome {
|
||||
var consecutiveMisses = 0
|
||||
while true {
|
||||
do {
|
||||
try await clock.sleep(for: interval, tolerance: nil)
|
||||
} catch {
|
||||
// Clock.sleep only throws CancellationError (FakeClock mirrors
|
||||
// ContinuousClock semantics) — normal teardown, not a failure.
|
||||
return .cancelled
|
||||
}
|
||||
let isPongReceived = await sendPing()
|
||||
consecutiveMisses = isPongReceived ? 0 : consecutiveMisses + 1
|
||||
if consecutiveMisses >= Tunables.pongMissLimit {
|
||||
return .connectionLost
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/// Pure reconnect back-off reducer (T-iOS-5, plan §3.2).
|
||||
///
|
||||
/// Mirrors the web client's behaviour in `public/terminal-session.ts`:
|
||||
/// - line 106: first retry delay is 1 s,
|
||||
/// - lines 352-361 (`scheduleReconnect`): the CURRENT delay is scheduled,
|
||||
/// then doubled and capped at 30 s (`Math.min(delay * 2, 30_000)`),
|
||||
/// - line 248: a successful open resets the delay to 1 s.
|
||||
/// Ladder: 1s → 2s → 4s → 8s → 16s → 30s → 30s …
|
||||
///
|
||||
/// PURE state machine: `reduce` never mutates the receiver — it returns a new
|
||||
/// immutable snapshot plus the single side-effect the caller (SessionEngine,
|
||||
/// T-iOS-10) must perform. No clock, no timers here; timing lives with the
|
||||
/// caller, which is what makes this fully testable with zero real waits.
|
||||
public struct ReconnectMachine: Sendable, Equatable {
|
||||
/// First retry delay after a disconnect
|
||||
/// (mirrors `public/terminal-session.ts:106` — `reconnectDelay = 1000`).
|
||||
static let initialRetryDelay: Duration = .seconds(1)
|
||||
|
||||
/// Back-off ceiling
|
||||
/// (mirrors `public/terminal-session.ts:356` — `Math.min(delay * 2, 30_000)`).
|
||||
static let maxRetryDelay: Duration = .seconds(30)
|
||||
|
||||
/// Doubling factor per failed attempt (same mirror line as the ceiling).
|
||||
static let backoffMultiplier = 2
|
||||
|
||||
public enum Input: Sendable, Equatable {
|
||||
case connected
|
||||
case disconnected
|
||||
case retryTimerFired
|
||||
case foregrounded
|
||||
case userRetry
|
||||
}
|
||||
|
||||
public enum Effect: Sendable, Equatable {
|
||||
case connectNow
|
||||
case scheduleRetry(after: Duration)
|
||||
case none
|
||||
}
|
||||
|
||||
public static let initial = ReconnectMachine(nextRetryDelay: initialRetryDelay)
|
||||
|
||||
/// Delay the NEXT `.disconnected` will schedule.
|
||||
private let nextRetryDelay: Duration
|
||||
|
||||
private init(nextRetryDelay: Duration) {
|
||||
self.nextRetryDelay = nextRetryDelay
|
||||
}
|
||||
|
||||
/// Feeds one input and returns `(next snapshot, effect to perform)`.
|
||||
///
|
||||
/// - `.connected`: back-off resets to the initial 1 s ladder rung; no effect.
|
||||
/// - `.disconnected`: schedule a retry after the current delay; the next
|
||||
/// snapshot carries the doubled (capped) delay.
|
||||
/// - `.retryTimerFired` / `.foregrounded` / `.userRetry`: connect NOW.
|
||||
/// Foreground/user-initiated attempts deliberately do NOT reset the
|
||||
/// ladder — only a successful `.connected` does, so mashing retry can
|
||||
/// never turn back-off into hammering.
|
||||
public func reduce(_ input: Input) -> (ReconnectMachine, Effect) {
|
||||
switch input {
|
||||
case .connected:
|
||||
return (.initial, .none)
|
||||
case .disconnected:
|
||||
let doubled = nextRetryDelay * Self.backoffMultiplier
|
||||
let next = ReconnectMachine(nextRetryDelay: min(doubled, Self.maxRetryDelay))
|
||||
return (next, .scheduleRetry(after: nextRetryDelay))
|
||||
case .retryTimerFired, .foregrounded, .userRetry:
|
||||
return (self, .connectNow)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 oldest→newest.
|
||||
#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)
|
||||
}
|
||||
}
|
||||
@@ -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 false→true 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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` disconnect→timer-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` disconnect→timer-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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user