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:
Rocky
2026-07-08 18:13:56 +08:00
parent 128aab1b02
commit c2f07bd8d2
99 changed files with 6735 additions and 740 deletions
@@ -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")
}
}
+86 -72
View File
@@ -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)
}
}