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>
189 lines
5.0 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|