feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
+786 -3
View File
@@ -5,14 +5,94 @@ import XCTest
@testable import OSGKeyboardShared
final class FlowSessionBridgeTests: XCTestCase {
private var suiteNames: Set<String> = []
private func makeDefaults() -> UserDefaults {
let suite = "group.com.osgkeyboard.shared.tests.flow.\(UUID().uuidString)"
override func tearDown() {
for suiteName in suiteNames {
UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName)
}
suiteNames.removeAll()
super.tearDown()
}
private func makeDefaults(suiteName: String? = nil) -> UserDefaults {
let suite = suiteName
?? "group.com.osgkeyboard.shared.tests.flow.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
if suiteNames.insert(suite).inserted {
defaults.removePersistentDomain(forName: suite)
}
return defaults
}
private func seedLifecycleState(in defaults: UserDefaults) {
defaults.set(true, forKey: FlowSessionKeys.flowSessionActive)
defaults.set(101.0, forKey: FlowSessionKeys.flowSessionExpires)
defaults.set(102.0, forKey: FlowSessionKeys.flowHeartbeat)
defaults.set(true, forKey: FlowSessionKeys.flowHostReady)
defaults.set(103.0, forKey: FlowSessionKeys.flowHostReadyAt)
defaults.set(
FlowSessionKeys.RecordingState.recording.rawValue,
forKey: FlowSessionKeys.keyboardRecordingState
)
defaults.set("en-US", forKey: FlowSessionKeys.transcriptionLanguage)
defaults.set("result", forKey: FlowSessionKeys.transcriptionResult)
defaults.set("partial", forKey: FlowSessionKeys.transcriptionPartial)
defaults.set("warning", forKey: FlowSessionKeys.transcriptionPolishWarning)
defaults.set("error", forKey: FlowSessionKeys.transcriptionError)
defaults.set(
FlowSessionKeys.TranscriptionErrorKind.asrFailed.rawValue,
forKey: FlowSessionKeys.transcriptionErrorKind
)
defaults.set([0.25, 0.5], forKey: FlowSessionKeys.audioLevels)
defaults.set("com.example.host", forKey: FlowSessionKeys.pendingHostBundleId)
defaults.set(104.0, forKey: FlowSessionKeys.lastPiPArmAttemptAt)
defaults.set(105.0, forKey: FlowSessionKeys.lastActivityAt)
defaults.set("host-generation", forKey: FlowSessionKeys.hostGeneration)
defaults.set(true, forKey: FlowSessionKeys.hostHeavy)
defaults.set(106.0, forKey: FlowSessionKeys.hostHeavyAt)
let payload = Data([0x01])
defaults.set(payload, forKey: FlowSessionKeys.flowCommandPayload)
defaults.set(payload, forKey: FlowSessionKeys.flowCommandJournalPayload)
defaults.set(payload, forKey: FlowSessionKeys.flowResultPayload)
defaults.set(payload, forKey: FlowSessionKeys.flowAckPayload)
defaults.set(payload, forKey: FlowSessionKeys.flowStartTransactionPayload)
defaults.set(
"AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE",
forKey: FlowSessionKeys.pendingKeyboardUtteranceId
)
defaults.set(payload, forKey: FlowSessionKeys.flowReadyPayload)
}
private func assertKeysAbsent(
_ keys: [String],
in defaults: UserDefaults,
file: StaticString = #filePath,
line: UInt = #line
) {
for key in keys {
XCTAssertNil(defaults.object(forKey: key), "Expected cleared key: \(key)", file: file, line: line)
}
}
private func assertKeysPresent(
_ keys: [String],
in defaults: UserDefaults,
file: StaticString = #filePath,
line: UInt = #line
) {
for key in keys {
XCTAssertNotNil(defaults.object(forKey: key), "Expected retained key: \(key)", file: file, line: line)
}
}
private func sortedJSONString<T: Encodable>(_ value: T) throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return try XCTUnwrap(String(data: encoder.encode(value), encoding: .utf8))
}
func testSessionActiveSurvivesStaleHeartbeatWhileNotExpired() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
@@ -224,6 +304,27 @@ final class FlowSessionBridgeTests: XCTestCase {
XCTAssertEqual(decoded?.fieldContext, context)
}
func testSubmitAIQuestionCommandRoundTripsPrefilledText() throws {
let command = FlowCommand(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 44,
action: .submitAIQuestion,
localeId: "zh-Hans",
utteranceMode: .aiQuestion,
aiConversationID: UUID(),
aiQuestionText: "总结这段剪贴板内容"
)
let decoded = try JSONDecoder().decode(
FlowCommand.self,
from: JSONEncoder().encode(command)
)
XCTAssertEqual(decoded, command)
XCTAssertEqual(decoded.aiQuestionText, "总结这段剪贴板内容")
}
func testSecureFieldContextRedactsText() {
let context = FlowFieldContext(
precedingText: "secret",
@@ -691,4 +792,686 @@ final class FlowSessionBridgeTests: XCTestCase {
[prime, cancel]
)
}
func testResultRejectsEqualAndDecreasingRevisionsAndTerminalDowngrade() {
let defaults = makeDefaults()
let sessionID = UUID()
let utteranceID = UUID()
let revisionTen = FlowResult(
sessionId: sessionID,
utteranceId: utteranceID,
commandSeq: 20,
status: .partial,
text: "revision 10",
revision: 10
)
FlowSessionBridge.writeResult(revisionTen, defaults: defaults)
FlowSessionBridge.writeResult(
FlowResult(
sessionId: sessionID,
utteranceId: utteranceID,
commandSeq: 20,
status: .final,
text: "equal revision",
revision: 10
),
defaults: defaults
)
XCTAssertEqual(FlowSessionBridge.latestResult(defaults: defaults), revisionTen)
FlowSessionBridge.writeResult(
FlowResult(
sessionId: sessionID,
utteranceId: utteranceID,
commandSeq: 20,
status: .final,
text: "decreasing revision",
revision: 9
),
defaults: defaults
)
XCTAssertEqual(FlowSessionBridge.latestResult(defaults: defaults), revisionTen)
let terminal = FlowResult(
sessionId: sessionID,
utteranceId: utteranceID,
commandSeq: 20,
status: .final,
text: "revision 11",
revision: 11
)
FlowSessionBridge.writeResult(terminal, defaults: defaults)
FlowSessionBridge.writeResult(
FlowResult(
sessionId: sessionID,
utteranceId: utteranceID,
commandSeq: 20,
status: .streaming,
text: "late revision 12",
revision: 12
),
defaults: defaults
)
XCTAssertEqual(FlowSessionBridge.latestResult(defaults: defaults), terminal)
}
func testCommandJournalDeduplicatesSequenceAndKeepsNewestTwelveSorted() {
let defaults = makeDefaults()
let sessionID = UUID()
let utteranceID = UUID()
for sequence in stride(from: 15, through: 1, by: -1) {
FlowSessionBridge.writeCommand(
FlowCommand(
sessionId: sessionID,
utteranceId: utteranceID,
commandSeq: Int64(sequence),
action: .startRecording,
localeId: "en-US",
createdAt: TimeInterval(sequence)
),
defaults: defaults
)
}
FlowSessionBridge.writeCommand(
FlowCommand(
sessionId: sessionID,
utteranceId: utteranceID,
commandSeq: 10,
action: .abort,
localeId: "en-US",
createdAt: 100
),
defaults: defaults
)
let journal = FlowSessionBridge.commands(after: 0, defaults: defaults)
XCTAssertEqual(journal.count, 12)
XCTAssertEqual(journal.map(\.commandSeq), Array(4...15).map(Int64.init))
XCTAssertEqual(journal.first(where: { $0.commandSeq == 10 })?.action, .startRecording)
XCTAssertEqual(
FlowSessionBridge.latestCommand(defaults: defaults)?.action,
.abort
)
}
func testMarkSessionInactiveKeyRetentionMatrix() {
let defaults = makeDefaults()
seedLifecycleState(in: defaults)
FlowSessionBridge.markSessionInactive(defaults: defaults)
assertKeysAbsent(
[
FlowSessionKeys.flowSessionExpires,
FlowSessionKeys.flowHeartbeat,
FlowSessionKeys.flowHostReady,
FlowSessionKeys.flowHostReadyAt,
FlowSessionKeys.transcriptionResult,
FlowSessionKeys.transcriptionPartial,
FlowSessionKeys.transcriptionPolishWarning,
FlowSessionKeys.transcriptionError,
FlowSessionKeys.transcriptionErrorKind,
FlowSessionKeys.flowCommandPayload,
FlowSessionKeys.flowCommandJournalPayload,
FlowSessionKeys.flowResultPayload,
FlowSessionKeys.flowAckPayload,
FlowSessionKeys.flowStartTransactionPayload,
FlowSessionKeys.pendingKeyboardUtteranceId,
FlowSessionKeys.flowReadyPayload,
],
in: defaults
)
assertKeysPresent(
[
FlowSessionKeys.flowSessionActive,
FlowSessionKeys.keyboardRecordingState,
FlowSessionKeys.transcriptionLanguage,
FlowSessionKeys.audioLevels,
FlowSessionKeys.pendingHostBundleId,
FlowSessionKeys.lastPiPArmAttemptAt,
FlowSessionKeys.lastActivityAt,
FlowSessionKeys.hostGeneration,
FlowSessionKeys.hostHeavy,
FlowSessionKeys.hostHeavyAt,
],
in: defaults
)
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowSessionActive))
XCTAssertTrue(defaults.bool(forKey: FlowSessionKeys.hostHeavy))
}
func testClearFlowStateKeyRetentionMatrix() {
let defaults = makeDefaults()
seedLifecycleState(in: defaults)
FlowSessionBridge.clearFlowState(defaults: defaults)
assertKeysAbsent(
[
FlowSessionKeys.flowSessionExpires,
FlowSessionKeys.flowHeartbeat,
FlowSessionKeys.flowHostReady,
FlowSessionKeys.flowHostReadyAt,
FlowSessionKeys.keyboardRecordingState,
FlowSessionKeys.transcriptionLanguage,
FlowSessionKeys.transcriptionResult,
FlowSessionKeys.transcriptionPartial,
FlowSessionKeys.transcriptionPolishWarning,
FlowSessionKeys.transcriptionError,
FlowSessionKeys.transcriptionErrorKind,
FlowSessionKeys.audioLevels,
FlowSessionKeys.pendingHostBundleId,
FlowSessionKeys.lastActivityAt,
FlowSessionKeys.hostHeavyAt,
FlowSessionKeys.flowCommandPayload,
FlowSessionKeys.flowCommandJournalPayload,
FlowSessionKeys.flowResultPayload,
FlowSessionKeys.flowAckPayload,
FlowSessionKeys.flowStartTransactionPayload,
FlowSessionKeys.pendingKeyboardUtteranceId,
FlowSessionKeys.flowReadyPayload,
],
in: defaults
)
assertKeysPresent(
[
FlowSessionKeys.flowSessionActive,
FlowSessionKeys.lastPiPArmAttemptAt,
FlowSessionKeys.hostGeneration,
FlowSessionKeys.hostHeavy,
],
in: defaults
)
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowSessionActive))
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.hostHeavy))
}
func testClearFlowStateOnHostLaunchKeyRetentionMatrix() {
let defaults = makeDefaults()
seedLifecycleState(in: defaults)
FlowSessionBridge.clearFlowStateOnHostLaunch(defaults: defaults)
assertKeysAbsent(
[
FlowSessionKeys.flowSessionExpires,
FlowSessionKeys.flowHeartbeat,
FlowSessionKeys.flowHostReady,
FlowSessionKeys.flowHostReadyAt,
FlowSessionKeys.keyboardRecordingState,
FlowSessionKeys.transcriptionResult,
FlowSessionKeys.transcriptionPartial,
FlowSessionKeys.transcriptionPolishWarning,
FlowSessionKeys.transcriptionError,
FlowSessionKeys.transcriptionErrorKind,
FlowSessionKeys.audioLevels,
FlowSessionKeys.lastActivityAt,
FlowSessionKeys.hostHeavyAt,
FlowSessionKeys.flowCommandPayload,
FlowSessionKeys.flowCommandJournalPayload,
FlowSessionKeys.flowResultPayload,
FlowSessionKeys.flowAckPayload,
FlowSessionKeys.flowStartTransactionPayload,
FlowSessionKeys.pendingKeyboardUtteranceId,
FlowSessionKeys.flowReadyPayload,
],
in: defaults
)
assertKeysPresent(
[
FlowSessionKeys.flowSessionActive,
FlowSessionKeys.transcriptionLanguage,
FlowSessionKeys.pendingHostBundleId,
FlowSessionKeys.lastPiPArmAttemptAt,
FlowSessionKeys.hostGeneration,
FlowSessionKeys.hostHeavy,
],
in: defaults
)
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowSessionActive))
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.hostHeavy))
XCTAssertEqual(
defaults.string(forKey: FlowSessionKeys.pendingHostBundleId),
"com.example.host"
)
XCTAssertEqual(
defaults.string(forKey: FlowSessionKeys.hostGeneration),
"host-generation"
)
}
func testPersistentActivationClearsMailboxesAndWritesStartingSnapshot() throws {
let defaults = makeDefaults()
let sessionID = UUID()
seedLifecycleState(in: defaults)
FlowSessionBridge.markSessionActivePersistent(
sessionId: sessionID,
defaults: defaults
)
XCTAssertTrue(defaults.bool(forKey: FlowSessionKeys.flowSessionActive))
XCTAssertNil(defaults.object(forKey: FlowSessionKeys.flowSessionExpires))
XCTAssertNotNil(defaults.object(forKey: FlowSessionKeys.flowHeartbeat))
XCTAssertNotNil(defaults.object(forKey: FlowSessionKeys.lastActivityAt))
XCTAssertNil(FlowSessionBridge.latestCommand(defaults: defaults))
XCTAssertTrue(FlowSessionBridge.commands(after: 0, defaults: defaults).isEmpty)
XCTAssertNil(FlowSessionBridge.latestResult(defaults: defaults))
XCTAssertNil(FlowSessionBridge.latestAck(defaults: defaults))
XCTAssertNil(FlowSessionBridge.startTransaction(defaults: defaults))
XCTAssertNil(FlowSessionBridge.pendingKeyboardUtteranceId(defaults: defaults))
XCTAssertEqual(
FlowSessionBridge.pendingHostBundleId(defaults: defaults),
"com.example.host"
)
let snapshot = try XCTUnwrap(FlowSessionBridge.readySnapshot(defaults: defaults))
XCTAssertEqual(snapshot.sessionId, sessionID)
XCTAssertEqual(snapshot.ready, false)
XCTAssertEqual(snapshot.reason, .starting)
XCTAssertNil(snapshot.sessionExpiresAt)
XCTAssertEqual(snapshot.hostGeneration, "host-generation")
XCTAssertTrue(defaults.bool(forKey: FlowSessionKeys.flowHostReady))
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
}
func testReloadFromDiskMakesWritesVisibleAcrossDefaultsInstances() {
let suiteName = "group.com.osgkeyboard.shared.tests.flow.shared.\(UUID().uuidString)"
let writer = makeDefaults(suiteName: suiteName)
let reader = makeDefaults(suiteName: suiteName)
XCTAssertFalse(writer === reader)
let command = FlowCommand(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 70,
action: .startRecording,
localeId: "en-US",
createdAt: 170
)
FlowSessionBridge.writeCommand(command, defaults: writer)
FlowSessionBridge.reloadFromDisk(defaults: reader)
XCTAssertEqual(FlowSessionBridge.latestCommand(defaults: reader), command)
let ack = FlowAck(
sessionId: command.sessionId,
utteranceId: command.utteranceId,
commandSeq: command.commandSeq,
consumedAt: 171
)
FlowSessionBridge.writeAck(ack, defaults: reader)
FlowSessionBridge.reloadFromDisk(defaults: writer)
XCTAssertEqual(FlowSessionBridge.latestAck(defaults: writer), ack)
}
func testAudioLevelsReadsCurrentDoubleStorage() {
let defaults = makeDefaults()
defaults.set([0.125, 0.5, 1.0] as [Double], forKey: FlowSessionKeys.audioLevels)
XCTAssertEqual(
FlowSessionBridge.audioLevels(defaults: defaults),
[0.125, 0.5, 1.0]
)
}
func testAudioLevelsReadsLegacyNSNumberStorage() {
let defaults = makeDefaults()
let legacyLevels = [
NSNumber(value: Float(0.25)),
NSNumber(value: Float(0.75)),
]
defaults.set(legacyLevels, forKey: FlowSessionKeys.audioLevels)
XCTAssertEqual(
FlowSessionBridge.audioLevels(defaults: defaults),
[0.25, 0.75]
)
}
func testBlankTranscriptionResultIsNoOp() {
let defaults = makeDefaults()
defaults.set("existing result", forKey: FlowSessionKeys.transcriptionResult)
defaults.set("existing partial", forKey: FlowSessionKeys.transcriptionPartial)
defaults.set("existing warning", forKey: FlowSessionKeys.transcriptionPolishWarning)
defaults.set("existing error", forKey: FlowSessionKeys.transcriptionError)
defaults.set(
FlowSessionKeys.TranscriptionErrorKind.noSpeech.rawValue,
forKey: FlowSessionKeys.transcriptionErrorKind
)
FlowSessionBridge.setRecordingState(.processing, defaults: defaults)
FlowSessionBridge.storeTranscriptionResult(" \n\t ", defaults: defaults)
XCTAssertEqual(
defaults.string(forKey: FlowSessionKeys.transcriptionResult),
"existing result"
)
XCTAssertEqual(
defaults.string(forKey: FlowSessionKeys.transcriptionPartial),
"existing partial"
)
XCTAssertEqual(
defaults.string(forKey: FlowSessionKeys.transcriptionPolishWarning),
"existing warning"
)
XCTAssertEqual(
defaults.string(forKey: FlowSessionKeys.transcriptionError),
"existing error"
)
XCTAssertEqual(
defaults.string(forKey: FlowSessionKeys.transcriptionErrorKind),
FlowSessionKeys.TranscriptionErrorKind.noSpeech.rawValue
)
XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .processing)
}
func testClearPendingTranscriptionClearsOnlyTranscriptionDeliveryKeys() {
let defaults = makeDefaults()
seedLifecycleState(in: defaults)
FlowSessionBridge.clearPendingTranscription(defaults: defaults)
assertKeysAbsent(
[
FlowSessionKeys.transcriptionResult,
FlowSessionKeys.transcriptionPartial,
FlowSessionKeys.transcriptionPolishWarning,
FlowSessionKeys.transcriptionError,
FlowSessionKeys.transcriptionErrorKind,
],
in: defaults
)
assertKeysPresent(
[
FlowSessionKeys.flowSessionActive,
FlowSessionKeys.flowHeartbeat,
FlowSessionKeys.keyboardRecordingState,
FlowSessionKeys.transcriptionLanguage,
FlowSessionKeys.audioLevels,
FlowSessionKeys.pendingHostBundleId,
FlowSessionKeys.hostGeneration,
FlowSessionKeys.flowCommandPayload,
FlowSessionKeys.flowCommandJournalPayload,
FlowSessionKeys.flowResultPayload,
FlowSessionKeys.flowAckPayload,
FlowSessionKeys.flowStartTransactionPayload,
FlowSessionKeys.pendingKeyboardUtteranceId,
FlowSessionKeys.flowReadyPayload,
],
in: defaults
)
XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .recording)
}
func testLegacyReadyBoolWithFreshHeartbeatIsHostReadyWithoutSnapshot() {
let defaults = makeDefaults()
defaults.set(true, forKey: FlowSessionKeys.flowSessionActive)
defaults.set(true, forKey: FlowSessionKeys.flowHostReady)
FlowSessionBridge.writeHeartbeat(defaults: defaults)
XCTAssertNil(FlowSessionBridge.readySnapshot(defaults: defaults))
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
}
func testProcessingSnapshotRefreshesHeartbeatWhileRemainingNotReady() {
let defaults = makeDefaults()
let sessionID = UUID()
let utteranceID = UUID()
let heartbeat = Date().timeIntervalSince1970
defaults.set(true, forKey: FlowSessionKeys.flowSessionActive)
defaults.set(heartbeat - 120, forKey: FlowSessionKeys.flowHeartbeat)
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: sessionID,
ready: false,
reason: .processing,
heartbeatAt: heartbeat,
engineMode: "local",
localeId: "zh-Hans",
busyUtteranceId: utteranceID
),
defaults: defaults
)
XCTAssertEqual(
defaults.double(forKey: FlowSessionKeys.flowHeartbeat),
heartbeat,
accuracy: 0.000_001
)
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
XCTAssertEqual(
FlowSessionBridge.readySnapshot(defaults: defaults)?.reason,
.processing
)
}
func testFlowCommandSortedKeysGoldenJSON() throws {
let sessionID = try XCTUnwrap(
UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF")
)
let utteranceID = try XCTUnwrap(
UUID(uuidString: "11111111-2222-3333-4444-555555555555")
)
let historyID = try XCTUnwrap(
UUID(uuidString: "22222222-3333-4444-5555-666666666666")
)
let conversationID = try XCTUnwrap(
UUID(uuidString: "33333333-4444-5555-6666-777777777777")
)
let command = FlowCommand(
sessionId: sessionID,
utteranceId: utteranceID,
commandSeq: 42,
action: .startRecording,
localeId: "en-US",
createdAt: 1_700_000_000.25,
fieldContext: FlowFieldContext(
precedingText: "before",
followingText: "after",
keyboardType: "default",
returnKeyType: "send",
isEmptyField: false,
isContextAvailable: true
),
utteranceMode: .editLastInput,
editSourceText: "draft",
sourceHistoryEntryID: historyID,
sourceHistoryEntryRevision: 7,
aiConversationID: conversationID,
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":5,"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)
}
func testFlowResultSortedKeysGoldenJSON() throws {
let sessionID = try XCTUnwrap(
UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF")
)
let utteranceID = try XCTUnwrap(
UUID(uuidString: "11111111-2222-3333-4444-555555555555")
)
let historyID = try XCTUnwrap(
UUID(uuidString: "22222222-3333-4444-5555-666666666666")
)
let conversationID = try XCTUnwrap(
UUID(uuidString: "33333333-4444-5555-6666-777777777777")
)
let result = FlowResult(
sessionId: sessionID,
utteranceId: utteranceID,
commandSeq: 42,
status: .final,
text: "polished",
warning: "fallback",
errorKind: .asrFailed,
rawText: "raw",
hostGeneration: "generation-1",
revision: 8,
fieldFingerprint: "default|send|before|after",
createdAt: 1_700_000_050.5,
utteranceMode: .aiQuestion,
historyEntryID: historyID,
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":5,"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)
}
func testFlowAckSortedKeysGoldenJSON() throws {
let sessionID = try XCTUnwrap(
UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF")
)
let utteranceID = try XCTUnwrap(
UUID(uuidString: "11111111-2222-3333-4444-555555555555")
)
let ack = FlowAck(
sessionId: sessionID,
utteranceId: utteranceID,
commandSeq: 42,
hostGeneration: "generation-1",
revision: 8,
deliveryOutcome: .replaced,
consumedAt: 1_700_000_060.75
)
let expected = #"{"commandSeq":42,"consumedAt":1700000060.75,"deliveryOutcome":"replaced","hostGeneration":"generation-1","protocolVersion":1,"revision":8,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF","utteranceId":"11111111-2222-3333-4444-555555555555"}"#
XCTAssertEqual(try sortedJSONString(ack), expected)
}
func testFlowReadySnapshotSortedKeysGoldenJSON() throws {
let sessionID = try XCTUnwrap(
UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF")
)
let utteranceID = try XCTUnwrap(
UUID(uuidString: "11111111-2222-3333-4444-555555555555")
)
let snapshot = FlowReadySnapshot(
sessionId: sessionID,
ready: false,
reason: .processing,
heartbeatAt: 1_700_000_070.25,
readyAt: 1_700_000_069.25,
audioProofAt: 1_700_000_068.25,
engineMode: "cloud",
localeId: "en-US",
busyUtteranceId: utteranceID,
sessionExpiresAt: 1_700_000_130.25,
hostGeneration: "generation-1"
)
let expected = #"{"audioProofAt":1700000068.25,"busyUtteranceId":"11111111-2222-3333-4444-555555555555","engineMode":"cloud","heartbeatAt":1700000070.25,"hostGeneration":"generation-1","localeId":"en-US","protocolVersion":1,"ready":false,"readyAt":1700000069.25,"reason":"processing","sessionExpiresAt":1700000130.25,"sessionId":"00112233-4455-6677-8899-AABBCCDDEEFF"}"#
XCTAssertEqual(try sortedJSONString(snapshot), expected)
}
func testFlowStartTransactionSortedKeysGoldenJSON() throws {
let sessionID = try XCTUnwrap(
UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEFF")
)
let utteranceID = try XCTUnwrap(
UUID(uuidString: "11111111-2222-3333-4444-555555555555")
)
let transaction = FlowStartTransaction(
sessionID: sessionID,
utteranceID: utteranceID,
deadlineAt: 1_700_000_100.25,
phase: .recording,
updatedAt: 1_700_000_090.5
)
let expected = #"{"deadlineAt":1700000100.25,"phase":"recording","sessionID":"00112233-4455-6677-8899-AABBCCDDEEFF","updatedAt":1700000090.5,"utteranceID":"11111111-2222-3333-4444-555555555555"}"#
XCTAssertEqual(try sortedJSONString(transaction), expected)
}
func testFlowWireEnumRawValuesRemainStable() {
XCTAssertEqual(
[
FlowCommand.Action.startRecording,
.stopRecording,
.abort,
.prewarm,
.primeAudio,
.cancelPrimeAudio,
.endAIConversation,
.submitAIQuestion,
].map(\.rawValue),
[
"startRecording",
"stopRecording",
"abort",
"prewarm",
"primeAudio",
"cancelPrimeAudio",
"endAIConversation",
"submitAIQuestion",
]
)
XCTAssertEqual(
[
FlowResult.Status.partial,
.rawReady,
.streaming,
.final,
.error,
.aborted,
.timeout,
].map(\.rawValue),
["partial", "rawReady", "streaming", "final", "error", "aborted", "timeout"]
)
XCTAssertEqual(
[
FlowAck.DeliveryOutcome.replaced,
.appended,
.rejected,
].map(\.rawValue),
["replaced", "appended", "rejected"]
)
XCTAssertEqual(
[
FlowStartTransaction.Phase.issued,
.starting,
.recording,
.terminal,
].map(\.rawValue),
["issued", "starting", "recording", "terminal"]
)
XCTAssertEqual(
[
FlowReadySnapshot.Reason.ready,
.noSession,
.starting,
.audioEngineNotLive,
.waitingForAudioProof,
.recording,
.processing,
.awaitingDelivery,
.permissionMissing,
.appGroupUnavailable,
.hostLost,
.error,
].map(\.rawValue),
[
"ready",
"noSession",
"starting",
"audioEngineNotLive",
"waitingForAudioProof",
"recording",
"processing",
"awaitingDelivery",
"permissionMissing",
"appGroupUnavailable",
"hostLost",
"error",
]
)
}
}