feat(keyboard): harden clipboard command and add What's New sheet
Stabilize clipboard long-press prepare/resume across paste alerts and cold start, add an in-app release-notes sheet with remote bilingual HTML, localize typing input settings, and bump build to 55.
This commit is contained in:
@@ -30,29 +30,22 @@ final class ClipboardCommandPromptComposerTests: XCTestCase {
|
||||
XCTAssertFalse(system.contains("不是向你提出的问题或命令"))
|
||||
}
|
||||
|
||||
func testEligibilityTrackerKeepsSameChangeCountWindow() {
|
||||
let first = ClipboardCommandEligibilityTracker.refresh(
|
||||
changeCount: 3,
|
||||
rawText: "周末有空一起吃个饭吗?我想聊下项目进度。",
|
||||
previous: nil,
|
||||
now: 1_000
|
||||
func testFailureLocalizationKeysAreStable() {
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandFailure.pasteDenied.localizationKey,
|
||||
"keyboard.clipboard.reject.pasteDenied"
|
||||
)
|
||||
XCTAssertNotNil(first)
|
||||
|
||||
let same = ClipboardCommandEligibilityTracker.refresh(
|
||||
changeCount: 3,
|
||||
rawText: "周末有空一起吃个饭吗?我想聊下项目进度。",
|
||||
previous: first,
|
||||
now: 1_010
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandFailure.material(.tooShort).localizationKey,
|
||||
"keyboard.clipboard.reject.tooShort"
|
||||
)
|
||||
XCTAssertEqual(same?.startedAt, first?.startedAt)
|
||||
|
||||
let expired = ClipboardCommandEligibilityTracker.refresh(
|
||||
changeCount: 3,
|
||||
rawText: "周末有空一起吃个饭吗?我想聊下项目进度。",
|
||||
previous: first,
|
||||
now: 1_040
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandFailure.secureField.localizationKey,
|
||||
"keyboard.clipboard.reject.secureField"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandFailure.prepareFailed.localizationKey,
|
||||
"keyboard.clipboard.reject.prepareFailed"
|
||||
)
|
||||
XCTAssertNil(expired)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// ClipboardCommandResumeTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class ClipboardCommandResumeTests: XCTestCase {
|
||||
private var defaults: UserDefaults!
|
||||
private var suiteName: String!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "ClipboardCommandResumeTests.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
if let suiteName {
|
||||
defaults?.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
defaults = nil
|
||||
suiteName = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testMarkPreferVoiceSurvivesUntilCleared() {
|
||||
XCTAssertFalse(ClipboardCommandResume.shouldPreferVoice(defaults: defaults))
|
||||
ClipboardCommandResume.markPreferVoice(defaults: defaults)
|
||||
XCTAssertTrue(ClipboardCommandResume.shouldPreferVoice(defaults: defaults))
|
||||
ClipboardCommandResume.clear(defaults: defaults)
|
||||
XCTAssertFalse(ClipboardCommandResume.shouldPreferVoice(defaults: defaults))
|
||||
}
|
||||
|
||||
func testStoreSnapshotRoundTrip() {
|
||||
ClipboardCommandResume.storeSnapshot("周末有空一起吃饭吗?", defaults: defaults)
|
||||
XCTAssertTrue(ClipboardCommandResume.shouldPreferVoice(defaults: defaults))
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandResume.pendingSnapshot(defaults: defaults),
|
||||
"周末有空一起吃饭吗?"
|
||||
)
|
||||
}
|
||||
|
||||
/// Simulates extension jetsam: writer process flushes, reader process is new.
|
||||
func testStickySurvivesNewUserDefaultsInstanceAfterSynchronize() {
|
||||
let text = "是AI语音输入法,更是好输入法。It is an AI voice input method."
|
||||
ClipboardCommandResume.markPreferVoice(defaults: defaults)
|
||||
ClipboardCommandResume.storeSnapshot(text, defaults: defaults)
|
||||
|
||||
// New UserDefaults handle on the same suite ≈ new extension process.
|
||||
let reopened = UserDefaults(suiteName: suiteName!)
|
||||
XCTAssertNotNil(reopened)
|
||||
guard let reopened else { return }
|
||||
|
||||
XCTAssertTrue(
|
||||
ClipboardCommandResume.shouldPreferVoice(defaults: reopened),
|
||||
"Recreated process must still prefer voice after paste-alert jetsam"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandResume.pendingSnapshot(defaults: reopened),
|
||||
text
|
||||
)
|
||||
|
||||
// And the open-surface policy must then force voice over default typing.
|
||||
XCTAssertEqual(
|
||||
KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: ClipboardCommandResume.shouldPreferVoice(defaults: reopened),
|
||||
preferred: .typing
|
||||
),
|
||||
.voice
|
||||
)
|
||||
}
|
||||
|
||||
func testOpenSurfacePolicyForcesVoiceWhenStickyEvenIfDefaultTyping() {
|
||||
let resolved = KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: true,
|
||||
preferred: .typing
|
||||
)
|
||||
XCTAssertEqual(resolved, .voice)
|
||||
}
|
||||
|
||||
func testOpenSurfacePolicyHonorsDefaultTypingWhenNoSticky() {
|
||||
let resolved = KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: false,
|
||||
preferred: .typing
|
||||
)
|
||||
XCTAssertEqual(resolved, .typing)
|
||||
}
|
||||
|
||||
func testOpenSurfacePolicyClipboardActiveBeatsTypingDefault() {
|
||||
let resolved = KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: true,
|
||||
stickyPreferVoice: false,
|
||||
preferred: .typing
|
||||
)
|
||||
XCTAssertEqual(resolved, .voice)
|
||||
}
|
||||
|
||||
func testPasteAlertReopenDecisionMatrix() {
|
||||
// Exact bug from device: default typing + sticky after Allow Paste.
|
||||
XCTAssertEqual(
|
||||
KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: true,
|
||||
preferred: .typing
|
||||
),
|
||||
.voice,
|
||||
"Allow Paste reopen must not land on default Chinese typing grid"
|
||||
)
|
||||
|
||||
// Recording lock also forces voice.
|
||||
XCTAssertEqual(
|
||||
KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: true,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: false,
|
||||
preferred: .typing
|
||||
),
|
||||
.voice
|
||||
)
|
||||
}
|
||||
|
||||
func testMarkStartIssuedBlocksDuplicateRoundClaim() {
|
||||
let id = UUID()
|
||||
XCTAssertFalse(ClipboardCommandResume.hasStartIssued(defaults: defaults))
|
||||
ClipboardCommandResume.markStartIssued(id, defaults: defaults)
|
||||
XCTAssertTrue(ClipboardCommandResume.hasStartIssued(defaults: defaults))
|
||||
XCTAssertEqual(ClipboardCommandResume.startIssuedUtteranceId(defaults: defaults), id)
|
||||
XCTAssertTrue(ClipboardCommandResume.shouldPreferVoice(defaults: defaults))
|
||||
|
||||
let reopened = UserDefaults(suiteName: suiteName!)
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandResume.startIssuedUtteranceId(defaults: reopened),
|
||||
id,
|
||||
"Recreated extension must see the already-issued start and not send another"
|
||||
)
|
||||
|
||||
ClipboardCommandResume.clear(defaults: defaults)
|
||||
XCTAssertFalse(ClipboardCommandResume.hasStartIssued(defaults: defaults))
|
||||
XCTAssertNil(ClipboardCommandResume.startIssuedUtteranceId(defaults: reopened))
|
||||
}
|
||||
|
||||
func testClaimThenClearSurvivesTwentyAlternatingRounds() {
|
||||
for round in 1...20 {
|
||||
let id = UUID()
|
||||
ClipboardCommandResume.storeSnapshot("材料-\(round)", defaults: defaults)
|
||||
ClipboardCommandResume.markStartIssued(id, defaults: defaults)
|
||||
|
||||
let reader = UserDefaults(suiteName: suiteName!)
|
||||
XCTAssertEqual(
|
||||
ClipboardCommandResume.startIssuedUtteranceId(defaults: reader),
|
||||
id,
|
||||
"round \(round) claim must survive new defaults handle"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: ClipboardCommandResume.shouldPreferVoice(defaults: reader),
|
||||
preferred: .typing
|
||||
),
|
||||
.voice,
|
||||
"round \(round) must stay on voice after paste-alert recreate"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: ClipboardCommandResume.hasStartIssued(defaults: reader),
|
||||
phase: .idle
|
||||
),
|
||||
.awaitExistingStart,
|
||||
"round \(round) must not pressBegan again"
|
||||
)
|
||||
|
||||
ClipboardCommandResume.clear(defaults: defaults)
|
||||
XCTAssertFalse(
|
||||
ClipboardCommandResume.hasStartIssued(defaults: reader),
|
||||
"round \(round) clear must drop claim for next independent long-press"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testPreparingTimeoutConstantIsPositive() {
|
||||
XCTAssertGreaterThan(ClipboardCommandResume.preparingTimeout, 1)
|
||||
XCTAssertLessThanOrEqual(ClipboardCommandResume.preparingTimeout, 15)
|
||||
}
|
||||
|
||||
func testColdStartStickyRestoreIsPreferVoiceOnlyWithoutClaim() {
|
||||
ClipboardCommandResume.storeSnapshot("冻结材料", defaults: defaults)
|
||||
XCTAssertFalse(ClipboardCommandResume.hasStartIssued(defaults: defaults))
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: false,
|
||||
phase: .idle
|
||||
),
|
||||
.preferVoiceOnly
|
||||
)
|
||||
XCTAssertEqual(
|
||||
KeyboardOpenSurfacePolicy.resolve(
|
||||
locksTypingSurface: false,
|
||||
clipboardCommandActive: false,
|
||||
stickyPreferVoice: ClipboardCommandResume.shouldPreferVoice(defaults: defaults),
|
||||
preferred: .typing
|
||||
),
|
||||
.voice,
|
||||
"Default typing keyboard must still open voice after cold-start sticky"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,6 @@ final class ClipboardMaterialFilterTests: XCTestCase {
|
||||
XCTAssertEqual(ClipboardMaterialFilter.maxSnapshotLength, 3_000)
|
||||
XCTAssertEqual(ClipboardMaterialFilter.longPressDuration, 0.45, accuracy: 0.001)
|
||||
XCTAssertEqual(ClipboardMaterialFilter.minimumRecordingAfterHostConfirm, 0.70, accuracy: 0.001)
|
||||
XCTAssertEqual(ClipboardMaterialFilter.eligibilityDuration, 30, accuracy: 0.001)
|
||||
XCTAssertEqual(ClipboardMaterialFilter.sessionDuration, 30, accuracy: 0.001)
|
||||
XCTAssertEqual(ClipboardMaterialFilter.failureHintDuration, 2.5, accuracy: 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
// ClipboardPreparingPolicyTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Decision-matrix coverage for clipboard prepare stuck / double-start bugs.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class ClipboardPreparingPolicyTests: XCTestCase {
|
||||
|
||||
// MARK: - Restore (paste-alert recreate)
|
||||
|
||||
func testRestoreAwaitExistingWhenStartAlreadyIssued() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: true,
|
||||
phase: .idle
|
||||
),
|
||||
.awaitExistingStart
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: true,
|
||||
phase: .error
|
||||
),
|
||||
.awaitExistingStart
|
||||
)
|
||||
}
|
||||
|
||||
func testRestorePreferVoiceOnlyWhenNoClaim() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: false,
|
||||
phase: .idle
|
||||
),
|
||||
.preferVoiceOnly,
|
||||
"Cold-start return must not auto-start recording"
|
||||
)
|
||||
}
|
||||
|
||||
func testRestoreRefreshOnlyWhenAlreadyLive() {
|
||||
for phase: ClipboardPreparingPhase in [
|
||||
.requestingPermissions, .recording, .processing
|
||||
] {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: true,
|
||||
phase: phase
|
||||
),
|
||||
.refreshOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact device bug: Allow Paste recreates keyboard while claim exists.
|
||||
func testPasteAlertReopenMustNotPreferVoiceOnlyWhenClaimed() {
|
||||
let action = ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: true,
|
||||
phase: .idle
|
||||
)
|
||||
XCTAssertNotEqual(action, .preferVoiceOnly)
|
||||
XCTAssertEqual(action, .awaitExistingStart)
|
||||
}
|
||||
|
||||
func testHostGateNeverAutoRecordsAfterWarmup() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.hostGateAction(micPressAction: .startRecording),
|
||||
.startRecordingNow
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.hostGateAction(micPressAction: .openHostColdStart),
|
||||
.openHostColdStart
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.hostGateAction(
|
||||
micPressAction: .waitForHostReady(recordWhenReady: true)
|
||||
),
|
||||
.waitForHost,
|
||||
"Clipboard must ignore recordWhenReady and require a second long-press"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.hostGateAction(micPressAction: .ignore),
|
||||
.ignore
|
||||
)
|
||||
}
|
||||
|
||||
func testMicChromeGreyWhilePreparingBlueOnlyAfterConfirm() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.micChrome(
|
||||
isClipboardUtterance: true,
|
||||
phase: .requestingPermissions,
|
||||
awaitingHostConfirm: true
|
||||
),
|
||||
.preparingDisabled
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.micChrome(
|
||||
isClipboardUtterance: true,
|
||||
phase: .recording,
|
||||
awaitingHostConfirm: false
|
||||
),
|
||||
.recordingBlue
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.micChrome(
|
||||
isClipboardUtterance: false,
|
||||
phase: .recording,
|
||||
awaitingHostConfirm: false
|
||||
),
|
||||
.none
|
||||
)
|
||||
// Paste-acquire (idle + active flag) must not go blue.
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.micChrome(
|
||||
isClipboardUtterance: true,
|
||||
phase: .idle,
|
||||
awaitingHostConfirm: false
|
||||
),
|
||||
.none
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Stop while preparing
|
||||
|
||||
func testTapDuringPreparingAbortsInsteadOfWaitingForever() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.stopWhilePreparing(awaitingHostConfirm: true),
|
||||
.abortPreparing
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.stopWhilePreparing(awaitingHostConfirm: false),
|
||||
.requestStop
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Recover while preparing
|
||||
|
||||
func testRecoverConfirmsMatchingRecordingUtterance() {
|
||||
let id = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: true,
|
||||
currentUtteranceId: id,
|
||||
hostBusyUtteranceId: id,
|
||||
hostReason: .recording,
|
||||
hasTerminalFailureForCurrent: false
|
||||
),
|
||||
.confirmRecording
|
||||
)
|
||||
}
|
||||
|
||||
func testRecoverAdoptsSiblingOnDoubleStart() {
|
||||
let ours = UUID()
|
||||
let sibling = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: true,
|
||||
currentUtteranceId: ours,
|
||||
hostBusyUtteranceId: sibling,
|
||||
hostReason: .recording,
|
||||
hasTerminalFailureForCurrent: false
|
||||
),
|
||||
.adoptSibling(sibling)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: true,
|
||||
currentUtteranceId: ours,
|
||||
hostBusyUtteranceId: sibling,
|
||||
hostReason: .processing,
|
||||
hasTerminalFailureForCurrent: false
|
||||
),
|
||||
.adoptSibling(sibling)
|
||||
)
|
||||
}
|
||||
|
||||
func testRecoverAbortsOnHostTerminalFailure() {
|
||||
let id = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: true,
|
||||
currentUtteranceId: id,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hasTerminalFailureForCurrent: true
|
||||
),
|
||||
.abortForHostFailure
|
||||
)
|
||||
}
|
||||
|
||||
func testRecoverDoesNothingWhenNotAwaiting() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: false,
|
||||
currentUtteranceId: UUID(),
|
||||
hostBusyUtteranceId: UUID(),
|
||||
hostReason: .recording,
|
||||
hasTerminalFailureForCurrent: true
|
||||
),
|
||||
.none
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Ensure single startRecording
|
||||
|
||||
func testEnsureStartSkipsWhenAlreadyInFlight() {
|
||||
let id = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: id,
|
||||
isFlowRecording: true,
|
||||
currentUtteranceId: id,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hostReadyWithSession: true
|
||||
),
|
||||
.alreadyInFlight
|
||||
)
|
||||
}
|
||||
|
||||
func testEnsureStartAdoptsHostBusyInsteadOfSecondWrite() {
|
||||
let issued = UUID()
|
||||
let busy = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: issued,
|
||||
isFlowRecording: false,
|
||||
currentUtteranceId: issued,
|
||||
hostBusyUtteranceId: busy,
|
||||
hostReason: .recording,
|
||||
hostReadyWithSession: true
|
||||
),
|
||||
.adoptBusy(busy, .recording)
|
||||
)
|
||||
}
|
||||
|
||||
func testEnsureStartWritesOnceWhenHostReady() {
|
||||
let id = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: id,
|
||||
isFlowRecording: false,
|
||||
currentUtteranceId: id,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hostReadyWithSession: true
|
||||
),
|
||||
.writeStart(id)
|
||||
)
|
||||
}
|
||||
|
||||
func testEnsureStartWaitsWhenHostNotReady() {
|
||||
let id = UUID()
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: id,
|
||||
isFlowRecording: false,
|
||||
currentUtteranceId: id,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hostReadyWithSession: false
|
||||
),
|
||||
.waitForHost
|
||||
)
|
||||
}
|
||||
|
||||
/// Simulate paste-alert reopen loop: claim → restore → ensure must not
|
||||
/// produce two writeStart for the same issued id across polls.
|
||||
func testDoubleStartStressMatrixTwentyRounds() {
|
||||
for round in 1...20 {
|
||||
let claim = UUID()
|
||||
// First long-press claims.
|
||||
XCTAssertTrue(claim.uuidString.isEmpty == false)
|
||||
|
||||
// Restore after Allow Paste / cold-start:
|
||||
// - with claim → awaitExistingStart
|
||||
// - without claim → preferVoiceOnly (no auto-record)
|
||||
let restoreClaimed = ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: true,
|
||||
phase: .idle
|
||||
)
|
||||
XCTAssertEqual(restoreClaimed, .awaitExistingStart, "round \(round)")
|
||||
let restoreWarm = ClipboardPreparingPolicy.restoreAction(
|
||||
hasStartIssued: false,
|
||||
phase: .idle
|
||||
)
|
||||
XCTAssertEqual(restoreWarm, .preferVoiceOnly, "round \(round)")
|
||||
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.hostGateAction(micPressAction: .openHostColdStart),
|
||||
.openHostColdStart,
|
||||
"round \(round)"
|
||||
)
|
||||
|
||||
// First ensure writes once.
|
||||
let first = ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: claim,
|
||||
isFlowRecording: false,
|
||||
currentUtteranceId: claim,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hostReadyWithSession: true
|
||||
)
|
||||
XCTAssertEqual(first, .writeStart(claim), "round \(round)")
|
||||
|
||||
// Second ensure (same process or restore) must not write again.
|
||||
let second = ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: claim,
|
||||
isFlowRecording: true,
|
||||
currentUtteranceId: claim,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hostReadyWithSession: true
|
||||
)
|
||||
XCTAssertEqual(second, .alreadyInFlight, "round \(round)")
|
||||
|
||||
// Sibling double-start on host → adopt, never another write.
|
||||
let sibling = UUID()
|
||||
let adopt = ClipboardPreparingPolicy.ensureStartAction(
|
||||
issuedUtteranceId: claim,
|
||||
isFlowRecording: false,
|
||||
currentUtteranceId: claim,
|
||||
hostBusyUtteranceId: sibling,
|
||||
hostReason: .recording,
|
||||
hostReadyWithSession: true
|
||||
)
|
||||
XCTAssertEqual(adopt, .adoptBusy(sibling, .recording), "round \(round)")
|
||||
|
||||
// Mic-not-ready failure while preparing → abort.
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.recoverWhilePreparing(
|
||||
awaitingHostConfirm: true,
|
||||
currentUtteranceId: claim,
|
||||
hostBusyUtteranceId: nil,
|
||||
hostReason: nil,
|
||||
hasTerminalFailureForCurrent: true
|
||||
),
|
||||
.abortForHostFailure,
|
||||
"round \(round)"
|
||||
)
|
||||
|
||||
// User tap while preparing cancels.
|
||||
XCTAssertEqual(
|
||||
ClipboardPreparingPolicy.stopWhilePreparing(awaitingHostConfirm: true),
|
||||
.abortPreparing,
|
||||
"round \(round)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,4 +330,113 @@ final class FlowHandoffPolicyTests: XCTestCase {
|
||||
.silence
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Proactive PiP arm (no re-jump)
|
||||
|
||||
func testProactivePiPArmSkippedWhenHostReady() {
|
||||
XCTAssertFalse(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: true,
|
||||
snapshotReason: .ready,
|
||||
sessionActive: true,
|
||||
hostReachable: true,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testProactivePiPArmSkippedWhileStartingOrCooldown() {
|
||||
XCTAssertFalse(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: .starting,
|
||||
sessionActive: true,
|
||||
hostReachable: true,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: false
|
||||
),
|
||||
"Warming session must not re-open startflow on Voice tab"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: nil,
|
||||
sessionActive: false,
|
||||
hostReachable: false,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: true
|
||||
),
|
||||
"Cooldown after a recent arm must block re-jump"
|
||||
)
|
||||
}
|
||||
|
||||
func testProactivePiPArmAllowsForceQuitZombieSessionAfterHeartbeatGrace() {
|
||||
// sessionActive still true, but heartbeat gone > 8s → allow one arm.
|
||||
XCTAssertTrue(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: .ready,
|
||||
sessionActive: true,
|
||||
hostReachable: false,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: false,
|
||||
heartbeatStaleness: FlowHandoffPolicy.proactiveUnreachableArmGrace
|
||||
)
|
||||
)
|
||||
// Brief switcher flap (< grace) must NOT re-jump.
|
||||
XCTAssertFalse(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: .ready,
|
||||
sessionActive: true,
|
||||
hostReachable: false,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: false,
|
||||
heartbeatStaleness: 3
|
||||
)
|
||||
)
|
||||
// Cooldown still wins after a recent arm.
|
||||
XCTAssertFalse(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: .ready,
|
||||
sessionActive: true,
|
||||
hostReachable: false,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: true,
|
||||
heartbeatStaleness: 30
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testProactivePiPArmAllowedWhenHostTrulyDead() {
|
||||
XCTAssertTrue(
|
||||
FlowHandoffPolicy.shouldProactivePiPArm(
|
||||
hostReady: false,
|
||||
snapshotReason: .noSession,
|
||||
sessionActive: false,
|
||||
hostReachable: false,
|
||||
hostStale: false,
|
||||
withinReadyGrace: false,
|
||||
inCooldown: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPiPArmCooldownRoundTrip() {
|
||||
let suite = "PiPArmCooldown.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
|
||||
XCTAssertFalse(FlowSessionBridge.isPiPArmInCooldown(defaults: defaults))
|
||||
FlowSessionBridge.markPiPArmAttempt(defaults: defaults)
|
||||
XCTAssertTrue(FlowSessionBridge.isPiPArmInCooldown(defaults: defaults))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user