feat(app): add resilient Flow recovery and privacy-safe analytics
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

Unify PiP recovery across startup and foreground transitions, surface actionable status, and add privacy-safe analytics plus the refreshed onboarding and account experience. Update documentation assets and advance the release build to 84.
This commit is contained in:
Rocky
2026-08-20 20:22:20 +08:00
parent ee7de5c934
commit 10bc457c72
69 changed files with 7891 additions and 598 deletions
@@ -0,0 +1,84 @@
// AnalyticsAIOperationTests.swift
// OSGKeyboardTests
//
// Ordering, duration, and exactly-once terminal semantics for AI operations.
import Foundation
@testable import OSGKeyboardShared
import XCTest
final class AnalyticsAIOperationTests: XCTestCase {
func testStartedIsPersistedBeforeTerminalWithDurationBucket() async throws {
let monotonicClock = AnalyticsTestMonotonicClock(1_000)
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: AnalyticsTestWallClock(),
uuidGenerator: AnalyticsTestUUIDGenerator()
)
let client = LiveAnalyticsClient(
repository: repository,
context: analyticsTestContext,
monotonicClock: monotonicClock
)
let operation = client.startAIFeature(.polish, executionMode: .managed)
monotonicClock.set(4_000_001_000)
operation.succeed()
_ = await analyticsWaitForPendingEventCount(2, repository: repository)
let events = try await analyticsDecodePendingEvents(repository: repository)
XCTAssertEqual(events.map(\.eventType), [.aiFeatureStarted, .aiFeatureSucceeded])
XCTAssertEqual(events.map(\.feature), [.polish, .polish])
XCTAssertEqual(events.map(\.executionMode), [.managed, .managed])
XCTAssertNil(events[0].durationBucket)
XCTAssertEqual(events[1].durationBucket, .threeToTenSeconds)
XCTAssertNil(events[1].failureCategory)
}
func testConcurrentTerminalCallsPersistExactlyOneTerminalEvent() async throws {
let monotonicClock = AnalyticsTestMonotonicClock(10)
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: AnalyticsTestWallClock(),
uuidGenerator: AnalyticsTestUUIDGenerator()
)
let client = LiveAnalyticsClient(
repository: repository,
context: analyticsTestContext,
monotonicClock: monotonicClock
)
let operation = client.startAIFeature(.aiAssistant, executionMode: .local)
monotonicClock.set(2_000_000_010)
await withTaskGroup(of: Void.self) { group in
for index in 0..<60 {
group.addTask {
switch index % 3 {
case 0:
operation.succeed()
case 1:
operation.fail(category: .provider)
default:
operation.cancel()
}
}
}
}
_ = await analyticsWaitForPendingEventCount(2, repository: repository)
let events = try await analyticsDecodePendingEvents(repository: repository)
XCTAssertEqual(events.first?.eventType, .aiFeatureStarted)
XCTAssertEqual(events.count, 2)
let terminalEvents = events.filter {
[.aiFeatureSucceeded, .aiFeatureFailed].contains($0.eventType)
}
XCTAssertEqual(terminalEvents.count, 1)
XCTAssertEqual(terminalEvents.first?.feature, .aiAssistant)
XCTAssertEqual(terminalEvents.first?.executionMode, .local)
XCTAssertEqual(terminalEvents.first?.durationBucket, .oneToThreeSeconds)
}
}
+165
View File
@@ -0,0 +1,165 @@
// AnalyticsModelTests.swift
// OSGKeyboardTests
//
// Wire-contract and privacy invariants for the allowlisted analytics schema.
import Foundation
@testable import OSGKeyboardShared
import XCTest
final class AnalyticsModelTests: XCTestCase {
func testEventEncodingContainsOnlyAllowlistedKeysAndNoFreeTextContainer() throws {
let event = try AnalyticsEvent(
installationId: analyticsTestUUID(1),
clientEventId: analyticsTestUUID(2),
eventType: .aiFeatureFailed,
occurredAt: Date(timeIntervalSince1970: 1_700_000_000.125),
surface: .keyboard,
appVersion: "2.0.0",
osVersion: "26.0",
feature: .polish,
executionMode: .managed,
failureCategory: .network,
durationBucket: .oneToThreeSeconds
)
let data = try JSONEncoder().encode(event)
let object = try XCTUnwrap(
JSONSerialization.jsonObject(with: data) as? [String: Any]
)
XCTAssertEqual(
Set(object.keys),
[
"installationId",
"clientEventId",
"eventType",
"occurredAt",
"surface",
"appVersion",
"osVersion",
"feature",
"executionMode",
"failureCategory",
"durationBucket"
]
)
XCTAssertFalse(object.keys.contains("properties"))
XCTAssertFalse(object.keys.contains("text"))
XCTAssertEqual(
Set(Mirror(reflecting: event).children.compactMap(\.label)),
Set(object.keys).union(["acquisitionChannel"])
)
}
func testUnknownFieldsAreRejectedAtEveryWireEnvelope() throws {
let event = try AnalyticsEvent(
installationId: analyticsTestUUID(1),
clientEventId: analyticsTestUUID(2),
eventType: .sessionStarted,
occurredAt: Date(timeIntervalSince1970: 1_700_000_000),
surface: .app,
appVersion: "2.0.0",
osVersion: "26.0"
)
var eventObject = try XCTUnwrap(
JSONSerialization.jsonObject(with: JSONEncoder().encode(event))
as? [String: Any]
)
eventObject["userText"] = "must be rejected"
assertUnknownField(
try JSONSerialization.data(withJSONObject: eventObject),
as: AnalyticsEvent.self,
expected: "userText"
)
let request = Data(#"{"events":[],"properties":{"secret":"value"}}"#.utf8)
assertUnknownField(
request,
as: AnalyticsUploadRequest.self,
expected: "properties"
)
let response = Data(#"{"accepted":1,"replayed":0,"debug":"secret"}"#.utf8)
assertUnknownField(
response,
as: AnalyticsUploadResponse.self,
expected: "debug"
)
}
func testEnvironmentFiltersAndTruncatesVersionsToSafeBound() {
let environment = AnalyticsEnvironment(
appVersion: String(repeating: "a", count: 40) + "/private",
osVersion: "26.0 (Build 23A)/用户"
)
XCTAssertEqual(environment.appVersion, String(repeating: "a", count: 32))
XCTAssertEqual(environment.appVersion.utf8.count, 32)
XCTAssertEqual(environment.osVersion, "26.0Build23A")
let empty = AnalyticsEnvironment(appVersion: "用户 /", osVersion: "")
XCTAssertEqual(empty.appVersion, "unknown")
XCTAssertEqual(empty.osVersion, "unknown")
}
func testPurchaseCancelledRequiresCancelledFailureCategory() throws {
XCTAssertNoThrow(
try AnalyticsEvent(
installationId: analyticsTestUUID(1),
clientEventId: analyticsTestUUID(2),
eventType: .purchaseCancelled,
occurredAt: Date(timeIntervalSince1970: 1_700_000_000),
surface: .app,
appVersion: "2.0.0",
osVersion: "26.0",
failureCategory: .cancelled
)
)
XCTAssertThrowsError(
try AnalyticsEvent(
installationId: analyticsTestUUID(1),
clientEventId: analyticsTestUUID(3),
eventType: .purchaseCancelled,
occurredAt: Date(timeIntervalSince1970: 1_700_000_000),
surface: .app,
appVersion: "2.0.0",
osVersion: "26.0",
failureCategory: .network
)
) { error in
guard case AnalyticsModelError.invalidDimensions(.purchaseCancelled) = error else {
return XCTFail("Unexpected error: \(error)")
}
}
}
func testDurationBucketBoundariesAreStable() {
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 999_999_999), .lessThanOneSecond)
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 1_000_000_000), .oneToThreeSeconds)
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 2_999_999_999), .oneToThreeSeconds)
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 3_000_000_000), .threeToTenSeconds)
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 10_000_000_000), .tenToThirtySeconds)
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 30_000_000_000), .thirtySecondsOrMore)
}
private func assertUnknownField<T: Decodable>(
_ data: Data,
as type: T.Type,
expected: String,
file: StaticString = #filePath,
line: UInt = #line
) {
XCTAssertThrowsError(
try JSONDecoder().decode(type, from: data),
file: file,
line: line
) { error in
guard case AnalyticsModelError.unknownField(let field) = error else {
return XCTFail("Unexpected error: \(error)", file: file, line: line)
}
XCTAssertEqual(field, expected, file: file, line: line)
}
}
}
@@ -0,0 +1,461 @@
// AnalyticsRepositoryTests.swift
// OSGKeyboardTests
//
// Durable identity, queue policy, retention, and cross-process lease coverage.
import Foundation
@testable import OSGKeyboardShared
import XCTest
final class AnalyticsRepositoryTests: XCTestCase {
func testFirstOpenMarkerAndEventAreCreatedExactlyOnceAcrossRestart() async throws {
let url = try analyticsTemporaryDatabaseURL()
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .referral
)
await repository.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .socialContent
)
let firstSnapshot = await repository.debugSnapshot()
XCTAssertTrue(firstSnapshot.firstOpenRecorded)
XCTAssertEqual(firstSnapshot.pendingEvents.map(\.eventType), [.firstOpen])
let decodedEvents = try await analyticsDecodePendingEvents(repository: repository)
let event = try XCTUnwrap(decodedEvents.first)
XCTAssertEqual(event.installationId, analyticsTestUUID(1))
XCTAssertEqual(event.clientEventId, analyticsTestUUID(2))
XCTAssertEqual(event.acquisitionChannel, .referral)
let restarted = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100)
)
await restarted.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .appStoreOrganic
)
let restartedSnapshot = await restarted.debugSnapshot()
XCTAssertEqual(restartedSnapshot.installationID, analyticsTestUUID(1))
XCTAssertEqual(restartedSnapshot.pendingEvents.map(\.eventType), [.firstOpen])
}
func testSessionWindowTracksThirtyMinutesOfInactivityPerSurface() async throws {
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.recordSessionIfNeeded(context: analyticsTestContext)
clock.advance(by: 29 * 60)
await repository.recordSessionIfNeeded(context: analyticsTestContext)
clock.advance(by: 29 * 60)
await repository.recordSessionIfNeeded(context: analyticsTestContext)
var snapshot = await repository.debugSnapshot()
XCTAssertEqual(snapshot.pendingEvents.map(\.eventType), [.sessionStarted])
clock.advance(by: 30 * 60)
await repository.recordSessionIfNeeded(context: analyticsTestContext)
snapshot = await repository.debugSnapshot()
XCTAssertEqual(
snapshot.pendingEvents.compactMap(\.eventType),
[.sessionStarted, .sessionStarted]
)
}
func testDisableClearsQueuePersistsAcrossRestartAndReenableRotatesIdentity() async throws {
let url = try analyticsTemporaryDatabaseURL()
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .unknown
)
let originalID = await repository.debugSnapshot().installationID
await repository.setEnabled(false)
var snapshot = await repository.debugSnapshot()
XCTAssertFalse(snapshot.enabled)
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
let restarted = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100)
)
let remainsDisabled = await restarted.isEnabled()
XCTAssertFalse(remainsDisabled)
await restarted.record(
eventType: .keyboardActivated,
context: analyticsTestContext
)
let disabledSnapshot = await restarted.debugSnapshot()
XCTAssertTrue(disabledSnapshot.pendingEvents.isEmpty)
await restarted.setEnabled(true)
await restarted.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .referral
)
await restarted.record(
eventType: .keyboardActivated,
context: analyticsTestContext
)
snapshot = await restarted.debugSnapshot()
XCTAssertTrue(snapshot.enabled)
XCTAssertNotEqual(snapshot.installationID, originalID)
XCTAssertTrue(snapshot.firstOpenRecorded)
XCTAssertEqual(snapshot.pendingEvents.map(\.eventType), [.keyboardActivated])
}
func testAccountObservationSwitchAndDeletionRotateWithoutRepeatingFirstOpen() async throws {
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: AnalyticsTestWallClock(),
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .appStoreOrganic
)
let installationBeforeAccount = await repository.debugSnapshot().installationID
let first = await repository.observeAccount(stableIdentifier: "account-a")
guard case .firstAccount = first else {
return XCTFail("Expected firstAccount, got \(first)")
}
let installationAfterFirstAccount = await repository.debugSnapshot().installationID
XCTAssertEqual(installationAfterFirstAccount, installationBeforeAccount)
let unchanged = await repository.observeAccount(stableIdentifier: "account-a")
guard case .unchanged = unchanged else {
return XCTFail("Expected unchanged, got \(unchanged)")
}
await repository.record(
eventType: .keyboardActivated,
context: analyticsTestContext
)
let switched = await repository.observeAccount(stableIdentifier: "account-b")
guard case .switchedAccount = switched else {
return XCTFail("Expected switchedAccount, got \(switched)")
}
let afterSwitch = await repository.debugSnapshot()
XCTAssertNotEqual(afterSwitch.installationID, installationBeforeAccount)
XCTAssertTrue(afterSwitch.pendingEvents.isEmpty)
XCTAssertTrue(afterSwitch.firstOpenRecorded)
await repository.record(
eventType: .purchaseViewed,
context: analyticsTestContext
)
let switchedID = afterSwitch.installationID
await repository.handleAccountDeletion()
let afterDeletion = await repository.debugSnapshot()
XCTAssertNotEqual(afterDeletion.installationID, switchedID)
XCTAssertTrue(afterDeletion.pendingEvents.isEmpty)
XCTAssertTrue(afterDeletion.firstOpenRecorded)
let firstAfterDeletion = await repository.observeAccount(stableIdentifier: "account-b")
guard case .firstAccount = firstAfterDeletion else {
return XCTFail("Expected firstAccount after deletion, got \(firstAfterDeletion)")
}
}
func testRetryPreservesCanonicalPayloadAndClientEventID() async throws {
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await analyticsRecordKeyboardEvents(count: 1, repository: repository)
let snapshot = await repository.debugSnapshot()
let rowID = try XCTUnwrap(snapshot.pendingEvents.first?.rowID)
let storedOriginalPayload = await repository.debugPayloadBytes(rowID: rowID)
let originalPayload = try XCTUnwrap(storedOriginalPayload)
let originalEvent = try JSONDecoder().decode(
AnalyticsEvent.self,
from: originalPayload
)
let upload = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!
)
let leasedFirstBatch = await repository.leaseBatch(
ownerID: "owner",
configuration: upload
)
let firstLease = try XCTUnwrap(leasedFirstBatch)
await repository.scheduleRetry(
events: firstLease.events,
leaseID: firstLease.leaseID,
delay: 10
)
let storedRetriedPayload = await repository.debugPayloadBytes(rowID: rowID)
let retriedPayload = try XCTUnwrap(storedRetriedPayload)
XCTAssertEqual(retriedPayload, originalPayload)
let retrySnapshot = await repository.debugSnapshot()
let retryDiagnostic = try XCTUnwrap(retrySnapshot.pendingEvents.first)
XCTAssertEqual(retryDiagnostic.attemptCount, 1)
XCTAssertEqual(retryDiagnostic.nextAttemptAt, clock.now().addingTimeInterval(10))
clock.advance(by: 10)
let leasedSecondBatch = await repository.leaseBatch(
ownerID: "owner",
configuration: upload
)
let secondLease = try XCTUnwrap(leasedSecondBatch)
XCTAssertEqual(secondLease.events.first?.payload, originalPayload)
let retriedEvent = try JSONDecoder().decode(
AnalyticsEvent.self,
from: try XCTUnwrap(secondLease.events.first?.payload)
)
XCTAssertEqual(retriedEvent.clientEventId, originalEvent.clientEventId)
}
func testConcurrentRepositoriesEnforceGlobalLeaseAndRecoverExpiredEventLease() async throws {
let url = try analyticsTemporaryDatabaseURL()
let clock = AnalyticsTestWallClock()
let firstRepository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
let secondRepository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100)
)
await analyticsRecordKeyboardEvents(count: 1, repository: firstRepository)
let upload = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!,
globalLeaseDuration: 60,
eventLeaseDuration: 60
)
async let first = firstRepository.leaseBatch(
ownerID: "first-owner",
configuration: upload
)
async let second = secondRepository.leaseBatch(
ownerID: "second-owner",
configuration: upload
)
let simultaneous = await (first, second)
XCTAssertEqual([simultaneous.0, simultaneous.1].compactMap { $0 }.count, 1)
clock.advance(by: 61)
let recovered = await secondRepository.leaseBatch(
ownerID: "recovery-owner",
configuration: upload
)
XCTAssertEqual(recovered?.events.count, 1)
}
func testCrashAfterServerAcceptanceBeforeDeleteReplaysOriginalBytes() async throws {
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await analyticsRecordKeyboardEvents(count: 1, repository: repository)
let configuration = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!,
globalLeaseDuration: 60,
eventLeaseDuration: 60
)
let crashedLease = await repository.leaseBatch(
ownerID: "crashed-owner",
configuration: configuration
)
let acceptedButNotDeleted = try XCTUnwrap(crashedLease)
clock.advance(by: 61)
let network = AnalyticsQueueNetwork([
.response(analyticsSuccessResponse(accepted: 0, replayed: 1))
])
let coordinator = AnalyticsUploadCoordinator(
repository: repository,
configuration: configuration,
network: network,
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 500),
random: AnalyticsTestRandomGenerator()
)
await coordinator.uploadAvailableEvents()
let requests = await network.requests()
XCTAssertEqual(requests.single?.body, acceptedButNotDeleted.body)
let completedSnapshot = await repository.debugSnapshot()
XCTAssertTrue(completedSnapshot.pendingEvents.isEmpty)
}
func testBatchHonorsFiftyEventAndDynamicBodyByteLimits() async throws {
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: AnalyticsTestWallClock(),
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await analyticsRecordKeyboardEvents(count: 55, repository: repository)
let defaultUpload = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!,
maximumBatchCount: 100,
maximumBodyBytes: 100_000
)
let countLease = await repository.leaseBatch(
ownerID: "count",
configuration: defaultUpload
)
let countLimited = try XCTUnwrap(countLease)
XCTAssertEqual(countLimited.events.count, 50)
XCTAssertLessThanOrEqual(countLimited.body.count, 60 * 1_024)
await repository.releaseEvents(
countLimited.events,
leaseID: countLimited.leaseID
)
await repository.releaseGlobalLease(ownerID: "count")
let byteUpload = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!,
maximumBatchCount: 50,
maximumBodyBytes: 1_024
)
let byteLease = await repository.leaseBatch(
ownerID: "bytes",
configuration: byteUpload
)
let byteLimited = try XCTUnwrap(byteLease)
XCTAssertFalse(byteLimited.events.isEmpty)
XCTAssertLessThan(byteLimited.events.count, 50)
XCTAssertLessThanOrEqual(byteLimited.body.count, 1_024)
let snapshot = await repository.debugSnapshot()
let selectedIDs = Set(byteLimited.events.map(\.rowID))
let nextDiagnostic = try XCTUnwrap(
snapshot.pendingEvents.first { !selectedIDs.contains($0.rowID) }
)
let storedNextPayload = await repository.debugPayloadBytes(
rowID: nextDiagnostic.rowID
)
let nextPayload = try XCTUnwrap(storedNextPayload)
XCTAssertGreaterThan(byteLimited.body.count + 1 + nextPayload.count, 1_024)
}
func testRetentionExpiresAfterThirtyFourDaysAndToleratesClockRollback() async throws {
let retention = 34 * 24 * 60 * 60.0
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL(),
eventRetention: retention
),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.record(
eventType: .keyboardActivated,
context: analyticsTestContext
)
clock.advance(by: retention)
await repository.record(
eventType: .purchaseViewed,
context: analyticsTestContext
)
let boundarySnapshot = await repository.debugSnapshot()
XCTAssertEqual(boundarySnapshot.pendingEvents.count, 2)
clock.advance(by: 0.001)
await repository.record(
eventType: .purchaseStarted,
context: analyticsTestContext
)
var events = try await analyticsDecodePendingEvents(repository: repository)
XCTAssertEqual(Set(events.map(\.eventType)), [.purchaseViewed, .purchaseStarted])
let future = clock.now().addingTimeInterval(24 * 60 * 60)
clock.set(future)
await repository.record(
eventType: .referralShared,
context: analyticsTestContext
)
clock.set(future.addingTimeInterval(-7 * 24 * 60 * 60))
let upload = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!
)
let batch = await repository.leaseBatch(ownerID: "rollback", configuration: upload)
events = try batch?.events.map {
try JSONDecoder().decode(AnalyticsEvent.self, from: $0.payload)
} ?? []
XCTAssertTrue(events.contains(where: { $0.eventType == .referralShared }))
}
func testCapacityEvictsSessionAndKeyboardBeforeHighValueEvents() async throws {
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL(),
maximumEventCount: 2
),
clock: AnalyticsTestWallClock(),
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .unknown
)
await repository.recordSessionIfNeeded(context: analyticsTestContext)
await repository.record(
eventType: .keyboardActivated,
context: analyticsTestContext
)
var eventTypes = await repository.debugSnapshot().pendingEvents.compactMap(\.eventType)
XCTAssertEqual(Set(eventTypes), [.firstOpen, .keyboardActivated])
await repository.record(
eventType: .aiFeatureSucceeded,
context: analyticsTestContext,
dimensions: AnalyticsEventDimensions(
feature: .polish,
executionMode: .managed,
durationBucket: .oneToThreeSeconds
)
)
eventTypes = await repository.debugSnapshot().pendingEvents.compactMap(\.eventType)
XCTAssertEqual(Set(eventTypes), [.firstOpen, .aiFeatureSucceeded])
}
}
private extension Array {
var single: Element? {
count == 1 ? first : nil
}
}
+267
View File
@@ -0,0 +1,267 @@
// AnalyticsTestSupport.swift
// OSGKeyboardTests
//
// Deterministic analytics clocks, identifiers, transport doubles, and fixtures.
import Foundation
@testable import OSGKeyboardShared
import XCTest
let analyticsTestContext = AnalyticsBootstrapContext(
surface: .app,
environment: AnalyticsEnvironment(appVersion: "2.0.0", osVersion: "26.0")
)
func analyticsTestUUID(_ value: Int) -> UUID {
UUID(uuidString: String(format: "00000000-0000-0000-0000-%012x", value))!
}
func analyticsTemporaryDatabaseURL() throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("OSGKeyboardAnalyticsTests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
return directory.appendingPathComponent("analytics.sqlite3")
}
final class AnalyticsTestWallClock: AnalyticsWallClock, @unchecked Sendable {
private let lock = NSLock()
private var value: Date
init(_ value: Date = Date(timeIntervalSince1970: 1_700_000_000)) {
self.value = value
}
func now() -> Date {
lock.withLock { value }
}
func set(_ value: Date) {
lock.withLock { self.value = value }
}
func advance(by interval: TimeInterval) {
lock.withLock { value = value.addingTimeInterval(interval) }
}
}
final class AnalyticsTestMonotonicClock: AnalyticsMonotonicClock, @unchecked Sendable {
private let lock = NSLock()
private var value: UInt64
init(_ value: UInt64 = 0) {
self.value = value
}
func nowNanoseconds() -> UInt64 {
lock.withLock { value }
}
func set(_ value: UInt64) {
lock.withLock { self.value = value }
}
}
final class AnalyticsTestUUIDGenerator: AnalyticsUUIDGenerating, @unchecked Sendable {
private let lock = NSLock()
private var nextValue: Int
init(startingAt value: Int = 1) {
nextValue = value
}
func makeUUID() -> UUID {
lock.withLock {
defer { nextValue += 1 }
return analyticsTestUUID(nextValue)
}
}
}
final class AnalyticsTestRandomGenerator: AnalyticsRandomGenerating, @unchecked Sendable {
enum Value {
case zero
case upperBound
case fixed(UInt64)
}
private let value: Value
init(_ value: Value = .zero) {
self.value = value
}
func next(upperBound: UInt64) -> UInt64 {
switch value {
case .zero:
return 0
case .upperBound:
return upperBound
case .fixed(let fixed):
return min(fixed, upperBound)
}
}
}
actor AnalyticsQueueNetwork: AnalyticsNetworking {
enum Outcome: Sendable {
case response(AnalyticsHTTPResponse)
case urlError(URLError.Code)
}
private var outcomes: [Outcome]
private var capturedRequests: [AnalyticsHTTPRequest] = []
init(_ outcomes: [Outcome]) {
self.outcomes = outcomes
}
func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse {
capturedRequests.append(request)
guard !outcomes.isEmpty else {
return AnalyticsHTTPResponse(statusCode: 500, headers: [:], body: Data())
}
switch outcomes.removeFirst() {
case .response(let response):
return response
case .urlError(let code):
throw URLError(code)
}
}
func requests() -> [AnalyticsHTTPRequest] {
capturedRequests
}
}
actor AnalyticsPoisonNetwork: AnalyticsNetworking {
private let statusCode: Int
private let poisonID: UUID
private var capturedRequests: [AnalyticsHTTPRequest] = []
init(statusCode: Int, poisonID: UUID) {
self.statusCode = statusCode
self.poisonID = poisonID
}
func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse {
capturedRequests.append(request)
let upload = try JSONDecoder().decode(AnalyticsUploadRequest.self, from: request.body)
if upload.events.contains(where: { $0.clientEventId == poisonID }) {
return AnalyticsHTTPResponse(statusCode: statusCode, headers: [:], body: Data())
}
return analyticsSuccessResponse(accepted: upload.events.count)
}
func requests() -> [AnalyticsHTTPRequest] {
capturedRequests
}
}
actor AnalyticsTestBearerProvider: AnalyticsBearerProviding {
private let initialToken: String?
private let refreshedToken: String?
private var refreshInputs: [String?] = []
init(initialToken: String?, refreshedToken: String?) {
self.initialToken = initialToken
self.refreshedToken = refreshedToken
}
func bearerToken() async throws -> String? {
initialToken
}
func refreshBearerToken(
afterUnauthorizedAccessToken failedToken: String?
) async throws -> String? {
refreshInputs.append(failedToken)
return refreshedToken
}
func recordedRefreshInputs() -> [String?] {
refreshInputs
}
}
func analyticsSuccessResponse(
accepted: Int,
replayed: Int = 0
) -> AnalyticsHTTPResponse {
AnalyticsHTTPResponse(
statusCode: 200,
headers: [:],
body: Data(#"{"accepted":\#(accepted),"replayed":\#(replayed)}"#.utf8)
)
}
func analyticsHTTPResponse(
statusCode: Int,
headers: [String: String] = [:],
body: Data = Data()
) -> AnalyticsHTTPResponse {
AnalyticsHTTPResponse(statusCode: statusCode, headers: headers, body: body)
}
func analyticsRecordKeyboardEvents(
count: Int,
repository: AnalyticsRepository,
context: AnalyticsBootstrapContext = analyticsTestContext
) async {
for _ in 0..<count {
await repository.record(
eventType: .keyboardActivated,
context: context
)
}
}
func analyticsPendingPayloads(
repository: AnalyticsRepository
) async throws -> [Data] {
let snapshot = await repository.debugSnapshot()
var payloads: [Data] = []
for diagnostic in snapshot.pendingEvents {
let payload = await repository.debugPayloadBytes(rowID: diagnostic.rowID)
payloads.append(try XCTUnwrap(payload))
}
return payloads
}
func analyticsDecodePendingEvents(
repository: AnalyticsRepository
) async throws -> [AnalyticsEvent] {
let payloads = try await analyticsPendingPayloads(repository: repository)
return try payloads.map { try JSONDecoder().decode(AnalyticsEvent.self, from: $0) }
}
func analyticsRequestBody(payloads: [Data]) -> Data {
var body = Data(#"{"events":["#.utf8)
for index in payloads.indices {
if index > 0 {
body.append(UInt8(ascii: ","))
}
body.append(payloads[index])
}
body.append(Data("]}".utf8))
return body
}
func analyticsWaitForPendingEventCount(
_ count: Int,
repository: AnalyticsRepository,
timeoutIterations: Int = 200
) async -> AnalyticsRepositoryDebugSnapshot {
for _ in 0..<timeoutIterations {
let snapshot = await repository.debugSnapshot()
if snapshot.pendingEvents.count >= count {
return snapshot
}
try? await Task.sleep(for: .milliseconds(10))
}
XCTFail("Timed out waiting for \(count) analytics events")
return await repository.debugSnapshot()
}
@@ -0,0 +1,308 @@
// AnalyticsUploadCoordinatorTests.swift
// OSGKeyboardTests
//
// HTTP outcome, authentication, retry, and poison-event isolation coverage.
import Foundation
@testable import OSGKeyboardShared
import XCTest
final class AnalyticsUploadCoordinatorTests: XCTestCase {
private let endpoint = URL(string: "https://analytics.test/v1/events")!
func testExactAcceptedAndReplayedCountDeletesUsingOriginalPayloadBytes() async throws {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(clock: clock, eventCount: 2)
let payloads = try await analyticsPendingPayloads(repository: repository)
let expectedBody = analyticsRequestBody(payloads: payloads)
let network = AnalyticsQueueNetwork([
.response(analyticsSuccessResponse(accepted: 1, replayed: 1))
])
let coordinator = makeCoordinator(
repository: repository,
network: network,
clock: clock
)
await coordinator.uploadAvailableEvents()
let snapshot = await repository.debugSnapshot()
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
let requests = await network.requests()
let request = try XCTUnwrap(requests.first)
XCTAssertEqual(requests.count, 1)
XCTAssertEqual(request.body, expectedBody)
XCTAssertEqual(request.url, endpoint)
XCTAssertEqual(request.headers["Content-Type"], "application/json")
XCTAssertNil(request.headers["Authorization"])
let topLevel = try XCTUnwrap(
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
)
XCTAssertEqual(Set(topLevel.keys), ["events"])
XCTAssertEqual((topLevel["events"] as? [Any])?.count, 2)
}
func testCountMismatchRetriesWithoutMutatingPayloadOrEventID() async throws {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(clock: clock, eventCount: 2)
let originalPayloads = try await analyticsPendingPayloads(repository: repository)
let originalIDs = try originalPayloads.map {
try JSONDecoder().decode(AnalyticsEvent.self, from: $0).clientEventId
}
let network = AnalyticsQueueNetwork([
.response(analyticsSuccessResponse(accepted: 1, replayed: 0))
])
let coordinator = makeCoordinator(
repository: repository,
network: network,
clock: clock,
random: AnalyticsTestRandomGenerator(.upperBound)
)
await coordinator.uploadAvailableEvents()
let snapshot = await repository.debugSnapshot()
XCTAssertEqual(snapshot.pendingEvents.map(\.attemptCount), [1, 1])
XCTAssertEqual(
snapshot.pendingEvents.map(\.nextAttemptAt),
[clock.now().addingTimeInterval(1), clock.now().addingTimeInterval(1)]
)
let retriedPayloads = try await analyticsPendingPayloads(repository: repository)
XCTAssertEqual(retriedPayloads, originalPayloads)
let retriedIDs = try retriedPayloads.map {
try JSONDecoder().decode(AnalyticsEvent.self, from: $0).clientEventId
}
XCTAssertEqual(retriedIDs, originalIDs)
}
func testNetwork408429And5xxScheduleBoundedBackoff() async throws {
let cases: [(AnalyticsQueueNetwork.Outcome, TimeInterval)] = [
(.urlError(.notConnectedToInternet), 1),
(.response(analyticsHTTPResponse(statusCode: 408)), 1),
(
.response(
analyticsHTTPResponse(
statusCode: 429,
headers: ["rEtRy-AfTeR": "120"]
)
),
120
),
(.response(analyticsHTTPResponse(statusCode: 503)), 1)
]
for (index, testCase) in cases.enumerated() {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(
clock: clock,
eventCount: 1,
uuidStart: 100 * (index + 1)
)
let originalPayloads = try await analyticsPendingPayloads(
repository: repository
)
let originalPayload = try XCTUnwrap(originalPayloads.first)
let network = AnalyticsQueueNetwork([testCase.0])
let coordinator = makeCoordinator(
repository: repository,
network: network,
clock: clock,
random: AnalyticsTestRandomGenerator(.upperBound)
)
await coordinator.uploadAvailableEvents()
let snapshot = await repository.debugSnapshot()
let diagnostic = try XCTUnwrap(snapshot.pendingEvents.first)
XCTAssertEqual(diagnostic.attemptCount, 1, "case \(index)")
XCTAssertEqual(
diagnostic.nextAttemptAt,
clock.now().addingTimeInterval(testCase.1),
"case \(index)"
)
let storedPayload = await repository.debugPayloadBytes(rowID: diagnostic.rowID)
XCTAssertEqual(storedPayload, originalPayload, "case \(index)")
}
}
func testUnauthorizedRefreshesOnceAndRetriesWithNewAuthorization() async throws {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(clock: clock, eventCount: 1)
let network = AnalyticsQueueNetwork([
.response(analyticsHTTPResponse(statusCode: 401)),
.response(analyticsSuccessResponse(accepted: 1))
])
let bearer = AnalyticsTestBearerProvider(
initialToken: "old-token",
refreshedToken: "new-token"
)
let coordinator = AnalyticsUploadCoordinator(
repository: repository,
configuration: uploadConfiguration(),
network: network,
bearerProvider: bearer,
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
random: AnalyticsTestRandomGenerator()
)
await coordinator.uploadAvailableEvents()
let requests = await network.requests()
XCTAssertEqual(requests.count, 2)
XCTAssertEqual(requests[0].headers["Authorization"], "Bearer old-token")
XCTAssertEqual(requests[1].headers["Authorization"], "Bearer new-token")
XCTAssertEqual(requests[0].body, requests[1].body)
let refreshInputs = await bearer.recordedRefreshInputs()
XCTAssertEqual(refreshInputs.count, 1)
XCTAssertEqual(refreshInputs[0], "old-token")
let snapshot = await repository.debugSnapshot()
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
}
func testSecondUnauthorizedDoesNotRefreshAgainAndSchedulesLongRetry() async throws {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(clock: clock, eventCount: 1)
let network = AnalyticsQueueNetwork([
.response(analyticsHTTPResponse(statusCode: 401)),
.response(analyticsHTTPResponse(statusCode: 401))
])
let bearer = AnalyticsTestBearerProvider(
initialToken: "old-token",
refreshedToken: "new-token"
)
let configuration = uploadConfiguration(maximumBackoff: 600)
let coordinator = AnalyticsUploadCoordinator(
repository: repository,
configuration: configuration,
network: network,
bearerProvider: bearer,
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
random: AnalyticsTestRandomGenerator(.upperBound)
)
await coordinator.uploadAvailableEvents()
let refreshInputs = await bearer.recordedRefreshInputs()
XCTAssertEqual(refreshInputs.count, 1)
let snapshot = await repository.debugSnapshot()
XCTAssertEqual(snapshot.pendingEvents.first?.attemptCount, 1)
XCTAssertEqual(
snapshot.pendingEvents.first?.nextAttemptAt,
clock.now().addingTimeInterval(600)
)
}
func testValidationStatusesRecursivelyIsolateOnlyPoisonEvent() async throws {
for statusCode in [400, 409, 422] {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(
clock: clock,
eventCount: 4,
uuidStart: statusCode
)
let events = try await analyticsDecodePendingEvents(repository: repository)
let poisonID = try XCTUnwrap(events.dropFirst().first?.clientEventId)
let network = AnalyticsPoisonNetwork(
statusCode: statusCode,
poisonID: poisonID
)
let coordinator = makeCoordinator(
repository: repository,
network: network,
clock: clock
)
await coordinator.uploadAvailableEvents()
let snapshot = await repository.debugSnapshot()
XCTAssertTrue(snapshot.pendingEvents.isEmpty, "HTTP \(statusCode)")
XCTAssertEqual(snapshot.quarantinedEvents.count, 1, "HTTP \(statusCode)")
XCTAssertEqual(
snapshot.quarantinedEvents.first?.reason,
"http\(statusCode)"
)
let quarantinedRowID = try XCTUnwrap(
snapshot.quarantinedEvents.first?.rowID
)
let quarantinedPayload = await repository.debugPayloadBytes(
rowID: quarantinedRowID,
quarantined: true
)
let quarantinedEvent = try JSONDecoder().decode(
AnalyticsEvent.self,
from: try XCTUnwrap(quarantinedPayload)
)
XCTAssertEqual(quarantinedEvent.clientEventId, poisonID)
let requests = await network.requests()
XCTAssertGreaterThan(requests.count, 1)
}
}
func testOtherPermanent4xxQuarantinesWholeBatchWithoutRetry() async throws {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(clock: clock, eventCount: 3)
let network = AnalyticsQueueNetwork([
.response(analyticsHTTPResponse(statusCode: 403))
])
let coordinator = makeCoordinator(
repository: repository,
network: network,
clock: clock
)
await coordinator.uploadAvailableEvents()
let snapshot = await repository.debugSnapshot()
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
XCTAssertEqual(snapshot.quarantinedEvents.count, 3)
XCTAssertEqual(Set(snapshot.quarantinedEvents.map(\.reason)), ["http403"])
let requests = await network.requests()
XCTAssertEqual(requests.count, 1)
}
private func makeRepository(
clock: AnalyticsTestWallClock,
eventCount: Int,
uuidStart: Int = 1
) async throws -> AnalyticsRepository {
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: uuidStart)
)
await analyticsRecordKeyboardEvents(
count: eventCount,
repository: repository
)
return repository
}
private func uploadConfiguration(
maximumBackoff: TimeInterval = 600
) -> AnalyticsUploadConfiguration {
AnalyticsUploadConfiguration(
endpoint: endpoint,
maximumBackoff: maximumBackoff
)
}
private func makeCoordinator(
repository: AnalyticsRepository,
network: some AnalyticsNetworking,
clock: AnalyticsTestWallClock,
random: AnalyticsTestRandomGenerator = AnalyticsTestRandomGenerator()
) -> AnalyticsUploadCoordinator {
AnalyticsUploadCoordinator(
repository: repository,
configuration: uploadConfiguration(),
network: network,
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
random: random
)
}
}
@@ -62,6 +62,50 @@ final class AppGroupOnboardingStoreTests: XCTestCase {
XCTAssertEqual(store3.onboardingPage, 0)
}
func testOnboardingPracticeWindowExpires() {
let now = Date(timeIntervalSince1970: 1_000)
KeyboardSetupBridge.setOnboardingPracticeActive(
true,
duration: 30,
defaults: defaults,
now: now
)
XCTAssertTrue(
KeyboardSetupBridge.onboardingPracticeIsActive(
defaults: defaults,
now: now.addingTimeInterval(29)
)
)
XCTAssertFalse(
KeyboardSetupBridge.onboardingPracticeIsActive(
defaults: defaults,
now: now.addingTimeInterval(31)
)
)
}
func testDisablingOnboardingPracticeClearsWindow() {
let now = Date(timeIntervalSince1970: 1_000)
KeyboardSetupBridge.setOnboardingPracticeActive(
true,
defaults: defaults,
now: now
)
KeyboardSetupBridge.setOnboardingPracticeActive(
false,
defaults: defaults,
now: now
)
XCTAssertFalse(
KeyboardSetupBridge.onboardingPracticeIsActive(
defaults: defaults,
now: now
)
)
}
// MARK: - App context detection round-trip
func testDetectedAppContextRoundTrip() throws {
@@ -0,0 +1,97 @@
// FlowHomePiPStatusPolicyTests.swift
// OSGKeyboardTests
@testable import OSGKeyboard
import XCTest
final class FlowHomePiPStatusPolicyTests: XCTestCase {
func testPreparingAndRecoveringExposeAttemptProgress() {
XCTAssertEqual(
descriptor(for: .preparing(attempt: 1, total: 3)),
.progress(
localizationKey: "home.flow.preparingProgress",
attempt: 1,
total: 3
)
)
XCTAssertEqual(
descriptor(for: .recovering(attempt: 2, total: 3)),
.progress(
localizationKey: "home.flow.recoveringProgress",
attempt: 2,
total: 3
)
)
}
func testTakeoverAndFailureUseActionableStatuses() {
XCTAssertEqual(
descriptor(for: .waitingForForeground),
.text(localizationKey: "home.flow.waitingForForeground")
)
XCTAssertEqual(
descriptor(for: .failed(.systemRejected)),
.text(localizationKey: "home.flow.recoveryFailed")
)
}
func testActiveSessionDoesNotClaimReadyWithoutReadyContract() {
XCTAssertEqual(
FlowHomePiPStatusPolicy.descriptor(
lifecycle: .active,
isStarting: false,
isRecording: false,
isProcessing: false,
isActive: true,
isHostReady: false
),
.text(localizationKey: "home.flow.notReady")
)
XCTAssertEqual(
FlowHomePiPStatusPolicy.descriptor(
lifecycle: .active,
isStarting: false,
isRecording: false,
isProcessing: false,
isActive: true,
isHostReady: true
),
.text(localizationKey: "home.flow.label")
)
}
func testRetryAppearsOnlyForRecoverableFailureWithPermissions() {
XCTAssertTrue(
FlowHomePiPStatusPolicy.canRetry(
lifecycle: .failed(.timedOut),
needsPermissionSetup: false
)
)
XCTAssertFalse(
FlowHomePiPStatusPolicy.canRetry(
lifecycle: .failed(.timedOut),
needsPermissionSetup: true
)
)
XCTAssertFalse(
FlowHomePiPStatusPolicy.canRetry(
lifecycle: .recovering(attempt: 1, total: 3),
needsPermissionSetup: false
)
)
}
private func descriptor(
for lifecycle: FlowPiPLifecycleState
) -> FlowHomePiPStatusDescriptor {
FlowHomePiPStatusPolicy.descriptor(
lifecycle: lifecycle,
isStarting: false,
isRecording: false,
isProcessing: false,
isActive: false,
isHostReady: false
)
}
}
@@ -0,0 +1,149 @@
// FlowPiPRecoveryPolicyTests.swift
// OSGKeyboardTests
@testable import OSGKeyboard
import XCTest
final class FlowPiPRecoveryPolicyTests: XCTestCase {
// MARK: - Reconciliation
func testNoSessionIntentNeverStartsRecovery() {
XCTAssertEqual(
FlowPiPRecoveryPolicy.reconciliationDecision(
wantsActiveSession: false,
isAppForeground: true,
isPictureInPictureActive: false
),
.noSessionIntent
)
}
func testActivePiPIsIdempotent() {
XCTAssertEqual(
FlowPiPRecoveryPolicy.reconciliationDecision(
wantsActiveSession: true,
isAppForeground: true,
isPictureInPictureActive: true
),
.alreadyActive
)
}
func testTakeoverWaitsWhileAppIsInBackground() {
XCTAssertEqual(
FlowPiPRecoveryPolicy.reconciliationDecision(
wantsActiveSession: true,
isAppForeground: false,
isPictureInPictureActive: false
),
.waitForForeground
)
}
func testReturningToForegroundStartsRecovery() {
XCTAssertEqual(
FlowPiPRecoveryPolicy.reconciliationDecision(
wantsActiveSession: true,
isAppForeground: true,
isPictureInPictureActive: false
),
.startRecovery
)
}
// MARK: - Bounded retry
func testRetryPolicyUsesThreeIncreasingBoundedAttempts() {
XCTAssertEqual(FlowPiPRecoveryPolicy.maxAttempts, 3)
XCTAssertEqual(
(1...FlowPiPRecoveryPolicy.maxAttempts).map {
FlowPiPRecoveryPolicy.retryDelay(beforeAttempt: $0)
},
[0, 0.25, 0.6]
)
XCTAssertEqual(FlowPiPRecoveryPolicy.totalBudget, 5)
}
func testRetryTimeoutsNeverExceedRemainingBudget() {
XCTAssertEqual(
FlowPiPRecoveryPolicy.hostTimeout(attempt: 1, remainingBudget: 0.4),
0.4
)
XCTAssertEqual(
FlowPiPRecoveryPolicy.hostTimeout(attempt: 2, remainingBudget: 4),
0.25
)
XCTAssertEqual(
FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 0.3),
0.3
)
XCTAssertEqual(
FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 4),
1.4
)
XCTAssertEqual(
FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 0.01),
0.01
)
}
// MARK: - Late completion safety
func testCurrentForegroundOperationCanContinue() {
XCTAssertTrue(
FlowPiPRecoveryPolicy.canContinue(
operation: 7,
currentOperation: 7,
wantsActiveSession: true,
isAppForeground: true,
taskIsCancelled: false
)
)
}
func testOldGenerationCannotOverwriteNewRecovery() {
XCTAssertFalse(
FlowPiPRecoveryPolicy.canContinue(
operation: 6,
currentOperation: 7,
wantsActiveSession: true,
isAppForeground: true,
taskIsCancelled: false
)
)
}
func testExplicitEndPreventsLateRecovery() {
XCTAssertFalse(
FlowPiPRecoveryPolicy.canContinue(
operation: 7,
currentOperation: 8,
wantsActiveSession: false,
isAppForeground: true,
taskIsCancelled: true
)
)
}
func testBackgroundAndCancellationBothStopRecovery() {
XCTAssertFalse(
FlowPiPRecoveryPolicy.canContinue(
operation: 7,
currentOperation: 7,
wantsActiveSession: true,
isAppForeground: false,
taskIsCancelled: false
)
)
XCTAssertFalse(
FlowPiPRecoveryPolicy.canContinue(
operation: 7,
currentOperation: 7,
wantsActiveSession: true,
isAppForeground: true,
taskIsCancelled: true
)
)
}
}
@@ -1342,7 +1342,7 @@ final class FlowSessionBridgeTests: XCTestCase {
startDeadlineAt: 1_700_000_008.25,
processingDeadlineAt: 1_700_000_045.25
)
let expected = #"{"action":"startRecording","aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000000.25,"editSourceText":"draft","fieldContext":{"followingText":"after","isContextAvailable":true,"isEmptyField":false,"isSecureEntry":false,"keyboardType":"default","precedingText":"before","returnKeyType":"send"},"localeId":"en-US","processingDeadlineAt":1700000045.25,"protocolVersion":6,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","sourceHistoryEntryID":"22222222-3333-4444-5555-666666666666","sourceHistoryEntryRevision":7,"startDeadlineAt":1700000008.25,"utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"editLastInput"}"#
let expected = #"{"action":"startRecording","aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000000.25,"editSourceText":"draft","fieldContext":{"followingText":"after","isContextAvailable":true,"isEmptyField":false,"isSecureEntry":false,"keyboardType":"default","precedingText":"before","returnKeyType":"send"},"localeId":"en-US","processingDeadlineAt":1700000045.25,"protocolVersion":7,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","sourceHistoryEntryID":"22222222-3333-4444-5555-666666666666","sourceHistoryEntryRevision":7,"startDeadlineAt":1700000008.25,"utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"editLastInput"}"#
XCTAssertEqual(try sortedJSONString(command), expected)
}
@@ -1378,7 +1378,7 @@ final class FlowSessionBridgeTests: XCTestCase {
historyEntryRevision: 9,
aiConversationID: conversationID
)
let expected = #"{"aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000050.5,"errorKind":"asrFailed","fieldFingerprint":"default|send|before|after","historyEntryID":"22222222-3333-4444-5555-666666666666","historyEntryRevision":9,"hostGeneration":"generation-1","protocolVersion":6,"rawText":"raw","revision":8,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","status":"final","text":"polished","utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"aiQuestion","warning":"fallback"}"#
let expected = #"{"aiConversationID":"33333333-4444-5555-6666-777777777777","commandSeq":42,"createdAt":1700000050.5,"errorKind":"asrFailed","fieldFingerprint":"default|send|before|after","historyEntryID":"22222222-3333-4444-5555-666666666666","historyEntryRevision":9,"hostGeneration":"generation-1","protocolVersion":7,"rawText":"raw","revision":8,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","status":"final","text":"polished","utteranceId":"11111111-2222-3333-4444-555555555555","utteranceMode":"aiQuestion","warning":"fallback"}"#
XCTAssertEqual(try sortedJSONString(result), expected)
}