feat(ios): W3 UI layer + integration CI + ntfy docs
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
This commit is contained in:
402
ios/App/WebTermTests/GateViewModelTests.swift
Normal file
402
ios/App/WebTermTests/GateViewModelTests.swift
Normal file
@@ -0,0 +1,402 @@
|
||||
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 条")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user