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
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user