Files
OSGKeyboard/OSGKeyboardTests/FlowHandoffPolicyTests.swift
T
Rocky 498f407585 feat(account): add managed credits and cloud gateway
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.
2026-08-20 11:43:21 +08:00

443 lines
14 KiB
Swift

// FlowHandoffPolicyTests.swift
// OSGKeyboardTests
@testable import OSGKeyboardShared
import XCTest
final class FlowHandoffPolicyTests: XCTestCase {
// MARK: - shouldTreatHostAsAlive / shouldOpenHostColdStart
func testAliveWhenSessionActiveAndReachable() {
XCTAssertTrue(
FlowHandoffPolicy.shouldTreatHostAsAlive(
sessionActive: true,
hostReachable: true,
hostStale: false,
withinReadyGrace: false
)
)
XCTAssertFalse(
FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: true,
hostReachable: true,
hostStale: false,
withinReadyGrace: false
)
)
}
func testAliveWhenSessionActiveWithinReadyGrace() {
// Finalize race: ready flap but we were ready moments ago.
XCTAssertTrue(
FlowHandoffPolicy.shouldTreatHostAsAlive(
sessionActive: true,
hostReachable: false,
hostStale: false,
withinReadyGrace: true
)
)
XCTAssertFalse(
FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: true,
hostReachable: false,
hostStale: false,
withinReadyGrace: true
)
)
}
func testAliveWhenSessionActiveEvenIfHeartbeatBrieflyStale() {
// Session flag still valid and not zombie → wait, do not jump.
XCTAssertTrue(
FlowHandoffPolicy.shouldTreatHostAsAlive(
sessionActive: true,
hostReachable: false,
hostStale: false,
withinReadyGrace: false
)
)
XCTAssertFalse(
FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: true,
hostReachable: false,
hostStale: false,
withinReadyGrace: false
)
)
}
func testDeadWhenHostStale() {
XCTAssertFalse(
FlowHandoffPolicy.shouldTreatHostAsAlive(
sessionActive: true,
hostReachable: false,
hostStale: true,
withinReadyGrace: true
)
)
XCTAssertTrue(
FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: true,
hostReachable: false,
hostStale: true,
withinReadyGrace: true
)
)
}
func testDeadWhenNoSession() {
XCTAssertFalse(
FlowHandoffPolicy.shouldTreatHostAsAlive(
sessionActive: false,
hostReachable: false,
hostStale: false,
withinReadyGrace: false
)
)
XCTAssertTrue(
FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: false,
hostReachable: false,
hostStale: false,
withinReadyGrace: false
)
)
}
// MARK: - micPressAction
func testMicPressReadyStartsRecording() {
let action = FlowHandoffPolicy.micPressAction(
availability: .ready,
sessionActive: true,
hostReachable: true,
hostStale: false,
withinReadyGrace: false
)
XCTAssertEqual(action, .startRecording)
}
func testMicPressPreparingSessionWaitsAndRecords() {
// 0.5.2 regression: preparingSession must NOT open cold start.
let action = FlowHandoffPolicy.micPressAction(
availability: .unavailable(.preparingSession),
sessionActive: true,
hostReachable: true,
hostStale: false,
withinReadyGrace: false
)
XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true))
}
func testMicPressHostNotReadyWithLiveSessionWaits() {
// User log scenario: finalize just completed, stale ready=false frame.
let action = FlowHandoffPolicy.micPressAction(
availability: .unavailable(.hostNotReady),
sessionActive: true,
hostReachable: true,
hostStale: false,
withinReadyGrace: true
)
XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true))
}
func testMicPressHostNotReadyWithActiveSessionNoGraceStillWaits() {
let action = FlowHandoffPolicy.micPressAction(
availability: .unavailable(.hostNotReady),
sessionActive: true,
hostReachable: false,
hostStale: false,
withinReadyGrace: false
)
XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true))
}
func testMicPressOnboardingIncompleteIgnores() {
let action = FlowHandoffPolicy.micPressAction(
availability: .unavailable(.onboardingIncomplete),
sessionActive: false,
hostReachable: false,
hostStale: false,
withinReadyGrace: false
)
XCTAssertEqual(action, .ignore)
}
func testMicPressHostNotReadyWhenDeadOpensColdStart() {
let action = FlowHandoffPolicy.micPressAction(
availability: .unavailable(.hostNotReady),
sessionActive: false,
hostReachable: false,
hostStale: false,
withinReadyGrace: false
)
XCTAssertEqual(action, .openHostColdStart)
}
func testMicPressHostNotReadyWhenStaleOpensColdStart() {
let action = FlowHandoffPolicy.micPressAction(
availability: .unavailable(.hostNotReady),
sessionActive: true,
hostReachable: false,
hostStale: true,
withinReadyGrace: false
)
XCTAssertEqual(action, .openHostColdStart)
}
func testMicPressBusyPhasesIgnored() {
XCTAssertEqual(
FlowHandoffPolicy.micPressAction(
availability: .recording,
sessionActive: true,
hostReachable: true,
hostStale: false,
withinReadyGrace: false
),
.ignore
)
XCTAssertEqual(
FlowHandoffPolicy.micPressAction(
availability: .processing,
sessionActive: true,
hostReachable: true,
hostStale: false,
withinReadyGrace: false
),
.ignore
)
}
// MARK: - coldStartOverlayDecision
func testOverlaySilencedWhenAlreadyReady() {
// User log: active=true hostReady=true coldStart=true → must silence.
XCTAssertEqual(
FlowHandoffPolicy.coldStartOverlayDecision(
sessionIsActive: true,
hostIsReady: true,
isUtteranceBusy: false
),
.silence
)
}
func testOverlaySilencedWhenUtteranceBusy() {
XCTAssertEqual(
FlowHandoffPolicy.coldStartOverlayDecision(
sessionIsActive: true,
hostIsReady: false,
isUtteranceBusy: true
),
.silence
)
}
func testOverlayPresentedForTrueColdStart() {
XCTAssertEqual(
FlowHandoffPolicy.coldStartOverlayDecision(
sessionIsActive: false,
hostIsReady: false,
isUtteranceBusy: false
),
.present
)
}
func testOverlayPresentedWhenActiveButNeedsRecovery() {
// Engine hiccup: session up, not ready, not busy → preparing UI OK.
XCTAssertEqual(
FlowHandoffPolicy.coldStartOverlayDecision(
sessionIsActive: true,
hostIsReady: false,
isUtteranceBusy: false
),
.present
)
}
// MARK: - debouncer + proactive launch flag
func testProactiveAutoLaunchEnabledForLowProfilePiP() {
XCTAssertTrue(FlowHandoffPolicy.allowsProactiveHostAutoLaunch)
}
func testDebouncerIgnoresSingleDeadSample() {
var debouncer = FlowColdStartDebouncer()
XCTAssertFalse(debouncer.observe(hostTrulyDead: true))
XCTAssertEqual(debouncer.consecutiveDeadSamples, 1)
XCTAssertTrue(debouncer.observe(hostTrulyDead: true))
XCTAssertEqual(debouncer.consecutiveDeadSamples, 2)
}
func testDebouncerResetsOnAliveSample() {
var debouncer = FlowColdStartDebouncer()
XCTAssertFalse(debouncer.observe(hostTrulyDead: true))
XCTAssertFalse(debouncer.observe(hostTrulyDead: false))
XCTAssertEqual(debouncer.consecutiveDeadSamples, 0)
XCTAssertFalse(debouncer.observe(hostTrulyDead: true))
}
func testDebouncerReset() {
var debouncer = FlowColdStartDebouncer()
_ = debouncer.observe(hostTrulyDead: true)
debouncer.reset()
XCTAssertEqual(debouncer.consecutiveDeadSamples, 0)
}
/// Mirrors the user log: session still active after finalize, ready flap
/// must not justify startflow even when availability reads hostNotReady.
func testFinalizeRaceDoesNotOpenColdStart() {
let suite = "group.com.osgkeyboard.shared.tests.handoff.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
let sessionId = UUID()
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: sessionId,
ready: false,
reason: .processing,
engineMode: "local",
localeId: "zh-Hans",
busyUtteranceId: UUID(),
sessionExpiresAt: nil,
hostGeneration: FlowSessionBridge.currentHostGeneration(defaults: defaults)
),
defaults: defaults
)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
let action = FlowHandoffPolicy.micPressAction(
availability: .unavailable(.hostNotReady),
sessionActive: FlowSessionBridge.isSessionActive(defaults: defaults),
hostReachable: FlowSessionBridge.isHostReachable(defaults: defaults),
hostStale: FlowSessionBridge.isHostStale(defaults: defaults),
withinReadyGrace: true
)
XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true))
XCTAssertEqual(
FlowHandoffPolicy.coldStartOverlayDecision(
sessionIsActive: true,
hostIsReady: true,
isUtteranceBusy: false
),
.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))
}
}