Slice 3 — OpenAI Realtime brain: swap echo for the brain #4
1
crates/rutster-tap/tests/fixtures/function_call.json
vendored
Normal file
1
crates/rutster-tap/tests/fixtures/function_call.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"v":1,"type":"function_call","seq":0,"ts":0,"id":"abc-123","name":"hangup","args":{}}
|
||||
1
crates/rutster-tap/tests/fixtures/function_call_output.json
vendored
Normal file
1
crates/rutster-tap/tests/fixtures/function_call_output.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"v":1,"type":"function_call_output","seq":0,"ts":0,"id":"abc-123","status":"ok","result":{"channel_state":"Closing"}}
|
||||
1
crates/rutster-tap/tests/fixtures/speech_started.json
vendored
Normal file
1
crates/rutster-tap/tests/fixtures/speech_started.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"v":1,"type":"speech_started","seq":7,"ts":100}
|
||||
1
crates/rutster-tap/tests/fixtures/speech_stopped.json
vendored
Normal file
1
crates/rutster-tap/tests/fixtures/speech_stopped.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"v":1,"type":"speech_stopped","seq":9,"ts":200}
|
||||
1
crates/rutster-tap/tests/fixtures/tools_update.json
vendored
Normal file
1
crates/rutster-tap/tests/fixtures/tools_update.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"v":1,"type":"tools.update","seq":0,"ts":0,"tools":[{"description":"hang up the call","name":"hangup"}]}
|
||||
225
crates/rutster-tap/tests/protocol_events.rs
Normal file
225
crates/rutster-tap/tests/protocol_events.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
//! 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/<name>`. 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user