T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
403 lines
18 KiB
Swift
403 lines
18 KiB
Swift
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 条")
|
|
}
|
|
}
|