Files
OSGKeyboard/OSGKeyboardTests/FlowHandoffPolicyTests.swift
T
Rocky cc8dd1070a feat: macOS architecture, cloud ASR/LLM providers, and 6-step iOS onboarding
- Add macOS menu-bar dictation app with local ASR models (SenseVoice/Qwen3),
  global Option hotkey, and bottom overlay
- Add cloud ASR/LLM providers (Anthropic, Volcengine, Bailian, and more) with
  provider logos, model listing, and connection checks
- Add shared 7-day usage stats UI (UsageStatsCluster / SevenDayUsageChart)
- Add iOS onboarding step 6 for polish LLM setup; hide custom-language-model
  diagnostic toggle behind DEBUG
- Unify iOS onboarding tagline with the macOS brand line ("开口即文字。")
- Rewrite README (Chinese-first, product-oriented) and refresh GitHub Pages
2026-07-11 19:10:20 +08:00

323 lines
10 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 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 testProactiveAutoLaunchDisabled() {
XCTAssertFalse(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
)
}
}