e5a83843db
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
253 lines
7.1 KiB
Swift
253 lines
7.1 KiB
Swift
// FlowSessionManagerAnalyticsTests.swift
|
|
// OSGKeyboardTests
|
|
//
|
|
// Pure coverage for the feature mapping and utterance-bound operation registry
|
|
// used by FlowSessionManager's asynchronous ASR/assistant pipeline.
|
|
|
|
import Foundation
|
|
@testable import OSGKeyboard
|
|
import OSGKeyboardShared
|
|
import XCTest
|
|
|
|
@MainActor
|
|
final class FlowSessionManagerAnalyticsTests: XCTestCase {
|
|
func testEveryFlowEntryMapsToOneStableFeature() {
|
|
XCTAssertEqual(
|
|
FlowAnalyticsFeatureMapping.feature(for: .aiQuestion),
|
|
.aiAssistant
|
|
)
|
|
XCTAssertEqual(
|
|
FlowAnalyticsFeatureMapping.feature(for: .clipboardTransform),
|
|
.agent
|
|
)
|
|
XCTAssertEqual(
|
|
FlowAnalyticsFeatureMapping.feature(for: .customSkill),
|
|
.agent
|
|
)
|
|
XCTAssertEqual(
|
|
FlowAnalyticsFeatureMapping.feature(for: .agentPlanning),
|
|
.agent
|
|
)
|
|
XCTAssertEqual(
|
|
FlowAnalyticsFeatureMapping.feature(for: .dictationPolish),
|
|
.polish
|
|
)
|
|
XCTAssertEqual(
|
|
FlowAnalyticsFeatureMapping.feature(for: .translation),
|
|
.polish
|
|
)
|
|
XCTAssertEqual(
|
|
FlowAnalyticsFeatureMapping.feature(for: .editLastInput),
|
|
.polish
|
|
)
|
|
XCTAssertEqual(
|
|
FlowAnalyticsFeatureMapping.recordingFeature(for: .dictation),
|
|
.transcription
|
|
)
|
|
XCTAssertEqual(
|
|
FlowAnalyticsFeatureMapping.recordingFeature(for: .aiQuestion),
|
|
.aiAssistant
|
|
)
|
|
}
|
|
|
|
func testStreamingCancelledProducesOneCancelledTerminal() {
|
|
let client = RecordingFlowAnalyticsClient()
|
|
let registry = FlowAnalyticsOperationRegistry()
|
|
let utteranceID = UUID()
|
|
registry.start(
|
|
utteranceID: utteranceID,
|
|
feature: .transcription,
|
|
executionMode: .managed,
|
|
client: client
|
|
)
|
|
|
|
registry.cancel(utteranceID: utteranceID)
|
|
|
|
XCTAssertEqual(
|
|
client.events(),
|
|
[
|
|
.started(.transcription),
|
|
.failed(.transcription, .cancelled)
|
|
]
|
|
)
|
|
}
|
|
|
|
func testASRSucceedsButAssistantFailureDoesNotRecordTranscriptionSuccess() {
|
|
let client = RecordingFlowAnalyticsClient()
|
|
let registry = FlowAnalyticsOperationRegistry()
|
|
let utteranceID = UUID()
|
|
registry.start(
|
|
utteranceID: utteranceID,
|
|
feature: .aiAssistant,
|
|
executionMode: .managed,
|
|
client: client
|
|
)
|
|
|
|
// ASR completion is intentionally not a terminal value event.
|
|
registry.fail(utteranceID: utteranceID, category: .network)
|
|
|
|
XCTAssertEqual(
|
|
client.events(),
|
|
[
|
|
.started(.aiAssistant),
|
|
.failed(.aiAssistant, .network)
|
|
]
|
|
)
|
|
}
|
|
|
|
func testOrdinaryDictationSuccessRecordsTranscription() {
|
|
let client = RecordingFlowAnalyticsClient()
|
|
let registry = FlowAnalyticsOperationRegistry()
|
|
let utteranceID = UUID()
|
|
registry.start(
|
|
utteranceID: utteranceID,
|
|
feature: FlowAnalyticsFeatureMapping.recordingFeature(for: .dictation),
|
|
executionMode: .local,
|
|
client: client
|
|
)
|
|
|
|
registry.succeed(utteranceID: utteranceID)
|
|
|
|
XCTAssertEqual(
|
|
client.events(),
|
|
[
|
|
.started(.transcription),
|
|
.succeeded(.transcription)
|
|
]
|
|
)
|
|
}
|
|
|
|
func testVoiceAssistantSuccessRecordsOneValueTask() {
|
|
let client = RecordingFlowAnalyticsClient()
|
|
let registry = FlowAnalyticsOperationRegistry()
|
|
let utteranceID = UUID()
|
|
registry.start(
|
|
utteranceID: utteranceID,
|
|
feature: FlowAnalyticsFeatureMapping.recordingFeature(for: .aiQuestion),
|
|
executionMode: .byok,
|
|
client: client
|
|
)
|
|
|
|
registry.operation(for: utteranceID)?.succeed()
|
|
registry.discard(utteranceID: utteranceID)
|
|
|
|
XCTAssertEqual(
|
|
client.events(),
|
|
[
|
|
.started(.aiAssistant),
|
|
.succeeded(.aiAssistant)
|
|
]
|
|
)
|
|
}
|
|
|
|
func testRepeatedTerminalCallsStillRecordOnlyOnce() {
|
|
let client = RecordingFlowAnalyticsClient()
|
|
let registry = FlowAnalyticsOperationRegistry()
|
|
let utteranceID = UUID()
|
|
registry.start(
|
|
utteranceID: utteranceID,
|
|
feature: .aiAssistant,
|
|
executionMode: .managed,
|
|
client: client
|
|
)
|
|
|
|
registry.succeed(utteranceID: utteranceID)
|
|
registry.fail(utteranceID: utteranceID, category: .provider)
|
|
registry.cancel(utteranceID: utteranceID)
|
|
|
|
XCTAssertEqual(
|
|
client.events(),
|
|
[
|
|
.started(.aiAssistant),
|
|
.succeeded(.aiAssistant)
|
|
]
|
|
)
|
|
}
|
|
}
|
|
|
|
private final class RecordingFlowAnalyticsClient:
|
|
AnalyticsClient,
|
|
@unchecked Sendable {
|
|
enum Event: Equatable {
|
|
case started(AnalyticsFeature)
|
|
case succeeded(AnalyticsFeature)
|
|
case failed(AnalyticsFeature, AnalyticsFailureCategory)
|
|
}
|
|
|
|
private let lock = NSLock()
|
|
private var recordedEvents: [Event] = []
|
|
|
|
func events() -> [Event] {
|
|
lock.withLock { recordedEvents }
|
|
}
|
|
|
|
func startAIFeature(
|
|
_ feature: AnalyticsFeature,
|
|
executionMode: AnalyticsExecutionMode
|
|
) -> any AnalyticsAIOperation {
|
|
append(.started(feature))
|
|
return RecordingFlowAnalyticsOperation(feature: feature) { [weak self] event in
|
|
self?.append(event)
|
|
}
|
|
}
|
|
|
|
func recordSessionActivity() {}
|
|
func recordKeyboardActivated() {}
|
|
func recordPurchaseViewed() {}
|
|
func recordPurchaseStarted() {}
|
|
func recordPurchaseCancelled() {}
|
|
func recordReferralShared() {}
|
|
|
|
func recordInviteOpened(
|
|
acquisitionChannel: AnalyticsAcquisitionChannel,
|
|
surface: AnalyticsSurface
|
|
) {}
|
|
|
|
private func append(_ event: Event) {
|
|
lock.withLock {
|
|
recordedEvents.append(event)
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class RecordingFlowAnalyticsOperation:
|
|
AnalyticsAIOperation,
|
|
@unchecked Sendable {
|
|
private let feature: AnalyticsFeature
|
|
private let onTerminal: @Sendable (RecordingFlowAnalyticsClient.Event) -> Void
|
|
private let lock = NSLock()
|
|
private var isFinished = false
|
|
|
|
init(
|
|
feature: AnalyticsFeature,
|
|
onTerminal: @escaping @Sendable (
|
|
RecordingFlowAnalyticsClient.Event
|
|
) -> Void
|
|
) {
|
|
self.feature = feature
|
|
self.onTerminal = onTerminal
|
|
}
|
|
|
|
func succeed() {
|
|
finish(.succeeded(feature))
|
|
}
|
|
|
|
func fail(category: AnalyticsFailureCategory) {
|
|
finish(.failed(feature, category))
|
|
}
|
|
|
|
func cancel() {
|
|
fail(category: .cancelled)
|
|
}
|
|
|
|
private func finish(_ event: RecordingFlowAnalyticsClient.Event) {
|
|
let shouldRecord = lock.withLock {
|
|
guard !isFinished else { return false }
|
|
isFinished = true
|
|
return true
|
|
}
|
|
guard shouldRecord else { return }
|
|
onTerminal(event)
|
|
}
|
|
}
|