498f407585
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
583 lines
18 KiB
Swift
583 lines
18 KiB
Swift
// FlowKeyboardPoliciesTests.swift
|
|
// OSGKeyboardTests
|
|
//
|
|
// Hermetic regression coverage for keyboard↔host Flow decision helpers.
|
|
|
|
@testable import OSGKeyboardShared
|
|
import XCTest
|
|
|
|
final class FlowKeyboardPoliciesTests: XCTestCase {
|
|
|
|
// MARK: - Host warming
|
|
|
|
func testHostBusyReasonIsNotPreparingSession() {
|
|
XCTAssertTrue(FlowKeyboardHostWarming.isHostBusy(reason: .recording))
|
|
XCTAssertTrue(FlowKeyboardHostWarming.isHostBusy(reason: .processing))
|
|
XCTAssertTrue(FlowKeyboardHostWarming.isHostBusy(reason: .awaitingDelivery))
|
|
XCTAssertFalse(FlowKeyboardHostWarming.isHostBusy(reason: .starting))
|
|
XCTAssertFalse(FlowKeyboardHostWarming.isHostBusy(reason: .ready))
|
|
|
|
let warming = FlowKeyboardHostWarming.isHostWarming(
|
|
hostReady: false,
|
|
hostBusy: true,
|
|
sessionActive: true,
|
|
hostReachable: true,
|
|
isPendingFlowStart: false,
|
|
withinReadyGrace: false,
|
|
snapshotReason: .recording
|
|
)
|
|
XCTAssertFalse(warming, "busy must not look like preparing/starting")
|
|
}
|
|
|
|
func testStartingReasonWithActiveSessionIsPreparing() {
|
|
let warming = FlowKeyboardHostWarming.isHostWarming(
|
|
hostReady: false,
|
|
hostBusy: false,
|
|
sessionActive: true,
|
|
hostReachable: false,
|
|
isPendingFlowStart: false,
|
|
withinReadyGrace: false,
|
|
snapshotReason: .starting
|
|
)
|
|
XCTAssertTrue(warming)
|
|
}
|
|
|
|
func testRecentReadyGraceDoesNotForcePreparingSession() {
|
|
// withinReadyGrace used to OR into warming and flash yellow after each
|
|
// utterance; sticky ready must hold green instead.
|
|
let warming = FlowKeyboardHostWarming.isHostWarming(
|
|
hostReady: false,
|
|
hostBusy: false,
|
|
sessionActive: true,
|
|
hostReachable: false,
|
|
isPendingFlowStart: false,
|
|
withinReadyGrace: true,
|
|
snapshotReason: .starting
|
|
)
|
|
// Still warming via reason=.starting when not yet proven ready on the
|
|
// keyboard side; grace alone must not be the trigger.
|
|
XCTAssertTrue(warming)
|
|
|
|
XCTAssertTrue(
|
|
FlowKeyboardHostWarming.shouldHoldReady(
|
|
hostReady: false,
|
|
hostBusy: false,
|
|
sessionActive: true,
|
|
sessionProvenReady: true,
|
|
isPendingFlowStart: false,
|
|
snapshotReason: .starting
|
|
),
|
|
"proven-ready session must hold green through starting flaps"
|
|
)
|
|
XCTAssertTrue(
|
|
FlowKeyboardHostWarming.shouldHoldReady(
|
|
hostReady: false,
|
|
hostBusy: true,
|
|
sessionActive: true,
|
|
sessionProvenReady: true,
|
|
isPendingFlowStart: false,
|
|
snapshotReason: .awaitingDelivery
|
|
),
|
|
"ack lag awaitingDelivery must not drop to preparingSession"
|
|
)
|
|
XCTAssertFalse(
|
|
FlowKeyboardHostWarming.shouldHoldReady(
|
|
hostReady: false,
|
|
hostBusy: true,
|
|
sessionActive: true,
|
|
sessionProvenReady: true,
|
|
isPendingFlowStart: false,
|
|
snapshotReason: .recording
|
|
),
|
|
"live recording must not be masked as sticky ready"
|
|
)
|
|
XCTAssertFalse(
|
|
FlowKeyboardHostWarming.shouldHoldReady(
|
|
hostReady: false,
|
|
hostBusy: false,
|
|
sessionActive: true,
|
|
sessionProvenReady: false,
|
|
isPendingFlowStart: true,
|
|
snapshotReason: .starting
|
|
),
|
|
"cold start pending must still show preparing"
|
|
)
|
|
}
|
|
|
|
func testHeldReadySuppressesWarming() {
|
|
let hold = FlowKeyboardHostWarming.shouldHoldReady(
|
|
hostReady: false,
|
|
hostBusy: false,
|
|
sessionActive: true,
|
|
sessionProvenReady: true,
|
|
isPendingFlowStart: false,
|
|
snapshotReason: .starting
|
|
)
|
|
XCTAssertTrue(hold)
|
|
let warming = FlowKeyboardHostWarming.isHostWarming(
|
|
hostReady: true, // effective ready after hold
|
|
hostBusy: false,
|
|
sessionActive: true,
|
|
hostReachable: true,
|
|
isPendingFlowStart: false,
|
|
withinReadyGrace: true,
|
|
snapshotReason: .starting
|
|
)
|
|
XCTAssertFalse(warming)
|
|
}
|
|
|
|
// MARK: - Adopt busy
|
|
|
|
func testReAdoptsRecordingAfterExtensionProcessLoss() {
|
|
let sessionId = UUID()
|
|
let utteranceId = UUID()
|
|
let snapshot = FlowReadySnapshot(
|
|
sessionId: sessionId,
|
|
ready: false,
|
|
reason: .recording,
|
|
engineMode: "cloud",
|
|
localeId: "zh-Hans",
|
|
busyUtteranceId: utteranceId,
|
|
hostGeneration: "gen-1"
|
|
)
|
|
let action = FlowKeyboardAdoptBusyPolicy.decide(
|
|
snapshot: snapshot,
|
|
currentHostGeneration: "gen-1",
|
|
isFlowRecording: false,
|
|
isAwaitingFlowResult: false,
|
|
lastConsumedUtteranceId: nil,
|
|
lastStoppedUtteranceId: nil
|
|
)
|
|
XCTAssertEqual(
|
|
action,
|
|
.adoptRecording(sessionId: sessionId, utteranceId: utteranceId)
|
|
)
|
|
}
|
|
|
|
func testIgnoresConsumedAndStoppedUtteranceIds() {
|
|
let sessionId = UUID()
|
|
let utteranceId = UUID()
|
|
let snapshot = FlowReadySnapshot(
|
|
sessionId: sessionId,
|
|
ready: false,
|
|
reason: .recording,
|
|
engineMode: "cloud",
|
|
localeId: "zh-Hans",
|
|
busyUtteranceId: utteranceId,
|
|
hostGeneration: "gen-1"
|
|
)
|
|
XCTAssertEqual(
|
|
FlowKeyboardAdoptBusyPolicy.decide(
|
|
snapshot: snapshot,
|
|
currentHostGeneration: "gen-1",
|
|
isFlowRecording: false,
|
|
isAwaitingFlowResult: false,
|
|
lastConsumedUtteranceId: utteranceId,
|
|
lastStoppedUtteranceId: nil
|
|
),
|
|
.none
|
|
)
|
|
XCTAssertEqual(
|
|
FlowKeyboardAdoptBusyPolicy.decide(
|
|
snapshot: snapshot,
|
|
currentHostGeneration: "gen-1",
|
|
isFlowRecording: false,
|
|
isAwaitingFlowResult: false,
|
|
lastConsumedUtteranceId: nil,
|
|
lastStoppedUtteranceId: utteranceId
|
|
),
|
|
.none
|
|
)
|
|
|
|
let processing = FlowReadySnapshot(
|
|
sessionId: sessionId,
|
|
ready: false,
|
|
reason: .processing,
|
|
engineMode: "cloud",
|
|
localeId: "zh-Hans",
|
|
busyUtteranceId: utteranceId,
|
|
hostGeneration: "gen-1"
|
|
)
|
|
XCTAssertEqual(
|
|
FlowKeyboardAdoptBusyPolicy.decide(
|
|
snapshot: processing,
|
|
currentHostGeneration: "gen-1",
|
|
isFlowRecording: false,
|
|
isAwaitingFlowResult: false,
|
|
lastConsumedUtteranceId: nil,
|
|
lastStoppedUtteranceId: utteranceId
|
|
),
|
|
.none,
|
|
"an aborted utterance must not come back as 识别中 on the next open"
|
|
)
|
|
}
|
|
|
|
func testIgnoresDeadHostGenerationSnapshot() {
|
|
let snapshot = FlowReadySnapshot(
|
|
sessionId: UUID(),
|
|
ready: false,
|
|
reason: .recording,
|
|
engineMode: "cloud",
|
|
localeId: "zh-Hans",
|
|
busyUtteranceId: UUID(),
|
|
hostGeneration: "old-gen"
|
|
)
|
|
XCTAssertEqual(
|
|
FlowKeyboardAdoptBusyPolicy.decide(
|
|
snapshot: snapshot,
|
|
currentHostGeneration: "live-gen",
|
|
isFlowRecording: false,
|
|
isAwaitingFlowResult: false,
|
|
lastConsumedUtteranceId: nil,
|
|
lastStoppedUtteranceId: nil
|
|
),
|
|
.none
|
|
)
|
|
}
|
|
|
|
func testClearsStickyProcessingWhenHostNoLongerBusy() {
|
|
let snapshot = FlowReadySnapshot(
|
|
sessionId: UUID(),
|
|
ready: true,
|
|
reason: .ready,
|
|
engineMode: "cloud",
|
|
localeId: "zh-Hans"
|
|
)
|
|
XCTAssertEqual(
|
|
FlowKeyboardAdoptBusyPolicy.decide(
|
|
snapshot: snapshot,
|
|
currentHostGeneration: nil,
|
|
isFlowRecording: false,
|
|
isAwaitingFlowResult: false,
|
|
lastConsumedUtteranceId: nil,
|
|
lastStoppedUtteranceId: nil
|
|
),
|
|
.clearStickyProcessing
|
|
)
|
|
}
|
|
|
|
func testStaleDeliveredProcessingRequiresMatchingAckWithoutResult() {
|
|
let busyId = UUID()
|
|
let sessionId = UUID()
|
|
XCTAssertFalse(
|
|
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
|
|
busyUtteranceId: busyId,
|
|
latestResult: nil,
|
|
latestAck: nil
|
|
),
|
|
"live ASR/LLM has no result and no ack yet — must wait, not abort"
|
|
)
|
|
|
|
let otherAck = FlowAck(
|
|
sessionId: sessionId,
|
|
utteranceId: UUID(),
|
|
commandSeq: 1
|
|
)
|
|
XCTAssertFalse(
|
|
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
|
|
busyUtteranceId: busyId,
|
|
latestResult: nil,
|
|
latestAck: otherAck
|
|
),
|
|
"ack for a previous utterance must not abort the live one"
|
|
)
|
|
|
|
let matchingAck = FlowAck(
|
|
sessionId: sessionId,
|
|
utteranceId: busyId,
|
|
commandSeq: 1
|
|
)
|
|
XCTAssertTrue(
|
|
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
|
|
busyUtteranceId: busyId,
|
|
latestResult: nil,
|
|
latestAck: matchingAck
|
|
),
|
|
"acked + empty mailbox + still processing is a leaked gate"
|
|
)
|
|
|
|
let liveResult = FlowResult(
|
|
sessionId: sessionId,
|
|
utteranceId: busyId,
|
|
commandSeq: 1,
|
|
status: .streaming,
|
|
text: "draft"
|
|
)
|
|
XCTAssertFalse(
|
|
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
|
|
busyUtteranceId: busyId,
|
|
latestResult: liveResult,
|
|
latestAck: matchingAck
|
|
),
|
|
"result still sitting for this utterance is waitable, not stale"
|
|
)
|
|
XCTAssertFalse(
|
|
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
|
|
busyUtteranceId: UUID(),
|
|
latestResult: liveResult,
|
|
latestAck: matchingAck
|
|
)
|
|
)
|
|
}
|
|
|
|
func testTerminalStorePolicyRejectsAbortAndReplacement() {
|
|
let live = UUID()
|
|
XCTAssertTrue(
|
|
FlowTerminalStorePolicy.canStore(
|
|
currentUtteranceId: live,
|
|
finishedUtteranceId: live,
|
|
alreadyTerminal: false
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
FlowTerminalStorePolicy.canStore(
|
|
currentUtteranceId: nil,
|
|
finishedUtteranceId: live,
|
|
alreadyTerminal: false
|
|
),
|
|
"abort cleared currentUtteranceId — do not deliver"
|
|
)
|
|
XCTAssertFalse(
|
|
FlowTerminalStorePolicy.canStore(
|
|
currentUtteranceId: UUID(),
|
|
finishedUtteranceId: live,
|
|
alreadyTerminal: false
|
|
),
|
|
"a newer utterance owns the gate"
|
|
)
|
|
XCTAssertFalse(
|
|
FlowTerminalStorePolicy.canStore(
|
|
currentUtteranceId: live,
|
|
finishedUtteranceId: live,
|
|
alreadyTerminal: true
|
|
),
|
|
"already terminal — abort already claimed"
|
|
)
|
|
}
|
|
|
|
func testAckGateDropsProcessingOnlyForTheLiveUtterance() {
|
|
let live = UUID()
|
|
XCTAssertTrue(
|
|
FlowHostAckGatePolicy.shouldDropProcessingGate(
|
|
ackUtteranceId: live,
|
|
currentUtteranceId: live,
|
|
isUtteranceProcessing: true
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
FlowHostAckGatePolicy.shouldDropProcessingGate(
|
|
ackUtteranceId: live,
|
|
currentUtteranceId: live,
|
|
isUtteranceProcessing: false
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
FlowHostAckGatePolicy.shouldDropProcessingGate(
|
|
ackUtteranceId: live,
|
|
currentUtteranceId: UUID(),
|
|
isUtteranceProcessing: true
|
|
),
|
|
"must not clobber a newer utterance"
|
|
)
|
|
XCTAssertFalse(
|
|
FlowHostAckGatePolicy.shouldDropProcessingGate(
|
|
ackUtteranceId: live,
|
|
currentUtteranceId: nil,
|
|
isUtteranceProcessing: true
|
|
)
|
|
)
|
|
}
|
|
|
|
// MARK: - Result matcher
|
|
|
|
func testMatchingResultRequiresAlignedSessionAndUtterance() {
|
|
let sessionId = UUID()
|
|
let utteranceId = UUID()
|
|
let result = FlowResult(
|
|
sessionId: sessionId,
|
|
utteranceId: utteranceId,
|
|
commandSeq: 1,
|
|
status: .final,
|
|
text: "ok"
|
|
)
|
|
XCTAssertNotNil(
|
|
FlowKeyboardResultMatcher.matchingResult(
|
|
latest: result,
|
|
activeSessionId: sessionId,
|
|
currentUtteranceId: utteranceId
|
|
)
|
|
)
|
|
XCTAssertNil(
|
|
FlowKeyboardResultMatcher.matchingResult(
|
|
latest: result,
|
|
activeSessionId: UUID(),
|
|
currentUtteranceId: utteranceId
|
|
)
|
|
)
|
|
XCTAssertNil(
|
|
FlowKeyboardResultMatcher.matchingResult(
|
|
latest: result,
|
|
activeSessionId: sessionId,
|
|
currentUtteranceId: UUID()
|
|
)
|
|
)
|
|
}
|
|
|
|
func testTerminalFailureStatuses() {
|
|
let base = FlowResult(
|
|
sessionId: UUID(),
|
|
utteranceId: UUID(),
|
|
commandSeq: 1,
|
|
status: .final
|
|
)
|
|
XCTAssertFalse(FlowKeyboardResultMatcher.isTerminalFailure(base))
|
|
XCTAssertTrue(
|
|
FlowKeyboardResultMatcher.isTerminalFailure(
|
|
FlowResult(
|
|
sessionId: base.sessionId,
|
|
utteranceId: base.utteranceId,
|
|
commandSeq: 1,
|
|
status: .error
|
|
)
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
FlowKeyboardResultMatcher.isTerminalFailure(
|
|
FlowResult(
|
|
sessionId: base.sessionId,
|
|
utteranceId: base.utteranceId,
|
|
commandSeq: 1,
|
|
status: .timeout
|
|
)
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
FlowKeyboardResultMatcher.isTerminalFailure(
|
|
FlowResult(
|
|
sessionId: base.sessionId,
|
|
utteranceId: base.utteranceId,
|
|
commandSeq: 1,
|
|
status: .aborted
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
// MARK: - Command gate
|
|
|
|
func testIgnoresStaleSessionAndNonIncreasingSeq() {
|
|
let sessionId = UUID()
|
|
XCTAssertEqual(
|
|
FlowCommandGatekeeper.decide(
|
|
commandSessionId: UUID(),
|
|
commandSeq: 2,
|
|
activeSessionId: sessionId,
|
|
lastHandledCommandSeq: 1
|
|
),
|
|
.rejectWrongSession
|
|
)
|
|
XCTAssertEqual(
|
|
FlowCommandGatekeeper.decide(
|
|
commandSessionId: sessionId,
|
|
commandSeq: 1,
|
|
activeSessionId: sessionId,
|
|
lastHandledCommandSeq: 1
|
|
),
|
|
.rejectStaleSeq
|
|
)
|
|
XCTAssertEqual(
|
|
FlowCommandGatekeeper.decide(
|
|
commandSessionId: sessionId,
|
|
commandSeq: 2,
|
|
activeSessionId: sessionId,
|
|
lastHandledCommandSeq: 1
|
|
),
|
|
.accept
|
|
)
|
|
}
|
|
|
|
// MARK: - Orphan reconcile
|
|
|
|
func testClearsOrphanedRecordingStateWhenHostInactive() {
|
|
XCTAssertEqual(
|
|
FlowOrphanRecordingReconciler.decide(
|
|
isHostStale: false,
|
|
isActive: false,
|
|
recordingState: .recording
|
|
),
|
|
.clearOrphanedRecording(.recording)
|
|
)
|
|
XCTAssertEqual(
|
|
FlowOrphanRecordingReconciler.decide(
|
|
isHostStale: true,
|
|
isActive: false,
|
|
recordingState: .idle
|
|
),
|
|
.clearZombieSession
|
|
)
|
|
XCTAssertEqual(
|
|
FlowOrphanRecordingReconciler.decide(
|
|
isHostStale: false,
|
|
isActive: true,
|
|
recordingState: .recording
|
|
),
|
|
.none
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty tap skip
|
|
|
|
extension FlowKeyboardPoliciesTests {
|
|
func testEmptyTapSkipRequiresShortDuration() {
|
|
XCTAssertTrue(
|
|
FlowEmptyTapSkipPolicy.shouldSkip(
|
|
durationSeconds: 0.2,
|
|
sampleCount: 0,
|
|
peakAmplitude: nil
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
FlowEmptyTapSkipPolicy.shouldSkip(
|
|
durationSeconds: 0.35,
|
|
sampleCount: 0,
|
|
peakAmplitude: nil
|
|
)
|
|
)
|
|
}
|
|
|
|
func testEmptyTapSkipTreatsMissingSamplesAsSilence() {
|
|
XCTAssertTrue(
|
|
FlowEmptyTapSkipPolicy.shouldSkip(
|
|
durationSeconds: 0.1,
|
|
sampleCount: 16,
|
|
peakAmplitude: nil
|
|
)
|
|
)
|
|
}
|
|
|
|
func testEmptyTapSkipKeepsShortSpeech() {
|
|
XCTAssertFalse(
|
|
FlowEmptyTapSkipPolicy.shouldSkip(
|
|
durationSeconds: 0.2,
|
|
sampleCount: 3_200,
|
|
peakAmplitude: 0.2
|
|
)
|
|
)
|
|
}
|
|
|
|
func testEmptyTapSkipDropsShortSilence() {
|
|
XCTAssertTrue(
|
|
FlowEmptyTapSkipPolicy.shouldSkip(
|
|
durationSeconds: 0.2,
|
|
sampleCount: 3_200,
|
|
peakAmplitude: 0.001
|
|
)
|
|
)
|
|
XCTAssertEqual(
|
|
FlowEmptyTapSkipPolicy.peakAbs([0.001, -0.004, 0.002]),
|
|
0.004
|
|
)
|
|
}
|
|
}
|