//! Slice-3 golden-fixture tests for the tap protocol's new event types. //! //! ## Why this file exists (alongside `protocol.rs`'s inline tests) //! //! `protocol.rs` already has inline round-trip tests for each new slice-3 //! event kind, but they assert with `String::contains` — a *substring* check. //! That catches "the right `type` tag is present" but it cannot catch: //! //! - a change in **field order** (the `Envelope` `Serialize` impl at //! `protocol.rs::Serialize_for_Envelope` pins field order deliberately — //! see its doc comment: "field order is pinned to make the wire bytes //! deterministic… helps snapshot tests and interop with brains that read //! key order for debugging"); //! - a stray extra field; //! - a missing field; //! - a change in whitespace or number formatting. //! //! These fixtures lock the **exact wire bytes** produced by each `encode_*` //! function. Any future refactor that alters the on-wire shape — even //! cosmetically — breaks a test here, forcing a conscious fixture update + //! a protocol-version bump conversation (spec §3.1: `PROTOCOL_VERSION` is //! the contract door). //! //! ## Forwards-compat (the slice-2 §3.4 "drop + observe" contract) //! //! The last test here re-asserts the load-bearing decode-side rule: an //! unknown `type` string deserializes to `DecodedPayload::Unknown` (via //! `FrameKind`'s `#[serde(other)]` fallback) rather than erroring. This is //! the foundation of the forwards-compat posture — a brain speaking a //! future protocol version's event kind must not crash an older core; it //! gets logged + counted + dropped (the behavioral side lives in //! `tap_client::handle_brain_frame` and is exercised end-to-end by the //! slice-2 / slice-3 integration tests, not here). //! //! ## Why string equality (not `serde_json::Value` equality) //! //! Comparing parsed `serde_json::Value`s would be *semantic* equality — it //! would treat `{"a":1,"b":2}` and `{"b":2,"a":1}` as the same. That //! throws away the field-order pinning the `Envelope` serializer //! guarantees. Comparing the raw encoded `String` to the fixture's bytes //! keeps the order contract load-bearing. use std::fs; use rutster_tap::{ DecodedPayload, decode_envelope, encode_function_call, encode_function_call_output, encode_speech_started, encode_speech_stopped, encode_tools_update, }; /// Read a fixture file from `tests/fixtures/`. Uses /// `CARGO_MANIFEST_DIR` so the path is stable regardless of which /// directory `cargo test` is invoked from. Trailing newlines are stripped /// because `serde_json::to_string` (what the encoders call) never emits one /// — a fixture checked into git shouldn't acquire a phantom newline mismatch. fn fixture(name: &str) -> String { let path = format!("{}/tests/fixtures/{}", env!("CARGO_MANIFEST_DIR"), name); let raw = fs::read_to_string(&path).unwrap_or_else(|e| panic!("read fixture {name} ({path}): {e}")); raw.trim_end().to_string() } // ─── speech_started ────────────────────────────────────────────────────── #[test] fn golden_speech_started_wire_bytes_match_fixture() { let encoded = encode_speech_started(7, 100).unwrap(); let golden = fixture("speech_started.json"); assert_eq!( encoded, golden, "speech_started wire bytes drifted from golden fixture" ); } #[test] fn speech_started_fixture_decodes_to_speechstarted_variant() { let golden = fixture("speech_started.json"); let decoded = decode_envelope(&golden).unwrap(); assert_eq!(decoded.seq, 7); assert_eq!(decoded.ts, 100); assert!( matches!(decoded.payload, DecodedPayload::SpeechStarted), "speech_started fixture did not decode to SpeechStarted: {:?}", decoded.payload ); } // ─── speech_stopped ────────────────────────────────────────────────────── #[test] fn golden_speech_stopped_wire_bytes_match_fixture() { let encoded = encode_speech_stopped(9, 200).unwrap(); let golden = fixture("speech_stopped.json"); assert_eq!( encoded, golden, "speech_stopped wire bytes drifted from golden fixture" ); } #[test] fn speech_stopped_fixture_decodes_to_speechstopped_variant() { let golden = fixture("speech_stopped.json"); let decoded = decode_envelope(&golden).unwrap(); assert_eq!(decoded.seq, 9); assert_eq!(decoded.ts, 200); assert!( matches!(decoded.payload, DecodedPayload::SpeechStopped), "speech_stopped fixture did not decode to SpeechStopped: {:?}", decoded.payload ); } // ─── function_call ─────────────────────────────────────────────────────── #[test] fn golden_function_call_wire_bytes_match_fixture() { // `args_json_str="{}"` — the encoder parses it into `serde_json::Value` // (an empty object) so the wire field is `"args":{}`, not `"args":"{}"`. // This is load-bearing: a brain emitting a function_call with a raw // JSON-string args would be a different wire shape (and a translator // bug). The fixture pins the parsed-object shape. let encoded = encode_function_call("abc-123", "hangup", "{}", 0, 0).unwrap(); let golden = fixture("function_call.json"); assert_eq!( encoded, golden, "function_call wire bytes drifted from golden fixture" ); } #[test] fn function_call_fixture_decodes_to_functioncall_with_fields() { let golden = fixture("function_call.json"); let decoded = decode_envelope(&golden).unwrap(); match decoded.payload { DecodedPayload::FunctionCall(p) => { assert_eq!(p.id, "abc-123"); assert_eq!(p.name, "hangup"); assert_eq!(p.args, serde_json::Value::Object(serde_json::Map::new())); } other => panic!("expected FunctionCall, got {other:?}"), } } // ─── function_call_output ─────────────────────────────────────────────── #[test] fn golden_function_call_output_wire_bytes_match_fixture() { let encoded = encode_function_call_output("abc-123", "ok", r#"{"channel_state":"Closing"}"#, 0, 0) .unwrap(); let golden = fixture("function_call_output.json"); assert_eq!( encoded, golden, "function_call_output wire bytes drifted from golden fixture" ); } #[test] fn function_call_output_fixture_decodes_to_functioncalloutput_with_fields() { let golden = fixture("function_call_output.json"); let decoded = decode_envelope(&golden).unwrap(); match decoded.payload { DecodedPayload::FunctionCallOutput(p) => { assert_eq!(p.id, "abc-123"); assert_eq!(p.status, "ok"); // The result field is a parsed JSON object, not a string. assert_eq!( p.result.get("channel_state"), Some(&serde_json::json!("Closing")) ); } other => panic!("expected FunctionCallOutput, got {other:?}"), } } // ─── tools.update ─────────────────────────────────────────────────────── #[test] fn golden_tools_update_wire_bytes_match_fixture() { let tools_json = r#"[{"name":"hangup","description":"hang up the call"}]"#; let encoded = encode_tools_update(tools_json, 0, 0).unwrap(); let golden = fixture("tools_update.json"); assert_eq!( encoded, golden, "tools.update wire bytes drifted from golden fixture" ); } #[test] fn tools_update_fixture_decodes_to_toolsupdate_with_array() { let golden = fixture("tools_update.json"); let decoded = decode_envelope(&golden).unwrap(); match decoded.payload { DecodedPayload::ToolsUpdate(p) => { let arr = p .tools .as_array() .expect("tools field must be a JSON array"); assert_eq!(arr.len(), 1); assert_eq!(arr[0].get("name"), Some(&serde_json::json!("hangup"))); } other => panic!("expected ToolsUpdate, got {other:?}"), } } // ─── forwards-compat (slice-2 §3.4 "drop + observe") ──────────────────── /// The load-bearing forwards-compat rule on the decode side: an unknown /// `type` string deserializes to `DecodedPayload::Unknown` (via /// `FrameKind`'s `#[serde(other)]`), **not** an error. A future-protocol /// brain emitting `"type":"future_event"` must not crash an older core; /// the core logs + counts + drops it (the observe-and-continue posture /// the eventual fuzz harness will stress). This re-asserts the contract /// the inline `unknown_type_decodes_to_unknown_variant` test established, /// here in the external fixture file so it sits next to the golden wire /// shapes it protects. #[test] fn unknown_wire_type_decodes_to_unknown_variant_not_error() { let wire = r#"{"v":1,"type":"future_event","seq":0,"ts":0}"#; let decoded = decode_envelope(wire).expect("unknown type must not error (forwards-compat)"); assert!( matches!(decoded.payload, DecodedPayload::Unknown), "forwards-compat broken: unknown type decoded to {:?}, expected Unknown", decoded.payload ); }