import Foundation import SessionCore import TestSupport import Testing import WireProtocol @testable import WebTerm /// T-iOS-11 · TerminalViewModel (plan §7). All tests run against a REAL /// `SessionEngine` over `TestSupport.FakeTransport` + `FakeClock` (the task's /// documented testability choice): the VM consumes `engine.events` exactly as /// production will, with zero real waits and zero network. /// /// Determinism: the VM's internal `waitUntilProcessed(eventCount:)` / /// `waitUntilForwarded(sendCount:)` barriers and the `onEventApplied` tap are /// the "event reached the VM" signals — never polling, never sleeping. @MainActor @Suite("TerminalViewModel") struct TerminalViewModelTests { // 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 output(_ data: String) -> String { #"{"type":"output","data":"\#(data)"}"# } static func exit(code: Int, reason: String) -> String { #"{"type":"exit","code":\#(code),"reason":"\#(reason)"}"# } } private struct TestStreamError: Error {} // MARK: - Harness /// Engine over fakes + the VM under test, wired exactly like production /// (T-iOS-15 passes `engine.events`; a fan-out branch arrives only when /// GateViewModel shares the stream). @MainActor private final class Harness { let transport = FakeTransport() let clock = FakeClock() let engine: SessionEngine let viewModel: TerminalViewModel init() 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: { _ in [] } ) viewModel = TerminalViewModel(engine: engine, events: engine.events) viewModel.start() } /// Standard preamble: open → .connecting → .connected → server confirms /// the attach. 3 events reach the VM. func openAndAdopt(_ id: UUID) async { await engine.open(sessionId: nil, cwd: nil) await transport.emit(frame: ServerFrames.attached(id)) await viewModel.waitUntilProcessed(eventCount: 3) } } /// Records `(banner, phase)` after every applied event — the deterministic /// way to observe transient states (`.connecting` is often already /// superseded by the time a barrier-based await resumes). @MainActor private final class StateRecorder { private(set) var banners: [TerminalViewModel.ConnectionBanner] = [] func attach(to viewModel: TerminalViewModel) { viewModel.onEventApplied = { [weak self, weak viewModel] _ in guard let self, let viewModel else { return } self.banners = self.banners + [viewModel.banner] } } } // MARK: - Output forwarding (feed order, MainActor by construction) @Test("output forwards to the terminal sink in arrival order; a late sink flushes the buffer first") func outputForwardsInOrderAndLateSinkFlushesBuffer() async throws { // Arrange let harness = try Harness() let sessionId = UUID() await harness.openAndAdopt(sessionId) // Act: two chunks arrive BEFORE any sink exists (screen not built yet). await harness.transport.emit(frame: ServerFrames.output("one")) await harness.transport.emit(frame: ServerFrames.output("two")) await harness.viewModel.waitUntilProcessed(eventCount: 5) // The sink attaches late (SwiftTerm view just got created): buffered // chunks must flush immediately, in order. The sink closure is // @MainActor-typed — feed-on-main is compiler-enforced (Swift 6). let received = Recorder() harness.viewModel.attachTerminalSink { received.append($0) } // Assert: buffered flush preserved order. #expect(received.chunks == ["one", "two"]) // Act: live output after the sink is attached appends behind it. await harness.transport.emit(frame: ServerFrames.output("three")) await harness.viewModel.waitUntilProcessed(eventCount: 6) // Assert #expect(received.chunks == ["one", "two", "three"]) #expect(harness.viewModel.sessionId == sessionId) } @MainActor private final class Recorder { private(set) var chunks: [String] = [] func append(_ chunk: String) { chunks = chunks + [chunk] } } @Test("start() is idempotent — a second start never double-delivers output") func startIsIdempotent() async throws { // Arrange let harness = try Harness() harness.viewModel.start() // second call must be a no-op await harness.openAndAdopt(UUID()) let received = Recorder() harness.viewModel.attachTerminalSink { received.append($0) } // Act await harness.transport.emit(frame: ServerFrames.output("once")) await harness.viewModel.waitUntilProcessed(eventCount: 4) // Assert #expect(received.chunks == ["once"]) } // MARK: - Reconnect banner @Test("banner: connecting → hidden on connect → reconnecting(attempt) on drop → hidden again after recovery") func bannerFollowsConnectionLifecycle() async throws { // Arrange let harness = try Harness() let recorder = StateRecorder() recorder.attach(to: harness.viewModel) await harness.openAndAdopt(UUID()) // Act: the transport drops → engine enters backoff (1s first rung). await harness.transport.emitError(TestStreamError()) await harness.viewModel.waitUntilProcessed(eventCount: 4) // Assert: the reconnect banner is up, carrying attempt + retry delay. #expect(harness.viewModel.banner == .reconnecting(attempt: 1, next: .seconds(1))) #expect(harness.viewModel.bannerModel == .reconnecting(attempt: 1, retryIn: .seconds(1))) // Act: fire the backoff timer — the engine reconnects and re-attaches. await harness.clock.waitForSleepers(count: 1) harness.clock.advance(by: .seconds(1)) await harness.viewModel.waitUntilProcessed(eventCount: 5) // Assert: banner hidden again; full observed banner timeline is exact // (events: connecting, connected, adopted, reconnecting, connected). #expect(harness.viewModel.banner == .none) #expect(harness.viewModel.bannerModel == nil) #expect(recorder.banners == [ .connecting, .none, .none, .reconnecting(attempt: 1, next: .seconds(1)), .none, ]) } // MARK: - Non-retryable failure (replayTooLarge) @Test("replayTooLarge → non-retrying failed state with the actionable SCROLLBACK_BYTES copy; no reconnect attempt") func replayTooLargeBecomesNonRetryingErrorWithActionableCopy() async throws { // Arrange let harness = try Harness() await harness.openAndAdopt(UUID()) // Act: the transport fails the way an oversized single-frame replay // does (EMSGSIZE → typed error, T-iOS-9). await harness.transport.emitError(TermTransportError.replayTooLarge) await harness.viewModel.waitUntilProcessed(eventCount: 4) // Assert: explicit error state with the plan §3.2 actionable copy — // NOT a spinner, NOT a reconnecting banner. #expect(harness.viewModel.phase == .failed(message: TerminalViewModel.replayTooLargeMessage)) #expect(TerminalViewModel.replayTooLargeMessage == "服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限") #expect(harness.viewModel.bannerModel == .failed(message: TerminalViewModel.replayTooLargeMessage)) #expect(harness.viewModel.isReadOnly) // Assert: deterministic failure never enters the backoff loop — the // one original connect stays the only one, and no retry timer sleeps. #expect(await harness.transport.connectAttempts.count == 1) #expect(harness.clock.pendingSleeperCount == 0) } // MARK: - Exit → read-only @Test("exited → read-only terminal + exit banner; input is dropped, never sent") func exitedBecomesReadOnlyAndDropsInput() async throws { // Arrange let harness = try Harness() await harness.openAndAdopt(UUID()) // Act await harness.transport.emit(frame: ServerFrames.exit(code: 0, reason: "bye")) await harness.viewModel.waitUntilProcessed(eventCount: 4) // Assert: read-only exit state, surfaced as the exit banner. #expect(harness.viewModel.phase == .exited(code: 0, reason: "bye")) #expect(harness.viewModel.isReadOnly) #expect(harness.viewModel.bannerModel == .exited(code: 0, reason: "bye")) // Act: typing after exit — both key-bar and delegate paths. harness.viewModel.send(key: .esc) harness.viewModel.sendInput("x") // Assert: dropped at the VM boundary (counted, not forwarded). #expect(harness.viewModel.droppedReadOnlyInputCount == 2) #expect(harness.viewModel.forwardedSendCount == 0) let frames = await harness.transport.sentFramesByConnection[0] #expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))]) } // MARK: - Outbound sends (single ordered pipeline through KeyByteMap) @Test("key + text + resize forward to the engine in submission order; key bytes come from KeyByteMap") func keyAndTextSendsForwardInOrderThroughKeyByteMap() async throws { // Arrange let harness = try Harness() await harness.openAndAdopt(UUID()) // Act: rapid mixed submissions (two taps around typed text + a fit). harness.viewModel.send(key: .esc) harness.viewModel.sendInput("abc") harness.viewModel.send(key: .enter) harness.viewModel.sendResize(cols: 120, rows: 40) await harness.viewModel.waitUntilForwarded(sendCount: 4) // Assert: exact wire frames, in order, bytes resolved via KeyByteMap // (Esc = 0x1B, Enter = \r — never \n). let frames = await harness.transport.sentFramesByConnection[0] #expect(frames == [ MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), MessageCodec.encode(.input(data: KeyByteMap.bytes(for: .esc))), MessageCodec.encode(.input(data: "abc")), MessageCodec.encode(.input(data: KeyByteMap.bytes(for: .enter))), MessageCodec.encode(.resize(cols: 120, rows: 40)), ]) } @Test("resize is NOT gated by read-only (engine owns terminal-state dropping)") func resizeStillForwardsAfterExit() async throws { // Arrange: exited session (read-only for INPUT only). let harness = try Harness() await harness.openAndAdopt(UUID()) await harness.transport.emit(frame: ServerFrames.exit(code: 1, reason: "done")) await harness.viewModel.waitUntilProcessed(eventCount: 4) // Act: a layout pass after exit still reports dims; the VM forwards and // the ENGINE (already terminal) is the layer that drops it. harness.viewModel.sendResize(cols: 80, rows: 24) await harness.viewModel.waitUntilForwarded(sendCount: 1) // Assert: VM forwarded (no read-only drop); no wire frame appeared // because the engine discarded it post-exit. #expect(harness.viewModel.droppedReadOnlyInputCount == 0) let frames = await harness.transport.sentFramesByConnection[0] #expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))]) } }