e5a83843db
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
378 lines
14 KiB
Swift
378 lines
14 KiB
Swift
// KeyboardUsageRepositoryTests.swift
|
|
// OSGKeyboardTests
|
|
//
|
|
// Session migration, UTC splitting, immutable IDs and cross-process SQLite safety.
|
|
|
|
import Foundation
|
|
@testable import OSGKeyboardShared
|
|
import XCTest
|
|
|
|
final class KeyboardUsageRepositoryTests: XCTestCase {
|
|
func testFourSessionClassesPartitionInputSessionCount() async throws {
|
|
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
|
let repository = makeRepository(clock: clock)
|
|
let date = clock.now()
|
|
|
|
await keyboardUsageRecord(
|
|
.init(chinese: 2, other: 1),
|
|
sessionID: analyticsTestUUID(1),
|
|
occurredAt: date,
|
|
repository: repository
|
|
)
|
|
await keyboardUsageRecord(
|
|
.init(english: 2),
|
|
sessionID: analyticsTestUUID(2),
|
|
occurredAt: date,
|
|
repository: repository
|
|
)
|
|
await keyboardUsageRecord(
|
|
.init(chinese: 1, english: 1),
|
|
sessionID: analyticsTestUUID(3),
|
|
occurredAt: date,
|
|
repository: repository
|
|
)
|
|
await keyboardUsageRecord(
|
|
.init(other: 3),
|
|
sessionID: analyticsTestUUID(4),
|
|
occurredAt: date,
|
|
repository: repository
|
|
)
|
|
|
|
let snapshot = await repository.debugSnapshot()
|
|
let daily = try XCTUnwrap(snapshot.daily.first)
|
|
XCTAssertEqual(daily.inputSessionCount, 4)
|
|
XCTAssertEqual(daily.chineseOnlySessionCount, 1)
|
|
XCTAssertEqual(daily.englishOnlySessionCount, 1)
|
|
XCTAssertEqual(daily.mixedLanguageSessionCount, 1)
|
|
XCTAssertEqual(daily.otherOnlySessionCount, 1)
|
|
XCTAssertEqual(
|
|
daily.chineseOnlySessionCount
|
|
+ daily.englishOnlySessionCount
|
|
+ daily.mixedLanguageSessionCount
|
|
+ daily.otherOnlySessionCount,
|
|
daily.inputSessionCount
|
|
)
|
|
}
|
|
|
|
func testLanguageChangeMigratesSessionWithoutDoubleCounting() async throws {
|
|
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
|
let repository = makeRepository(clock: clock)
|
|
let sessionID = analyticsTestUUID(10)
|
|
|
|
await keyboardUsageRecord(
|
|
.init(chinese: 2),
|
|
sessionID: sessionID,
|
|
occurredAt: clock.now(),
|
|
repository: repository
|
|
)
|
|
await keyboardUsageRecord(
|
|
.init(english: 3),
|
|
sessionID: sessionID,
|
|
occurredAt: clock.now(),
|
|
repository: repository
|
|
)
|
|
|
|
let snapshot = await repository.debugSnapshot()
|
|
let daily = try XCTUnwrap(snapshot.daily.first)
|
|
XCTAssertEqual(daily.chineseCharacterCount, 2)
|
|
XCTAssertEqual(daily.englishCharacterCount, 3)
|
|
XCTAssertEqual(daily.inputSessionCount, 1)
|
|
XCTAssertEqual(daily.chineseOnlySessionCount, 0)
|
|
XCTAssertEqual(daily.englishOnlySessionCount, 0)
|
|
XCTAssertEqual(daily.mixedLanguageSessionCount, 1)
|
|
XCTAssertEqual(daily.otherOnlySessionCount, 0)
|
|
}
|
|
|
|
func testSamePresentationSplitsAcrossUTCDaysAndTodayIsNotLeased() async throws {
|
|
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
|
let repository = makeRepository(clock: clock)
|
|
let sessionID = analyticsTestUUID(20)
|
|
|
|
await keyboardUsageRecord(
|
|
.init(chinese: 1),
|
|
sessionID: sessionID,
|
|
occurredAt: keyboardUsageDate(2026, 8, 20, hour: 23),
|
|
repository: repository
|
|
)
|
|
await keyboardUsageRecord(
|
|
.init(english: 1),
|
|
sessionID: sessionID,
|
|
occurredAt: keyboardUsageDate(2026, 8, 21, hour: 0),
|
|
repository: repository
|
|
)
|
|
|
|
let snapshot = await repository.debugSnapshot()
|
|
XCTAssertEqual(snapshot.pending.map(\.summaryDate), ["2026-08-20"])
|
|
let today = try XCTUnwrap(snapshot.daily.first)
|
|
XCTAssertEqual(today.summaryDate, "2026-08-21")
|
|
XCTAssertEqual(today.inputSessionCount, 1)
|
|
XCTAssertEqual(today.englishOnlySessionCount, 1)
|
|
|
|
let batch = await repository.leaseBatch(
|
|
ownerID: "test",
|
|
configuration: uploadConfiguration()
|
|
)
|
|
let leased = try XCTUnwrap(batch)
|
|
XCTAssertEqual(leased.summaries.count, 1)
|
|
let summary = try JSONDecoder().decode(
|
|
KeyboardUsageSummary.self,
|
|
from: try XCTUnwrap(leased.summaries.first?.payload)
|
|
)
|
|
XCTAssertEqual(summary.summaryDate, "2026-08-20")
|
|
XCTAssertEqual(summary.inputSessionCount, 1)
|
|
XCTAssertEqual(summary.chineseOnlySessionCount, 1)
|
|
}
|
|
|
|
func testConcurrentRepositoriesDoNotLoseCountsOrDuplicateFinalization() async throws {
|
|
let url = try keyboardUsageTemporaryDatabaseURL()
|
|
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 20))
|
|
let first = makeRepository(url: url, clock: clock, uuidStart: 1)
|
|
let second = makeRepository(url: url, clock: clock, uuidStart: 1_000)
|
|
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for index in 0..<100 {
|
|
group.addTask {
|
|
let repository = index.isMultiple(of: 2) ? first : second
|
|
await keyboardUsageRecord(
|
|
.init(chinese: 1, english: 1, other: 1),
|
|
sessionID: analyticsTestUUID(index + 1),
|
|
occurredAt: clock.now(),
|
|
repository: repository
|
|
)
|
|
}
|
|
}
|
|
}
|
|
let concurrentSnapshot = await first.debugSnapshot()
|
|
let daily = try XCTUnwrap(concurrentSnapshot.daily.first)
|
|
XCTAssertEqual(daily.chineseCharacterCount, 100)
|
|
XCTAssertEqual(daily.englishCharacterCount, 100)
|
|
XCTAssertEqual(daily.otherCharacterCount, 100)
|
|
XCTAssertEqual(daily.inputSessionCount, 100)
|
|
XCTAssertEqual(daily.mixedLanguageSessionCount, 100)
|
|
|
|
clock.advance(by: 24 * 60 * 60)
|
|
async let firstFinalize: Void = first.finalizeCompletedDays()
|
|
async let secondFinalize: Void = second.finalizeCompletedDays()
|
|
_ = await (firstFinalize, secondFinalize)
|
|
|
|
let finalized = await first.debugSnapshot()
|
|
XCTAssertTrue(finalized.daily.isEmpty)
|
|
XCTAssertEqual(finalized.pending.count, 1)
|
|
XCTAssertEqual(finalized.pending.first?.summaryDate, "2026-08-20")
|
|
}
|
|
|
|
func testRetryKeepsStableClientSummaryID() async throws {
|
|
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 20))
|
|
let repository = makeRepository(clock: clock)
|
|
await keyboardUsageRecord(
|
|
.init(other: 1),
|
|
sessionID: analyticsTestUUID(30),
|
|
occurredAt: clock.now(),
|
|
repository: repository
|
|
)
|
|
clock.advance(by: 24 * 60 * 60)
|
|
await repository.finalizeCompletedDays()
|
|
let finalizedSnapshot = await repository.debugSnapshot()
|
|
let originalID = try XCTUnwrap(
|
|
finalizedSnapshot.pending.first?.clientSummaryID
|
|
)
|
|
|
|
let firstLeasedBatch = await repository.leaseBatch(
|
|
ownerID: "retry",
|
|
configuration: uploadConfiguration()
|
|
)
|
|
let firstLease = try XCTUnwrap(firstLeasedBatch)
|
|
await repository.scheduleRetry(
|
|
summaries: firstLease.summaries,
|
|
leaseID: firstLease.leaseID,
|
|
delay: 10
|
|
)
|
|
clock.advance(by: 10)
|
|
let secondLeasedBatch = await repository.leaseBatch(
|
|
ownerID: "retry",
|
|
configuration: uploadConfiguration()
|
|
)
|
|
let secondLease = try XCTUnwrap(secondLeasedBatch)
|
|
let retriedSummary = try JSONDecoder().decode(
|
|
KeyboardUsageSummary.self,
|
|
from: try XCTUnwrap(secondLease.summaries.first?.payload)
|
|
)
|
|
XCTAssertEqual(retriedSummary.clientSummaryId, originalID)
|
|
}
|
|
|
|
func testEachLeasedBatchContainsOnlyOneInstallation() async throws {
|
|
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
|
let repository = makeRepository(clock: clock)
|
|
for installationValue in [900, 901] {
|
|
await keyboardUsageRecord(
|
|
.init(chinese: 1),
|
|
sessionID: analyticsTestUUID(installationValue),
|
|
occurredAt: keyboardUsageDate(2026, 8, 20),
|
|
repository: repository,
|
|
installationID: analyticsTestUUID(installationValue)
|
|
)
|
|
}
|
|
|
|
let firstBatch = await repository.leaseBatch(
|
|
ownerID: "first",
|
|
configuration: uploadConfiguration()
|
|
)
|
|
let first = try XCTUnwrap(firstBatch)
|
|
XCTAssertEqual(first.summaries.count, 1)
|
|
XCTAssertTrue(
|
|
[analyticsTestUUID(900), analyticsTestUUID(901)]
|
|
.contains(first.installationID)
|
|
)
|
|
let completed = await repository.complete(
|
|
rowIDs: first.summaries.map(\.rowID),
|
|
leaseID: first.leaseID
|
|
)
|
|
XCTAssertTrue(completed)
|
|
await repository.releaseGlobalLease(ownerID: "first")
|
|
|
|
let secondBatch = await repository.leaseBatch(
|
|
ownerID: "second",
|
|
configuration: uploadConfiguration()
|
|
)
|
|
let second = try XCTUnwrap(secondBatch)
|
|
XCTAssertNotEqual(second.installationID, first.installationID)
|
|
XCTAssertEqual(second.summaries.count, 1)
|
|
}
|
|
|
|
func testClearAllInvalidatesAnAlreadyLeasedBatch() async throws {
|
|
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
|
let repository = makeRepository(clock: clock)
|
|
await keyboardUsageRecord(
|
|
.init(english: 1),
|
|
sessionID: analyticsTestUUID(50),
|
|
occurredAt: keyboardUsageDate(2026, 8, 20),
|
|
repository: repository
|
|
)
|
|
let leasedBatch = await repository.leaseBatch(
|
|
ownerID: "old-account",
|
|
configuration: uploadConfiguration()
|
|
)
|
|
let batch = try XCTUnwrap(leasedBatch)
|
|
|
|
await repository.clearAll()
|
|
|
|
let renewed = await repository.renewLease(
|
|
ownerID: "old-account",
|
|
leaseID: batch.leaseID,
|
|
configuration: uploadConfiguration()
|
|
)
|
|
XCTAssertFalse(renewed)
|
|
let snapshot = await repository.debugSnapshot()
|
|
XCTAssertTrue(snapshot.daily.isEmpty)
|
|
XCTAssertTrue(snapshot.pending.isEmpty)
|
|
XCTAssertTrue(snapshot.quarantined.isEmpty)
|
|
}
|
|
|
|
func testDatabaseSchemaContainsNoRawInputOrHostContextColumns() async throws {
|
|
let url = try keyboardUsageTemporaryDatabaseURL()
|
|
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
|
let repository = makeRepository(url: url, clock: clock)
|
|
await keyboardUsageRecord(
|
|
.init(chinese: 1),
|
|
sessionID: analyticsTestUUID(40),
|
|
occurredAt: clock.now(),
|
|
repository: repository
|
|
)
|
|
|
|
let database = try SQLiteDatabase(
|
|
url: url,
|
|
busyTimeoutMilliseconds: 2_000
|
|
)
|
|
let tables = [
|
|
"usage_daily_counters",
|
|
"usage_session_fragments",
|
|
"usage_outbox",
|
|
"usage_quarantine"
|
|
]
|
|
let forbidden = Set([
|
|
"text",
|
|
"raw_text",
|
|
"pinyin",
|
|
"candidate",
|
|
"context_before",
|
|
"context_after",
|
|
"host_app",
|
|
"bundle_id",
|
|
"field_type",
|
|
"transcript",
|
|
"prompt",
|
|
"clipboard"
|
|
])
|
|
for table in tables {
|
|
let columns = try database.query("PRAGMA table_info(\(table))")
|
|
.compactMap { $0.text(at: 1) }
|
|
XCTAssertTrue(forbidden.isDisjoint(with: columns), table)
|
|
if table == "usage_quarantine" {
|
|
XCTAssertTrue(
|
|
Set([
|
|
"installation_id",
|
|
"client_summary_id",
|
|
"payload"
|
|
]).isDisjoint(with: columns)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testSuspendedDatabaseDropsCountsUntilExplicitResume() async throws {
|
|
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
|
let repository = makeRepository(clock: clock)
|
|
await keyboardUsageRecord(
|
|
.init(chinese: 1),
|
|
sessionID: analyticsTestUUID(60),
|
|
occurredAt: clock.now(),
|
|
repository: repository
|
|
)
|
|
|
|
await repository.suspendDatabaseAccess()
|
|
await keyboardUsageRecord(
|
|
.init(english: 1),
|
|
sessionID: analyticsTestUUID(61),
|
|
occurredAt: clock.now(),
|
|
repository: repository
|
|
)
|
|
let suspendedSnapshot = await repository.debugSnapshot()
|
|
XCTAssertFalse(suspendedSnapshot.isAvailable)
|
|
|
|
await repository.resumeDatabaseAccess()
|
|
await keyboardUsageRecord(
|
|
.init(other: 1),
|
|
sessionID: analyticsTestUUID(62),
|
|
occurredAt: clock.now(),
|
|
repository: repository
|
|
)
|
|
let resumedSnapshot = await repository.debugSnapshot()
|
|
let daily = try XCTUnwrap(resumedSnapshot.daily.first)
|
|
XCTAssertEqual(daily.chineseCharacterCount, 1)
|
|
XCTAssertEqual(daily.englishCharacterCount, 0)
|
|
XCTAssertEqual(daily.otherCharacterCount, 1)
|
|
}
|
|
|
|
private func makeRepository(
|
|
url: URL? = nil,
|
|
clock: AnalyticsTestWallClock,
|
|
uuidStart: Int = 100
|
|
) -> KeyboardUsageRepository {
|
|
KeyboardUsageRepository(
|
|
configuration: KeyboardUsageRepositoryConfiguration(
|
|
databaseURL: url ?? (try! keyboardUsageTemporaryDatabaseURL())
|
|
),
|
|
clock: clock,
|
|
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: uuidStart)
|
|
)
|
|
}
|
|
|
|
private func uploadConfiguration() -> KeyboardUsageUploadConfiguration {
|
|
KeyboardUsageUploadConfiguration(
|
|
endpoint: URL(
|
|
string: "https://analytics.test/v1/analytics/keyboard-usage"
|
|
)!
|
|
)
|
|
}
|
|
}
|