Files
OSGKeyboard/OSGKeyboardTests/CloudASRTests.swift
T
Rocky 498f407585 feat(account): add managed credits and cloud gateway
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
2026-08-20 11:43:21 +08:00

344 lines
17 KiB
Swift

// CloudASRTests.swift
// OSGKeyboardTests
@testable import OSGKeyboardHostSupport
@testable import OSGKeyboardShared
import XCTest
final class CloudASRTests: XCTestCase {
func testCloudASRStrategyRouting() {
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "zhipu"), .zhipuHotwords)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .localFallback)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "bailian"), .bailianStreaming)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openai"), .openaiRealtimeStreaming)
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: "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-realtime-whisper")
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)
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 testTrueStreamingASRProviders() {
XCTAssertTrue(CloudASRModelCatalog.supportsTrueStreamingASR(for: "bailian"))
XCTAssertTrue(CloudASRModelCatalog.supportsTrueStreamingASR(for: "volcengine"))
XCTAssertTrue(CloudASRModelCatalog.supportsTrueStreamingASR(for: "openai"))
XCTAssertTrue(LLMProvider.provider(id: "bailian").supportsStreamingCloudASR)
XCTAssertTrue(LLMProvider.provider(id: "volcengine").supportsStreamingCloudASR)
XCTAssertTrue(LLMProvider.provider(id: "openai").supportsStreamingCloudASR)
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "mimo"))
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "zhipu"))
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "groq"))
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "whisper"))
}
func testUpsample16kTo24kPreservesDurationRatio() {
let input = [Float](repeating: 0.25, count: 1_600) // 100 ms @ 16 kHz
let output = CloudASRStreamingPCM.upsample16kTo24k(input)
XCTAssertEqual(output.count, 2_400) // 100 ms @ 24 kHz
}
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 testLegacyQwenASRConfigCopiesDedicatedKeyToBailian() throws {
clearQwenMigrationKeys()
defer { clearQwenMigrationKeys() }
let suite = "group.com.osgkeyboard.tests.qwen-asr-key.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
defer { defaults.removePersistentDomain(forName: suite) }
defaults.set(false, forKey: AppGroupConfiguration.Keys.settingsICloudSyncEnabled)
defaults.set("qwen", forKey: AppGroupConfiguration.Keys.asrProviderId)
try Keychain.setASRAPIKey("dedicated-credential", for: "qwen", useICloudSync: false)
_ = AppGroupConfiguration.load(fromAvailable: defaults)
XCTAssertNotNil(Keychain.asrApiKey(for: "qwen", preferICloudSync: false))
XCTAssertNotNil(Keychain.asrApiKey(for: "bailian", preferICloudSync: false))
}
func testLegacyQwenASRConfigFallsBackToProviderKey() throws {
clearQwenMigrationKeys()
defer { clearQwenMigrationKeys() }
let suite = "group.com.osgkeyboard.tests.qwen-provider-key.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
defer { defaults.removePersistentDomain(forName: suite) }
defaults.set(false, forKey: AppGroupConfiguration.Keys.settingsICloudSyncEnabled)
defaults.set("qwen", forKey: AppGroupConfiguration.Keys.asrProviderId)
try Keychain.setAPIKey("provider-credential", for: "qwen", useICloudSync: false)
_ = AppGroupConfiguration.load(fromAvailable: defaults)
XCTAssertNotNil(Keychain.apiKey(for: "qwen", preferICloudSync: false))
XCTAssertNotNil(Keychain.asrApiKey(for: "bailian", preferICloudSync: false))
}
func testLegacyQwenASRConfigDoesNotOverwriteDifferentBailianKey() throws {
clearQwenMigrationKeys()
defer { clearQwenMigrationKeys() }
let suite = "group.com.osgkeyboard.tests.qwen-key-conflict.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
defer { defaults.removePersistentDomain(forName: suite) }
defaults.set(false, forKey: AppGroupConfiguration.Keys.settingsICloudSyncEnabled)
defaults.set("qwen", forKey: AppGroupConfiguration.Keys.asrProviderId)
try Keychain.setASRAPIKey("qwen-credential", for: "qwen", useICloudSync: false)
try Keychain.setASRAPIKey("bailian-credential", for: "bailian", useICloudSync: false)
_ = AppGroupConfiguration.load(fromAvailable: defaults)
XCTAssertTrue(
Keychain.asrApiKey(for: "bailian", preferICloudSync: false)
== "bailian-credential"
)
XCTAssertNotNil(Keychain.asrApiKey(for: "qwen", preferICloudSync: false))
}
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.authMode, .appToken)
// Custom resource IDs are ignored; product is locked to SAUC 2.0 duration.
XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
XCTAssertTrue(fields.hasUsableCredentials)
}
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.authMode, .appToken)
XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
XCTAssertTrue(fields.encodedAPIKey.contains("app-1"))
XCTAssertTrue(fields.encodedAPIKey.contains("auth_mode"))
}
func testVolcengineASRFieldsAPIKeyModeParsing() {
let json = #"{"auth_mode":"api_key","api_key":"vk-new-console"}"#
let fields = VolcengineASRFields.parse(apiKey: json)
XCTAssertEqual(fields.authMode, .apiKey)
XCTAssertEqual(fields.apiKeyCredential, "vk-new-console")
XCTAssertTrue(fields.hasUsableCredentials)
XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
var request = URLRequest(url: URL(string: "wss://example.invalid")!)
fields.applyWebSocketAuthHeaders(to: &request, connectID: "conn-1")
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Key"), "vk-new-console")
XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-App-Key"))
XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-Access-Key"))
XCTAssertEqual(
request.value(forHTTPHeaderField: "X-Api-Resource-Id"),
VolcengineASRFields.fixedResourceID
)
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Connect-Id"), "conn-1")
}
func testVolcengineASRFieldsAppTokenHeaders() {
let fields = VolcengineASRFields(
authMode: .appToken,
appID: "app-1",
accessToken: "tok-2"
)
var request = URLRequest(url: URL(string: "wss://example.invalid")!)
fields.applyWebSocketAuthHeaders(to: &request, connectID: "conn-2")
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-App-Key"), "app-1")
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Access-Key"), "tok-2")
XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-Key"))
XCTAssertEqual(
request.value(forHTTPHeaderField: "X-Api-Resource-Id"),
VolcengineASRFields.fixedResourceID
)
}
func testVolcengineASRFieldsTogglePreservesBothCredentialSets() {
var fields = VolcengineASRFields(
authMode: .appToken,
appID: "app-1",
accessToken: "tok-2",
apiKeyCredential: "vk-keep"
)
fields.authMode = .apiKey
let encoded = fields.encodedAPIKey
let parsed = VolcengineASRFields.parse(apiKey: encoded)
XCTAssertEqual(parsed.authMode, .apiKey)
XCTAssertEqual(parsed.apiKeyCredential, "vk-keep")
XCTAssertEqual(parsed.appID, "app-1")
XCTAssertEqual(parsed.accessToken, "tok-2")
}
func testVolcengineASRFieldsEmptyDefaultsToAPIKeyMode() {
let fields = VolcengineASRFields.parse(apiKey: "")
XCTAssertEqual(fields.authMode, .apiKey)
XCTAssertFalse(fields.hasUsableCredentials)
}
func testVolcengineASRFieldsEmptyAPIKeyModeIsNotUsable() {
let json = #"{"auth_mode":"api_key"}"#
let fields = VolcengineASRFields.parse(apiKey: json)
XCTAssertEqual(fields.authMode, .apiKey)
XCTAssertFalse(fields.hasUsableCredentials)
}
func testPersonalDictionaryASRHotwordsDedupesTerms() {
let dict = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Kubernetes", category: .technical, source: .manual),
PersonalDictionary.Entry(term: "kubernetes", category: .technical, source: .manual),
PersonalDictionary.Entry(term: "OSGKeyboard", category: .productName, source: .manual)
])
let hotwords = dict.asrHotwords()
XCTAssertEqual(hotwords.count, 2)
XCTAssertTrue(hotwords.contains("Kubernetes"))
XCTAssertTrue(hotwords.contains("OSGKeyboard"))
}
func testPersonalDictionaryASRPromptIncludesAliases() {
var dict = PersonalDictionary.empty
_ = dict.upsertManual(term: "Kubernetes")
dict.updateAliases(
for: dict.entries[0].id,
aliases: ["k8s", "库伯内特斯"]
)
let prompt = dict.asrPromptBias()
XCTAssertTrue(prompt.contains("Kubernetes"))
XCTAssertTrue(prompt.contains("k8s"))
}
func testPersonalDictionaryAlibabaHotwordEntries() {
let dict = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Cursor", category: .productName, source: .manual)
])
let entries = dict.alibabaHotwordEntries()
// Includes built-in system term "OSGKeyboard" via effectiveEntries.
XCTAssertEqual(entries.count, 2)
XCTAssertTrue(entries.contains(where: { $0.text == "Cursor" }))
XCTAssertTrue(entries.contains(where: { $0.text == "OSGKeyboard" }))
XCTAssertEqual(entries.first(where: { $0.text == "Cursor" })?.weight, 4)
}
func testPCMSampleWavEncoderProducesHeader() {
let wav = PCMSampleWavEncoder.encode(samples: [0.0, 0.5, -0.5], sampleRate: 16_000)
XCTAssertGreaterThan(wav.count, 44)
XCTAssertEqual(String(data: wav.prefix(4), encoding: .ascii), "RIFF")
XCTAssertEqual(String(data: wav.dropFirst(8).prefix(4), encoding: .ascii), "WAVE")
}
func testOpenAIRealtimeProbeCancellationDoesNotUseBatchFallback() {
XCTAssertFalse(
OpenAIRealtimeASRClient.shouldFallbackToBatch(afterProbeError: CancellationError())
)
XCTAssertFalse(
OpenAIRealtimeASRClient.shouldFallbackToBatch(afterProbeError: URLError(.cancelled))
)
XCTAssertTrue(
OpenAIRealtimeASRClient.shouldFallbackToBatch(
afterProbeError: CloudASRError.transport("handshake failed")
)
)
}
func testVocabularyFingerprintChangesWhenDictionaryChanges() {
let emptyFP = PersonalDictionary.empty.vocabularySyncFingerprint()
let withTerm = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Kubernetes", category: .technical, source: .manual)
])
XCTAssertNotEqual(emptyFP, withTerm.vocabularySyncFingerprint())
}
private func clearQwenMigrationKeys() {
try? Keychain.deleteAPIKey(for: "qwen", useICloudSync: true)
try? Keychain.deleteASRAPIKey(for: "qwen", useICloudSync: true)
try? Keychain.deleteASRAPIKey(for: "bailian", useICloudSync: true)
}
}