77504facc8
Ship automatic low-profile PiP keep-alive so background dictation stays ready without visible startup UI or idle audio work.
334 lines
11 KiB
Swift
334 lines
11 KiB
Swift
// FlowHandoffPolicyTests.swift
|
|
// OSGKeyboardTests
|
|
|
|
import XCTest
|
|
@testable import OSGKeyboardShared
|
|
|
|
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.markSessionActive(duration: 1_800, sessionId: sessionId, defaults: defaults)
|
|
FlowSessionBridge.writeReadySnapshot(
|
|
FlowReadySnapshot(
|
|
sessionId: sessionId,
|
|
ready: false,
|
|
reason: .processing,
|
|
engineMode: "local",
|
|
localeId: "zh-Hans",
|
|
busyUtteranceId: UUID(),
|
|
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(defaults: defaults),
|
|
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
|
|
)
|
|
}
|
|
}
|