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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
@@ -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<Vec<String>, 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<String>) {
|
||||
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 <missing>",
|
||||
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 <missing>, 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 <missing>",
|
||||
child_path,
|
||||
format_value(ev)
|
||||
)),
|
||||
(None, Some(av)) => diffs.push(format!(
|
||||
"{}: expected <missing>, 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(),
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf>,
|
||||
|
||||
/// 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<PathBuf>,
|
||||
|
||||
/// Disconnect after N ticks
|
||||
#[arg(long)]
|
||||
ticks: Option<u64>,
|
||||
}
|
||||
|
||||
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<ObserverSnapshot> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PlayerInput> per tick.
|
||||
pub fn load_replay(path: &Path) -> Result<Vec<Vec<PlayerInput>>, 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<PlayerInput> =
|
||||
serde_json::from_str(line).map_err(|e| format!("replay line {}: {}", i + 1, e))?;
|
||||
ticks.push(inputs);
|
||||
}
|
||||
Ok(ticks)
|
||||
}
|
||||
Reference in New Issue
Block a user