Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
// 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"
|
||||
)!
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user