feat: harden Flow cold-start/force-quit and polish macOS dictation UX
Fix cold-start overlay recursion that overflowed the main-thread stack when recording began while the ready overlay was still up; also remove temporary on-screen Flow DEBUG panels after the orange-mic investigation, and land the macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
@@ -20,7 +20,8 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
XCTAssertEqual(config.providerId, "openai")
|
||||
XCTAssertEqual(config.modeId, "polish")
|
||||
XCTAssertEqual(config.localeId, "auto")
|
||||
XCTAssertEqual(config.engineMode, "cloud")
|
||||
// Privacy-critical: the default engine must keep audio on-device.
|
||||
XCTAssertEqual(config.engineMode, "local")
|
||||
XCTAssertFalse(config.hasCompletedOnboarding)
|
||||
XCTAssertEqual(config.onboardingPage, 0)
|
||||
XCTAssertFalse(config.hasAcknowledgedCloudSharing)
|
||||
@@ -31,7 +32,7 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
XCTAssertEqual(config.polishIntensity, .default)
|
||||
XCTAssertTrue(config.personalDictionary.entries.isEmpty)
|
||||
XCTAssertTrue(config.flowSkipAppSwitch)
|
||||
XCTAssertEqual(config.flowInactivityDuration, .twelveHours)
|
||||
XCTAssertEqual(config.flowInactivityDuration, .thirtyMinutes)
|
||||
}
|
||||
|
||||
func testSaveAndLoadRoundTrip() {
|
||||
@@ -40,9 +41,13 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
config.providerId = "anthropic"
|
||||
config.baseURL = "https://example.com/v1"
|
||||
config.model = "claude-test"
|
||||
config.asrProviderId = "zhipu"
|
||||
config.asrBaseURL = "https://open.bigmodel.cn/api/paas/v4"
|
||||
config.asrModel = "glm-asr-2512"
|
||||
config.modeId = "polish"
|
||||
config.localeId = "zh-Hans"
|
||||
config.engineMode = "local"
|
||||
// Non-default value so the round-trip proves persistence.
|
||||
config.engineMode = "cloud"
|
||||
config.hasCompletedOnboarding = true
|
||||
config.onboardingPage = 2
|
||||
config.hasAcknowledgedCloudSharing = true
|
||||
@@ -52,15 +57,19 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
config.cursorDragNavigationEnabled = false
|
||||
config.polishIntensity = .light
|
||||
config.flowSkipAppSwitch = false
|
||||
config.flowInactivityDuration = .thirtyMinutes
|
||||
// Use a non-default value so the round-trip actually proves persistence.
|
||||
config.flowInactivityDuration = .threeHours
|
||||
config.save(to: defaults)
|
||||
|
||||
let loaded = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
XCTAssertEqual(loaded.providerId, "anthropic")
|
||||
XCTAssertEqual(loaded.baseURL, "https://example.com/v1")
|
||||
XCTAssertEqual(loaded.model, "claude-test")
|
||||
XCTAssertEqual(loaded.asrProviderId, "zhipu")
|
||||
XCTAssertEqual(loaded.asrBaseURL, "https://open.bigmodel.cn/api/paas/v4")
|
||||
XCTAssertEqual(loaded.asrModel, "glm-asr-2512")
|
||||
XCTAssertEqual(loaded.localeId, "zh-Hans")
|
||||
XCTAssertEqual(loaded.engineMode, "local")
|
||||
XCTAssertEqual(loaded.engineMode, "cloud")
|
||||
XCTAssertTrue(loaded.hasCompletedOnboarding)
|
||||
XCTAssertEqual(loaded.onboardingPage, 2)
|
||||
XCTAssertTrue(loaded.hasAcknowledgedCloudSharing)
|
||||
@@ -71,7 +80,37 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
XCTAssertFalse(loaded.cursorDragNavigationEnabled)
|
||||
XCTAssertEqual(loaded.polishIntensity, .light)
|
||||
XCTAssertFalse(loaded.flowSkipAppSwitch)
|
||||
XCTAssertEqual(loaded.flowInactivityDuration, .thirtyMinutes)
|
||||
XCTAssertEqual(loaded.flowInactivityDuration, .threeHours)
|
||||
}
|
||||
|
||||
/// Existing installs (onboarding completed, no explicit engineMode key)
|
||||
/// ran on the old "cloud"/12h defaults — a silent flip to the new
|
||||
/// privacy defaults would change their engine under them AND propagate
|
||||
/// through settings sync as a fake fresh edit to their other devices.
|
||||
func testDefaultMigrationPreservesExistingInstallBehavior() {
|
||||
let defaults = makeDefaults()
|
||||
defaults.set(true, forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding)
|
||||
|
||||
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
|
||||
XCTAssertEqual(config.engineMode, "cloud", "pre-picker installs stay on their old default")
|
||||
XCTAssertEqual(config.flowInactivityDuration, .twelveHours)
|
||||
// The resolution is persisted so it is stable and sync-invisible.
|
||||
XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.engineMode), "cloud")
|
||||
XCTAssertEqual(
|
||||
defaults.string(forKey: AppGroupConfiguration.Keys.flowInactivityDuration),
|
||||
FlowInactivityDuration.twelveHours.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
func testDefaultMigrationGivesFreshInstallPrivacyDefaults() {
|
||||
let defaults = makeDefaults()
|
||||
|
||||
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
|
||||
XCTAssertEqual(config.engineMode, "local")
|
||||
XCTAssertEqual(config.flowInactivityDuration, .thirtyMinutes)
|
||||
XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.engineMode), "local")
|
||||
}
|
||||
|
||||
func testTranslationEnabledDerivedFromTargetLocale() {
|
||||
|
||||
@@ -41,4 +41,30 @@ final class ConfigurationStoreTests: XCTestCase {
|
||||
let service = ASRServiceFactory.make(store: store as any ConfigurationStore)
|
||||
XCTAssertTrue(service is SpeechAnalyzerASR)
|
||||
}
|
||||
|
||||
func testASRAndPolishProvidersAreIndependent() throws {
|
||||
try Keychain.setAPIKey("sk-llm", for: "openai", useICloudSync: false)
|
||||
try Keychain.setASRAPIKey("sk-asr", for: "zhipu", useICloudSync: false)
|
||||
|
||||
var config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
config.engineMode = "cloud"
|
||||
config.providerId = "openai"
|
||||
config.asrProviderId = "zhipu"
|
||||
config.save(to: defaults)
|
||||
|
||||
let loaded = AppGroupStore(defaults: defaults)
|
||||
XCTAssertEqual(loaded.providerId, "openai")
|
||||
XCTAssertEqual(loaded.asrProviderId, "zhipu")
|
||||
XCTAssertEqual(loaded.apiKey, "sk-llm")
|
||||
XCTAssertEqual(loaded.asrApiKey, "sk-asr")
|
||||
|
||||
let asrClient = CloudASRClientFactory.make(store: loaded)
|
||||
XCTAssertTrue(asrClient is ZhipuCloudASRClient)
|
||||
}
|
||||
|
||||
func testLegacyInstallCopiesProviderIdToAsrProviderId() {
|
||||
defaults.set("qwen", forKey: AppGroupConfiguration.Keys.providerId)
|
||||
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
XCTAssertEqual(config.asrProviderId, "qwen")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// FlowBudgetAndMergeTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Guards the cross-cutting invariants introduced by the reliability
|
||||
// overhaul: timeout budgets derived from a single source, LWW clock
|
||||
// clamping, and mutation-rebase for the speech history store.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class FlowBudgetAndMergeTests: XCTestCase {
|
||||
|
||||
// MARK: - Timeout budget invariant
|
||||
|
||||
/// The keyboard's post-stop watchdog must outlast the host's worst case
|
||||
/// (ASR drain wait + LLM polish cap) with real margin — otherwise the
|
||||
/// keyboard reports a timeout for transcriptions that are still going
|
||||
/// to succeed, and hand-tuned constants have drifted below the host
|
||||
/// maximum before.
|
||||
func testKeyboardResultTimeoutOutlastsHostWorstCase() {
|
||||
for engineMode in ["local", "cloud"] {
|
||||
let hostWorstCase = (engineMode == "local"
|
||||
? FlowSessionKeys.localASRWaitTimeout
|
||||
: FlowSessionKeys.cloudASRWaitTimeout)
|
||||
+ FlowSessionKeys.maxPolishTimeout
|
||||
let keyboardTimeout = FlowSessionKeys.keyboardResultTimeout(engineMode: engineMode)
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
keyboardTimeout,
|
||||
hostWorstCase + 10,
|
||||
"keyboard watchdog (\(engineMode)) must exceed host worst case with margin"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SyncedField future-clock clamping
|
||||
|
||||
func testMergePrefersGenuinelyNewerRemote() {
|
||||
let older = SyncedField(value: "a", updatedAt: Date(timeIntervalSinceNow: -100), deviceID: "A")
|
||||
let newer = SyncedField(value: "b", updatedAt: Date(timeIntervalSinceNow: -10), deviceID: "B")
|
||||
XCTAssertEqual(SyncedField.merge(local: older, remote: newer).value, "b")
|
||||
XCTAssertEqual(SyncedField.merge(local: newer, remote: older).value, "b")
|
||||
}
|
||||
|
||||
/// A device with a clock years in the future must not win every merge
|
||||
/// forever: its timestamp is clamped to "now" for comparison, so an
|
||||
/// edit carrying a trusted (within-skew) later stamp still beats it —
|
||||
/// with unclamped LWW the year-ahead stamp would win against everything
|
||||
/// until that wall-clock date actually arrived.
|
||||
func testMergeClampsAbsurdFutureRemoteTimestamp() {
|
||||
let farFuture = Date().addingTimeInterval(365 * 24 * 3600)
|
||||
let brokenClock = SyncedField(value: "broken", updatedAt: farFuture, deviceID: "B")
|
||||
// Sane edit one minute ahead of now: inside the trusted skew window,
|
||||
// so it is NOT clamped — while the broken stamp collapses to ~now.
|
||||
let local = SyncedField(value: "sane", updatedAt: Date().addingTimeInterval(60), deviceID: "A")
|
||||
XCTAssertEqual(
|
||||
SyncedField.merge(local: local, remote: brokenClock).value,
|
||||
"sane",
|
||||
"a year-ahead stamp must lose to a trusted, genuinely newer edit"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SyncedField.merge(local: brokenClock, remote: local).value,
|
||||
"sane",
|
||||
"clamping must be symmetric regardless of which side is remote"
|
||||
)
|
||||
}
|
||||
|
||||
/// The winner's untrusted future stamp must be REWRITTEN to now in the
|
||||
/// merged result — otherwise the stored far-future stamp keeps beating
|
||||
/// every later genuine edit until that wall-clock date arrives.
|
||||
func testMergeFlattensUntrustedWinnerStamp() {
|
||||
let farFuture = Date().addingTimeInterval(365 * 24 * 3600)
|
||||
let broken = SyncedField(value: "broken", updatedAt: farFuture, deviceID: "B")
|
||||
let old = SyncedField(value: "old", updatedAt: Date(timeIntervalSinceNow: -9999), deviceID: "A")
|
||||
let merged = SyncedField.merge(local: old, remote: broken)
|
||||
XCTAssertEqual(merged.value, "broken", "newer (clamped) edit still wins this merge")
|
||||
XCTAssertLessThan(
|
||||
merged.updatedAt.timeIntervalSinceNow, 60,
|
||||
"the far-future stamp must be flattened so later real edits can outrank it"
|
||||
)
|
||||
}
|
||||
|
||||
func testMergeTrustsModestFutureSkew() {
|
||||
// Small forward skew (minutes) is normal clock drift and stays trusted.
|
||||
let slightlyAhead = SyncedField(value: "ahead", updatedAt: Date().addingTimeInterval(120), deviceID: "A")
|
||||
let past = SyncedField(value: "past", updatedAt: Date(timeIntervalSinceNow: -3600), deviceID: "B")
|
||||
XCTAssertEqual(SyncedField.merge(local: past, remote: slightlyAhead).value, "ahead")
|
||||
}
|
||||
|
||||
// MARK: - History push byte budget
|
||||
|
||||
/// A history that outgrew the KVS byte budget must be trimmed (oldest
|
||||
/// entries first) for upload, not fail forever — automatic pushes are
|
||||
/// fire-and-forget, so a throwing encode would silently kill sync with
|
||||
/// no recovery path short of clearing all history.
|
||||
@MainActor
|
||||
func testOversizedHistoryPushTrimsOldestEntriesToFitBudget() throws {
|
||||
let sync = SpeechHistoryCloudSync(
|
||||
kvs: FakeUbiquitousKeyValueStore(),
|
||||
makeStore: { AppGroupStore(defaults: self.makeDefaults()) },
|
||||
historyDefaults: { self.makeDefaults() }
|
||||
)
|
||||
|
||||
// ~300 entries × ~2.4 KB ≈ 720 KB encoded — over the 400 KB budget.
|
||||
let filler = String(repeating: "很长的听写内容 long dictation text ", count: 80)
|
||||
let now = Date()
|
||||
var history = SyncedSpeechHistory.empty
|
||||
history.entries = (0..<300).map { index in
|
||||
SpeechHistoryEntry(
|
||||
text: "\(filler)#\(index)",
|
||||
createdAt: now.addingTimeInterval(TimeInterval(-index)),
|
||||
engineMode: "local"
|
||||
)
|
||||
}
|
||||
|
||||
let data = try sync.encodeFittingBudget(history)
|
||||
XCTAssertLessThanOrEqual(data.count, SpeechHistoryCloudSync.maxPayloadBytes)
|
||||
|
||||
let decoded = try sync.decode(data)
|
||||
XCTAssertFalse(decoded.entries.isEmpty)
|
||||
// Newest entries must survive the trim.
|
||||
XCTAssertTrue(decoded.entries.contains { $0.text.hasSuffix("#0") })
|
||||
XCTAssertFalse(decoded.entries.contains { $0.text.hasSuffix("#299") })
|
||||
}
|
||||
|
||||
// MARK: - Insertion word-boundary hygiene
|
||||
|
||||
func testInsertionSeparatorAddsSpaceBetweenLatinWords() {
|
||||
XCTAssertEqual(
|
||||
DictationTextComposer.insertionSeparator(previousContext: "Hello", insertion: "world"),
|
||||
" "
|
||||
)
|
||||
XCTAssertEqual(
|
||||
DictationTextComposer.insertionSeparator(previousContext: "version 2", insertion: "is out"),
|
||||
" "
|
||||
)
|
||||
}
|
||||
|
||||
func testInsertionSeparatorSkipsWhitespaceCJKAndPunctuationBoundaries() {
|
||||
XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "Hello ", insertion: "world"), "")
|
||||
XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "line\n", insertion: "next"), "")
|
||||
XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "你好", insertion: "世界"), "")
|
||||
XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "说英文", insertion: "now"), "")
|
||||
XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "see (", insertion: "note"), "")
|
||||
XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "wait", insertion: ", then go"), "")
|
||||
XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: nil, insertion: "fresh"), "")
|
||||
XCTAssertEqual(DictationTextComposer.insertionSeparator(previousContext: "", insertion: "fresh"), "")
|
||||
}
|
||||
|
||||
// MARK: - SpeechHistoryStore rebase-before-mutation
|
||||
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let suite = "group.com.osgkeyboard.shared.tests.history.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
return defaults
|
||||
}
|
||||
|
||||
/// Cloud pulls write merged history to disk and only *schedule* the
|
||||
/// in-memory reload. A mutation racing that reload must not wipe what
|
||||
/// the merge brought in.
|
||||
@MainActor
|
||||
func testAppendDoesNotEraseEntriesMergedToDiskBehindItsBack() {
|
||||
let defaults = makeDefaults()
|
||||
let store = SpeechHistoryStore(defaults: defaults)
|
||||
|
||||
store.append(text: "本地第一条", engineMode: "local")
|
||||
XCTAssertEqual(store.entries.count, 1)
|
||||
|
||||
// Simulate a cloud merge landing on disk without the store's
|
||||
// in-memory payload being reloaded yet.
|
||||
var onDisk = SpeechHistoryStorage.load(from: defaults)
|
||||
let remoteEntry = SpeechHistoryEntry(text: "远端合并进来的一条", engineMode: "cloud")
|
||||
onDisk.entries.append(remoteEntry)
|
||||
onDisk.updatedAt = Date()
|
||||
SpeechHistoryStorage.save(onDisk, to: defaults)
|
||||
|
||||
// Mutate through the store — pre-fix this overwrote the disk state
|
||||
// with the stale in-memory payload, deleting the remote entry.
|
||||
store.append(text: "本地第二条", engineMode: "local")
|
||||
|
||||
let persisted = SpeechHistoryStorage.load(from: defaults)
|
||||
XCTAssertTrue(
|
||||
persisted.entries.contains { $0.id == remoteEntry.id },
|
||||
"append must rebase on the persisted state instead of clobbering the cloud merge"
|
||||
)
|
||||
XCTAssertTrue(persisted.entries.contains { $0.text == "本地第二条" })
|
||||
XCTAssertTrue(persisted.entries.contains { $0.text == "本地第一条" })
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDeleteAfterExternalDiskMergeStillTombstones() {
|
||||
let defaults = makeDefaults()
|
||||
let store = SpeechHistoryStore(defaults: defaults)
|
||||
store.append(text: "要删除的一条", engineMode: "local")
|
||||
guard let target = store.entries.first else {
|
||||
return XCTFail("expected an entry")
|
||||
}
|
||||
|
||||
// External merge adds an unrelated entry on disk.
|
||||
var onDisk = SpeechHistoryStorage.load(from: defaults)
|
||||
onDisk.entries.append(SpeechHistoryEntry(text: "外部条目", engineMode: "cloud"))
|
||||
onDisk.updatedAt = Date()
|
||||
SpeechHistoryStorage.save(onDisk, to: defaults)
|
||||
|
||||
store.delete(id: target.id)
|
||||
|
||||
let persisted = SpeechHistoryStorage.load(from: defaults)
|
||||
XCTAssertFalse(persisted.entries.contains { $0.id == target.id })
|
||||
XCTAssertNotNil(persisted.deletedEntryIDs[target.id], "delete must record a tombstone")
|
||||
XCTAssertTrue(persisted.entries.contains { $0.text == "外部条目" }, "external entry must survive")
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,142 @@ final class FlowSessionBridgeTests: XCTestCase {
|
||||
XCTAssertEqual(FlowSessionBridge.latestAck(defaults: defaults), ack)
|
||||
}
|
||||
|
||||
func testNotReadySnapshotDoesNotRefreshHeartbeat() {
|
||||
let defaults = makeDefaults()
|
||||
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
|
||||
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
|
||||
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
|
||||
// A host stuck in a failed cold start writes not-ready snapshots on
|
||||
// every engine flap; those must NOT revive the heartbeat, or zombie
|
||||
// detection is postponed forever.
|
||||
FlowSessionBridge.writeReadySnapshot(
|
||||
FlowReadySnapshot(
|
||||
sessionId: UUID(),
|
||||
ready: false,
|
||||
reason: .waitingForAudioProof,
|
||||
engineMode: "local",
|
||||
localeId: "zh-Hans"
|
||||
),
|
||||
defaults: defaults
|
||||
)
|
||||
|
||||
XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults))
|
||||
}
|
||||
|
||||
func testBusySnapshotStillRefreshesHeartbeat() {
|
||||
let defaults = makeDefaults()
|
||||
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
|
||||
let staleHeartbeat = Date().timeIntervalSince1970 - 10
|
||||
defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
|
||||
// Recording/processing proves the host is alive even though the
|
||||
// snapshot is not "ready" — the heartbeat must keep flowing so the
|
||||
// keyboard does not declare a mid-utterance host dead.
|
||||
let sessionId = UUID()
|
||||
FlowSessionBridge.writeReadySnapshot(
|
||||
FlowReadySnapshot(
|
||||
sessionId: sessionId,
|
||||
ready: false,
|
||||
reason: .recording,
|
||||
engineMode: "local",
|
||||
localeId: "zh-Hans",
|
||||
busyUtteranceId: UUID()
|
||||
),
|
||||
defaults: defaults
|
||||
)
|
||||
|
||||
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
|
||||
// Not-ready busy snapshots must remain readable so the keyboard can
|
||||
// distinguish "host is recording" from "host is still starting".
|
||||
let snap = FlowSessionBridge.readySnapshot(defaults: defaults)
|
||||
XCTAssertEqual(snap?.reason, .recording)
|
||||
XCTAssertEqual(snap?.ready, false)
|
||||
XCTAssertEqual(snap?.sessionId, sessionId)
|
||||
}
|
||||
|
||||
func testNotReadyStartingSnapshotIsRetainedWithoutRevivingHeartbeat() {
|
||||
let defaults = makeDefaults()
|
||||
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
|
||||
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
|
||||
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
|
||||
FlowSessionBridge.writeReadySnapshot(
|
||||
FlowReadySnapshot(
|
||||
sessionId: UUID(),
|
||||
ready: false,
|
||||
reason: .waitingForAudioProof,
|
||||
engineMode: "local",
|
||||
localeId: "zh-Hans"
|
||||
),
|
||||
defaults: defaults
|
||||
)
|
||||
|
||||
XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults))
|
||||
XCTAssertEqual(
|
||||
FlowSessionBridge.readySnapshot(defaults: defaults)?.reason,
|
||||
.waitingForAudioProof
|
||||
)
|
||||
}
|
||||
|
||||
func testStaleGenerationSnapshotIsNotReady() {
|
||||
let defaults = makeDefaults()
|
||||
let sessionId = UUID()
|
||||
let now = Date().timeIntervalSince1970
|
||||
FlowSessionBridge.rotateHostGeneration(defaults: defaults)
|
||||
let liveGeneration = FlowSessionBridge.currentHostGeneration(defaults: defaults)
|
||||
FlowSessionBridge.markSessionActive(duration: 60, sessionId: sessionId, defaults: defaults)
|
||||
FlowSessionBridge.writeReadySnapshot(
|
||||
FlowReadySnapshot(
|
||||
sessionId: sessionId,
|
||||
ready: true,
|
||||
reason: .ready,
|
||||
heartbeatAt: now,
|
||||
readyAt: now,
|
||||
engineMode: "local",
|
||||
localeId: "zh-Hans",
|
||||
hostGeneration: liveGeneration
|
||||
),
|
||||
defaults: defaults
|
||||
)
|
||||
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
|
||||
// Host relaunches (force-quit path) → new generation. The old ready
|
||||
// snapshot must be void instantly, without waiting out the 60 s
|
||||
// heartbeat-zombie window.
|
||||
FlowSessionBridge.rotateHostGeneration(defaults: defaults)
|
||||
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
}
|
||||
|
||||
func testClearFlowStateOnHostLaunchPreservesPendingHost() {
|
||||
let defaults = makeDefaults()
|
||||
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
|
||||
FlowSessionBridge.setHostReady(true, defaults: defaults)
|
||||
FlowSessionBridge.setPendingHostBundleId("com.example.host", defaults: defaults)
|
||||
|
||||
FlowSessionBridge.clearFlowStateOnHostLaunch(defaults: defaults)
|
||||
|
||||
XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
|
||||
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
// The startflow scene-delegate write happens before the session
|
||||
// manager exists — launch reconciliation must not eat it.
|
||||
XCTAssertEqual(
|
||||
FlowSessionBridge.pendingHostBundleId(defaults: defaults),
|
||||
"com.example.host"
|
||||
)
|
||||
}
|
||||
|
||||
func testRotateHostGenerationReturnsPreviousToken() {
|
||||
let defaults = makeDefaults()
|
||||
XCTAssertNil(FlowSessionBridge.rotateHostGeneration(defaults: defaults))
|
||||
let first = FlowSessionBridge.currentHostGeneration(defaults: defaults)
|
||||
XCTAssertNotNil(first)
|
||||
|
||||
let previous = FlowSessionBridge.rotateHostGeneration(defaults: defaults)
|
||||
XCTAssertEqual(previous, first)
|
||||
XCTAssertNotEqual(FlowSessionBridge.currentHostGeneration(defaults: defaults), first)
|
||||
}
|
||||
|
||||
func testReadySnapshotDrivesHostReady() {
|
||||
let defaults = makeDefaults()
|
||||
let sessionId = UUID()
|
||||
|
||||
@@ -17,10 +17,10 @@ final class FlowSessionPolicyTests: XCTestCase {
|
||||
XCTAssertTrue(FlowSessionPolicy.skipAppSwitch(defaults: defaults))
|
||||
}
|
||||
|
||||
func testInactivityDurationDefaultsToTwelveHours() {
|
||||
func testInactivityDurationDefaultsToThirtyMinutes() {
|
||||
let defaults = makeDefaults()
|
||||
XCTAssertEqual(FlowSessionPolicy.inactivityDuration(defaults: defaults), .twelveHours)
|
||||
XCTAssertEqual(FlowSessionPolicy.sessionDuration(defaults: defaults), 12 * 60 * 60)
|
||||
XCTAssertEqual(FlowSessionPolicy.inactivityDuration(defaults: defaults), .thirtyMinutes)
|
||||
XCTAssertEqual(FlowSessionPolicy.sessionDuration(defaults: defaults), 30 * 60)
|
||||
}
|
||||
|
||||
func testTouchLastActivityExtendsExpiry() {
|
||||
|
||||
@@ -340,7 +340,7 @@ final class IntelligentPolishTests: XCTestCase {
|
||||
|
||||
@MainActor
|
||||
func testFlowFallbackDeliveryCleansTextAndCarriesWeakNetworkWarning() {
|
||||
let delivery = FlowSessionManager.makeFallbackDelivery(
|
||||
let delivery = TranscriptionPolishFallback.makeDelivery(
|
||||
rawText: " 你 是不是 已经 解决了 这个 问题 ? ",
|
||||
error: LLMError.transport("offline"),
|
||||
engineMode: "cloud",
|
||||
@@ -352,6 +352,20 @@ final class IntelligentPolishTests: XCTestCase {
|
||||
XCTAssertEqual(delivery.polishWarning, SharedL10n.string("flow.warning.polishDegraded"))
|
||||
}
|
||||
|
||||
func testTranscriptionPolishFallbackLocalMissingKeyWarning() {
|
||||
let delivery = TranscriptionPolishFallback.makeDelivery(
|
||||
rawText: "测试文本",
|
||||
error: PolishingService.PolishError.missingAPIKey,
|
||||
engineMode: "local",
|
||||
chunkWarning: nil
|
||||
)
|
||||
XCTAssertEqual(delivery.text, "测试文本")
|
||||
XCTAssertEqual(
|
||||
delivery.polishWarning,
|
||||
SharedL10n.string("flow.warning.localPolishUnavailable")
|
||||
)
|
||||
}
|
||||
|
||||
func testHasStructureSignalDetectsChineseEnumeration() {
|
||||
XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "首先测试其次上线"))
|
||||
XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "第一点修复"))
|
||||
|
||||
@@ -13,7 +13,20 @@ final class LocalASRModelCatalogTests: XCTestCase {
|
||||
XCTAssertFalse(catalog.models.contains { $0.id == "qwen3-mlx-1.7b" })
|
||||
XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-0.6b-int8" })
|
||||
XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-1.7b-int8" })
|
||||
XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-paraformer-zh-int8" })
|
||||
XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-sensevoice-small-int8" })
|
||||
XCTAssertFalse(catalog.models.contains { $0.id == "sherpa-paraformer-zh-int8" })
|
||||
XCTAssertEqual(
|
||||
LocalASRModelCatalog.model("sherpa-sensevoice-small-int8", in: catalog)?.badgeKey,
|
||||
"mac.localASR.badge.fastest"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
LocalASRModelCatalog.model("sherpa-qwen3-0.6b-int8", in: catalog)?.badgeKey,
|
||||
"mac.localASR.badge.balanced"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
LocalASRModelCatalog.model("sherpa-qwen3-1.7b-int8", in: catalog)?.badgeKey,
|
||||
"mac.localASR.badge.quality"
|
||||
)
|
||||
}
|
||||
|
||||
func testSherpaQwen317BUsesRepositoryInstall() throws {
|
||||
@@ -32,9 +45,9 @@ final class LocalASRModelCatalogTests: XCTestCase {
|
||||
XCTAssertTrue(model.supportsHotwords)
|
||||
}
|
||||
|
||||
func testCapabilitiesForParaformer() throws {
|
||||
func testCapabilitiesForSenseVoice() throws {
|
||||
let catalog = try LocalASRModelCatalog.loadBundled()
|
||||
let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-paraformer-zh-int8", in: catalog))
|
||||
let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-sensevoice-small-int8", in: catalog))
|
||||
let caps = LocalASRModelCatalog.capabilities(for: model)
|
||||
XCTAssertEqual(caps.hotwordMode, .none)
|
||||
XCTAssertFalse(model.supportsHotwords)
|
||||
|
||||
@@ -44,6 +44,9 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
providerId: SyncedField(value: "openai", updatedAt: stampA, deviceID: deviceA),
|
||||
baseURL: SyncedField(value: "https://local.example", updatedAt: stampA, deviceID: deviceA),
|
||||
model: SyncedField(value: "gpt-local", updatedAt: stampA, deviceID: deviceA),
|
||||
asrProviderId: SyncedField(value: "zhipu", updatedAt: stampA, deviceID: deviceA),
|
||||
asrBaseURL: SyncedField(value: "https://asr-local.example", updatedAt: stampA, deviceID: deviceA),
|
||||
asrModel: SyncedField(value: "glm-asr-local", updatedAt: stampA, deviceID: deviceA),
|
||||
modeId: SyncedField(value: "polish", updatedAt: stampA, deviceID: deviceA),
|
||||
localeId: SyncedField(value: "auto", updatedAt: stampA, deviceID: deviceA),
|
||||
engineMode: SyncedField(value: "cloud", updatedAt: stampA, deviceID: deviceA),
|
||||
@@ -64,6 +67,9 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
providerId: SyncedField(value: "openai", updatedAt: stampA, deviceID: deviceB),
|
||||
baseURL: SyncedField(value: "https://remote.example", updatedAt: stampB, deviceID: deviceB),
|
||||
model: SyncedField(value: "gpt-remote", updatedAt: stampB, deviceID: deviceB),
|
||||
asrProviderId: SyncedField(value: "qwen", updatedAt: stampB, deviceID: deviceB),
|
||||
asrBaseURL: SyncedField(value: "https://asr-remote.example", updatedAt: stampB, deviceID: deviceB),
|
||||
asrModel: SyncedField(value: "fun-asr-remote", updatedAt: stampB, deviceID: deviceB),
|
||||
modeId: SyncedField(value: "polish", updatedAt: stampA, deviceID: deviceB),
|
||||
localeId: SyncedField(value: "ja", updatedAt: stampB, deviceID: deviceB),
|
||||
engineMode: SyncedField(value: "local", updatedAt: stampB, deviceID: deviceB),
|
||||
@@ -80,6 +86,7 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
|
||||
|
||||
XCTAssertEqual(merged.baseURL.value, "https://remote.example")
|
||||
XCTAssertEqual(merged.asrProviderId.value, "qwen")
|
||||
XCTAssertEqual(merged.localeId.value, "ja")
|
||||
XCTAssertEqual(merged.engineMode.value, "local")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user