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:
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user