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:
Rocky
2026-08-08 00:20:42 +08:00
parent f197b68573
commit 9da32b81e9
45 changed files with 3670 additions and 422 deletions
@@ -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))
}
}