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:
Rocky
2026-07-11 19:10:20 +08:00
parent cdf833935a
commit cc8dd1070a
116 changed files with 6659 additions and 2634 deletions
@@ -17,7 +17,8 @@ final class AppGroupConfigurationTests: XCTestCase {
let defaults = makeDefaults()
let config = AppGroupConfiguration.load(fromAvailable: defaults)
XCTAssertEqual(config.providerId, "openai")
XCTAssertEqual(config.providerId, "deepseek")
XCTAssertEqual(config.asrProviderId, "volcengine")
XCTAssertEqual(config.modeId, "polish")
XCTAssertEqual(config.localeId, "auto")
// Privacy-critical: the default engine must keep audio on-device.
@@ -125,14 +126,14 @@ final class AppGroupConfigurationTests: XCTestCase {
XCTAssertFalse(config.translationEnabled)
}
func testCloudDeepSeekProviderMigratesToOpenAI() {
func testCloudDeepSeekProviderIsPreserved() {
let defaults = makeDefaults()
defaults.set("deepseek", forKey: AppGroupConfiguration.Keys.providerId)
defaults.set("cloud", forKey: AppGroupConfiguration.Keys.engineMode)
let config = AppGroupConfiguration.load(fromAvailable: defaults)
XCTAssertEqual(config.providerId, "openai")
XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.providerId), "openai")
XCTAssertEqual(config.providerId, "deepseek")
XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.providerId), "deepseek")
}
func testPolishIntensityLegacyOffMigratesToMedium() {
+99 -3
View File
@@ -8,26 +8,122 @@ final class CloudASRTests: XCTestCase {
func testCloudASRStrategyRouting() {
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "zhipu"), .zhipuHotwords)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .alibabaVocabulary)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .localFallback)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "bailian"), .bailianStreaming)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openai"), .prompt)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "whisper"), .prompt)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "mimo"), .prompt)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "groq"), .prompt)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "siliconflow"), .prompt)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openrouter"), .openRouterJson)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "volcengine"), .volcengineStreaming)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "moonshot"), .localFallback)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "ark"), .localFallback)
}
func testCloudASRModelDefaults() {
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "qwen"), "fun-asr-flash-2026-06-15")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "bailian"), "fun-asr-realtime")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "zhipu"), "glm-asr-2512")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "mimo"), "mimo-v2.5-asr")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openai"), "gpt-4o-mini-transcribe")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "whisper"), "whisper-1")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "groq"), "whisper-large-v3-turbo")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "siliconflow"), "FunAudioLLM/SenseVoiceSmall")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openrouter"), "openai/whisper-large-v3-turbo")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "volcengine"), "volc.seedasr.sauc.duration")
}
func testAsrSelectablePresetsAllowlist() {
let ids = Set(LLMProvider.asrSelectablePresets.map(\.id))
XCTAssertTrue(ids.contains("groq"))
XCTAssertTrue(ids.contains("siliconflow"))
XCTAssertTrue(ids.contains("openrouter"))
XCTAssertTrue(ids.contains("bailian"))
XCTAssertTrue(ids.contains("whisper"))
XCTAssertTrue(ids.contains("volcengine"))
XCTAssertFalse(ids.contains("qwen"))
XCTAssertFalse(ids.contains("moonshot"))
XCTAssertFalse(ids.contains("ark"))
XCTAssertFalse(ids.contains("anthropic"))
XCTAssertFalse(ids.contains("gemini"))
}
func testPolishOnlyProvidersExcludedFromASRPicker() {
let polishIds = Set(LLMProvider.userSelectablePresets.map(\.id))
let asrIds = Set(LLMProvider.asrSelectablePresets.map(\.id))
XCTAssertTrue(polishIds.contains("ark"))
XCTAssertFalse(asrIds.contains("ark"))
XCTAssertTrue(polishIds.contains("gemini"))
XCTAssertFalse(asrIds.contains("gemini"))
}
func testPersonalDictionaryCloudASRBadgeProviders() {
XCTAssertTrue(LLMProvider.provider(id: "zhipu").supportsPersonalDictionaryCloudASR)
XCTAssertTrue(LLMProvider.provider(id: "qwen").supportsPersonalDictionaryCloudASR)
XCTAssertFalse(LLMProvider.provider(id: "qwen").supportsPersonalDictionaryCloudASR)
XCTAssertFalse(LLMProvider.provider(id: "bailian").supportsPersonalDictionaryCloudASR)
XCTAssertFalse(LLMProvider.provider(id: "openai").supportsPersonalDictionaryCloudASR)
XCTAssertFalse(LLMProvider.provider(id: "moonshot").supportsPersonalDictionaryCloudASR)
}
func testShowsASREndpointField() {
XCTAssertTrue(CloudASRModelCatalog.showsASREndpointField(for: "bailian"))
XCTAssertTrue(CloudASRModelCatalog.showsASREndpointField(for: "openai"))
XCTAssertFalse(CloudASRModelCatalog.showsASREndpointField(for: "qwen"))
XCTAssertFalse(CloudASRModelCatalog.showsASREndpointField(for: "volcengine"))
}
func testBailianMergeSegmentsDedupesOverlap() {
let merged = BailianRealtimeASRClient.mergeSegments(["你好吗", "好吗我们"])
XCTAssertEqual(merged, "你好吗我们")
}
func testBailianRunTaskMessageIncludesModel() {
let json = BailianRealtimeASRClient.runTaskMessage(
taskID: "task-1",
model: "fun-asr-realtime",
vocabularyID: nil
)
XCTAssertTrue(json.contains("fun-asr-realtime"))
XCTAssertTrue(json.contains("run-task"))
XCTAssertTrue(json.contains("\"format\":\"pcm\"") || json.contains("\"format\": \"pcm\""))
XCTAssertTrue(json.contains("16000") || json.contains("16_000"))
}
func testLegacyQwenASRConfigMigratesToBailian() {
let suite = "group.com.osgkeyboard.tests.qwen-asr.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
defer { defaults.removePersistentDomain(forName: suite) }
defaults.set("qwen", forKey: AppGroupConfiguration.Keys.asrProviderId)
defaults.set("https://dashscope.aliyuncs.com/compatible-mode/v1", forKey: AppGroupConfiguration.Keys.asrBaseURL)
defaults.set("fun-asr-flash-2026-06-15", forKey: AppGroupConfiguration.Keys.asrModel)
let config = AppGroupConfiguration.load(fromAvailable: defaults)
XCTAssertEqual(config.asrProviderId, "bailian")
XCTAssertEqual(config.asrBaseURL, CloudASRModelCatalog.bailianDefaultEndpoint)
XCTAssertEqual(config.asrModel, CloudASRModelCatalog.alibabaFunASRRealtime)
}
func testVolcengineASRFieldsJSONParsing() {
let json = #"{"app_id":"app-1","access_token":"tok-2","resource_id":"res-3"}"#
let fields = VolcengineASRFields.parse(apiKey: json, resourceFallback: "")
XCTAssertEqual(fields.appID, "app-1")
XCTAssertEqual(fields.accessToken, "tok-2")
XCTAssertEqual(fields.resourceID, "res-3")
}
func testVolcengineASRFieldsColonParsing() {
let fields = VolcengineASRFields.parse(
apiKey: "app-1:tok-2:res-3",
resourceFallback: CloudASRModelCatalog.defaultModel(for: "volcengine")
)
XCTAssertEqual(fields.appID, "app-1")
XCTAssertEqual(fields.accessToken, "tok-2")
XCTAssertEqual(fields.resourceID, "res-3")
XCTAssertTrue(fields.encodedAPIKey.contains("app-1"))
}
func testPersonalDictionaryASRHotwordsDedupesTerms() {
let dict = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Kubernetes", category: .technical, source: .manual),
@@ -62,9 +62,9 @@ final class ConfigurationStoreTests: XCTestCase {
XCTAssertTrue(asrClient is ZhipuCloudASRClient)
}
func testLegacyInstallCopiesProviderIdToAsrProviderId() {
func testLegacyInstallCopiesProviderIdToAsrProviderIdThenMigratesQwen() {
defaults.set("qwen", forKey: AppGroupConfiguration.Keys.providerId)
let config = AppGroupConfiguration.load(fromAvailable: defaults)
XCTAssertEqual(config.asrProviderId, "qwen")
XCTAssertEqual(config.asrProviderId, "bailian")
}
}
@@ -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
)
}
}
+77
View File
@@ -347,6 +347,83 @@ final class LLMClientTests: XCTestCase {
/// Local engine pins DeepSeek cloud-provider URL/model in App Group
/// must not leak into the LLM request (regression: Qwen URL + DeepSeek key 401).
func testLLMClientFactoryRoutesAnthropic() {
let client = LLMClientFactory.make(
providerId: "anthropic",
baseURL: "",
apiKey: "sk-test",
model: "claude-sonnet-4-6"
)
XCTAssertTrue(client is AnthropicMessagesClient)
}
func testLLMClientFactoryResolvesGeminiOpenAICompatBaseURL() {
let client = LLMClientFactory.make(
providerId: "gemini",
baseURL: "",
apiKey: "key",
model: "gemini-2.5-flash"
) as! OpenAICompatibleClient
XCTAssertEqual(
client.baseURL,
"https://generativelanguage.googleapis.com/v1beta/openai"
)
}
/// DeepSeek V4 thinking defaults ON server-side; polish must explicitly disable it.
func testDeepSeekPolishDisablesThinkingByDefault() {
var body: [String: Any] = ["model": "deepseek-v4-flash"]
LLMThinkingControl.apply(
to: &body,
providerId: "deepseek",
baseURL: "https://api.deepseek.com/v1",
model: "deepseek-v4-flash",
enabled: false
)
let thinking = body["thinking"] as? [String: Any]
XCTAssertEqual(thinking?["type"] as? String, "disabled")
XCTAssertNil(body["reasoning_effort"], "disabled path must not send reasoning_effort (low→high on DeepSeek)")
}
func testDeepSeekPolishEnablesThinkingWhenToggledOn() {
var body: [String: Any] = ["model": "deepseek-v4-flash"]
LLMThinkingControl.apply(
to: &body,
providerId: "deepseek",
baseURL: "https://api.deepseek.com/v1",
model: "deepseek-v4-flash",
enabled: true
)
let thinking = body["thinking"] as? [String: Any]
XCTAssertEqual(thinking?["type"] as? String, "enabled")
XCTAssertEqual(body["reasoning_effort"] as? String, "high")
}
/// Ordinary OpenAI chat models must not get thinking fields when the toggle is off.
func testOpenAIChatDoesNotInjectThinkingWhenDisabled() {
var body: [String: Any] = ["model": "gpt-4o-mini"]
LLMThinkingControl.apply(
to: &body,
providerId: "openai",
baseURL: "https://api.openai.com/v1",
model: "gpt-4o-mini",
enabled: false
)
XCTAssertNil(body["thinking"])
XCTAssertNil(body["reasoning_effort"])
XCTAssertNil(body["thinking_config"])
}
func testLLMThinkingDefaultIsOffInFreshConfiguration() {
let suiteName = "group.com.osgkeyboard.shared.tests.thinking.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let config = ProviderConfig(defaults: defaults)
XCTAssertFalse(config.llmThinkingEnabled, "cloud polish thinking must default off")
}
func testResolveLLMEndpointUsesPresetWhenProviderPinned() {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
@@ -60,6 +60,7 @@ final class SettingsCloudSyncTests: XCTestCase {
handednessPreference: SyncedField(value: .left, updatedAt: stampA, deviceID: deviceA),
cursorDragNavigationEnabled: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
polishIntensity: SyncedField(value: .medium, updatedAt: stampA, deviceID: deviceA),
llmThinkingEnabled: SyncedField(value: false, updatedAt: stampA, deviceID: deviceA),
flowSkipAppSwitch: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
flowInactivityDuration: SyncedField(value: .twelveHours, updatedAt: stampA, deviceID: deviceA)
)
@@ -79,6 +80,7 @@ final class SettingsCloudSyncTests: XCTestCase {
handednessPreference: SyncedField(value: .right, updatedAt: stampB, deviceID: deviceB),
cursorDragNavigationEnabled: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
polishIntensity: SyncedField(value: .light, updatedAt: stampB, deviceID: deviceB),
llmThinkingEnabled: SyncedField(value: true, updatedAt: stampB, deviceID: deviceB),
flowSkipAppSwitch: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
flowInactivityDuration: SyncedField(value: .threeHours, updatedAt: stampB, deviceID: deviceB)
)
@@ -129,6 +131,7 @@ final class SettingsCloudSyncTests: XCTestCase {
try await settingsSync.enableSync()
XCTAssertTrue(store.settingsICloudSyncEnabled)
XCTAssertTrue(store.personalDictionaryICloudSyncEnabled)
XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: true), "sk-local-openai")
}