From 1da81a21ea901802b559033a78543dd00214d278 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 01:39:37 +0100 Subject: [PATCH] feat(simulation): add test client binary scaffolding (#480) Separate workspace crate at tooling/test-client/ importing bridge types from server crate. CLI: --connect, --replay, --text, --json, --quiet, --golden, --ticks. Exit codes: 0=success, 1=golden mismatch, 2=connection error. Golden file comparison with recursive JSON diff. JSONL replay loader for tick-scheduled input sending. Co-Authored-By: Claude Opus 4.6 --- tooling/test-client/Cargo.toml | 16 +++ tooling/test-client/src/golden.rs | 104 +++++++++++++++++ tooling/test-client/src/main.rs | 188 ++++++++++++++++++++++++++++++ tooling/test-client/src/replay.rs | 24 ++++ 4 files changed, 332 insertions(+) create mode 100644 tooling/test-client/Cargo.toml create mode 100644 tooling/test-client/src/golden.rs create mode 100644 tooling/test-client/src/main.rs create mode 100644 tooling/test-client/src/replay.rs diff --git a/tooling/test-client/Cargo.toml b/tooling/test-client/Cargo.toml new file mode 100644 index 000000000..5e59ee4ab --- /dev/null +++ b/tooling/test-client/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "settled-reach-test-client" +version = "0.1.0" +edition = "2021" +description = "Test client for the Settled Reach simulation server. Connects via TCP, receives ObserverSnapshots, sends replay inputs." + +[[bin]] +name = "settled-reach-test-client" +path = "src/main.rs" + +[dependencies] +settled-reach-server = { path = "../../server" } +clap = { version = "4", features = ["derive"] } +serde_json = "1" +serde = { version = "1", features = ["derive"] } +rmp-serde = "1" diff --git a/tooling/test-client/src/golden.rs b/tooling/test-client/src/golden.rs new file mode 100644 index 000000000..cb797969b --- /dev/null +++ b/tooling/test-client/src/golden.rs @@ -0,0 +1,104 @@ +// Golden file comparison for test-client. +// Compares the final ObserverSnapshot (as JSON) against a golden file. +// Reports field-by-field differences with JSON paths. + +use serde_json::Value; +use settled_reach_server::bridge::types::ObserverSnapshot; +use std::path::Path; + +/// Compare an ObserverSnapshot against a golden JSON file. +/// Returns a list of difference descriptions (empty = match). +pub fn compare_golden( + golden_path: &Path, + snapshot: &ObserverSnapshot, +) -> Result, String> { + let golden_str = std::fs::read_to_string(golden_path).map_err(|e| { + format!( + "failed to read golden file {}: {}", + golden_path.display(), + e + ) + })?; + let golden: Value = serde_json::from_str(&golden_str) + .map_err(|e| format!("failed to parse golden file: {}", e))?; + let actual: Value = serde_json::to_value(snapshot) + .map_err(|e| format!("failed to serialize snapshot: {}", e))?; + + let mut diffs = Vec::new(); + diff_values("", &golden, &actual, &mut diffs); + Ok(diffs) +} + +fn diff_values(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec) { + match (expected, actual) { + (Value::Object(e), Value::Object(a)) => { + for key in e.keys() { + let child_path = if path.is_empty() { + format!(".{}", key) + } else { + format!("{}.{}", path, key) + }; + match a.get(key) { + Some(av) => diff_values(&child_path, &e[key], av, diffs), + None => diffs.push(format!( + "{}: expected {}, got ", + child_path, + format_value(&e[key]) + )), + } + } + for key in a.keys() { + if !e.contains_key(key) { + let child_path = if path.is_empty() { + format!(".{}", key) + } else { + format!("{}.{}", path, key) + }; + diffs.push(format!( + "{}: expected , got {}", + child_path, + format_value(&a[key]) + )); + } + } + } + (Value::Array(e), Value::Array(a)) => { + let max_len = e.len().max(a.len()); + for i in 0..max_len { + let child_path = format!("{}[{}]", path, i); + match (e.get(i), a.get(i)) { + (Some(ev), Some(av)) => diff_values(&child_path, ev, av, diffs), + (Some(ev), None) => diffs.push(format!( + "{}: expected {}, got ", + child_path, + format_value(ev) + )), + (None, Some(av)) => diffs.push(format!( + "{}: expected , got {}", + child_path, + format_value(av) + )), + (None, None) => unreachable!(), + } + } + } + _ => { + if expected != actual { + diffs.push(format!( + "{}: expected {}, got {}", + path, + format_value(expected), + format_value(actual) + )); + } + } + } +} + +fn format_value(v: &Value) -> String { + match v { + Value::String(s) => format!("{:?}", s), + Value::Null => "null".to_string(), + other => other.to_string(), + } +} diff --git a/tooling/test-client/src/main.rs b/tooling/test-client/src/main.rs new file mode 100644 index 000000000..c6b537bd4 --- /dev/null +++ b/tooling/test-client/src/main.rs @@ -0,0 +1,188 @@ +// test-client: Test client for the Settled Reach simulation server. +// +// Connects via TCP, receives ObserverSnapshots, optionally sends replay +// inputs, and outputs snapshots as text/JSON. Supports golden file +// comparison for deterministic regression testing. +// +// Exit codes: +// 0 = success +// 1 = golden file mismatch +// 2 = connection/protocol error + +use clap::Parser; +use std::io::{BufReader, BufWriter}; +use std::net::TcpStream; +use std::path::PathBuf; +use std::process; + +use settled_reach_server::bridge::framing::{read_framed, write_framed}; +use settled_reach_server::bridge::text_renderer::format_snapshot_text; +use settled_reach_server::bridge::types::ObserverSnapshot; + +mod golden; +mod replay; + +#[derive(Parser)] +#[command( + name = "test-client", + about = "Test client for the Settled Reach simulation server" +)] +struct Cli { + /// Server address (host:port) + #[arg(long, default_value = "127.0.0.1:9876")] + connect: String, + + /// JSONL replay file (one JSON array of PlayerInput per tick) + #[arg(long)] + replay: Option, + + /// Output format: structured text to stdout (default) + #[arg(long)] + text: bool, + + /// Output format: JSON snapshots to stdout + #[arg(long)] + json: bool, + + /// No output (CI assertions only) + #[arg(long)] + quiet: bool, + + /// Compare final snapshot against golden JSON file, exit 1 on diff + #[arg(long)] + golden: Option, + + /// Disconnect after N ticks + #[arg(long)] + ticks: Option, +} + +enum OutputMode { + Text, + Json, + Quiet, +} + +fn main() { + let cli = Cli::parse(); + + let output_mode = if cli.quiet { + OutputMode::Quiet + } else if cli.json { + OutputMode::Json + } else { + OutputMode::Text + }; + + // Connect to server + let stream = match TcpStream::connect(&cli.connect) { + Ok(s) => s, + Err(e) => { + eprintln!("Connection error: {} (address: {})", e, cli.connect); + process::exit(2); + } + }; + + let mut reader = BufReader::new(stream.try_clone().unwrap_or_else(|e| { + eprintln!("Failed to clone TCP stream: {}", e); + process::exit(2); + })); + let mut writer = BufWriter::new(stream); + + // Load replay inputs if provided + let replay_inputs = cli.replay.as_ref().map(|path| { + replay::load_replay(path).unwrap_or_else(|e| { + eprintln!("Replay load error: {}", e); + process::exit(2); + }) + }); + + let mut tick_count: u64 = 0; + let mut last_snapshot: Option = None; + + loop { + // Receive snapshot (blocking read) + let payload = match read_framed(&mut reader) { + Ok(Some(data)) => data, + Ok(None) => { + // Server closed connection — normal shutdown + break; + } + Err(e) => { + eprintln!("Read error: {}", e); + process::exit(2); + } + }; + + let snapshot: ObserverSnapshot = match rmp_serde::from_slice(&payload) { + Ok(s) => s, + Err(e) => { + eprintln!("Deserialization error: {}", e); + process::exit(2); + } + }; + + tick_count += 1; + + // Output snapshot + match output_mode { + OutputMode::Text => { + print!("{}", format_snapshot_text(&snapshot)); + } + OutputMode::Json => { + let json = serde_json::to_string_pretty(&snapshot).unwrap(); + println!("{}", json); + } + OutputMode::Quiet => {} + } + + last_snapshot = Some(snapshot); + + // Check tick limit before sending next input + if let Some(max_ticks) = cli.ticks { + if tick_count >= max_ticks { + break; + } + } + + // Send inputs for next tick + let inputs = replay_inputs + .as_ref() + .and_then(|r| r.get(tick_count as usize - 1)) + .cloned() + .unwrap_or_default(); + + let input_payload = rmp_serde::to_vec(&inputs).unwrap_or_else(|e| { + eprintln!("Input serialization error: {}", e); + process::exit(2); + }); + if let Err(e) = write_framed(&mut writer, &input_payload) { + eprintln!("Write error: {}", e); + process::exit(2); + } + } + + // Golden file comparison + if let Some(golden_path) = &cli.golden { + let snapshot = match last_snapshot { + Some(s) => s, + None => { + eprintln!("No snapshot received for golden comparison"); + process::exit(2); + } + }; + + let diffs = golden::compare_golden(golden_path, &snapshot).unwrap_or_else(|e| { + eprintln!("Golden file error: {}", e); + process::exit(2); + }); + + if !diffs.is_empty() { + eprintln!("GOLDEN FILE MISMATCH: {}", golden_path.display()); + for diff in &diffs { + eprintln!(" {}", diff); + } + process::exit(1); + } + } +} diff --git a/tooling/test-client/src/replay.rs b/tooling/test-client/src/replay.rs new file mode 100644 index 000000000..45c01b4f1 --- /dev/null +++ b/tooling/test-client/src/replay.rs @@ -0,0 +1,24 @@ +// JSONL replay file loader for test-client. +// Each line is a JSON array of PlayerInput for one tick. +// Empty array = idle tick (no input sent). + +use settled_reach_server::bridge::types::PlayerInput; +use std::path::Path; + +/// Load a JSONL replay file. Returns one Vec per tick. +pub fn load_replay(path: &Path) -> Result>, String> { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read replay file {}: {}", path.display(), e))?; + + let mut ticks = Vec::new(); + for (i, line) in content.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let inputs: Vec = + serde_json::from_str(line).map_err(|e| format!("replay line {}: {}", i + 1, e))?; + ticks.push(inputs); + } + Ok(ticks) +}