// 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, 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 { 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 = 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) { 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 ", child_path, format_value(ev) )), (None, Some(av)) => diffs.push(format!( "{}: expected , 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 ", child_path, format_value(ev) )), (None, Some(av)) => diffs.push(format!( "{}: expected , 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("")); } #[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 ")); } #[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("")); } #[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")); } }