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:
Rocky
2026-08-22 16:33:18 +08:00
parent ac374631ae
commit e5a83843db
162 changed files with 23574 additions and 1013 deletions
@@ -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"
)!
)
}
}