Files
settled-reach/tooling/test-client/src/main.rs
T
jpmschweitzerandClaude Opus 4.6 1da81a21ea 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>
2026-02-18 01:39:37 +01:00

189 lines
5.0 KiB
Rust

// 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);
}
}
}