feat(02-01): add Bus package with discriminated-union Codable + fixtures
- New `packages/Bus` SPM package (Swift 6 strict concurrency, WebKit linked).
- `BUS_PROTOCOL_VERSION = "2.0.0"` + shared `HudState` / `TurnTerminator` /
`BusCoder` in `Protocol.swift`.
- `BusOutbound` (8 cases) + `BusInbound` (2 cases) with hand-written
`Codable` using a `type` discriminator and exhaustive switches — SE-0295
synthesized `{"case": {...}}` shape is rejected by design.
- `BusReply` plist-serializable reply + `BusError` error surface.
- 14 JSON fixtures + 24 XCTest round-trip tests asserting byte-equal
re-encoding, shape invariants, UUID-lowercasing, and negative-path decode
failures. All green.
- `JarvisLogChannel` gains `case bus` (OBS-06 extension per RESEARCH-07);
`LoggingTests.test_fiveChannelsAreDefined` updated to match.
This commit is contained in:
24 files changed
+636
-2
No files matched your search
@@ -0,0 +1,31 @@
|
||||
// swift-tools-version:6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Bus",
|
||||
platforms: [.macOS(.v13)],
|
||||
products: [
|
||||
.library(name: "Bus", targets: ["Bus"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../Logging"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Bus",
|
||||
dependencies: [
|
||||
.product(name: "JarvisLogging", package: "Logging"),
|
||||
],
|
||||
swiftSettings: [.swiftLanguageMode(.v6)],
|
||||
linkerSettings: [
|
||||
.linkedFramework("WebKit"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "BusTests",
|
||||
dependencies: ["Bus"],
|
||||
resources: [.process("Fixtures")],
|
||||
swiftSettings: [.swiftLanguageMode(.v6)]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors the bus raises across decode, send, and handshake paths.
|
||||
///
|
||||
/// Surfaces:
|
||||
/// - `.decodeFailed` — inbound JSON failed to decode into a `BusInbound`.
|
||||
/// - `.bridgeNotReady` — `send(_:)` called before the handshake armed.
|
||||
/// - `.handshakeMismatch` — `helloAck` returned a version the Swift side
|
||||
/// does not recognize; both sides recorded for alerting.
|
||||
/// - `.handshakeTimeout` — the webview failed to send `helloAck` within the
|
||||
/// 2-second window defined by `HandshakeTiming.timeout`.
|
||||
public enum BusError: Error, Sendable, Equatable {
|
||||
case decodeFailed(String)
|
||||
case bridgeNotReady
|
||||
case handshakeMismatch(swift: String, js: String)
|
||||
case handshakeTimeout
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
/// Messages the webview sends to the Swift side.
|
||||
///
|
||||
/// Minimal P2 scope (per RESEARCH §Deferred Ideas) — only the handshake ack
|
||||
/// and a UI-ready ping. P3+ adds user-input, tool-confirm, and other cases.
|
||||
///
|
||||
/// Hand-written `Codable` mirrors `BusOutbound` — same `type` discriminator,
|
||||
/// same exhaustive-switch drift preventer.
|
||||
public enum BusInbound: Equatable, Sendable {
|
||||
case helloAck(version: String)
|
||||
case uiReady
|
||||
}
|
||||
|
||||
extension BusInbound: Codable {
|
||||
private enum Discriminator: String, Codable {
|
||||
case helloAck
|
||||
case uiReady
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case version
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let tag = try container.decode(Discriminator.self, forKey: .type)
|
||||
switch tag {
|
||||
case .helloAck:
|
||||
let version = try container.decode(String.self, forKey: .version)
|
||||
self = .helloAck(version: version)
|
||||
case .uiReady:
|
||||
self = .uiReady
|
||||
}
|
||||
// NO default — adding a case without a matching arm is a compile error.
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .helloAck(let version):
|
||||
try container.encode(Discriminator.helloAck, forKey: .type)
|
||||
try container.encode(version, forKey: .version)
|
||||
case .uiReady:
|
||||
try container.encode(Discriminator.uiReady, forKey: .type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
|
||||
/// Messages the Swift side sends to the webview.
|
||||
///
|
||||
/// IMPORTANT: `Codable` conformance is declared in an **extension** (below),
|
||||
/// not on the enum declaration. This is load-bearing: Swift's synthesized
|
||||
/// `Codable` (SE-0295) would encode associated-value enums as
|
||||
/// `{"hudState": {"_0": "idle"}}`, which does NOT match the TS mirror's
|
||||
/// `{"type": "hudState", "state": "idle"}` shape. Hand-writing the
|
||||
/// `init(from:)` and `encode(to:)` methods with exhaustive switches is the
|
||||
/// schema-drift preventer at the language level — adding a case without
|
||||
/// updating both methods is a compile error.
|
||||
public enum BusOutbound: Equatable, Sendable {
|
||||
case hello(version: String)
|
||||
case hudState(HudState)
|
||||
case tokenDelta(text: String)
|
||||
case audioLevel(rms: Float)
|
||||
case toolCallStart(id: UUID, name: String, argsPreview: String)
|
||||
case toolCallEnd(id: UUID, ok: Bool, previewOrError: String)
|
||||
case turnStarted(id: UUID)
|
||||
case turnEnded(id: UUID, terminator: TurnTerminator)
|
||||
}
|
||||
|
||||
extension BusOutbound: Codable {
|
||||
/// Discriminator tag used as the `type` field in the wire format.
|
||||
/// Declaring this as a `String`-raw-value enum means an unknown tag in
|
||||
/// inbound JSON surfaces as `DecodingError.dataCorrupted` from the
|
||||
/// `try c.decode(Discriminator.self, forKey: .type)` call below.
|
||||
private enum Discriminator: String, Codable {
|
||||
case hello
|
||||
case hudState
|
||||
case tokenDelta
|
||||
case audioLevel
|
||||
case toolCallStart
|
||||
case toolCallEnd
|
||||
case turnStarted
|
||||
case turnEnded
|
||||
}
|
||||
|
||||
/// All `CodingKeys` across every case. Swift's keyed container does not
|
||||
/// care about unused keys per-case; the per-case `encode(to:)` only
|
||||
/// writes the fields relevant to the variant.
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case version
|
||||
case state
|
||||
case text
|
||||
case rms
|
||||
case id
|
||||
case name
|
||||
case argsPreview
|
||||
case ok
|
||||
case previewOrError
|
||||
case terminator
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let tag = try container.decode(Discriminator.self, forKey: .type)
|
||||
switch tag {
|
||||
case .hello:
|
||||
let version = try container.decode(String.self, forKey: .version)
|
||||
self = .hello(version: version)
|
||||
case .hudState:
|
||||
let state = try container.decode(HudState.self, forKey: .state)
|
||||
self = .hudState(state)
|
||||
case .tokenDelta:
|
||||
let text = try container.decode(String.self, forKey: .text)
|
||||
self = .tokenDelta(text: text)
|
||||
case .audioLevel:
|
||||
let rms = try container.decode(Float.self, forKey: .rms)
|
||||
self = .audioLevel(rms: rms)
|
||||
case .toolCallStart:
|
||||
let id = try Self.decodeUUID(container: container, forKey: .id)
|
||||
let name = try container.decode(String.self, forKey: .name)
|
||||
let argsPreview = try container.decode(String.self, forKey: .argsPreview)
|
||||
self = .toolCallStart(id: id, name: name, argsPreview: argsPreview)
|
||||
case .toolCallEnd:
|
||||
let id = try Self.decodeUUID(container: container, forKey: .id)
|
||||
let ok = try container.decode(Bool.self, forKey: .ok)
|
||||
let previewOrError = try container.decode(String.self, forKey: .previewOrError)
|
||||
self = .toolCallEnd(id: id, ok: ok, previewOrError: previewOrError)
|
||||
case .turnStarted:
|
||||
let id = try Self.decodeUUID(container: container, forKey: .id)
|
||||
self = .turnStarted(id: id)
|
||||
case .turnEnded:
|
||||
let id = try Self.decodeUUID(container: container, forKey: .id)
|
||||
let terminator = try container.decode(TurnTerminator.self, forKey: .terminator)
|
||||
self = .turnEnded(id: id, terminator: terminator)
|
||||
}
|
||||
// NO default branch — adding a Discriminator case without also adding
|
||||
// a matching switch arm here is a compile error. That is the entire
|
||||
// reason this Codable is hand-written.
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .hello(let version):
|
||||
try container.encode(Discriminator.hello, forKey: .type)
|
||||
try container.encode(version, forKey: .version)
|
||||
case .hudState(let state):
|
||||
try container.encode(Discriminator.hudState, forKey: .type)
|
||||
try container.encode(state, forKey: .state)
|
||||
case .tokenDelta(let text):
|
||||
try container.encode(Discriminator.tokenDelta, forKey: .type)
|
||||
try container.encode(text, forKey: .text)
|
||||
case .audioLevel(let rms):
|
||||
try container.encode(Discriminator.audioLevel, forKey: .type)
|
||||
try container.encode(rms, forKey: .rms)
|
||||
case .toolCallStart(let id, let name, let argsPreview):
|
||||
try container.encode(Discriminator.toolCallStart, forKey: .type)
|
||||
try container.encode(id.uuidString.lowercased(), forKey: .id)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(argsPreview, forKey: .argsPreview)
|
||||
case .toolCallEnd(let id, let ok, let previewOrError):
|
||||
try container.encode(Discriminator.toolCallEnd, forKey: .type)
|
||||
try container.encode(id.uuidString.lowercased(), forKey: .id)
|
||||
try container.encode(ok, forKey: .ok)
|
||||
try container.encode(previewOrError, forKey: .previewOrError)
|
||||
case .turnStarted(let id):
|
||||
try container.encode(Discriminator.turnStarted, forKey: .type)
|
||||
try container.encode(id.uuidString.lowercased(), forKey: .id)
|
||||
case .turnEnded(let id, let terminator):
|
||||
try container.encode(Discriminator.turnEnded, forKey: .type)
|
||||
try container.encode(id.uuidString.lowercased(), forKey: .id)
|
||||
try container.encode(terminator, forKey: .terminator)
|
||||
}
|
||||
// Exhaustive at encode site — adding a case without encoding it is a
|
||||
// compile error, mirroring the init(from:) drift preventer.
|
||||
}
|
||||
|
||||
/// `UUID` is encoded as a lowercase string (Pitfall 6). Decoder accepts
|
||||
/// either case since `UUID(uuidString:)` is case-insensitive, but we
|
||||
/// route the parse through an explicit helper so bad payloads surface
|
||||
/// as structured `DecodingError`s instead of silent `fatalError`s.
|
||||
private static func decodeUUID(
|
||||
container: KeyedDecodingContainer<CodingKeys>,
|
||||
forKey key: CodingKeys
|
||||
) throws -> UUID {
|
||||
let raw = try container.decode(String.self, forKey: key)
|
||||
guard let uuid = UUID(uuidString: raw) else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: key,
|
||||
in: container,
|
||||
debugDescription: "invalid UUID string: \(raw)"
|
||||
)
|
||||
}
|
||||
return uuid
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
/// Reply shape returned via `WKScriptMessageHandlerWithReply`'s `replyHandler`.
|
||||
///
|
||||
/// WebKit requires replies to be plist-serializable (`NSNumber`, `NSString`,
|
||||
/// `NSArray`, `NSDictionary`, `NSNull`, nested) — arbitrary `Codable` types
|
||||
/// are rejected at runtime. `asPlist()` produces the accepted dictionary form.
|
||||
///
|
||||
/// JS semantics (per HUD-03):
|
||||
/// - `(value, nil)` → JS-side `Promise.resolve(value)`
|
||||
/// - `(nil, errorString)` → JS-side `Promise.reject(new Error(errorString))`
|
||||
public struct BusReply: Sendable, Equatable {
|
||||
public let ok: Bool
|
||||
/// JSON-encoded payload as a string, or `nil` for success with no body.
|
||||
public let value: String?
|
||||
public let error: String?
|
||||
|
||||
public init(ok: Bool, value: String? = nil, error: String? = nil) {
|
||||
self.ok = ok
|
||||
self.value = value
|
||||
self.error = error
|
||||
}
|
||||
|
||||
/// Canonical empty-success reply.
|
||||
public static let success = BusReply(ok: true)
|
||||
|
||||
/// Canonical failure reply. `message` is what the JS `Error.message`
|
||||
/// will surface after the promise rejects.
|
||||
public static func failure(_ message: String) -> BusReply {
|
||||
BusReply(ok: false, error: message)
|
||||
}
|
||||
|
||||
/// Plist-serializable dictionary form accepted by
|
||||
/// `WKScriptMessageHandlerWithReply.replyHandler`. Strings and booleans
|
||||
/// bridge to `NSString` / `NSNumber`; nil slots become `NSNull`.
|
||||
public func asPlist() -> [String: Any] {
|
||||
if ok {
|
||||
return [
|
||||
"ok": true,
|
||||
"value": (value as Any?) ?? NSNull(),
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
"ok": false,
|
||||
"error": error ?? "unknown",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// The wire-format version for the Swift ↔ JS bus. Bumping this rejects any
|
||||
/// webview bundle that was built against a different constant (HUD-05).
|
||||
///
|
||||
/// Versioning policy (per RESEARCH §HUD-05):
|
||||
/// - `MAJOR` — structural schema change (field renamed, removed, re-typed).
|
||||
/// - `MINOR` — additive case or field (webview still decodes older shape).
|
||||
/// - `PATCH` — bugfix / fixture correction with no behavioural change.
|
||||
///
|
||||
/// Strict equality is enforced at runtime by the handshake. Additive-only
|
||||
/// relaxation (match on `MAJOR` alone) is deferred to a later phase.
|
||||
public let BUS_PROTOCOL_VERSION: String = "2.0.0"
|
||||
|
||||
/// UI states the HUD can occupy. Encoded as its `rawValue` inside
|
||||
/// `BusOutbound.hudState(_:)` — see `BusOutbound.encode(to:)`.
|
||||
public enum HudState: String, Codable, Sendable, CaseIterable {
|
||||
case idle
|
||||
case listening
|
||||
case thinking
|
||||
case speaking
|
||||
case awaitingConfirmation
|
||||
case reconfiguring
|
||||
case booting
|
||||
}
|
||||
|
||||
/// Terminal reason for a conversation turn. Encoded as its `rawValue` inside
|
||||
/// `BusOutbound.turnEnded(id:terminator:)`.
|
||||
public enum TurnTerminator: String, Codable, Sendable, CaseIterable {
|
||||
case completed
|
||||
case cancelled
|
||||
case errored
|
||||
case superseded
|
||||
}
|
||||
|
||||
/// Shared JSON coder configuration. Single source of truth for the wire
|
||||
/// format so `WebviewBridge`, `CodableRoundTripTests`, and any downstream
|
||||
/// consumers all agree on encoding.
|
||||
///
|
||||
/// Notes:
|
||||
/// - `outputFormatting` is intentionally empty — the wire form is minimal,
|
||||
/// not pretty-printed or key-sorted. Round-trip tests normalize via
|
||||
/// `JSONSerialization` to tolerate key-order differences.
|
||||
/// - `UUID` values are encoded as *lowercase* strings at each call site
|
||||
/// inside `BusOutbound.encode(to:)` because `JSONEncoder` encodes `UUID`
|
||||
/// as uppercase by default, which the TS mirror does not emit
|
||||
/// (Pitfall 6 in RESEARCH §pitfalls).
|
||||
public enum BusCoder {
|
||||
public static func makeEncoder() -> JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = []
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
return encoder
|
||||
}
|
||||
|
||||
public static func makeDecoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import XCTest
|
||||
@testable import Bus
|
||||
|
||||
/// Round-trip parity: decode fixture → re-encode → normalize → compare.
|
||||
///
|
||||
/// Plan 02 (TS mirror) reads the same fixture files and Plan 04's parity
|
||||
/// script asserts the two fixture directories stay in sync. Together with
|
||||
/// the exhaustive switches in `BusOutbound` / `BusInbound`, these tests are
|
||||
/// the build-time line of defense against schema drift.
|
||||
final class CodableRoundTripTests: XCTestCase {
|
||||
// MARK: - Helpers
|
||||
|
||||
private func loadFixture(_ name: String) throws -> Data {
|
||||
guard let url = Bundle.module.url(forResource: name, withExtension: "json") else {
|
||||
XCTFail("missing fixture: \(name).json")
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
|
||||
/// Normalize JSON so key-order differences don't fail comparisons.
|
||||
/// `NSDictionary.isEqual` is order-independent, so we decode both sides
|
||||
/// to a generic JSON object graph first.
|
||||
private func normalize(_ data: Data) throws -> NSObject {
|
||||
let value = try JSONSerialization.jsonObject(with: data, options: [])
|
||||
return value as! NSObject
|
||||
}
|
||||
|
||||
private func assertOutboundRoundTrips(
|
||||
fixture: String,
|
||||
expect: BusOutbound,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) throws {
|
||||
let data = try loadFixture(fixture)
|
||||
let decoded = try BusCoder.makeDecoder().decode(BusOutbound.self, from: data)
|
||||
XCTAssertEqual(decoded, expect, "decoded value mismatch for \(fixture)", file: file, line: line)
|
||||
|
||||
let reencoded = try BusCoder.makeEncoder().encode(decoded)
|
||||
let originalNorm = try normalize(data)
|
||||
let reencodedNorm = try normalize(reencoded)
|
||||
XCTAssertEqual(
|
||||
originalNorm, reencodedNorm,
|
||||
"round-trip mismatch for \(fixture).\nexpected: \(String(data: data, encoding: .utf8) ?? "<?>")\ngot: \(String(data: reencoded, encoding: .utf8) ?? "<?>")",
|
||||
file: file, line: line
|
||||
)
|
||||
}
|
||||
|
||||
private func assertInboundRoundTrips(
|
||||
fixture: String,
|
||||
expect: BusInbound,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) throws {
|
||||
let data = try loadFixture(fixture)
|
||||
let decoded = try BusCoder.makeDecoder().decode(BusInbound.self, from: data)
|
||||
XCTAssertEqual(decoded, expect, "decoded value mismatch for \(fixture)", file: file, line: line)
|
||||
|
||||
let reencoded = try BusCoder.makeEncoder().encode(decoded)
|
||||
let originalNorm = try normalize(data)
|
||||
let reencodedNorm = try normalize(reencoded)
|
||||
XCTAssertEqual(
|
||||
originalNorm, reencodedNorm,
|
||||
"round-trip mismatch for \(fixture)",
|
||||
file: file, line: line
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Outbound round-trips (8 variants; HudState expanded × 7)
|
||||
|
||||
func test_roundTrip_hello() throws {
|
||||
try assertOutboundRoundTrips(
|
||||
fixture: "hello",
|
||||
expect: .hello(version: "2.0.0")
|
||||
)
|
||||
}
|
||||
|
||||
func test_roundTrip_hudState_idle() throws {
|
||||
try assertOutboundRoundTrips(fixture: "hudState.idle", expect: .hudState(.idle))
|
||||
}
|
||||
|
||||
func test_roundTrip_hudState_listening() throws {
|
||||
try assertOutboundRoundTrips(fixture: "hudState.listening", expect: .hudState(.listening))
|
||||
}
|
||||
|
||||
func test_roundTrip_hudState_thinking() throws {
|
||||
try assertOutboundRoundTrips(fixture: "hudState.thinking", expect: .hudState(.thinking))
|
||||
}
|
||||
|
||||
func test_roundTrip_hudState_speaking() throws {
|
||||
try assertOutboundRoundTrips(fixture: "hudState.speaking", expect: .hudState(.speaking))
|
||||
}
|
||||
|
||||
func test_roundTrip_hudState_awaitingConfirmation() throws {
|
||||
try assertOutboundRoundTrips(
|
||||
fixture: "hudState.awaitingConfirmation",
|
||||
expect: .hudState(.awaitingConfirmation)
|
||||
)
|
||||
}
|
||||
|
||||
func test_roundTrip_hudState_reconfiguring() throws {
|
||||
try assertOutboundRoundTrips(
|
||||
fixture: "hudState.reconfiguring",
|
||||
expect: .hudState(.reconfiguring)
|
||||
)
|
||||
}
|
||||
|
||||
func test_roundTrip_hudState_booting() throws {
|
||||
try assertOutboundRoundTrips(fixture: "hudState.booting", expect: .hudState(.booting))
|
||||
}
|
||||
|
||||
func test_roundTrip_tokenDelta() throws {
|
||||
try assertOutboundRoundTrips(
|
||||
fixture: "tokenDelta",
|
||||
expect: .tokenDelta(text: "Hello, world!")
|
||||
)
|
||||
}
|
||||
|
||||
func test_roundTrip_audioLevel() throws {
|
||||
try assertOutboundRoundTrips(
|
||||
fixture: "audioLevel",
|
||||
expect: .audioLevel(rms: 0.42)
|
||||
)
|
||||
}
|
||||
|
||||
func test_roundTrip_toolCallStart() throws {
|
||||
let id = UUID(uuidString: "550e8400-e29b-41d4-a716-446655440000")!
|
||||
try assertOutboundRoundTrips(
|
||||
fixture: "toolCallStart",
|
||||
expect: .toolCallStart(id: id, name: "get_time", argsPreview: "{}")
|
||||
)
|
||||
}
|
||||
|
||||
func test_roundTrip_toolCallEnd() throws {
|
||||
let id = UUID(uuidString: "550e8400-e29b-41d4-a716-446655440000")!
|
||||
try assertOutboundRoundTrips(
|
||||
fixture: "toolCallEnd",
|
||||
expect: .toolCallEnd(id: id, ok: true, previewOrError: "\"2026-04-23T16:00:00Z\"")
|
||||
)
|
||||
}
|
||||
|
||||
func test_roundTrip_turnStarted() throws {
|
||||
let id = UUID(uuidString: "6ba7b810-9dad-11d1-80b4-00c04fd430c8")!
|
||||
try assertOutboundRoundTrips(fixture: "turnStarted", expect: .turnStarted(id: id))
|
||||
}
|
||||
|
||||
func test_roundTrip_turnEnded() throws {
|
||||
let id = UUID(uuidString: "6ba7b810-9dad-11d1-80b4-00c04fd430c8")!
|
||||
try assertOutboundRoundTrips(
|
||||
fixture: "turnEnded",
|
||||
expect: .turnEnded(id: id, terminator: .completed)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Inbound round-trips
|
||||
|
||||
func test_roundTrip_helloAck() throws {
|
||||
try assertInboundRoundTrips(
|
||||
fixture: "helloAck",
|
||||
expect: .helloAck(version: "2.0.0")
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Shape invariants (these prove hand-written Codable is load-bearing)
|
||||
|
||||
/// If SE-0295 synthesis leaked, we'd see `{"hudState":{"state":"idle"}}`.
|
||||
/// The required wire shape is `{"type":"hudState","state":"idle"}`.
|
||||
func test_hudStateEncodesWithTypeDiscriminatorNotSynthesizedShape() throws {
|
||||
let encoded = try BusCoder.makeEncoder().encode(BusOutbound.hudState(.idle))
|
||||
let obj = try JSONSerialization.jsonObject(with: encoded, options: []) as! [String: Any]
|
||||
|
||||
XCTAssertEqual(obj["type"] as? String, "hudState")
|
||||
XCTAssertEqual(obj["state"] as? String, "idle")
|
||||
XCTAssertNil(obj["hudState"], "synthesized SE-0295 Codable leaked: nested payload under case name")
|
||||
}
|
||||
|
||||
/// Same invariant for `tokenDelta` — required wire shape is
|
||||
/// `{"type":"tokenDelta","text":"hi"}`.
|
||||
func test_tokenDeltaEncodesWithTypeDiscriminator() throws {
|
||||
let encoded = try BusCoder.makeEncoder().encode(BusOutbound.tokenDelta(text: "hi"))
|
||||
let obj = try JSONSerialization.jsonObject(with: encoded, options: []) as! [String: Any]
|
||||
|
||||
XCTAssertEqual(obj["type"] as? String, "tokenDelta")
|
||||
XCTAssertEqual(obj["text"] as? String, "hi")
|
||||
XCTAssertNil(obj["tokenDelta"])
|
||||
}
|
||||
|
||||
/// The `helloAck` shape is `{"type":"helloAck","version":"2.0.0"}`.
|
||||
func test_helloAckEncodesWithTypeDiscriminator() throws {
|
||||
let encoded = try BusCoder.makeEncoder().encode(BusInbound.helloAck(version: "2.0.0"))
|
||||
let obj = try JSONSerialization.jsonObject(with: encoded, options: []) as! [String: Any]
|
||||
|
||||
XCTAssertEqual(obj["type"] as? String, "helloAck")
|
||||
XCTAssertEqual(obj["version"] as? String, "2.0.0")
|
||||
}
|
||||
|
||||
// MARK: - UUID formatting (Pitfall 6: JSONEncoder defaults to uppercase)
|
||||
|
||||
func test_uuidIsEncodedAsLowercaseString() throws {
|
||||
let id = UUID(uuidString: "550E8400-E29B-41D4-A716-446655440000")!
|
||||
let encoded = try BusCoder.makeEncoder().encode(BusOutbound.turnStarted(id: id))
|
||||
let obj = try JSONSerialization.jsonObject(with: encoded, options: []) as! [String: Any]
|
||||
|
||||
let raw = obj["id"] as? String
|
||||
XCTAssertEqual(raw, "550e8400-e29b-41d4-a716-446655440000")
|
||||
XCTAssertFalse(raw?.contains(where: { $0.isUppercase }) ?? true, "UUID must encode lowercase")
|
||||
}
|
||||
|
||||
// MARK: - Negative tests (drift detection)
|
||||
|
||||
func test_decodeUnknownDiscriminatorFails() {
|
||||
let payload = #"{"type":"unknown","state":"idle"}"#.data(using: .utf8)!
|
||||
XCTAssertThrowsError(
|
||||
try BusCoder.makeDecoder().decode(BusOutbound.self, from: payload)
|
||||
) { error in
|
||||
XCTAssertTrue(error is DecodingError, "expected DecodingError for unknown discriminator")
|
||||
}
|
||||
}
|
||||
|
||||
func test_decodeMissingDiscriminatorFails() {
|
||||
let payload = #"{"version":"2.0.0"}"#.data(using: .utf8)!
|
||||
XCTAssertThrowsError(
|
||||
try BusCoder.makeDecoder().decode(BusOutbound.self, from: payload)
|
||||
) { error in
|
||||
XCTAssertTrue(error is DecodingError, "expected DecodingError for missing type key")
|
||||
}
|
||||
}
|
||||
|
||||
func test_decodeInvalidUUIDFails() {
|
||||
let payload = #"{"type":"turnStarted","id":"not-a-uuid"}"#.data(using: .utf8)!
|
||||
XCTAssertThrowsError(
|
||||
try BusCoder.makeDecoder().decode(BusOutbound.self, from: payload)
|
||||
) { error in
|
||||
XCTAssertTrue(error is DecodingError, "expected DecodingError for malformed UUID")
|
||||
}
|
||||
}
|
||||
|
||||
func test_inboundDecodeUnknownDiscriminatorFails() {
|
||||
let payload = #"{"type":"unknown"}"#.data(using: .utf8)!
|
||||
XCTAssertThrowsError(
|
||||
try BusCoder.makeDecoder().decode(BusInbound.self, from: payload)
|
||||
) { error in
|
||||
XCTAssertTrue(error is DecodingError, "expected DecodingError for unknown inbound discriminator")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HudState case coverage guard
|
||||
|
||||
func test_allHudStateCasesHaveFixtures() throws {
|
||||
// Every HudState case must have a dedicated fixture. If this test
|
||||
// fails after an enum case is added, add the fixture and update the
|
||||
// switch below.
|
||||
for state in HudState.allCases {
|
||||
let fixtureName = "hudState.\(state.rawValue)"
|
||||
try assertOutboundRoundTrips(fixture: fixtureName, expect: .hudState(state))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"audioLevel","rms":0.42}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"hello","version":"2.0.0"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"helloAck","version":"2.0.0"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"hudState","state":"awaitingConfirmation"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"hudState","state":"booting"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"hudState","state":"idle"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"hudState","state":"listening"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"hudState","state":"reconfiguring"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"hudState","state":"speaking"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"hudState","state":"thinking"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"tokenDelta","text":"Hello, world!"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"toolCallEnd","id":"550e8400-e29b-41d4-a716-446655440000","ok":true,"previewOrError":"\"2026-04-23T16:00:00Z\""}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"toolCallStart","id":"550e8400-e29b-41d4-a716-446655440000","name":"get_time","argsPreview":"{}"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"turnEnded","id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","terminator":"completed"}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"turnStarted","id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8"}
|
||||
@@ -5,4 +5,5 @@ public enum JarvisLogChannel: String, Sendable, CaseIterable {
|
||||
case tools
|
||||
case ui
|
||||
case system
|
||||
case bus
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import Logging
|
||||
@testable import JarvisLogging
|
||||
|
||||
final class LoggingTests: XCTestCase {
|
||||
func test_fourChannelsAreDefined() {
|
||||
let expected: Set<String> = ["agent", "tools", "ui", "system"]
|
||||
func test_fiveChannelsAreDefined() {
|
||||
// `bus` was added in Phase 2 for bus-specific logging (RESEARCH §OBS-06
|
||||
// open question #7). Keep this set in sync with `JarvisLogChannel`.
|
||||
let expected: Set<String> = ["agent", "tools", "ui", "system", "bus"]
|
||||
let actual = Set(JarvisLogChannel.allCases.map(\.rawValue))
|
||||
XCTAssertEqual(actual, expected)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user