Files
settled-reach/server/tests/golden_suite.rs
T
jpmschweitzerandClaude Sonnet 4.6 bee93963d9 test(simulation): cross-room transition scenarios T1-T8 + test suite expansion (#506)
Cross-room transition tests (server/tests/cross_room_transitions.rs):
- T1: sprint suppresses interaction buffer, restores on Walk (D-055)
- T2: CarriedBy survives room transition — no TilePosition leak (D-065)
- T3: pause mid-corridor discards movement, Unpause resumes (D-031)
- T4: KnowledgeGraph persists across player position change (D-041)
- T5: entity knowledge downgrades Direct→KnowsDetails on LOS exit (D-060)
- T6: eavesdrop cut immediately on first movement out of corner (D-071)
- T7: confrontation verb disappears on retreat beyond MID_RANGE=5 (D-057/D-070)
- T8: Sprint blocks eavesdrop accumulation, Careful enables it (D-055+D-071)

All 8 tests pass. Test suite grows from 545 → 563 (18 tests added across sprint).
Tests use direct ECS World + Schedule pattern; T3 uses full App + SimulationPlugin.

Test suite expansion:
- content_scaling.rs: max_npc_pack_behavioral_regression + stress tests (#513)
- golden/proof_room_tick_10.json: updated golden file for gauntlet world changes
- golden_suite.rs, serialization.rs, bridge_ipc.rs, bridge_tcp.rs: adapted to
  new world entity count and wire types
- gen_fixtures.rs, perf_bench.rs, content_runtime.rs: minor test adaptations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 12:04:29 +01:00

393 lines
13 KiB
Rust

//! Golden file regression test (#485)
//!
//! Runs a 10-tick deterministic replay, serializes the final ObserverSnapshot
//! to JSON, and compares against a committed golden file. Any deviation fails
//! the test with a field-level diff.
//!
//! To regenerate golden files after intentional changes:
//! UPDATE_GOLDEN=1 cargo test --test golden_suite
//!
//! Spec references: D-010 (deterministic simulation), D-030 (testability)
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
use settled_reach_server::npc::{
Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold,
Want, WantKind,
};
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
use settled_reach_server::simulation::listening::ListeningFocus;
use settled_reach_server::simulation::monologue::{
MonologueBuffer, MonologueState, SprintAnomalyQueue,
};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::rng::SimRng;
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
use settled_reach_server::simulation::time::DayPhase;
use settled_reach_server::simulation::SimulationPlugin;
use bevy_app::prelude::*;
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::PathBuf;
const GOLDEN_DIR: &str = "tests/golden";
const GOLDEN_FILE: &str = "tests/golden/proof_room_tick_10.json";
const SEED: u64 = 42;
const NUM_TICKS: usize = 10;
/// Build a deterministic simulation app with the proof room.
/// Mirrors the setup in determinism.rs / main.rs.
fn build_app(seed: u64) -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.add_plugins(NpcPlugin);
app.insert_resource(SimRng::new(seed));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
{
let mut wm = app.world_mut().resource_mut::<WalkabilityMap>();
wm.set_walkable(&TilePosition::new(16, 14, 0), false);
}
let mut registry = EntityRegistry::new(0);
let profile = MovementProfile::smuggler();
let player = app
.world_mut()
.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
CognitiveDelay::default(),
ListeningFocus::new(TilePosition::new(16, 16, 0)),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
))
.id();
registry.register(player);
let npc1 = app
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(16, 13, 0),
Want {
primary: WantKind::Wealth,
intensity: 6,
description: "Wants a bigger share of docking fees".into(),
},
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(16, 13, 0),
activity: "Prep cargo bay".into(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(20, 10, 0),
activity: "Unload freight".into(),
},
RoutineEntry {
phase: DayPhase::Evening,
location: TilePosition::new(10, 20, 0),
activity: "Drink at canteen".into(),
},
RoutineEntry {
phase: DayPhase::Night,
location: TilePosition::new(16, 13, 0),
activity: "Sleep in bunk".into(),
},
],
description: "Dock worker shift pattern".into(),
},
Contentment { level: 20 },
ToleranceThreshold {
current_stress: 30,
threshold: 70,
},
MovementSpeed::new(2),
))
.id();
let npc1_sid = registry.register(npc1);
let npc2 = app
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(14, 18, 0),
Want {
primary: WantKind::Knowledge,
intensity: 8,
description: "Obsessed with pre-Collapse sensor arrays".into(),
},
DailyRoutine {
entries: vec![
RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(14, 18, 0),
activity: "Calibrate instruments".into(),
},
RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(22, 22, 0),
activity: "Field survey".into(),
},
],
description: "Field tech survey pattern".into(),
},
Contentment { level: 45 },
ToleranceThreshold {
current_stress: 10,
threshold: 60,
},
MovementSpeed::default(),
))
.id();
let npc2_sid = registry.register(npc2);
let npc3 = app
.world_mut()
.spawn((
Npc,
Interactable,
TilePosition::new(18, 14, 0),
Want {
primary: WantKind::Safety,
intensity: 4,
description: "Wants a quiet shift".into(),
},
Contentment { level: -5 },
ToleranceThreshold {
current_stress: 45,
threshold: 55,
},
))
.id();
let npc3_sid = registry.register(npc3);
{
let mut rel_graph = app.world_mut().resource_mut::<RelationshipGraph>();
rel_graph.set_relationship(
npc1_sid,
npc3_sid,
RelationshipEdge {
kind: RelationshipKind::Colleague,
trust: 3,
history: vec![],
last_interaction_tick: 0,
},
);
rel_graph.set_relationship(
npc3_sid,
npc2_sid,
RelationshipEdge {
kind: RelationshipKind::Rival,
trust: -4,
history: vec![],
last_interaction_tick: 0,
},
);
}
app.insert_resource(registry);
app
}
/// Standard 10-tick input sequence for golden file tests.
/// Matches the first 10 ticks of the determinism test in determinism.rs.
fn standard_inputs() -> Vec<Vec<PlayerInput>> {
vec![
// Tick 0: idle — baseline snapshot
vec![],
// Tick 1: move north
vec![PlayerInput {
tick: 1,
action: PlayerAction::MoveNorth,
}],
// Tick 2: idle
vec![],
// Tick 3: move east
vec![PlayerInput {
tick: 3,
action: PlayerAction::MoveEast,
}],
// Tick 4: idle
vec![],
// Tick 5: move north
vec![PlayerInput {
tick: 5,
action: PlayerAction::MoveNorth,
}],
// Tick 6: stance toggle up
vec![PlayerInput {
tick: 6,
action: PlayerAction::ToggleStanceUp,
}],
// Tick 7: move north
vec![PlayerInput {
tick: 7,
action: PlayerAction::MoveNorth,
}],
// Tick 8: pause
vec![PlayerInput {
tick: 8,
action: PlayerAction::Pause,
}],
// Tick 9: move while paused (should be discarded)
vec![PlayerInput {
tick: 9,
action: PlayerAction::MoveNorth,
}],
]
}
/// Recursively sort all object keys for deterministic JSON output.
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(),
}
}
/// Recursive JSON diff — reports all field-level differences with paths.
fn diff_json(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<String>) {
match (expected, actual) {
(Value::Object(e), Value::Object(a)) => {
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 = if path.is_empty() {
format!(".{}", key)
} else {
format!("{}.{}", path, key)
};
match (e.get(key), a.get(key)) {
(Some(ev), Some(av)) => diff_json(&child, ev, av, diffs),
(Some(_), None) => diffs.push(format!("{}: missing in actual", child)),
(None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)),
(None, None) => unreachable!(),
}
}
}
(Value::Array(e), Value::Array(a)) => {
for i in 0..e.len().max(a.len()) {
let child = format!("{}[{}]", path, i);
match (e.get(i), a.get(i)) {
(Some(ev), Some(av)) => diff_json(&child, ev, av, diffs),
(Some(_), None) => diffs.push(format!("{}: missing in actual", child)),
(None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)),
(None, None) => unreachable!(),
}
}
}
_ => {
if expected != actual {
diffs.push(format!("{}: expected {}, got {}", path, expected, actual));
}
}
}
}
#[test]
fn proof_room_tick_10_matches_golden() {
let mut app = build_app(SEED);
let inputs = standard_inputs();
assert_eq!(inputs.len(), NUM_TICKS);
let mut last_snapshot: Option<ObserverSnapshot> = None;
for tick_inputs in &inputs {
{
let mut queue = app
.world_mut()
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
for input in tick_inputs {
queue.push(input.clone());
}
}
app.update();
let buffer = app.world().resource::<SnapshotBuffer>();
if let Some(snapshot) = &buffer.snapshot {
last_snapshot = Some(snapshot.clone());
}
}
let snapshot = last_snapshot.expect("no snapshot produced after 10 ticks");
// Serialize to sorted JSON for deterministic comparison
let actual_value: Value = serde_json::to_value(&snapshot).expect("serialize to JSON");
let actual_sorted = sort_json_keys(&actual_value);
let actual_json = serde_json::to_string_pretty(&actual_sorted).expect("format JSON") + "\n";
let golden_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_FILE);
// UPDATE_GOLDEN=1 mode: write the golden file and return
if std::env::var("UPDATE_GOLDEN").is_ok() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_DIR);
std::fs::create_dir_all(&dir).expect("create golden directory");
std::fs::write(&golden_path, &actual_json).expect("write golden file");
eprintln!(
"Golden file written: {} ({} bytes)",
golden_path.display(),
actual_json.len()
);
return;
}
// Normal mode: compare against golden file
let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| {
panic!(
"Golden file not found: {}. Run with UPDATE_GOLDEN=1 to generate.\nError: {}",
golden_path.display(),
e
)
});
let golden_value: Value =
serde_json::from_str(&golden_json).expect("parse golden file as JSON");
let mut diffs = Vec::new();
diff_json("", &golden_value, &actual_sorted, &mut diffs);
if !diffs.is_empty() {
let mut msg = format!("Golden file mismatch ({} differences):\n", diffs.len());
for diff in &diffs {
msg.push_str(&format!(" {}\n", diff));
}
msg.push_str(&format!(
"\nTo update: UPDATE_GOLDEN=1 cargo test --test golden_suite\n\
Golden file: {}",
golden_path.display()
));
panic!("{}", msg);
}
}