Files
OSGKeyboard/OSGKeyboardTests/FlowSessionManagerAnalyticsTests.swift
Rocky e5a83843db Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
2026-08-22 16:33:18 +08:00

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)
}
}