import Foundation import SessionCore import TestSupport import Testing import WireProtocol @testable import WebTerm /// T-iOS-14 · GateViewModel + gate/digest components (plan §7). All tests run /// against a REAL `SessionEngine` over `TestSupport.FakeTransport` + /// `FakeClock` (same testability choice as TerminalViewModelTests): the VM /// consumes `engine.events` exactly as the T-iOS-15 wiring will — zero real /// waits, zero network, and the engine's `GateTracker.canDecide` second-line /// guard stays live underneath the VM's first-line tap-epoch guard. @MainActor @Suite("GateViewModel") struct GateViewModelTests { // MARK: - Server-side frame fixtures (untrusted wire input, hand-built JSON) private enum ServerFrames { static func attached(_ id: UUID) -> String { #"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"# } static func status(pending: Bool, gate: String? = nil, detail: String? = nil) -> String { var fields = ["\"type\":\"status\"", "\"status\":\"working\"", "\"pending\":\(pending)"] if let gate { fields.append("\"gate\":\"\(gate)\"") } if let detail { fields.append("\"detail\":\"\(detail)\"") } return "{\(fields.joined(separator: ","))}" } } private struct TestStreamError: Error {} // MARK: - Test doubles /// Records haptic firings — the "exactly once per gate epoch" probe. @MainActor private final class HapticRecorder: HapticSignaling { private(set) var gateArrivalCount = 0 func gateDidArrive() { gateArrivalCount += 1 } } // MARK: - Harness /// Engine over fakes + the VM under test, wired exactly like production. @MainActor private final class Harness { let transport = FakeTransport() let clock = FakeClock() let haptics = HapticRecorder() let engine: SessionEngine let viewModel: GateViewModel init( eventsSource: @escaping @Sendable (UUID) async throws -> [TimelineEvent] = { _ in [] } ) throws { let baseURL = try #require(URL(string: "http://192.168.1.5:3000")) let endpoint = try #require(HostEndpoint(baseURL: baseURL)) engine = SessionEngine( transport: transport, clock: clock, endpoint: endpoint, eventsSource: eventsSource ) viewModel = GateViewModel( engine: engine, events: engine.events, haptics: haptics, clock: clock ) viewModel.start() } /// Standard preamble: open → .connecting → .connected → server confirms /// the attach. 3 events reach the VM. func openAndAdopt(_ id: UUID = UUID()) async { await engine.open(sessionId: nil, cwd: nil) await transport.emit(frame: ServerFrames.attached(id)) await viewModel.waitUntilProcessed(eventCount: 3) } /// Frames the client sent on connection 0 (attach first, then decisions). func wireFrames() async -> [String] { let byConnection = await transport.sentFramesByConnection return byConnection.first ?? [] } /// Drop the wire, ride the 1s backoff rung, reconnect, re-adopt `id` — /// the away window that makes the engine emit exactly one digest. /// Event count after: 7 (3 preamble + reconnecting + connected + /// adopted + digest). func reconnectForDigest(_ id: UUID) async { await transport.emitError(TestStreamError()) await viewModel.waitUntilProcessed(eventCount: 4) // .reconnecting await clock.waitForSleepers(count: 1) // backoff rung parked clock.advance(by: .seconds(1)) await transport.emit(frame: ServerFrames.attached(id)) await viewModel.waitUntilProcessed(eventCount: 7) } } // MARK: - Gate routing (tool → banner, plan → sheet) @Test("tool gate → two-button banner state; plan gate → three-way sheet state; lifted → neither") func gateRoutingToolVsPlan() async throws { // Arrange let harness = try Harness() await harness.openAndAdopt() // Act: tool gate rises (epoch 1). await harness.transport.emit( frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash")) await harness.viewModel.waitUntilProcessed(eventCount: 4) // Assert: banner state only. #expect(harness.viewModel.toolGate == GateState(kind: .tool, detail: "Bash", epoch: 1)) #expect(harness.viewModel.planGate == nil) // Act: gate lifted. await harness.transport.emit(frame: ServerFrames.status(pending: false)) await harness.viewModel.waitUntilProcessed(eventCount: 5) // Assert: neither surface renders. #expect(harness.viewModel.toolGate == nil) #expect(harness.viewModel.planGate == nil) // Act: plan gate rises (epoch 2). await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan")) await harness.viewModel.waitUntilProcessed(eventCount: 6) // Assert: sheet state only. #expect(harness.viewModel.planGate == GateState(kind: .plan, detail: nil, epoch: 2)) #expect(harness.viewModel.toolGate == nil) } // MARK: - Wire mapping (mirror of public/tabs.ts:345-347 / src/types.ts:84-86) @Test("plan three-way maps EXACTLY: Approve+Auto→acceptEdits · Approve+Review→default · Keep Planning→reject — no allowAutoMode gate anywhere") func planThreeWayMappingMirrorsWebClient() async throws { // Arrange: a held plan gate (epoch 1). Note the VM has NO HTTP client // and never consults /config/ui — uiConfig.allowAutoMode is reserved // for a future permission-mode picker (plan §3.1 note). let harness = try Harness() await harness.openAndAdopt() await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan")) await harness.viewModel.waitUntilProcessed(eventCount: 4) let epoch = try #require(harness.viewModel.planGate?.epoch) // Act: all three choices against the held gate (web sends per click). harness.viewModel.decide(.approveAuto, epoch: epoch) harness.viewModel.decide(.approveReview, epoch: epoch) harness.viewModel.decide(.keepPlanning, epoch: epoch) await harness.viewModel.waitUntilForwarded(decisionCount: 3) // Assert: exact wire frames, in order — never raw `auto`. #expect(await harness.wireFrames() == [ MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), MessageCodec.encode(.approve(mode: .acceptEdits)), MessageCodec.encode(.approve(mode: .default)), MessageCodec.encode(.reject), ]) } @Test("tool two-way maps: Approve→approve(mode:nil) · Reject→reject") func toolTwoWayMapping() async throws { // Arrange let harness = try Harness() await harness.openAndAdopt() await harness.transport.emit( frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash")) await harness.viewModel.waitUntilProcessed(eventCount: 4) let epoch = try #require(harness.viewModel.toolGate?.epoch) // Act harness.viewModel.decide(.approve, epoch: epoch) harness.viewModel.decide(.reject, epoch: epoch) await harness.viewModel.waitUntilForwarded(decisionCount: 2) // Assert: bare approve (no mode key) then reject. #expect(await harness.wireFrames() == [ MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), MessageCodec.encode(.approve(mode: nil)), MessageCodec.encode(.reject), ]) } // MARK: - Tap-epoch guard (first line; engine canDecide is the second) @Test("a tap carrying a stale epoch is dropped and never sent — a slow tap must not approve the NEXT gate") func staleEpochTapIsDroppedNotSent() async throws { // Arrange let harness = try Harness() await harness.openAndAdopt() // Act: tap before any gate ever rose → dropped. harness.viewModel.decide(.approve, epoch: 0) #expect(harness.viewModel.droppedStaleDecisionCount == 1) // Arrange: gate 1 rises, resolves, gate 2 rises (the dangerous window). await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool")) await harness.viewModel.waitUntilProcessed(eventCount: 4) await harness.transport.emit(frame: ServerFrames.status(pending: false)) await harness.viewModel.waitUntilProcessed(eventCount: 5) await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool")) await harness.viewModel.waitUntilProcessed(eventCount: 6) // Act: a tap rendered against gate 1 lands now → dropped, not sent. harness.viewModel.decide(.approve, epoch: 1) #expect(harness.viewModel.droppedStaleDecisionCount == 2) #expect(harness.viewModel.forwardedDecisionCount == 0) // Act: a fresh tap against the CURRENT gate (epoch 2) goes through. harness.viewModel.decide(.approve, epoch: 2) await harness.viewModel.waitUntilForwarded(decisionCount: 1) // Assert: exactly one approve ever reached the wire. #expect(await harness.wireFrames() == [ MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), MessageCodec.encode(.approve(mode: nil)), ]) } @Test("same-epoch kind morph (tool→plan sustained refresh): the tool tap is dropped, the plan choice goes through") func kindMorphSameEpochDropsForeignAffordance() async throws { // Arrange: tool gate epoch 1, then a sustained-pending refresh flips // the kind to plan WITHOUT minting a new epoch (GateTracker semantics). let harness = try Harness() await harness.openAndAdopt() await harness.transport.emit( frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash")) await harness.viewModel.waitUntilProcessed(eventCount: 4) await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan")) await harness.viewModel.waitUntilProcessed(eventCount: 5) #expect(harness.viewModel.planGate?.epoch == 1) // Act: the tap rendered on the old TOOL banner is no longer an offered // affordance — dropped even though the epoch still matches. harness.viewModel.decide(.approve, epoch: 1) #expect(harness.viewModel.droppedStaleDecisionCount == 1) // Act: a choice from the CURRENT plan sheet goes through. harness.viewModel.decide(.approveAuto, epoch: 1) await harness.viewModel.waitUntilForwarded(decisionCount: 1) // Assert #expect(await harness.wireFrames() == [ MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), MessageCodec.encode(.approve(mode: .acceptEdits)), ]) } // MARK: - Haptics (exactly once per gate epoch) @Test("haptic fires exactly once per gate epoch — sustained refreshes and lifts never re-buzz") func hapticFiresExactlyOncePerGateEpoch() async throws { // Arrange let harness = try Harness() await harness.openAndAdopt() #expect(harness.haptics.gateArrivalCount == 0) // Act: rising edge mints epoch 1 → one buzz. await harness.transport.emit( frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash")) await harness.viewModel.waitUntilProcessed(eventCount: 4) #expect(harness.haptics.gateArrivalCount == 1) // Act: sustained-pending refresh (same epoch, new detail) → NO re-buzz. await harness.transport.emit( frame: ServerFrames.status(pending: true, gate: "tool", detail: "Read")) await harness.viewModel.waitUntilProcessed(eventCount: 5) #expect(harness.haptics.gateArrivalCount == 1) // Act: gate lifted → no buzz for a nil gate. await harness.transport.emit(frame: ServerFrames.status(pending: false)) await harness.viewModel.waitUntilProcessed(eventCount: 6) #expect(harness.haptics.gateArrivalCount == 1) // Act: a NEW gate (epoch 2) → second buzz. await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan")) await harness.viewModel.waitUntilProcessed(eventCount: 7) #expect(harness.haptics.gateArrivalCount == 2) } // MARK: - Away digest (render / fade / expand) @Test("non-zero digest renders the summary state; the all-zero digest renders nothing and parks no fade timer") func digestNonZeroRendersAndAllZeroDoesNot() async throws { // Arrange: one away-window event (far-future `at` passes the `since` // filter; the engine stamps the disconnect moment with the real Date). let sessionId = UUID() let awayEvent = TimelineEvent( at: Int.max / 2, class: "tool", toolName: "Bash", label: "ran Bash") let harness = try Harness(eventsSource: { _ in [awayEvent] }) await harness.openAndAdopt(sessionId) // Act await harness.reconnectForDigest(sessionId) // Assert: summary state set, collapsed by default. #expect(harness.viewModel.digest == AwayDigest( toolRuns: 1, waitingCount: 0, sawDone: false, sawStuck: false, recent: [awayEvent])) #expect(!harness.viewModel.isDigestExpanded) // Arrange/Act: an empty away timeline → `.digest(.empty)`. let empty = try Harness(eventsSource: { _ in [] }) await empty.openAndAdopt(sessionId) await empty.reconnectForDigest(sessionId) // Assert: nothing rendered, and no fade timer was ever scheduled. #expect(empty.viewModel.digest == nil) #expect(empty.clock.pendingSleeperCount == 0) } @Test("digest auto-fades after Tunables.digestFadeDelay on the injected clock") func digestAutoFadesAfterDelay() async throws { // Arrange: a visible digest. let sessionId = UUID() let awayEvent = TimelineEvent( at: Int.max / 2, class: "waiting", toolName: nil, label: "requested approval") let harness = try Harness(eventsSource: { _ in [awayEvent] }) await harness.openAndAdopt(sessionId) await harness.reconnectForDigest(sessionId) #expect(harness.viewModel.digest != nil) // Act: the fade timer parks on the fake clock; fire it. await harness.clock.waitForSleepers(count: 1) harness.clock.advance(by: Tunables.digestFadeDelay) await harness.viewModel.waitUntilFadeCompleted(count: 1) // Assert #expect(harness.viewModel.digest == nil) } @Test("manual expand shows recent entries and cancels the fade — an expanded digest never vanishes under the reader") func expandedDigestNeverFadesAndShowsRecent() async throws { // Arrange: a visible digest with its fade timer parked. let sessionId = UUID() let awayEvent = TimelineEvent( at: Int.max / 2, class: "tool", toolName: "Edit", label: "edited 3 files") let harness = try Harness(eventsSource: { _ in [awayEvent] }) await harness.openAndAdopt(sessionId) await harness.reconnectForDigest(sessionId) await harness.clock.waitForSleepers(count: 1) // Act: the user expands the summary row. harness.viewModel.expandDigest() // Assert: expanded, recent entries available, fade timer CANCELLED. #expect(harness.viewModel.isDigestExpanded) #expect(harness.viewModel.digest?.recent == [awayEvent]) #expect(harness.clock.pendingSleeperCount == 0) // Act: even way past the fade delay the digest stays. harness.clock.advance(by: Tunables.digestFadeDelay * 10) #expect(harness.viewModel.digest != nil) // Act: explicit dismiss clears everything. harness.viewModel.dismissDigest() #expect(harness.viewModel.digest == nil) #expect(!harness.viewModel.isDigestExpanded) } // MARK: - Component copy (mirror of public/tabs.ts:326-350) @Test("banner/sheet copy and choice labels mirror the web client exactly") func componentCopyMirrorsWebClient() { // Tool banner: "Claude wants to use ${pendingTool ?? 'a tool'}". let toolGate = GateState(kind: .tool, detail: "Bash", epoch: 1) #expect(GateBanner.message(for: toolGate) == "Claude wants to use Bash") #expect(GateBanner.message(for: GateState(kind: .tool, detail: nil, epoch: 1)) == "Claude wants to use a tool") #expect(GateChoiceSpec.specs(for: toolGate).map(\.label) == ["✓ Approve", "✗ Reject"]) #expect(GateChoiceSpec.specs(for: toolGate).map(\.affordance) == [.approve, .reject]) // Plan sheet: three-way, exact labels and title. let planGate = GateState(kind: .plan, detail: nil, epoch: 2) #expect(GateChoiceSpec.specs(for: planGate).map(\.label) == ["✓ Approve + Auto", "✓ Approve + Review", "✎ Keep Planning"]) #expect(GateChoiceSpec.specs(for: planGate).map(\.affordance) == [.approveAuto, .approveReview, .keepPlanning]) #expect(PlanGateSheet.title == "Claude finished planning — how should it proceed?") } @Test("digest summary composes non-zero parts only; user-only activity falls back to a count") func digestSummaryComposition() { // Arrange / Act / Assert: all four signal parts. let full = AwayDigest( toolRuns: 3, waitingCount: 1, sawDone: true, sawStuck: false, recent: []) #expect(AwayDigestView.summary(for: full) == "离开期间:工具调用 3 次 · 等待审批 1 次 · 已完成") // Stuck-only digest. let stuck = AwayDigest( toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: true, recent: []) #expect(AwayDigestView.summary(for: stuck) == "离开期间:曾卡住") // Non-empty digest whose counted signals are all zero (e.g. only // `user` events) still gets a rendered fallback line. let userOnly = AwayDigest( toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false, recent: [TimelineEvent(at: 1, class: "user", toolName: nil, label: "typed a prompt")]) #expect(AwayDigestView.summary(for: userOnly) == "离开期间:活动 1 条") } }