feat(macos): add macOS menu-bar app and harden cross-device iCloud sync
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.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
// ConfigurationStoreTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Locks `AppGroupStore` conformance to `ConfigurationStore` and ensures
|
||||
// pipeline helpers accept the protocol without changing iOS behavior.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class ConfigurationStoreTests: XCTestCase {
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
private var store: AppGroupStore!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "group.com.osgkeyboard.shared.tests.configuration.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
store = AppGroupStore(defaults: defaults)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defaults = nil
|
||||
store = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testAppGroupStoreConformsToConfigurationStore() {
|
||||
let configuration: any ConfigurationStore = store
|
||||
XCTAssertEqual(configuration.cloudASRPersistence, defaults)
|
||||
XCTAssertEqual(
|
||||
PolishingService.resolvedProviderId(store: configuration, providerIdOverride: nil),
|
||||
PolishingService.resolvedProviderId(store: store, providerIdOverride: nil)
|
||||
)
|
||||
}
|
||||
|
||||
func testASRFactoryAcceptsConfigurationStore() {
|
||||
store.setEngineMode("local")
|
||||
let service = ASRServiceFactory.make(store: store as any ConfigurationStore)
|
||||
XCTAssertTrue(service is SpeechAnalyzerASR)
|
||||
}
|
||||
}
|
||||
@@ -141,5 +141,46 @@ final class FlowSessionBridgeTests: XCTestCase {
|
||||
|
||||
func testDarwinNotificationPostsWithoutCrashing() {
|
||||
FlowSessionDarwin.postSessionChanged()
|
||||
FlowSessionDarwin.postHostReadyChanged()
|
||||
}
|
||||
|
||||
func testHostReadyRequiresExplicitContract() {
|
||||
let defaults = makeDefaults()
|
||||
FlowSessionBridge.markSessionActive(duration: 60, defaults: defaults)
|
||||
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
|
||||
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
|
||||
FlowSessionBridge.setHostReady(true, defaults: defaults)
|
||||
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
}
|
||||
|
||||
func testHostReadyFalseWhenHeartbeatStale() {
|
||||
let defaults = makeDefaults()
|
||||
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
|
||||
FlowSessionBridge.setHostReady(true, defaults: defaults)
|
||||
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
|
||||
let staleHeartbeat = Date().timeIntervalSince1970 - 10
|
||||
defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
}
|
||||
|
||||
func testHeartbeatRefreshKeepsHostReadyPublished() {
|
||||
let defaults = makeDefaults()
|
||||
FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
|
||||
FlowSessionBridge.setHostReady(true, defaults: defaults)
|
||||
|
||||
FlowSessionBridge.writeHeartbeat(defaults: defaults)
|
||||
|
||||
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
}
|
||||
|
||||
func testClearFlowStateClearsHostReady() {
|
||||
let defaults = makeDefaults()
|
||||
FlowSessionBridge.markSessionActive(defaults: defaults)
|
||||
FlowSessionBridge.setHostReady(true, defaults: defaults)
|
||||
FlowSessionBridge.clearFlowState(defaults: defaults)
|
||||
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowHostReady))
|
||||
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// MicVoiceAvailabilityTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class MicVoiceAvailabilityTests: XCTestCase {
|
||||
|
||||
func testReadyWhenHostReadyAndIdle() {
|
||||
let availability = MicVoiceAvailabilityResolver.resolve(
|
||||
phase: .idle,
|
||||
micDisabled: false,
|
||||
hasFullAccess: true,
|
||||
appGroupAvailable: true,
|
||||
hostReady: true,
|
||||
isPreparingSession: false
|
||||
)
|
||||
XCTAssertEqual(availability, .ready)
|
||||
}
|
||||
|
||||
func testUnavailableWhenMissingAPIKey() {
|
||||
let availability = MicVoiceAvailabilityResolver.resolve(
|
||||
phase: .idle,
|
||||
micDisabled: true,
|
||||
hasFullAccess: true,
|
||||
appGroupAvailable: true,
|
||||
hostReady: true,
|
||||
isPreparingSession: false
|
||||
)
|
||||
XCTAssertEqual(availability, .unavailable(.missingAPIKey))
|
||||
}
|
||||
|
||||
func testUnavailableWhenHostNotReady() {
|
||||
let availability = MicVoiceAvailabilityResolver.resolve(
|
||||
phase: .idle,
|
||||
micDisabled: false,
|
||||
hasFullAccess: true,
|
||||
appGroupAvailable: true,
|
||||
hostReady: false,
|
||||
isPreparingSession: false
|
||||
)
|
||||
XCTAssertEqual(availability, .unavailable(.hostNotReady))
|
||||
}
|
||||
|
||||
func testUnavailableWhenPreparingSession() {
|
||||
let availability = MicVoiceAvailabilityResolver.resolve(
|
||||
phase: .idle,
|
||||
micDisabled: false,
|
||||
hasFullAccess: true,
|
||||
appGroupAvailable: true,
|
||||
hostReady: false,
|
||||
isPreparingSession: true
|
||||
)
|
||||
XCTAssertEqual(availability, .unavailable(.preparingSession))
|
||||
}
|
||||
|
||||
func testRecordingOverridesReady() {
|
||||
let availability = MicVoiceAvailabilityResolver.resolve(
|
||||
phase: .recording,
|
||||
micDisabled: false,
|
||||
hasFullAccess: true,
|
||||
appGroupAvailable: true,
|
||||
hostReady: true,
|
||||
isPreparingSession: false
|
||||
)
|
||||
XCTAssertEqual(availability, .recording)
|
||||
}
|
||||
|
||||
func testProcessingOverridesUnavailable() {
|
||||
let availability = MicVoiceAvailabilityResolver.resolve(
|
||||
phase: .processing,
|
||||
micDisabled: false,
|
||||
hasFullAccess: true,
|
||||
appGroupAvailable: true,
|
||||
hostReady: false,
|
||||
isPreparingSession: false
|
||||
)
|
||||
XCTAssertEqual(availability, .processing)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// PersonalDictionaryMergeTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Hermetic tests for dictionary tombstone merge semantics.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class PersonalDictionaryMergeTests: XCTestCase {
|
||||
func testDeletedEntryDoesNotResurrectFromRemote() {
|
||||
let id = UUID()
|
||||
let deletedAt = Date()
|
||||
let local = PersonalDictionary(
|
||||
entries: [],
|
||||
deletedEntryIDs: [id: deletedAt]
|
||||
)
|
||||
let remote = PersonalDictionary(
|
||||
entries: [
|
||||
PersonalDictionary.Entry(
|
||||
id: id,
|
||||
term: "OSG",
|
||||
category: .productName,
|
||||
source: .manual
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
let merged = PersonalDictionary.merge(local: local, remote: remote)
|
||||
|
||||
XCTAssertTrue(merged.entries.isEmpty)
|
||||
XCTAssertEqual(merged.deletedEntryIDs[id], deletedAt)
|
||||
}
|
||||
|
||||
func testClearAllExcludesOlderRemoteEntries() {
|
||||
let clearedAt = Date(timeIntervalSince1970: 500)
|
||||
let local = PersonalDictionary(entries: [], clearedAt: clearedAt)
|
||||
let remote = PersonalDictionary(
|
||||
entries: [
|
||||
PersonalDictionary.Entry(
|
||||
term: "old",
|
||||
category: .custom,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 100)
|
||||
),
|
||||
PersonalDictionary.Entry(
|
||||
term: "new",
|
||||
category: .custom,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 600)
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
let merged = PersonalDictionary.merge(local: local, remote: remote)
|
||||
|
||||
XCTAssertEqual(merged.entries.count, 1)
|
||||
XCTAssertEqual(merged.entries.first?.term, "new")
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,15 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
private var store: AppGroupStore!
|
||||
private var kvs: FakeUbiquitousKeyValueStore!
|
||||
private var settingsSync: SettingsCloudSync!
|
||||
private let deviceA = "device-a"
|
||||
private let deviceB = "device-b"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "group.com.osgkeyboard.shared.tests.settings.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defaults.set(deviceA, forKey: "sync.deviceID.v1")
|
||||
store = AppGroupStore(defaults: defaults)
|
||||
kvs = FakeUbiquitousKeyValueStore()
|
||||
settingsSync = SettingsCloudSync(kvs: kvs) { [unowned self] in store }
|
||||
@@ -27,87 +30,66 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
try? Keychain.deleteAPIKey(for: "openai", useICloudSync: false)
|
||||
try? Keychain.deleteAPIKey(for: "openai", useICloudSync: true)
|
||||
try? Keychain.deleteAPIKey(for: "qwen", useICloudSync: false)
|
||||
try? Keychain.deleteAPIKey(for: "qwen", useICloudSync: true)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testMergePrefersNewerUpdatedAt() {
|
||||
let older = SyncedAppSettings(
|
||||
updatedAt: Date(timeIntervalSince1970: 100),
|
||||
providerId: "openai",
|
||||
baseURL: "https://old.example",
|
||||
model: "gpt-old",
|
||||
modeId: "polish",
|
||||
localeId: "auto",
|
||||
engineMode: "cloud",
|
||||
hasAcknowledgedCloudSharing: false,
|
||||
uiLanguage: .english,
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
handednessPreference: .left,
|
||||
cursorDragNavigationEnabled: true,
|
||||
polishIntensity: .medium,
|
||||
flowSkipAppSwitch: true,
|
||||
flowInactivityDuration: .twelveHours
|
||||
func testPerFieldMergeKeepsIndependentChanges() {
|
||||
let stampA = Date(timeIntervalSince1970: 100)
|
||||
let stampB = Date(timeIntervalSince1970: 200)
|
||||
let local = SyncedAppSettingsV2(
|
||||
providerId: SyncedField(value: "openai", updatedAt: stampA, deviceID: deviceA),
|
||||
baseURL: SyncedField(value: "https://local.example", updatedAt: stampA, deviceID: deviceA),
|
||||
model: SyncedField(value: "gpt-local", updatedAt: stampA, deviceID: deviceA),
|
||||
modeId: SyncedField(value: "polish", updatedAt: stampA, deviceID: deviceA),
|
||||
localeId: SyncedField(value: "auto", updatedAt: stampA, deviceID: deviceA),
|
||||
engineMode: SyncedField(value: "cloud", updatedAt: stampA, deviceID: deviceA),
|
||||
hasAcknowledgedCloudSharing: SyncedField(value: false, updatedAt: stampA, deviceID: deviceA),
|
||||
uiLanguage: SyncedField(value: .english, updatedAt: stampA, deviceID: deviceA),
|
||||
translationTargetLocaleId: SyncedField(
|
||||
value: TranslationLanguageCatalog.offLocaleId,
|
||||
updatedAt: stampA,
|
||||
deviceID: deviceA
|
||||
),
|
||||
handednessPreference: SyncedField(value: .left, updatedAt: stampA, deviceID: deviceA),
|
||||
cursorDragNavigationEnabled: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
|
||||
polishIntensity: SyncedField(value: .medium, updatedAt: stampA, deviceID: deviceA),
|
||||
flowSkipAppSwitch: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
|
||||
flowInactivityDuration: SyncedField(value: .twelveHours, updatedAt: stampA, deviceID: deviceA)
|
||||
)
|
||||
let newer = SyncedAppSettings(
|
||||
updatedAt: Date(timeIntervalSince1970: 200),
|
||||
providerId: "openai",
|
||||
baseURL: "https://new.example",
|
||||
model: "gpt-new",
|
||||
modeId: "polish",
|
||||
localeId: "zh-Hans",
|
||||
engineMode: "local",
|
||||
hasAcknowledgedCloudSharing: true,
|
||||
uiLanguage: .chinese,
|
||||
translationTargetLocaleId: "en",
|
||||
handednessPreference: .right,
|
||||
cursorDragNavigationEnabled: false,
|
||||
polishIntensity: .light,
|
||||
flowSkipAppSwitch: false,
|
||||
flowInactivityDuration: .threeHours
|
||||
let remote = SyncedAppSettingsV2(
|
||||
providerId: SyncedField(value: "openai", updatedAt: stampA, deviceID: deviceB),
|
||||
baseURL: SyncedField(value: "https://remote.example", updatedAt: stampB, deviceID: deviceB),
|
||||
model: SyncedField(value: "gpt-remote", updatedAt: stampB, deviceID: deviceB),
|
||||
modeId: SyncedField(value: "polish", updatedAt: stampA, deviceID: deviceB),
|
||||
localeId: SyncedField(value: "ja", updatedAt: stampB, deviceID: deviceB),
|
||||
engineMode: SyncedField(value: "local", updatedAt: stampB, deviceID: deviceB),
|
||||
hasAcknowledgedCloudSharing: SyncedField(value: true, updatedAt: stampB, deviceID: deviceB),
|
||||
uiLanguage: SyncedField(value: .chinese, updatedAt: stampB, deviceID: deviceB),
|
||||
translationTargetLocaleId: SyncedField(value: "en", updatedAt: stampB, deviceID: deviceB),
|
||||
handednessPreference: SyncedField(value: .right, updatedAt: stampB, deviceID: deviceB),
|
||||
cursorDragNavigationEnabled: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
|
||||
polishIntensity: SyncedField(value: .light, updatedAt: stampB, deviceID: deviceB),
|
||||
flowSkipAppSwitch: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
|
||||
flowInactivityDuration: SyncedField(value: .threeHours, updatedAt: stampB, deviceID: deviceB)
|
||||
)
|
||||
|
||||
let merged = SyncedAppSettings.merge(local: older, remote: newer)
|
||||
XCTAssertEqual(merged.model, "gpt-new")
|
||||
XCTAssertEqual(merged.localeId, "zh-Hans")
|
||||
XCTAssertEqual(merged.engineMode, "local")
|
||||
let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
|
||||
|
||||
XCTAssertEqual(merged.baseURL.value, "https://remote.example")
|
||||
XCTAssertEqual(merged.localeId.value, "ja")
|
||||
XCTAssertEqual(merged.engineMode.value, "local")
|
||||
}
|
||||
|
||||
func testEnableSyncUploadsMergedSettingsAndToggle() async throws {
|
||||
store.setModeId("polish")
|
||||
store.setLocaleId("zh-Hans")
|
||||
|
||||
let remote = SyncedAppSettings(
|
||||
updatedAt: Date().addingTimeInterval(3600),
|
||||
providerId: "openai",
|
||||
baseURL: "https://remote.example",
|
||||
model: "remote-model",
|
||||
modeId: "polish",
|
||||
localeId: "en",
|
||||
engineMode: "cloud",
|
||||
hasAcknowledgedCloudSharing: true,
|
||||
uiLanguage: .english,
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
handednessPreference: .right,
|
||||
cursorDragNavigationEnabled: true,
|
||||
polishIntensity: .medium,
|
||||
flowSkipAppSwitch: true,
|
||||
flowInactivityDuration: .twelveHours
|
||||
)
|
||||
try settingsSync.push(remote)
|
||||
|
||||
try await settingsSync.enableSync()
|
||||
|
||||
XCTAssertTrue(store.settingsICloudSyncEnabled)
|
||||
XCTAssertEqual(kvs.object(forKey: ICloudSyncPreferences.settingsEnabledKey) as? Bool, true)
|
||||
XCTAssertEqual(settingsSync.loadRemote()?.localeId, "en")
|
||||
}
|
||||
|
||||
func testPullAndMergeAppliesRemoteSettingsToAppGroup() async throws {
|
||||
func testLegacyV1PullDoesNotClearKeychain() async throws {
|
||||
try Keychain.setAPIKey("sk-local-openai", for: "openai", useICloudSync: false)
|
||||
store.setSettingsICloudSyncEnabled(true)
|
||||
store.setLocaleId("auto")
|
||||
|
||||
let remote = SyncedAppSettings(
|
||||
updatedAt: Date(timeIntervalSince1970: 900),
|
||||
let legacy = SyncedAppSettings(
|
||||
updatedAt: Date().addingTimeInterval(3600),
|
||||
providerId: "openai",
|
||||
baseURL: "https://remote.example",
|
||||
model: "remote-model",
|
||||
@@ -123,12 +105,44 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
flowSkipAppSwitch: true,
|
||||
flowInactivityDuration: .twelveHours
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(legacy)
|
||||
kvs.set(data, forKey: SettingsCloudSync.legacyKVSKey)
|
||||
|
||||
await settingsSync.pullAndMerge(store: store)
|
||||
|
||||
XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: false), "sk-local-openai")
|
||||
XCTAssertEqual(store.localeId, "ja")
|
||||
}
|
||||
|
||||
func testEnableSyncMigratesKeysToICloudKeychain() async throws {
|
||||
try Keychain.setAPIKey("sk-local-openai", for: "openai", useICloudSync: false)
|
||||
|
||||
try await settingsSync.enableSync()
|
||||
|
||||
XCTAssertTrue(store.settingsICloudSyncEnabled)
|
||||
XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: true), "sk-local-openai")
|
||||
}
|
||||
|
||||
func testPullAndMergeAppliesRemoteSettingsToAppGroup() async throws {
|
||||
store.setSettingsICloudSyncEnabled(true)
|
||||
store.setLocaleId("auto")
|
||||
|
||||
let deviceID = SyncDeviceID.current(defaults: defaults)
|
||||
let stamp = Date(timeIntervalSince1970: 900)
|
||||
var remote = SyncedAppSettingsV2.seeded(
|
||||
from: AppGroupConfiguration.load(fromAvailable: defaults),
|
||||
deviceID: deviceB,
|
||||
updatedAt: stamp
|
||||
)
|
||||
remote.localeId = SyncedField(value: "ja", updatedAt: stamp, deviceID: deviceB)
|
||||
try settingsSync.push(remote)
|
||||
|
||||
await settingsSync.pullAndMerge(store: store)
|
||||
|
||||
XCTAssertEqual(store.localeId, "ja")
|
||||
XCTAssertEqual(store.settingsCloudUpdatedAt?.timeIntervalSince1970, 900, accuracy: 1)
|
||||
XCTAssertEqual(store.settingsCloudUpdatedAt?.timeIntervalSince1970 ?? 0, 900, accuracy: 1)
|
||||
}
|
||||
|
||||
func testPushLocalIfEnabledSkipsWhenDisabled() async throws {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// UsageStatisticsCloudSyncTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Hermetic tests for cumulative usage statistics iCloud merge.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
final class UsageStatisticsCloudSyncTests: XCTestCase {
|
||||
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
private var store: AppGroupStore!
|
||||
private var kvs: FakeUbiquitousKeyValueStore!
|
||||
private var sync: UsageStatisticsCloudSync!
|
||||
private let deviceA = "device-a"
|
||||
private let deviceB = "device-b"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "group.com.osgkeyboard.shared.tests.usage.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defaults.set(deviceA, forKey: "sync.deviceID.v1")
|
||||
store = AppGroupStore(defaults: defaults)
|
||||
store.setSettingsICloudSyncEnabled(true)
|
||||
kvs = FakeUbiquitousKeyValueStore()
|
||||
sync = UsageStatisticsCloudSync(kvs: kvs) { [unowned self] in store }
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testGCounterMergeSumsAcrossDevices() {
|
||||
let local = SyncedUsageStatisticsV2(devices: [
|
||||
deviceA: UsageStatisticsDeviceSlice(
|
||||
updatedAt: Date(timeIntervalSince1970: 100),
|
||||
dictationDurationSeconds: 30,
|
||||
dictationCharacterCount: 120,
|
||||
translationCharacterCount: 10
|
||||
),
|
||||
])
|
||||
let remote = SyncedUsageStatisticsV2(devices: [
|
||||
deviceB: UsageStatisticsDeviceSlice(
|
||||
updatedAt: Date(timeIntervalSince1970: 200),
|
||||
dictationDurationSeconds: 45,
|
||||
dictationCharacterCount: 80,
|
||||
translationCharacterCount: 25
|
||||
),
|
||||
])
|
||||
|
||||
let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote).aggregated
|
||||
|
||||
XCTAssertEqual(merged.dictationDurationSeconds, 75)
|
||||
XCTAssertEqual(merged.dictationCharacterCount, 200)
|
||||
XCTAssertEqual(merged.translationCharacterCount, 35)
|
||||
}
|
||||
|
||||
func testGCounterMergeTakesMaxForSameDevice() {
|
||||
let local = SyncedUsageStatisticsV2(devices: [
|
||||
deviceA: UsageStatisticsDeviceSlice(
|
||||
updatedAt: Date(timeIntervalSince1970: 100),
|
||||
dictationDurationSeconds: 30,
|
||||
dictationCharacterCount: 120,
|
||||
translationCharacterCount: 10
|
||||
),
|
||||
])
|
||||
let remote = SyncedUsageStatisticsV2(devices: [
|
||||
deviceA: UsageStatisticsDeviceSlice(
|
||||
updatedAt: Date(timeIntervalSince1970: 200),
|
||||
dictationDurationSeconds: 45,
|
||||
dictationCharacterCount: 80,
|
||||
translationCharacterCount: 25
|
||||
),
|
||||
])
|
||||
|
||||
let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote).aggregated
|
||||
|
||||
XCTAssertEqual(merged.dictationDurationSeconds, 45)
|
||||
XCTAssertEqual(merged.dictationCharacterCount, 120)
|
||||
XCTAssertEqual(merged.translationCharacterCount, 25)
|
||||
}
|
||||
|
||||
func testPullAndMergeAppliesRemoteTotals() async throws {
|
||||
let remote = SyncedUsageStatisticsV2(devices: [
|
||||
deviceB: UsageStatisticsDeviceSlice(
|
||||
updatedAt: Date(),
|
||||
dictationDurationSeconds: 90,
|
||||
dictationCharacterCount: 500,
|
||||
translationCharacterCount: 40
|
||||
),
|
||||
])
|
||||
try sync.push(remote)
|
||||
|
||||
await sync.pullAndMerge(store: store)
|
||||
|
||||
let loaded = SyncedUsageStatisticsStorage.load(from: defaults).aggregated
|
||||
XCTAssertEqual(loaded.dictationDurationSeconds, 90)
|
||||
XCTAssertEqual(loaded.dictationCharacterCount, 500)
|
||||
XCTAssertEqual(loaded.translationCharacterCount, 40)
|
||||
}
|
||||
|
||||
func testPullUnionsIndependentDeviceTotals() async throws {
|
||||
SyncedUsageStatisticsStorage.upsertCurrentDeviceSlice(
|
||||
UsageStatisticsDeviceSlice(
|
||||
updatedAt: Date(),
|
||||
dictationDurationSeconds: 10,
|
||||
dictationCharacterCount: 300,
|
||||
translationCharacterCount: 0
|
||||
),
|
||||
defaults: defaults,
|
||||
deviceID: deviceA
|
||||
)
|
||||
let remote = SyncedUsageStatisticsV2(devices: [
|
||||
deviceB: UsageStatisticsDeviceSlice(
|
||||
updatedAt: Date(),
|
||||
dictationDurationSeconds: 20,
|
||||
dictationCharacterCount: 150,
|
||||
translationCharacterCount: 5
|
||||
),
|
||||
])
|
||||
try sync.push(remote)
|
||||
|
||||
await sync.pullAndMerge(store: store)
|
||||
|
||||
let loaded = SyncedUsageStatisticsStorage.load(from: defaults).aggregated
|
||||
XCTAssertEqual(loaded.dictationDurationSeconds, 30)
|
||||
XCTAssertEqual(loaded.dictationCharacterCount, 450)
|
||||
XCTAssertEqual(loaded.translationCharacterCount, 5)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user