89 lines
4.2 KiB
Rust
89 lines
4.2 KiB
Rust
//! slice-3 integration test: brain-process-level round-trip through the
|
|
//! MockRealtimeBrain + the translator + `run_openai_pump`. Drives the brain
|
|
//! the way the core's TapClient would (synthetic tap frames in, observes tap
|
|
//! frames out) — proves the dev-loop interop contract the PM directive's
|
|
//! Task 4 calls out ("actually start + interop on a basic audio round-trip")
|
|
//! without requiring a real WebRTC peer (browser-driven, same constraint as
|
|
//! slice-1/2).
|
|
//!
|
|
//! What this exercises end-to-end:
|
|
//! 1. MockRealtimeBrain accepts a WS connection + asserts session.update
|
|
//! has turn_detection: null (S4 — spec §4.3).
|
|
//! 2. `run_openai_pump` sends session.update + bridges tap frames ↔ OpenAI
|
|
//! events via the translator.
|
|
//! 3. A tap `audio_in` frames the test pushes through `tap_via` → translator
|
|
//! formats as `input_audio_buffer.append` → mock generates a canned
|
|
//! `response.audio.delta` → translator formats as tap `audio_out` →
|
|
//! arrives on `pump_tap_tx` (the pump's outbound mpsc).
|
|
|
|
use rutster_brain_realtime::{MockRealtimeBrain, openai_client::run_openai_pump};
|
|
use rutster_tap::{PcmFrame, decode_envelope, encode_audio_in, encode_hello};
|
|
use serde_json::Value;
|
|
use std::time::Duration;
|
|
use tokio::sync::mpsc;
|
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
|
|
#[tokio::test]
|
|
async fn brain_mock_round_trip_translates_audio_in_to_audio_out() {
|
|
let mock = MockRealtimeBrain::start().await.unwrap();
|
|
let url = mock.url();
|
|
let req = url.as_str().into_client_request().unwrap();
|
|
let (openai_ws, _resp) = tokio_tungstenite::connect_async(req).await.unwrap();
|
|
|
|
// Bridge channels matching `run_openai_pump`'s signature (it owns neither
|
|
// side of the tap WS — the binary's main.rs does the WS forwarding; here
|
|
// we drive the pump directly with mpsc as the test's tap peer).
|
|
let (tap_via, pump_tap_rx) = mpsc::channel::<String>(64);
|
|
let (pump_tap_tx, mut tap_out_rx) = mpsc::channel::<String>(64);
|
|
|
|
// The pump sends session.update + then runs the select! loop. Spawn it.
|
|
let pump = tokio::spawn(async move {
|
|
run_openai_pump(openai_ws, pump_tap_rx, pump_tap_tx, "alloy".to_string()).await
|
|
});
|
|
|
|
// Push a hello (the pump ignores hello per its `_ => warn!(...)` arm,
|
|
// but real tap traffic always starts with hello).
|
|
let hello = encode_hello("test-session", 0, 0).unwrap();
|
|
tap_via.send(hello).await.unwrap();
|
|
|
|
// Push one audio_in frame. The pump translates to
|
|
// input_audio_buffer.append; the mock replies with a canned
|
|
// response.audio.delta; the pump translates back to a tap audio_out
|
|
// frame on pump_tap_tx.
|
|
let frame = PcmFrame::zeroed();
|
|
let audio_in = encode_audio_in(&frame, 1, 100).unwrap();
|
|
tap_via.send(audio_in).await.unwrap();
|
|
|
|
// Bounded-wait the audio_out frame coming back. Generous bound: the
|
|
// pump's tokio select! cycle + the mock's WS round-trip on loopback
|
|
// are sub-millisecond; 500 ms is fail-fast for any real regression.
|
|
let out_str = tokio::time::timeout(Duration::from_millis(500), tap_out_rx.recv())
|
|
.await
|
|
.expect("audio_out within 500ms")
|
|
.expect("channel not closed");
|
|
|
|
let decoded = decode_envelope(&out_str).unwrap();
|
|
match decoded.payload {
|
|
rutster_tap::DecodedPayload::AudioOut(p) => {
|
|
// The mock's canned PCM is 480 zeroed samples; the brain's
|
|
// translator round-trips it through PcmFrame (decode +
|
|
// re-encode), so the wire shape is exactly slice-2's audio_out.
|
|
assert_eq!(p.samples, 480);
|
|
let recovered = rutster_tap::decode_pcm(&p.pcm, p.samples).unwrap();
|
|
assert_eq!(recovered.samples.len(), 480);
|
|
// Canned zeros — every sample is 0.
|
|
assert_eq!(recovered.samples[0], 0);
|
|
assert_eq!(recovered.samples[479], 0);
|
|
// seq + ts carried through from the translator's per-event
|
|
// counter (initial value 0 on the first outbound frame).
|
|
assert_eq!(decoded.seq, 0);
|
|
}
|
|
other => panic!("expected AudioOut, got {other:?}"),
|
|
}
|
|
|
|
// Drop the input channel to signal the pump to exit cleanly.
|
|
drop(tap_via);
|
|
let _ = pump.await;
|
|
let _ = Value::Null; // suppress unused-import if Value unused here
|
|
}
|