feat(app): add resilient Flow recovery and privacy-safe analytics
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user