feat(dictionary): add iCloud KVS sync, cloud ASR, and lexicon expansion
Mirror the personal dictionary through iCloud Key-Value Store with deterministic merge rules, main-app-only sync UI, and App Group as the keyboard runtime cache. Add cloud-engine ASR with dictionary bias and expand the bundled custom language model lexicon.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
// CloudASRTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class CloudASRTests: XCTestCase {
|
||||
|
||||
func testCloudASRStrategyRouting() {
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "zhipu"), .zhipuHotwords)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .alibabaVocabulary)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openai"), .prompt)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "mimo"), .prompt)
|
||||
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "moonshot"), .localFallback)
|
||||
}
|
||||
|
||||
func testCloudASRModelDefaults() {
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "qwen"), "fun-asr-flash-2026-06-15")
|
||||
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")
|
||||
}
|
||||
|
||||
func testPersonalDictionaryCloudASRBadgeProviders() {
|
||||
XCTAssertTrue(LLMProvider.provider(id: "zhipu").supportsPersonalDictionaryCloudASR)
|
||||
XCTAssertTrue(LLMProvider.provider(id: "qwen").supportsPersonalDictionaryCloudASR)
|
||||
XCTAssertFalse(LLMProvider.provider(id: "openai").supportsPersonalDictionaryCloudASR)
|
||||
XCTAssertFalse(LLMProvider.provider(id: "moonshot").supportsPersonalDictionaryCloudASR)
|
||||
}
|
||||
|
||||
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()
|
||||
XCTAssertEqual(entries.count, 1)
|
||||
XCTAssertEqual(entries[0].text, "Cursor")
|
||||
XCTAssertEqual(entries[0].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 testVocabularyFingerprintChangesWhenDictionaryChanges() {
|
||||
var dict = PersonalDictionary.empty
|
||||
let emptyFP = dict.vocabularySyncFingerprint()
|
||||
_ = dict.upsertManual(term: "OSGKeyboard")
|
||||
XCTAssertNotEqual(emptyFP, dict.vocabularySyncFingerprint())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// PersonalDictionaryCloudSyncTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Hermetic tests for iCloud KVS dictionary merge + sync service.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
// MARK: - Fake KVS
|
||||
|
||||
private final class FakeUbiquitousKeyValueStore: UbiquitousKeyValueStoreing, @unchecked Sendable {
|
||||
private var storage: [String: Data] = [:]
|
||||
|
||||
func data(forKey key: String) -> Data? {
|
||||
storage[key]
|
||||
}
|
||||
|
||||
func set(_ value: Data?, forKey key: String) {
|
||||
if let value {
|
||||
storage[key] = value
|
||||
} else {
|
||||
storage.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func synchronize() -> Bool { true }
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
final class PersonalDictionaryCloudSyncTests: XCTestCase {
|
||||
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
private var store: AppGroupStore!
|
||||
private var kvs: FakeUbiquitousKeyValueStore!
|
||||
private var sync: PersonalDictionaryCloudSync!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "group.com.osgkeyboard.shared.tests.sync.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
store = AppGroupStore(defaults: defaults)
|
||||
kvs = FakeUbiquitousKeyValueStore()
|
||||
sync = PersonalDictionaryCloudSync(kvs: kvs) { [unowned self] in store }
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Merge
|
||||
|
||||
func testMergeKeepsNewerEntryForSameID() {
|
||||
let id = UUID()
|
||||
let older = PersonalDictionary.Entry(
|
||||
id: id,
|
||||
term: "Kubernetes",
|
||||
category: .technical,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
updatedAt: Date(timeIntervalSince1970: 100),
|
||||
usageCount: 1
|
||||
)
|
||||
let newer = PersonalDictionary.Entry(
|
||||
id: id,
|
||||
term: "Kubernetes",
|
||||
aliases: ["k8s"],
|
||||
category: .technical,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
updatedAt: Date(timeIntervalSince1970: 200),
|
||||
usageCount: 3
|
||||
)
|
||||
|
||||
let merged = PersonalDictionary.merge(
|
||||
local: PersonalDictionary(entries: [older]),
|
||||
remote: PersonalDictionary(entries: [newer])
|
||||
)
|
||||
|
||||
XCTAssertEqual(merged.entries.count, 1)
|
||||
XCTAssertEqual(merged.entries[0].aliases, ["k8s"])
|
||||
XCTAssertEqual(merged.entries[0].usageCount, 3)
|
||||
}
|
||||
|
||||
func testMergeUnionsAliasesForSameCanonicalTerm() {
|
||||
let local = PersonalDictionary.Entry(
|
||||
term: "Cursor",
|
||||
aliases: ["cursor ide"],
|
||||
category: .productName,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
updatedAt: Date(timeIntervalSince1970: 150)
|
||||
)
|
||||
let remote = PersonalDictionary.Entry(
|
||||
term: "cursor",
|
||||
aliases: ["光标"],
|
||||
category: .productName,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 120),
|
||||
updatedAt: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
|
||||
let merged = PersonalDictionary.merge(
|
||||
local: PersonalDictionary(entries: [local]),
|
||||
remote: PersonalDictionary(entries: [remote])
|
||||
)
|
||||
|
||||
XCTAssertEqual(merged.entries.count, 1)
|
||||
XCTAssertEqual(Set(merged.entries[0].aliases), Set(["cursor ide", "光标"]))
|
||||
XCTAssertEqual(merged.entries[0].term, "cursor")
|
||||
}
|
||||
|
||||
func testMergeCombinesDistinctTerms() {
|
||||
let local = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "Alpha", category: .custom, source: .manual),
|
||||
])
|
||||
let remote = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "Beta", category: .custom, source: .manual),
|
||||
])
|
||||
|
||||
let merged = PersonalDictionary.merge(local: local, remote: remote)
|
||||
XCTAssertEqual(Set(merged.entries.map(\.term)), Set(["Alpha", "Beta"]))
|
||||
}
|
||||
|
||||
// MARK: - Backward-compatible decode
|
||||
|
||||
func testEntryDecodesWithoutUpdatedAt() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": "A0000000-0000-4000-8000-000000000099",
|
||||
"term": "Legacy",
|
||||
"aliases": [],
|
||||
"category": "custom",
|
||||
"source": "manual",
|
||||
"createdAt": 1_700_000_000,
|
||||
"usageCount": 2
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let entry = try JSONDecoder().decode(PersonalDictionary.Entry.self, from: json)
|
||||
XCTAssertEqual(entry.term, "Legacy")
|
||||
XCTAssertEqual(entry.updatedAt.timeIntervalSince1970, 1_700_000_000, accuracy: 1)
|
||||
}
|
||||
|
||||
// MARK: - Sync service
|
||||
|
||||
func testEnableSyncMergesLocalAndRemoteThenUploads() async throws {
|
||||
let remoteEntry = PersonalDictionary.Entry(
|
||||
term: "RemoteTerm",
|
||||
category: .custom,
|
||||
source: .manual,
|
||||
updatedAt: Date(timeIntervalSince1970: 500)
|
||||
)
|
||||
let remoteData = try sync.encode(PersonalDictionary(entries: [remoteEntry]))
|
||||
kvs.set(remoteData, forKey: PersonalDictionaryCloudSync.kvsKey)
|
||||
|
||||
store.personalDictionary = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(
|
||||
term: "LocalTerm",
|
||||
category: .custom,
|
||||
source: .manual,
|
||||
updatedAt: Date(timeIntervalSince1970: 400)
|
||||
),
|
||||
])
|
||||
|
||||
try await sync.enableSync()
|
||||
|
||||
XCTAssertTrue(store.personalDictionaryICloudSyncEnabled)
|
||||
XCTAssertEqual(Set(store.personalDictionary.entries.map(\.term)), Set(["LocalTerm", "RemoteTerm"]))
|
||||
XCTAssertNotNil(sync.loadRemote())
|
||||
}
|
||||
|
||||
func testPushLocalIfEnabledSkipsWhenDisabled() async throws {
|
||||
store.personalDictionary = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "OnlyLocal", category: .custom, source: .manual),
|
||||
])
|
||||
store.setPersonalDictionaryICloudSyncEnabled(false)
|
||||
|
||||
try await sync.pushLocalIfEnabled(store.personalDictionary)
|
||||
|
||||
XCTAssertNil(sync.loadRemote())
|
||||
}
|
||||
|
||||
func testPullAndMergeWritesMergedDictionaryToAppGroup() async {
|
||||
store.setPersonalDictionaryICloudSyncEnabled(true)
|
||||
store.personalDictionary = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "LocalOnly", category: .custom, source: .manual),
|
||||
])
|
||||
|
||||
let remote = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(
|
||||
term: "CloudOnly",
|
||||
category: .custom,
|
||||
source: .manual,
|
||||
updatedAt: Date(timeIntervalSince1970: 900)
|
||||
),
|
||||
])
|
||||
kvs.set(try! sync.encode(remote), forKey: PersonalDictionaryCloudSync.kvsKey)
|
||||
|
||||
await sync.pullAndMerge(store: store)
|
||||
|
||||
XCTAssertEqual(Set(store.personalDictionary.entries.map(\.term)), Set(["LocalOnly", "CloudOnly"]))
|
||||
}
|
||||
|
||||
func testEncodeRejectsOversizedPayload() {
|
||||
let hugeTerm = String(repeating: "x", count: PersonalDictionaryCloudSync.maxPayloadBytes)
|
||||
let dictionary = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: hugeTerm, category: .custom, source: .manual),
|
||||
])
|
||||
|
||||
XCTAssertThrowsError(try sync.encode(dictionary)) { error in
|
||||
guard case .payloadTooLarge = error as? PersonalDictionaryCloudSyncError else {
|
||||
return XCTFail("Expected payloadTooLarge, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testAppGroupConfigurationDefaultsICloudSyncToOn() {
|
||||
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
XCTAssertTrue(config.personalDictionaryICloudSyncEnabled)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user