Files
web-terminal/ios/App/WebTermTests/VoicePTTTests.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
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.
2026-07-30 15:58:01 +02:00

636 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import SessionCore
import TestSupport
import Testing
import UIKit
@testable import WebTerm
/// T-iOS-31 · PTT + ios-completion §4 RED 1956
///
/// 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. 1924
@Test("19 · 普通文本 → 首尾 trim 后原样")
func sanitizeKeepsPlainText() {
#expect(VoiceTranscript.sanitize(" git status ") == "git status")
}
@Test("20 · \\r0x0D被剥除 —— 口述绝不自己回车执行命令")
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. 2533
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 → .textv1 只作用 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「不清楚」→ textnow 不被 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 降级 textreject 不受影响")
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 3443
@Test("34 · pressDown → .listeningpartial 回调更新 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 4448
@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. 4952
@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 5356
@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)))
}
}