Files
settled-reach/tooling/test-client/src/golden.rs
T
jpmschweitzerandClaude Opus 4.6 e66352e0ea feat(simulation): sprint 9 gauntlet — test infrastructure and first 3 rooms
Add Gauntlet test world with 3 rooms (Inventory Warehouse, Occlusion
Corridor, Pause Chamber) + Central Hub, room constants module, room
reset trigger mechanism, Layer 3 subprocess integration test, golden
file comparison engine and test suite, and content runtime validation.

Tickets: #482, #484, #485, #487, #488, #489, #490

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:25:57 +01:00

301 lines
10 KiB
Rust

// Golden file comparison for test-client (#484).
// Compares the final ObserverSnapshot (as JSON) against a golden file.
// Reports field-by-field differences with JSON paths.
//
// Key properties:
// - Recursive diff with JSON path tracking (e.g. ".entities[0].x")
// - Object keys sorted before compare (BTreeMap semantics via serde_json)
// - Pretty-printed field-level diffs: path + expected + got
// - Used by #485 golden file test suite and --golden CLI flag
use serde_json::Value;
use settled_reach_server::bridge::types::ObserverSnapshot;
use std::collections::BTreeMap;
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))?;
Ok(diff_json_values(&golden, &actual))
}
/// Compare two JSON values and return all field-level differences.
/// Object keys are sorted (BTreeMap order) for deterministic comparison.
/// Returns an empty Vec when the values are equal.
pub fn diff_json_values(expected: &Value, actual: &Value) -> Vec<String> {
let mut diffs = Vec::new();
diff_values("", expected, actual, &mut diffs);
diffs
}
/// Generate a golden file from an ObserverSnapshot.
/// Writes sorted, pretty-printed JSON for human readability and stable diffs.
pub fn generate_golden(golden_path: &Path, snapshot: &ObserverSnapshot) -> Result<(), String> {
let value: Value = serde_json::to_value(snapshot)
.map_err(|e| format!("failed to serialize snapshot: {}", e))?;
let sorted = sort_json_keys(&value);
let json = serde_json::to_string_pretty(&sorted)
.map_err(|e| format!("failed to format JSON: {}", e))?;
if let Some(parent) = golden_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
format!(
"failed to create directory {}: {}",
parent.display(),
e
)
})?;
}
std::fs::write(golden_path, format!("{}\n", json)).map_err(|e| {
format!(
"failed to write golden file {}: {}",
golden_path.display(),
e
)
})
}
/// Recursively sort all object keys in a JSON value.
/// Ensures deterministic serialization regardless of insertion order.
fn sort_json_keys(value: &Value) -> Value {
match value {
Value::Object(map) => {
let sorted: BTreeMap<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), sort_json_keys(v)))
.collect();
Value::Object(sorted.into_iter().collect())
}
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
other => other.clone(),
}
}
fn diff_values(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<String>) {
match (expected, actual) {
(Value::Object(e), Value::Object(a)) => {
// Collect all keys from both sides, sorted for deterministic output
let mut all_keys: Vec<&String> = e.keys().chain(a.keys()).collect();
all_keys.sort();
all_keys.dedup();
for key in all_keys {
let child_path = if path.is_empty() {
format!(".{}", key)
} else {
format!("{}.{}", path, key)
};
match (e.get(key), a.get(key)) {
(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!(),
}
}
}
(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!(),
}
}
}
(e, a) if std::mem::discriminant(e) != std::mem::discriminant(a) => {
// Type mismatch (e.g. number vs string)
diffs.push(format!(
"{}: type mismatch — expected {}, got {}",
path,
format_value(e),
format_value(a)
));
}
_ => {
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(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Array(arr) => format!("[...] ({} elements)", arr.len()),
Value::Object(map) => format!("{{...}} ({} keys)", map.len()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn identical_values_produce_no_diffs() {
let a = json!({"version": 8, "tick": 0, "entities": []});
let b = json!({"version": 8, "tick": 0, "entities": []});
assert!(diff_json_values(&a, &b).is_empty());
}
#[test]
fn primitive_mismatch_reports_path() {
let a = json!({"version": 8, "tick": 0});
let b = json!({"version": 8, "tick": 5});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".tick"));
assert!(diffs[0].contains("expected 0"));
assert!(diffs[0].contains("got 5"));
}
#[test]
fn missing_key_in_actual() {
let expected = json!({"version": 8, "tick": 0});
let actual = json!({"version": 8});
let diffs = diff_json_values(&expected, &actual);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".tick"));
assert!(diffs[0].contains("<missing>"));
}
#[test]
fn extra_key_in_actual() {
let expected = json!({"version": 8});
let actual = json!({"version": 8, "tick": 0});
let diffs = diff_json_values(&expected, &actual);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".tick"));
assert!(diffs[0].contains("expected <missing>"));
}
#[test]
fn nested_object_diff() {
let a = json!({"game_time": {"day": 0, "time_of_day": 100}});
let b = json!({"game_time": {"day": 0, "time_of_day": 200}});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".game_time.time_of_day"));
}
#[test]
fn array_length_difference() {
let a = json!({"entities": [{"id": 1}, {"id": 2}]});
let b = json!({"entities": [{"id": 1}]});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".entities[1]"));
assert!(diffs[0].contains("<missing>"));
}
#[test]
fn array_element_diff() {
let a = json!({"entities": [{"x": 10.5, "y": 20.0}]});
let b = json!({"entities": [{"x": 10.5, "y": 25.0}]});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".entities[0].y"));
}
#[test]
fn type_mismatch_reports_clearly() {
let a = json!({"tick": 0});
let b = json!({"tick": "zero"});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains("type mismatch"));
}
#[test]
fn deeply_nested_path() {
let a = json!({"a": {"b": {"c": {"d": 1}}}});
let b = json!({"a": {"b": {"c": {"d": 2}}}});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0], ".a.b.c.d: expected 1, got 2");
}
#[test]
fn multiple_diffs_all_reported() {
let a = json!({"version": 7, "tick": 0, "entities": [{"id": 1}]});
let b = json!({"version": 8, "tick": 5, "entities": [{"id": 2}]});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 3);
}
#[test]
fn sort_json_keys_is_deterministic() {
let a = json!({"z": 1, "a": 2, "m": {"z": 3, "a": 4}});
let sorted = sort_json_keys(&a);
let output = serde_json::to_string(&sorted).unwrap();
assert_eq!(output, r#"{"a":2,"m":{"a":4,"z":3},"z":1}"#);
}
#[test]
fn empty_objects_match() {
let a = json!({});
let b = json!({});
assert!(diff_json_values(&a, &b).is_empty());
}
#[test]
fn null_values_match() {
let a = json!({"field": null});
let b = json!({"field": null});
assert!(diff_json_values(&a, &b).is_empty());
}
#[test]
fn null_vs_absent_reports_diff() {
let a = json!({"field": null});
let b = json!({});
let diffs = diff_json_values(&a, &b);
assert_eq!(diffs.len(), 1);
assert!(diffs[0].contains(".field"));
}
}