c2f07bd8d2
Introduce a standalone macOS menu-bar app (OSGKeyboardMac) that reuses the platform-agnostic OSGKeyboardShared core: record -> cloud/local ASR -> polish -> insert. Local mode uses Qwen3-ASR via mlx-swift-asr (macOS 15+, Apple Silicon); iOS targets stay zero-SPM. Harden iCloud sync for multi-device correctness: - Per-field settings merge (appSettings.v2) so concurrent edits no longer clobber each other's unrelated fields. - Per-device usage statistics (G-Counter) that sum instead of max(). - Tombstoned dictionary/history merge so deletes propagate and entries can't resurrect. - API keys replicate via iCloud Keychain, never iCloud KVS JSON; pulling a legacy blob without key fields no longer wipes local Keychain entries. - Add a low-risk "Sync Now" action in Settings. Fix Flow keyboard mic state: stay orange until the host publishes a real ready contract, share a single MicVoiceAvailability gate, and self-heal stale cross-process heartbeat jitter instead of getting stuck. Extract shared storage (SpeechHistoryStore/UsageStatisticsStore, ConfigurationStore) into OSGKeyboardShared and add tests for the new sync/merge logic.
139 lines
5.2 KiB
Swift
139 lines
5.2 KiB
Swift
// SpeechHistoryCloudSyncTests.swift
|
|
// OSGKeyboardTests
|
|
//
|
|
// Hermetic tests for speech history iCloud merge, tombstones, and caps.
|
|
|
|
import XCTest
|
|
@testable import OSGKeyboardShared
|
|
|
|
@MainActor
|
|
final class SpeechHistoryCloudSyncTests: XCTestCase {
|
|
|
|
private var suiteName: String!
|
|
private var configDefaults: UserDefaults!
|
|
private var historyDefaults: UserDefaults!
|
|
private var store: AppGroupStore!
|
|
private var kvs: FakeUbiquitousKeyValueStore!
|
|
private var sync: SpeechHistoryCloudSync!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
suiteName = "group.com.osgkeyboard.shared.tests.history.\(UUID().uuidString)"
|
|
configDefaults = UserDefaults(suiteName: suiteName)!
|
|
configDefaults.removePersistentDomain(forName: suiteName)
|
|
historyDefaults = UserDefaults(suiteName: "\(suiteName).history")!
|
|
historyDefaults.removePersistentDomain(forName: "\(suiteName).history")
|
|
store = AppGroupStore(defaults: configDefaults)
|
|
store.setSettingsICloudSyncEnabled(true)
|
|
kvs = FakeUbiquitousKeyValueStore()
|
|
sync = SpeechHistoryCloudSync(kvs: kvs, makeStore: { [unowned self] in store }) { [unowned self] in
|
|
historyDefaults
|
|
}
|
|
}
|
|
|
|
override func tearDown() {
|
|
configDefaults.removePersistentDomain(forName: suiteName)
|
|
historyDefaults.removePersistentDomain(forName: "\(suiteName).history")
|
|
super.tearDown()
|
|
}
|
|
|
|
func testMergeUnionsDistinctEntriesByID() {
|
|
let idA = UUID()
|
|
let idB = UUID()
|
|
let local = SyncedSpeechHistory(
|
|
updatedAt: Date(timeIntervalSince1970: 100),
|
|
entries: [
|
|
SpeechHistoryEntry(id: idA, text: "local", createdAt: Date(timeIntervalSince1970: 10))
|
|
]
|
|
)
|
|
let remote = SyncedSpeechHistory(
|
|
updatedAt: Date(timeIntervalSince1970: 200),
|
|
entries: [
|
|
SpeechHistoryEntry(id: idB, text: "remote", createdAt: Date(timeIntervalSince1970: 20))
|
|
]
|
|
)
|
|
|
|
let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
|
|
|
|
XCTAssertEqual(Set(merged.entries.map(\.id)), Set([idA, idB]))
|
|
XCTAssertEqual(merged.updatedAt, remote.updatedAt)
|
|
}
|
|
|
|
func testMergeAppliesDeletedEntryIDs() {
|
|
let id = UUID()
|
|
let local = SyncedSpeechHistory(
|
|
entries: [SpeechHistoryEntry(id: id, text: "gone", createdAt: Date())]
|
|
)
|
|
let remote = SyncedSpeechHistory(deletedEntryIDs: [id: Date()])
|
|
|
|
let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
|
|
|
|
XCTAssertTrue(merged.entries.isEmpty)
|
|
XCTAssertNotNil(merged.deletedEntryIDs[id])
|
|
}
|
|
|
|
func testMergeAppliesClearedAt() {
|
|
let clearedAt = Date(timeIntervalSince1970: 500)
|
|
let local = SyncedSpeechHistory(
|
|
entries: [
|
|
SpeechHistoryEntry(text: "old", createdAt: Date(timeIntervalSince1970: 100)),
|
|
SpeechHistoryEntry(text: "new", createdAt: Date(timeIntervalSince1970: 600))
|
|
]
|
|
)
|
|
let remote = SyncedSpeechHistory(clearedAt: clearedAt)
|
|
|
|
let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
|
|
|
|
XCTAssertEqual(merged.entries.count, 1)
|
|
XCTAssertEqual(merged.entries.first?.text, "new")
|
|
}
|
|
|
|
func testMergeCapsAt300Entries() {
|
|
let localEntries = (0..<200).map { index in
|
|
SpeechHistoryEntry(
|
|
text: "local-\(index)",
|
|
createdAt: Date(timeIntervalSince1970: TimeInterval(index))
|
|
)
|
|
}
|
|
let remoteEntries = (0..<200).map { index in
|
|
SpeechHistoryEntry(
|
|
text: "remote-\(index)",
|
|
createdAt: Date(timeIntervalSince1970: TimeInterval(index) + 0.5)
|
|
)
|
|
}
|
|
let merged = SyncedSpeechHistory.merge(
|
|
local: SyncedSpeechHistory(entries: localEntries),
|
|
remote: SyncedSpeechHistory(entries: remoteEntries)
|
|
)
|
|
|
|
XCTAssertEqual(merged.entries.count, SyncedSpeechHistory.maxEntries)
|
|
}
|
|
|
|
func testPullAndMergeAppliesRemoteHistory() async throws {
|
|
let entry = SpeechHistoryEntry(text: "hello", createdAt: Date())
|
|
let remote = SyncedSpeechHistory(updatedAt: Date(), entries: [entry])
|
|
try sync.push(remote)
|
|
|
|
await sync.pullAndMerge(store: store)
|
|
|
|
let loaded = SpeechHistoryStorage.load(from: historyDefaults)
|
|
XCTAssertEqual(loaded.entries.map(\.text), ["hello"])
|
|
}
|
|
|
|
func testMergeAndPushUnionsLocalAndRemote() async throws {
|
|
let localEntry = SpeechHistoryEntry(text: "iphone", createdAt: Date(timeIntervalSince1970: 10))
|
|
SpeechHistoryStorage.save(
|
|
SyncedSpeechHistory(updatedAt: Date(), entries: [localEntry]),
|
|
to: historyDefaults
|
|
)
|
|
let remoteEntry = SpeechHistoryEntry(text: "mac", createdAt: Date(timeIntervalSince1970: 20))
|
|
try sync.push(SyncedSpeechHistory(updatedAt: Date(), entries: [remoteEntry]))
|
|
|
|
try await sync.mergeAndPushIfEnabled()
|
|
|
|
let loaded = SpeechHistoryStorage.load(from: historyDefaults)
|
|
XCTAssertEqual(Set(loaded.entries.map(\.text)), Set(["iphone", "mac"]))
|
|
XCTAssertEqual(sync.loadRemote()?.entries.count, 2)
|
|
}
|
|
}
|