Files
OSGKeyboard/OSGKeyboardTests/FlowSessionBridgeTests.swift
T
Rocky 9f308fadd2 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.
2026-08-13 01:00:51 +08:00

1478 lines
58 KiB
Swift

// FlowSessionBridgeTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class FlowSessionBridgeTests: XCTestCase {
private var suiteNames: Set<String> = []
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)!
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)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
let staleHeartbeat = Date().timeIntervalSince1970 - 10
defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostReachable(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostStale(defaults: defaults))
}
func testHostStaleWhenHeartbeatVeryOld() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostReachable(defaults: defaults))
XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults))
}
func testClearIfHostStaleRemovesZombieSession() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setRecordingState(.stopped, defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
XCTAssertTrue(FlowSessionBridge.clearIfHostStale(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .idle)
}
func testHostStaleWhenSessionActiveButNoHeartbeat() {
let defaults = makeDefaults()
defaults.set(true, forKey: FlowSessionKeys.flowSessionActive)
defaults.set(Date().timeIntervalSince1970 + 3_600, forKey: FlowSessionKeys.flowSessionExpires)
XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults))
XCTAssertTrue(FlowSessionBridge.clearIfHostStale(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
}
func testPersistentSessionIgnoresLegacyExpiry() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let expired = Date().timeIntervalSince1970 - 5
defaults.set(expired, forKey: FlowSessionKeys.flowSessionExpires)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
}
func testRecordingStateRoundTrip() {
let defaults = makeDefaults()
FlowSessionBridge.setRecordingState(.recording, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .recording)
FlowSessionBridge.setRecordingState(.stopped, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .stopped)
}
func testConsumeTranscriptionResultClearsKey() {
let defaults = makeDefaults()
FlowSessionBridge.storeTranscriptionResult("hello", defaults: defaults)
XCTAssertEqual(FlowSessionBridge.consumeTranscriptionResult(defaults: defaults), "hello")
XCTAssertNil(FlowSessionBridge.consumeTranscriptionResult(defaults: defaults))
}
func testConsumeTranscriptionDeliveryIncludesPolishWarning() {
let defaults = makeDefaults()
FlowSessionBridge.storeTranscriptionResult(
"raw text",
polishWarning: "polish failed",
defaults: defaults
)
let delivery = FlowSessionBridge.consumeTranscriptionDelivery(defaults: defaults)
XCTAssertEqual(delivery?.text, "raw text")
XCTAssertEqual(delivery?.polishWarning, "polish failed")
XCTAssertNil(FlowSessionBridge.consumeTranscriptionDelivery(defaults: defaults))
}
func testClearFlowStateRemovesSessionKeys() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.storeTranscriptionResult("x", defaults: defaults)
FlowSessionBridge.clearFlowState(defaults: defaults)
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowSessionActive))
XCTAssertNil(FlowSessionBridge.consumeTranscriptionResult(defaults: defaults))
XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .idle)
}
func testPersistentActivationClearsLegacyExpiry() {
let defaults = makeDefaults()
defaults.set(Date().timeIntervalSince1970 + 60, forKey: FlowSessionKeys.flowSessionExpires)
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
XCTAssertNil(defaults.object(forKey: FlowSessionKeys.flowSessionExpires))
}
func testConsumeTranscriptionErrorIncludesKind() {
let defaults = makeDefaults()
FlowSessionBridge.storeTranscriptionError(
"no speech",
kind: .noSpeech,
defaults: defaults
)
let error = FlowSessionBridge.consumeTranscriptionError(defaults: defaults)
XCTAssertEqual(error?.message, "no speech")
XCTAssertEqual(error?.kind, .noSpeech)
XCTAssertNil(FlowSessionBridge.consumeTranscriptionError(defaults: defaults))
}
func testTranscriptionPartialRoundTrip() {
let defaults = makeDefaults()
FlowSessionBridge.storeTranscriptionPartial("你好世界", defaults: defaults)
XCTAssertEqual(FlowSessionBridge.transcriptionPartial(defaults: defaults), "你好世界")
FlowSessionBridge.storeTranscriptionResult("final", defaults: defaults)
XCTAssertNil(FlowSessionBridge.transcriptionPartial(defaults: defaults))
}
func testDarwinNotificationPostsWithoutCrashing() {
FlowSessionDarwin.postSessionChanged()
FlowSessionDarwin.postCommandChanged()
FlowSessionDarwin.postHostReadyChanged()
}
func testHostReadyRequiresExplicitContract() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
FlowSessionBridge.setHostReady(true, defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
}
func testHostReadyFalseWhenHeartbeatStale() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
let staleHeartbeat = Date().timeIntervalSince1970 - 10
defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
}
func testHeartbeatRefreshKeepsHostReadyPublished() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
FlowSessionBridge.writeHeartbeat(defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
}
func testClearFlowStateClearsHostReady() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
FlowSessionBridge.clearFlowState(defaults: defaults)
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowHostReady))
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
}
func testFlowCommandRoundTrip() {
let defaults = makeDefaults()
let sessionId = UUID()
let utteranceId = UUID()
let command = FlowCommand(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: 42,
action: .startRecording,
localeId: "zh-Hans",
createdAt: 123
)
FlowSessionBridge.writeCommand(command, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.latestCommand(defaults: defaults), command)
}
func testFlowCommandRoundTripsFieldContext() {
let context = FlowFieldContext(
precedingText: "前文",
followingText: "后文",
keyboardType: "default",
returnKeyType: "send",
isEmptyField: false,
isContextAvailable: true
)
let command = FlowCommand(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 43,
action: .stopRecording,
localeId: "zh-Hans",
fieldContext: context
)
let decoded = try? JSONDecoder().decode(
FlowCommand.self,
from: JSONEncoder().encode(command)
)
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",
followingText: "value",
isSecureEntry: true,
isEmptyField: true,
isContextAvailable: true
)
XCTAssertNil(context.precedingText)
XCTAssertNil(context.followingText)
XCTAssertFalse(context.isContextAvailable)
XCTAssertFalse(context.isEmptyField)
}
func testFlowCommandDecodesWithoutFieldContext() throws {
let command = FlowCommand(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 44,
action: .startRecording,
localeId: "en-US"
)
let encoded = try JSONEncoder().encode(command)
var object = try XCTUnwrap(
JSONSerialization.jsonObject(with: encoded) as? [String: Any]
)
object.removeValue(forKey: "fieldContext")
let legacyPayload = try JSONSerialization.data(withJSONObject: object)
let decoded = try JSONDecoder().decode(FlowCommand.self, from: legacyPayload)
XCTAssertNil(decoded.fieldContext)
}
func testLegacyClipboardCommandDecodesAsUnsupportedAndDropsRetiredPayload() throws {
let command = FlowCommand(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 45,
action: .startRecording,
localeId: "en-US"
)
var object = try XCTUnwrap(
JSONSerialization.jsonObject(with: JSONEncoder().encode(command))
as? [String: Any]
)
object["utteranceMode"] = "clipboardCommand"
object["clipboardSnapshot"] = "retired material"
object["previousOutput"] = "retired output"
let legacyPayload = try JSONSerialization.data(withJSONObject: object)
let decoded = try JSONDecoder().decode(FlowCommand.self, from: legacyPayload)
XCTAssertEqual(decoded.resolvedUtteranceMode, .unsupportedLegacy)
let reencoded = try XCTUnwrap(
JSONSerialization.jsonObject(with: JSONEncoder().encode(decoded))
as? [String: Any]
)
XCTAssertEqual(reencoded["utteranceMode"] as? String, "unsupportedLegacy")
XCTAssertNil(reencoded["clipboardSnapshot"])
XCTAssertNil(reencoded["previousOutput"])
}
func testAIQuestionCommandRoundTripPreservesConversationIdentity() throws {
let conversationID = UUID()
let command = FlowCommand(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 46,
action: .startRecording,
localeId: "zh-Hans",
utteranceMode: .aiQuestion,
aiConversationID: conversationID
)
let decoded = try JSONDecoder().decode(
FlowCommand.self,
from: JSONEncoder().encode(command)
)
XCTAssertEqual(decoded.resolvedUtteranceMode, .aiQuestion)
XCTAssertEqual(decoded.aiConversationID, conversationID)
}
func testAIQuestionResultNeverAllowsRawASRFallback() {
let result = FlowResult(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 47,
status: .rawReady,
text: "原始问题",
rawText: "原始问题",
utteranceMode: .aiQuestion,
aiConversationID: UUID()
)
XCTAssertFalse(result.allowsRawFallback)
}
func testFlowResultRoundTripPreservesUtteranceIdentity() {
let defaults = makeDefaults()
let sessionId = UUID()
let utteranceId = UUID()
let result = FlowResult(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: 43,
status: .final,
text: "hello",
warning: "raw fallback",
createdAt: 124
)
FlowSessionBridge.writeResult(result, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.latestResult(defaults: defaults), result)
FlowSessionBridge.clearResult(defaults: defaults)
XCTAssertNil(FlowSessionBridge.latestResult(defaults: defaults))
}
func testFlowAckRoundTrip() {
let defaults = makeDefaults()
let ack = FlowAck(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 44,
consumedAt: 125
)
FlowSessionBridge.writeAck(ack, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.latestAck(defaults: defaults), ack)
}
func testNotReadySnapshotDoesNotRefreshHeartbeat() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
// A host stuck in a failed cold start writes not-ready snapshots on
// every engine flap; those must NOT revive the heartbeat, or zombie
// detection is postponed forever.
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: UUID(),
ready: false,
reason: .waitingForAudioProof,
engineMode: "local",
localeId: "zh-Hans"
),
defaults: defaults
)
XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults))
}
func testBusySnapshotStillRefreshesHeartbeat() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let staleHeartbeat = Date().timeIntervalSince1970 - 10
defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
// Recording/processing proves the host is alive even though the
// snapshot is not "ready" — the heartbeat must keep flowing so the
// keyboard does not declare a mid-utterance host dead.
let sessionId = UUID()
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: sessionId,
ready: false,
reason: .recording,
engineMode: "local",
localeId: "zh-Hans",
busyUtteranceId: UUID()
),
defaults: defaults
)
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
// Not-ready busy snapshots must remain readable so the keyboard can
// distinguish "host is recording" from "host is still starting".
let snap = FlowSessionBridge.readySnapshot(defaults: defaults)
XCTAssertEqual(snap?.reason, .recording)
XCTAssertEqual(snap?.ready, false)
XCTAssertEqual(snap?.sessionId, sessionId)
}
func testNotReadyStartingSnapshotIsRetainedWithoutRevivingHeartbeat() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: UUID(),
ready: false,
reason: .waitingForAudioProof,
engineMode: "local",
localeId: "zh-Hans"
),
defaults: defaults
)
XCTAssertTrue(FlowSessionBridge.isHostStale(defaults: defaults))
XCTAssertEqual(
FlowSessionBridge.readySnapshot(defaults: defaults)?.reason,
.waitingForAudioProof
)
}
func testStaleGenerationSnapshotIsNotReady() {
let defaults = makeDefaults()
let sessionId = UUID()
let now = Date().timeIntervalSince1970
FlowSessionBridge.rotateHostGeneration(defaults: defaults)
let liveGeneration = FlowSessionBridge.currentHostGeneration(defaults: defaults)
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: sessionId,
ready: true,
reason: .ready,
heartbeatAt: now,
readyAt: now,
engineMode: "local",
localeId: "zh-Hans",
hostGeneration: liveGeneration
),
defaults: defaults
)
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
// Host relaunches (force-quit path) → new generation. The old ready
// snapshot must be void instantly, without waiting out the 60 s
// heartbeat-zombie window.
FlowSessionBridge.rotateHostGeneration(defaults: defaults)
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
}
func testClearFlowStateOnHostLaunchPreservesPendingHost() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
FlowSessionBridge.setPendingHostBundleId("com.example.host", defaults: defaults)
FlowSessionBridge.clearFlowStateOnHostLaunch(defaults: defaults)
XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
// The startflow scene-delegate write happens before the session
// manager exists — launch reconciliation must not eat it.
XCTAssertEqual(
FlowSessionBridge.pendingHostBundleId(defaults: defaults),
"com.example.host"
)
}
func testHostHeavyClearsOnFlowStateReset() {
let defaults = makeDefaults()
FlowSessionBridge.setHostHeavy(true, defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostHeavy(defaults: defaults))
FlowSessionBridge.clearFlowState(defaults: defaults)
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
FlowSessionBridge.setHostHeavy(true, defaults: defaults)
FlowSessionBridge.setPendingHostBundleId("com.example.host", defaults: defaults)
FlowSessionBridge.clearFlowStateOnHostLaunch(defaults: defaults)
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
XCTAssertEqual(
FlowSessionBridge.pendingHostBundleId(defaults: defaults),
"com.example.host"
)
}
func testStaleHostHeavyDoesNotBlockTyping() {
let defaults = makeDefaults()
// Legacy sticky bool with no timestamp — must not brick 中文/EN.
defaults.set(true, forKey: FlowSessionKeys.hostHeavy)
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.hostHeavy))
FlowSessionBridge.setHostHeavy(true, defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostHeavy(defaults: defaults))
// Expired timestamp → treat as clear so cold keyboard can switch.
let expired = Date().timeIntervalSince1970 - FlowSessionKeys.hostHeavyMaxAge - 1
defaults.set(expired, forKey: FlowSessionKeys.hostHeavyAt)
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.hostHeavy))
}
func testRotateHostGenerationReturnsPreviousToken() {
let defaults = makeDefaults()
XCTAssertNil(FlowSessionBridge.rotateHostGeneration(defaults: defaults))
let first = FlowSessionBridge.currentHostGeneration(defaults: defaults)
XCTAssertNotNil(first)
let previous = FlowSessionBridge.rotateHostGeneration(defaults: defaults)
XCTAssertEqual(previous, first)
XCTAssertNotEqual(FlowSessionBridge.currentHostGeneration(defaults: defaults), first)
}
func testReadySnapshotDrivesHostReady() {
let defaults = makeDefaults()
let sessionId = UUID()
let now = Date().timeIntervalSince1970
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
let snapshot = FlowReadySnapshot(
sessionId: sessionId,
ready: true,
reason: .ready,
heartbeatAt: now,
readyAt: now,
audioProofAt: now,
engineMode: "local",
localeId: "zh-Hans",
sessionExpiresAt: now + 60
)
FlowSessionBridge.writeReadySnapshot(snapshot, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.readySnapshot(defaults: defaults), snapshot)
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
}
func testHostReadyRejectedWhenReadyAtSkewsFromHeartbeat() {
let defaults = makeDefaults()
let sessionId = UUID()
let now = Date().timeIntervalSince1970
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
let skewed = FlowReadySnapshot(
sessionId: sessionId,
ready: true,
reason: .ready,
heartbeatAt: now,
readyAt: now - FlowSessionKeys.hostReadyMaxHeartbeatSkew - 1,
audioProofAt: now,
engineMode: "local",
localeId: "zh-Hans",
sessionExpiresAt: now + 60
)
FlowSessionBridge.writeReadySnapshot(skewed, defaults: defaults)
// Keep heartbeat fresh so reachability alone would pass.
defaults.set(now, forKey: FlowSessionKeys.flowHeartbeat)
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
}
func testFlowAckRoundTripAndClearedByClearFlowState() {
let defaults = makeDefaults()
let sessionId = UUID()
let utteranceId = UUID()
let ack = FlowAck(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: 3
)
FlowSessionBridge.writeAck(ack, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.latestAck(defaults: defaults), ack)
FlowSessionBridge.clearFlowState(defaults: defaults)
XCTAssertNil(FlowSessionBridge.latestAck(defaults: defaults))
}
func testClearFlowStateRemovesProtocolPayloads() {
let defaults = makeDefaults()
let sessionId = UUID()
let utteranceId = UUID()
FlowSessionBridge.writeCommand(
FlowCommand(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: 1,
action: .startRecording,
localeId: "en-US"
),
defaults: defaults
)
FlowSessionBridge.writeResult(
FlowResult(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: 1,
status: .partial,
text: "hello"
),
defaults: defaults
)
FlowSessionBridge.clearFlowState(defaults: defaults)
XCTAssertNil(FlowSessionBridge.latestCommand(defaults: defaults))
XCTAssertNil(FlowSessionBridge.latestResult(defaults: defaults))
XCTAssertNil(FlowSessionBridge.readySnapshot(defaults: defaults))
}
func testEditCommandRoundTripIncludesDeadlinesAndSource() {
let defaults = makeDefaults()
let historyID = UUID()
let command = FlowCommand(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 4,
action: .startRecording,
localeId: "zh-Hans",
utteranceMode: .editLastInput,
editSourceText: "原文",
sourceHistoryEntryID: historyID,
startDeadlineAt: 108,
processingDeadlineAt: 145
)
FlowSessionBridge.writeCommand(command, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.latestCommand(defaults: defaults), command)
XCTAssertFalse(
FlowResult(
sessionId: command.sessionId,
utteranceId: command.utteranceId,
commandSeq: command.commandSeq,
status: .final,
text: "结果",
utteranceMode: .editLastInput
).allowsRawFallback
)
}
func testStartTransactionRoundTripAndClear() {
let defaults = makeDefaults()
let transaction = FlowStartTransaction(
sessionID: UUID(),
utteranceID: UUID(),
deadlineAt: 108,
phase: .starting
)
FlowSessionBridge.writeStartTransaction(transaction, defaults: defaults)
XCTAssertEqual(
FlowSessionBridge.startTransaction(defaults: defaults),
transaction
)
FlowSessionBridge.clearFlowState(defaults: defaults)
XCTAssertNil(FlowSessionBridge.startTransaction(defaults: defaults))
}
func testAudioPrimeActionsRoundTripOnSharedCommandWire() {
let defaults = makeDefaults()
let sessionID = UUID()
let primeID = UUID()
let prime = FlowCommand(
sessionId: sessionID,
utteranceId: primeID,
commandSeq: 10,
action: .primeAudio,
localeId: "auto"
)
let cancel = FlowCommand(
sessionId: sessionID,
utteranceId: primeID,
commandSeq: 11,
action: .cancelPrimeAudio,
localeId: "auto"
)
FlowSessionBridge.writeCommand(prime, defaults: defaults)
FlowSessionBridge.writeCommand(cancel, defaults: defaults)
XCTAssertEqual(
FlowSessionBridge.commands(after: 9, defaults: defaults),
[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",
]
)
}
}