import Foundation import SessionCore import TestSupport import Testing import UIKit @testable import WebTerm /// T-iOS-31 · 语音 PTT + 确认(ios-completion §4 RED 清单 19–56)。 /// /// 三件套逐条移植 web:`public/voice.ts`(PTT 生命周期、`autoSend:false` ⇒ 绝不 /// 自动回车)、`public/voice-commands.ts`(整句精确匹配器 + 否定守卫 + 0.6 置信度 /// 门)、`public/voice-confirm.ts`(1500ms 可撤销窗口)。epoch 防误发沿用 /// `SessionCore/GateState.canDecide(epoch:)` 的既有先例。 /// /// 识别器经协议注入 —— 真机口述属 DEFERRED(需硬件麦克风),本套用替身把 /// **匹配器 / 撤销窗口 / epoch 闸门**全部钉死,零真睡(FakeClock)。 @MainActor @Suite("VoicePTT (T-iOS-31)") struct VoicePTTTests { // MARK: - Fakes /// 一次性闸门:让被测代码停在某个 `await` 上,测试侧确定性放行(零轮询、 /// 零真睡 —— 全员在 MainActor 上,注册先于挂起,故顺序是确定的)。 @MainActor private final class Gate { var isArmed = false private var didReach = false private var blocked: CheckedContinuation? private var arrival: CheckedContinuation? /// 被测代码经过闸门;armed 时在此挂起。 func passThrough() async { guard isArmed else { return } didReach = true arrival?.resume() arrival = nil await withCheckedContinuation { blocked = $0 } } /// 等到被测代码真的到达闸门。 func waitUntilReached() async { guard !didReach else { return } await withCheckedContinuation { arrival = $0 } } func open() { isArmed = false let blocked = self.blocked self.blocked = nil blocked?.resume() } } /// 识别器替身:授权结果、抛错、最终转写全部由测试注入;两个闸门可确定性 /// 复现「授权期间松手」与「录音启动期间松手」两种 PTT 竞态。 @MainActor private final class FakeDictation: VoiceDictating { var authorization: VoiceAuthorization = .granted var startError: (any Error)? var result = VoiceDictationResult(transcript: "", confidence: nil) private(set) var startCount = 0 private(set) var stopCount = 0 private(set) var partialSink: (@MainActor (String) -> Void)? let authorizationGate = Gate() let startGate = Gate() func requestAuthorization() async -> VoiceAuthorization { await authorizationGate.passThrough() return authorization } func start(onPartial: @escaping @MainActor (String) -> Void) async throws { startCount += 1 if let startError { throw startError } partialSink = onPartial await startGate.passThrough() } func stop() async -> VoiceDictationResult { stopCount += 1 partialSink = nil return result } } /// 可变 epoch 源(测试侧模拟会话切换/重连)。 @MainActor private final class EpochBox { var epoch: VoiceEpoch init(_ epoch: VoiceEpoch = VoiceEpoch(sessionId: nil, generation: 0)) { self.epoch = epoch } } /// 注入出口记录仪:断言「零注入」/「恰一次注入」的唯一判据。 @MainActor private final class InjectRecorder { private(set) var texts: [String] = [] private(set) var decisions: [VoiceDecision] = [] func inject(_ text: String) { texts = texts + [text] } func decide(_ decision: VoiceDecision) { decisions = decisions + [decision] } } @MainActor private struct Harness { let dictation = FakeDictation() let clock = FakeClock() let epochBox = EpochBox() let recorder = InjectRecorder() let viewModel: VoicePTTViewModel init(isReadOnly: Bool = false, gate: VoiceGateSnapshot? = nil) { let dictation = self.dictation let clock = self.clock let epochBox = self.epochBox let recorder = self.recorder let bridge: VoiceGateBridge? = gate.map { snapshot in VoiceGateBridge( snapshot: { snapshot }, decide: { decision in recorder.decide(decision) } ) } viewModel = VoicePTTViewModel( dictation: dictation, clock: clock, epoch: { epochBox.epoch }, isReadOnly: { isReadOnly }, inject: { text in recorder.inject(text) }, gate: bridge ) } /// 完整一次口述:按下 → 松手,转写由 `dictation.result` 提供。 func dictate(_ transcript: String, confidence: Double? = nil) async { dictation.result = VoiceDictationResult( transcript: transcript, confidence: confidence ) await viewModel.pressDown() await viewModel.pressUp() } /// 推进到撤销窗口到期并等注入落地(零真睡)。 func elapseUndoWindow() async { await clock.waitForSleepers(count: 1) clock.advance(by: VoicePTTViewModel.undoWindow) await viewModel.waitUntilWindowSettled() } } private static let sessionA = UUID() private static let sessionB = UUID() // MARK: - A. 转写清洗(19–24) @Test("19 · 普通文本 → 首尾 trim 后原样") func sanitizeKeepsPlainText() { #expect(VoiceTranscript.sanitize(" git status ") == "git status") } @Test("20 · \\r(0x0D)被剥除 —— 口述绝不自己回车执行命令") func sanitizeStripsCarriageReturn() { let sanitized = VoiceTranscript.sanitize("rm -rf /\r") #expect(!sanitized.contains("\r")) #expect(sanitized == "rm -rf /") } @Test("21 · C0/C1 控制字符(\\n \\t ESC BEL DEL)全部剥除") func sanitizeStripsControlCharacters() { let raw = "ec\u{1b}ho\u{7}\t hi\u{7f}\u{0}\u{9b}" let sanitized = VoiceTranscript.sanitize(raw) #expect(sanitized == "echo hi") } @Test("22 · 多行折成单行(换行→空格 + 合并连续空白)") func sanitizeCollapsesToOneLine() { #expect(VoiceTranscript.sanitize("git\ncommit -m\n\n test") == "git commit -m test") } @Test("23 · 超长转写截断到上限") func sanitizeTruncatesOverlongTranscript() { let raw = String(repeating: "a", count: VoiceTranscript.maxLength + 500) #expect(VoiceTranscript.sanitize(raw).count == VoiceTranscript.maxLength) } @Test("24 · 清洗后为空 → 不可注入") func sanitizeEmptyIsNotInjectable() { #expect(!VoiceTranscript.isInjectable(VoiceTranscript.sanitize("\u{1b}\r\n \t"))) #expect(VoiceTranscript.isInjectable("ok")) } // MARK: - B. 匹配器移植(25–33) private func context( pendingApproval: Bool, gate: VoiceGateKind?, confidence: Double? = nil ) -> VoiceMatchContext { VoiceMatchContext(pendingApproval: pendingApproval, gate: gate, confidence: confidence) } @Test("25 · 无 held gate → 任何话语都是 .text(含「确认」)") func matcherFallsThroughWithoutHeldGate() { let ctx = context(pendingApproval: false, gate: .tool) #expect(VoiceCommandMatcher.match(transcript: "确认", context: ctx) == .text) #expect(VoiceCommandMatcher.match(transcript: "approve", context: ctx) == .text) } @Test("26 · gate 是 .plan → .text(v1 只作用 tool gate)") func matcherIgnoresPlanGate() { let ctx = context(pendingApproval: true, gate: .plan) #expect(VoiceCommandMatcher.match(transcript: "确认", context: ctx) == .text) } @Test("27 · tool gate + 肯定短语 → .approve") func matcherApprovesAffirmativePhrases() { let ctx = context(pendingApproval: true, gate: .tool) for phrase in ["确认", "批准", "同意", "好的", "ok", "go ahead", "proceed"] { #expect(VoiceCommandMatcher.match(transcript: phrase, context: ctx) == .approve, "\(phrase) 应判 approve") } } @Test("28 · 归一化:大小写 + 去标点 + 合并空白(don't → dont)") func matcherNormalizesTranscript() { let ctx = context(pendingApproval: true, gate: .tool) #expect(VoiceCommandMatcher.match(transcript: "OK.", context: ctx) == .approve) #expect(VoiceCommandMatcher.match(transcript: "好的!", context: ctx) == .approve) #expect(VoiceCommandMatcher.normalize("Don't DO it") == "dont do it") } @Test("29 · 整句精确匹配 —— 「确认一下这个」落 .text(绝不子串匹配)") func matcherIsWholeUtteranceExact() { let ctx = context(pendingApproval: true, gate: .tool) #expect(VoiceCommandMatcher.match(transcript: "确认一下这个", context: ctx) == .text) #expect(VoiceCommandMatcher.match(transcript: "ok let's ship it", context: ctx) == .text) } @Test("30 · tool gate + 否定短语 → .reject") func matcherRejectsNegativePhrases() { let ctx = context(pendingApproval: true, gate: .tool) for phrase in ["拒绝", "取消", "stop", "abort", "no"] { #expect(VoiceCommandMatcher.match(transcript: phrase, context: ctx) == .reject, "\(phrase) 应判 reject") } } @Test("31 · 否定守卫:「不批准」→ reject;「不清楚」→ text;now 不被 no 否定") func matcherNegationGuard() { let ctx = context(pendingApproval: true, gate: .tool) #expect(VoiceCommandMatcher.match(transcript: "不批准", context: ctx) == .reject) #expect(VoiceCommandMatcher.match(transcript: "不清楚", context: ctx) == .text) #expect(VoiceCommandMatcher.match(transcript: "now", context: ctx) == .text) #expect(VoiceCommandMatcher.startsWithNegation("不批准")) #expect(!VoiceCommandMatcher.startsWithNegation("now")) } @Test("32 · 置信度门:approve 低于 0.6 降级 text;reject 不受影响") func matcherConfidenceGateAppliesToApproveOnly() { let low = context(pendingApproval: true, gate: .tool, confidence: 0.4) #expect(VoiceCommandMatcher.match(transcript: "确认", context: low) == .text) #expect(VoiceCommandMatcher.match(transcript: "取消", context: low) == .reject) let atThreshold = context( pendingApproval: true, gate: .tool, confidence: VoiceCommandMatcher.minApproveConfidence ) #expect(VoiceCommandMatcher.match(transcript: "确认", context: atThreshold) == .approve) } @Test("33 · 空/纯标点话语 → .text") func matcherEmptyUtteranceIsText() { let ctx = context(pendingApproval: true, gate: .tool) #expect(VoiceCommandMatcher.match(transcript: "", context: ctx) == .text) #expect(VoiceCommandMatcher.match(transcript: "……", context: ctx) == .text) } // MARK: - C. 确认 + 1.5s 撤销窗口(34–43) @Test("34 · pressDown → .listening;partial 回调更新 partial 文本") func pressDownStartsListeningAndSurfacesPartials() async { let harness = Harness() await harness.viewModel.pressDown() #expect(harness.viewModel.phase == .listening(partial: "")) #expect(harness.dictation.startCount == 1) harness.dictation.partialSink?("git st") #expect(harness.viewModel.phase == .listening(partial: "git st")) } @Test("35 · 空转写 → .idle + .empty + 零注入") func emptyTranscriptResolvesEmpty() async { let harness = Harness() await harness.dictate(" \r\n ") #expect(harness.viewModel.phase == .idle) #expect(harness.viewModel.lastResolution == .empty) #expect(harness.recorder.texts.isEmpty) } @Test("36 · 有效转写 → .confirming,且此刻零注入(确认前绝不注入)") func validTranscriptWaitsForExplicitConfirm() async { let harness = Harness() await harness.dictate("git status") #expect(harness.viewModel.phase == .confirming(VoicePendingAction( intent: .text("git status"), epoch: VoiceEpoch(sessionId: nil, generation: 0), preview: "git status" ))) #expect(harness.recorder.texts.isEmpty) } @Test("37 · .confirming 下 cancel() → .idle + .discarded + 零注入") func cancelDiscardsBeforeInjection() async { let harness = Harness() await harness.dictate("git status") harness.viewModel.cancel() #expect(harness.viewModel.phase == .idle) #expect(harness.viewModel.lastResolution == .discarded) #expect(harness.recorder.texts.isEmpty) } @Test("38 · confirm() → .undoWindow,此刻仍零注入") func confirmArmsUndoWindowWithoutInjecting() async { let harness = Harness() await harness.dictate("git status") harness.viewModel.confirm() #expect(harness.viewModel.isUndoWindowOpen) #expect(harness.recorder.texts.isEmpty) } @Test("39 · +1.4s 仍零注入;到 1.5s → 恰一次注入 + .committed") func injectionLandsOnlyAfterTheFullUndoWindow() async { let harness = Harness() await harness.dictate("git status") harness.viewModel.confirm() await harness.clock.waitForSleepers(count: 1) harness.clock.advance(by: .milliseconds(1400)) #expect(harness.recorder.texts.isEmpty) harness.clock.advance(by: .milliseconds(100)) await harness.viewModel.waitUntilWindowSettled() #expect(harness.recorder.texts == ["git status"]) #expect(harness.viewModel.lastResolution == .committed(.text("git status"))) #expect(harness.viewModel.phase == .idle) } @Test("40 · 撤销窗口内 undo() → 零注入 + .undone;再推进 10s 也不注入") func undoCancelsTheInjectionForGood() async { let harness = Harness() await harness.dictate("git status") harness.viewModel.confirm() await harness.clock.waitForSleepers(count: 1) harness.viewModel.undo() await harness.viewModel.waitUntilWindowSettled() harness.clock.advance(by: .seconds(10)) await harness.viewModel.waitUntilWindowSettled() #expect(harness.recorder.texts.isEmpty) #expect(harness.viewModel.lastResolution == .undone) #expect(harness.viewModel.phase == .idle) } @Test("41 · 注入内容不以 \\r 结尾(用户自己按 ⏎)") func injectedTextNeverEndsWithCarriageReturn() async { let harness = Harness() await harness.dictate("npm test") harness.viewModel.confirm() await harness.elapseUndoWindow() #expect(harness.recorder.texts == ["npm test"]) #expect(!(harness.recorder.texts.first ?? "\r").hasSuffix("\r")) } @Test("42 · 非 .confirming 态调 confirm() → 无副作用") func confirmOutsideConfirmingIsNoop() async { let harness = Harness() harness.viewModel.confirm() #expect(harness.viewModel.phase == .idle) #expect(harness.viewModel.lastResolution == nil) #expect(harness.recorder.texts.isEmpty) } @Test("43 · 重复 confirm() 只 arm 一次、只注入一次") func repeatedConfirmArmsOnce() async { let harness = Harness() await harness.dictate("git status") harness.viewModel.confirm() harness.viewModel.confirm() harness.viewModel.confirm() await harness.elapseUndoWindow() #expect(harness.recorder.texts == ["git status"]) } // MARK: - D. epoch 防误发(44–48) @Test("44 · 口述→确认之间 epoch 变了 → 零注入 + .staleEpoch") func epochChangeBeforeConfirmDiscards() async { let harness = Harness() harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0) await harness.dictate("git status") harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionB, generation: 0) harness.viewModel.confirm() #expect(harness.recorder.texts.isEmpty) #expect(harness.viewModel.lastResolution == .staleEpoch) #expect(harness.viewModel.phase == .idle) } @Test("45 · epoch 在撤销窗口内变 → 到期仍零注入 + .staleEpoch(第二道闸)") func epochChangeInsideUndoWindowDiscards() async { let harness = Harness() harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0) await harness.dictate("git status") harness.viewModel.confirm() await harness.clock.waitForSleepers(count: 1) harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 1) harness.clock.advance(by: VoicePTTViewModel.undoWindow) await harness.viewModel.waitUntilWindowSettled() #expect(harness.recorder.texts.isEmpty) #expect(harness.viewModel.lastResolution == .staleEpoch) } @Test("46 · epoch 不变 → 正常注入(防闸门过严的回归保护)") func stableEpochStillInjects() async { let harness = Harness() harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 3) await harness.dictate("ls -la") harness.viewModel.confirm() await harness.elapseUndoWindow() #expect(harness.recorder.texts == ["ls -la"]) } @Test("47 · 口述时 sessionId 未 adopt、确认时已 adopt → 视为变化 → 零注入") func dictatingBeforeAdoptionDiscardsAfterAdoption() async { let harness = Harness() await harness.dictate("git status") harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0) harness.viewModel.confirm() #expect(harness.recorder.texts.isEmpty) #expect(harness.viewModel.lastResolution == .staleEpoch) } @Test("48 · VoiceEpochPolicy:.reconnecting 作废 epoch;.connecting/.none 不作废") func epochPolicyInvalidatesOnReconnect() { #expect(VoiceEpochPolicy.invalidates( .reconnecting(attempt: 1, next: .seconds(1)) )) #expect(!VoiceEpochPolicy.invalidates(.connecting)) #expect(!VoiceEpochPolicy.invalidates(.none)) let source = VoiceEpochSource() #expect(source.epoch == VoiceEpoch(sessionId: nil, generation: 0)) source.noteBanner(.reconnecting(attempt: 1, next: .seconds(1))) source.noteBanner(.connecting) source.noteSession(Self.sessionA) #expect(source.epoch == VoiceEpoch(sessionId: Self.sessionA, generation: 1)) } // MARK: - E. 权限与失败路径(49–52) @Test("49 · 麦克风授权被拒 → .denied(.microphone),零录音零注入") func microphoneDenialBlocksDictation() async { let harness = Harness() harness.dictation.authorization = .deniedMicrophone await harness.viewModel.pressDown() #expect(harness.viewModel.phase == .denied(.microphone)) #expect(harness.dictation.startCount == 0) #expect(harness.recorder.texts.isEmpty) #expect(VoiceDenialReason.microphone.message.contains("麦克风")) } @Test("50 · 语音识别授权被拒 → .denied(.speech),文案指向语音识别开关") func speechDenialBlocksDictation() async { let harness = Harness() harness.dictation.authorization = .deniedSpeech await harness.viewModel.pressDown() #expect(harness.viewModel.phase == .denied(.speech)) #expect(harness.dictation.startCount == 0) #expect(VoiceDenialReason.speech.message.contains("语音识别")) } @Test("51 · 识别器抛错 → .failed(message:),零注入,可再按重试") func recognizerFailureIsRecoverable() async { struct Boom: Error {} let harness = Harness() harness.dictation.startError = Boom() await harness.viewModel.pressDown() guard case .failed(let message) = harness.viewModel.phase else { Issue.record("期望 .failed,实际 \(harness.viewModel.phase)") return } #expect(!message.isEmpty) #expect(harness.recorder.texts.isEmpty) harness.dictation.startError = nil await harness.viewModel.pressDown() #expect(harness.viewModel.phase == .listening(partial: "")) } @Test("52 · 终端只读 → pressDown 拒绝,不启动录音") func readOnlyTerminalRefusesDictation() async { let harness = Harness(isReadOnly: true) await harness.viewModel.pressDown() #expect(harness.viewModel.phase == .idle) #expect(harness.viewModel.lastResolution == .readOnly) #expect(harness.dictation.startCount == 0) } @Test("PTT 竞态 A:录音启动期间松手 → 启动完立刻收尾(麦克风绝不卡在开着)") func releaseDuringStartStillFinishes() async { let harness = Harness() harness.dictation.startGate.isArmed = true harness.dictation.result = VoiceDictationResult( transcript: "git status", confidence: nil ) let down = Task { await harness.viewModel.pressDown() } await harness.dictation.startGate.waitUntilReached() let up = Task { await harness.viewModel.pressUp() } await up.value harness.dictation.startGate.open() await down.value #expect(harness.dictation.stopCount == 1) #expect(harness.viewModel.phase == .confirming(VoicePendingAction( intent: .text("git status"), epoch: VoiceEpoch(sessionId: nil, generation: 0), preview: "git status" ))) } @Test("PTT 竞态 B:授权期间就松手 → 麦克风一次都不开(零 start)") func releaseDuringAuthorizationNeverOpensTheMic() async { let harness = Harness() harness.dictation.authorizationGate.isArmed = true let down = Task { await harness.viewModel.pressDown() } await harness.dictation.authorizationGate.waitUntilReached() let up = Task { await harness.viewModel.pressUp() } await up.value harness.dictation.authorizationGate.open() await down.value #expect(harness.dictation.startCount == 0) #expect(harness.dictation.stopCount == 0) #expect(harness.viewModel.phase == .idle) } // MARK: - F. 键栏集成 + gate 桥(53–56) @Test("53 · 不提供语音闭包 → 无麦克风键,按钮数不变(零回归)") func keyBarWithoutVoiceHandlersIsUnchanged() { let bar = KeyBarView() #expect(bar.voiceButton == nil) #expect(bar.keyButtons.count == KeyBarLayout.buttons.count) } @Test("54 · 提供语音闭包 → 末尾多一个 🎤 键,前 17 键顺序/标签完全不变") func keyBarAppendsMicWithoutDisturbingExistingKeys() { let bar = KeyBarView(voice: KeyBarVoiceHandlers(onDown: {}, onUp: {})) #expect(bar.keyButtons.count == KeyBarLayout.buttons.count) #expect(bar.keyButtons.map(\.accessibilityLabel) == KeyBarLayout.buttons.map(\.title)) let mic = bar.voiceButton #expect(mic != nil) #expect(mic?.accessibilityLabel == KeyBarLayout.voiceTitle) } @Test("55 · 麦克风键 touchDown/touchUpInside 各触发一次,绝不经 onKey") func micButtonRoutesPressAndReleaseOnly() throws { var down = 0 var up = 0 var keys: [KeyByteMap.Key] = [] let bar = KeyBarView(voice: KeyBarVoiceHandlers( onDown: { down += 1 }, onUp: { up += 1 } )) bar.onKey = { key in keys = keys + [key] } let mic = try #require(bar.voiceButton) mic.sendActions(for: .touchDown) #expect((down, up) == (1, 0)) mic.sendActions(for: .touchUpInside) #expect((down, up) == (1, 1)) #expect(keys.isEmpty) } @Test("56 · 匹配到 approve → 同一 1.5s 窗口,到期调 decide(.approve) 而不注入文本") func approveIntentGoesThroughTheSameWindow() async { let harness = Harness(gate: VoiceGateSnapshot(pendingApproval: true, gate: .tool)) await harness.dictate("确认", confidence: 0.9) #expect(harness.viewModel.phase == .confirming(VoicePendingAction( intent: .decision(.approve), epoch: VoiceEpoch(sessionId: nil, generation: 0), preview: "确认" ))) harness.viewModel.confirm() await harness.elapseUndoWindow() #expect(harness.recorder.decisions == [.approve]) #expect(harness.recorder.texts.isEmpty) #expect(harness.viewModel.lastResolution == .committed(.decision(.approve))) } }