App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
636 lines
25 KiB
Swift
636 lines
25 KiB
Swift
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<Void, Never>?
|
||
private var arrival: CheckedContinuation<Void, Never>?
|
||
|
||
/// 被测代码经过闸门;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)))
|
||
}
|
||
}
|