Files
OSGKeyboard/OSGKeyboardTests/FlowHandoffPolicyTests.swift
T
Rocky 41b8db87b5 refactor(flow): remove Live Activity and keep-alive mode picker
Keep voice sessions on silent low-profile PiP only, and fix custom polish style editor presentation via sheet(item:).
2026-08-07 11:32:31 +08:00

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.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
)
}
}