//! End-to-end tests for the template instantiation engine (#161). //! //! Verifies the full pipeline: //! load YAML → validate → spawn NPCs → generate triangles → lifecycle //! //! Spec refs: //! - D-023: three-tier content model //! - D-024: 10-axis NPC model, minimum 2 triangles per social site //! - D-025: social site as atomic template unit, single-ownership //! - D-010: determinism (same seed → same layout) use std::path::PathBuf; use bevy_ecs::prelude::*; use settled_reach_server::{ content::{ instantiation::{ instantiate_template, load_template_from_file, unload_template, ActiveTemplateInstances, }, template::{TemplateId, TemplateOwnership, TriangleState}, }, knowledge::{registry::EntityRegistry, StableEntityId}, simulation::rng::SimRng, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn templates_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data/templates") } fn make_test_world() -> World { let mut world = World::new(); world.init_resource::(); world } // --------------------------------------------------------------------------- // End-to-end: load logistics-hub YAML, instantiate, assert structure (#161) // --------------------------------------------------------------------------- #[test] fn template_instantiation_end_to_end_logistics_hub() { let path = templates_dir().join("logistics-hub.yaml"); let template_def = load_template_from_file(&path).expect("logistics-hub.yaml must load and parse"); let mut world = make_test_world(); let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub"); let mut rng = SimRng::new(42); let instance = instantiate_template(&mut world, &template_def, template_id, 42, &mut rng) .expect("logistics-hub must instantiate without validation errors"); // --- NPCs: all 4 role slots filled --- assert_eq!( instance.npc_entities.len(), 4, "logistics-hub has 4 role slots — 4 NPC entities expected" ); // --- TemplateOwnership on every NPC --- let expected_roles = [ "logistics-manager", "dock-worker", "ring-contact", "security-guard", ]; let mut seen_roles: Vec = Vec::new(); for &entity in &instance.npc_entities { let ownership = world .get::(entity) .expect("every spawned NPC must have TemplateOwnership"); assert_eq!( ownership.template_id, template_id, "TemplateOwnership.template_id must match the instantiated template" ); let role = &ownership.role_id.0; assert!( expected_roles.contains(&role.as_str()), "unexpected role '{}' — not in logistics-hub role list", role, ); seen_roles.push(role.clone()); } // Every role slot must appear exactly once. for role in &expected_roles { assert_eq!( seen_roles.iter().filter(|r| r.as_str() == *role).count(), 1, "role '{}' must appear exactly once", role, ); } // --- 2+ TriangleState entities (D-024 minimum) --- assert!( instance.triangle_entities.len() >= 2, "logistics-hub must produce at least 2 TriangleState entities (D-024), got {}", instance.triangle_entities.len(), ); for &entity in &instance.triangle_entities { assert!( world.get::(entity).is_some(), "triangle entity {:?} must carry a TriangleState component", entity, ); } // --- Instance registered in ActiveTemplateInstances --- let active = world.resource::(); assert!( active.get(template_id).is_some(), "instantiated template must be tracked in ActiveTemplateInstances", ); } // --------------------------------------------------------------------------- // Lifecycle: unload despawns all entities // --------------------------------------------------------------------------- #[test] fn template_instantiation_unload_despawns_entities() { let path = templates_dir().join("logistics-hub.yaml"); let template_def = load_template_from_file(&path).expect("must parse"); let mut world = make_test_world(); let template_id = TemplateId::from_seed_and_slug(99, "logistics-hub"); let mut rng = SimRng::new(99); let instance = instantiate_template(&mut world, &template_def, template_id, 99, &mut rng) .expect("must instantiate"); let all_entities: Vec = instance .npc_entities .iter() .chain(instance.triangle_entities.iter()) .cloned() .collect(); assert!(!all_entities.is_empty(), "sanity: some entities were spawned"); unload_template(&mut world, template_id); // All spawned entities must be gone. for entity in &all_entities { assert!( world.get_entity(*entity).is_err(), "entity {:?} must be despawned after unload_template", entity, ); } // Instance removed from tracking. let active = world.resource::(); assert!( active.get(template_id).is_none(), "unloaded template must be removed from ActiveTemplateInstances", ); } // --------------------------------------------------------------------------- // Determinism: same seed → same NPC StableId assignment (D-010) // --------------------------------------------------------------------------- #[test] fn template_instantiation_is_deterministic() { let path = templates_dir().join("logistics-hub.yaml"); let template_def = load_template_from_file(&path).expect("must parse"); let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub"); let mut world1 = make_test_world(); let instance1 = instantiate_template(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42)) .expect("must instantiate"); let mut world2 = make_test_world(); let instance2 = instantiate_template(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42)) .expect("must instantiate"); // Collect (role → StableId) pairs from each world and compare. let role_stable_ids = |world: &World, entities: &[Entity]| { let mut pairs: Vec<(String, u64)> = entities .iter() .map(|&e| { let role = world.get::(e).unwrap().role_id.0.clone(); let sid = world.get::(e).unwrap().0 .0; (role, sid) }) .collect(); pairs.sort(); pairs }; let pairs1 = role_stable_ids(&world1, &instance1.npc_entities); let pairs2 = role_stable_ids(&world2, &instance2.npc_entities); assert_eq!( pairs1, pairs2, "instantiate_template must be deterministic (D-010): same seed → same layout" ); } // --------------------------------------------------------------------------- // YAML loading: invalid path returns Err // --------------------------------------------------------------------------- #[test] fn load_template_from_file_nonexistent_path_returns_err() { let path = templates_dir().join("nonexistent-template-xyzzy.yaml"); let result = load_template_from_file(&path); assert!( result.is_err(), "loading a nonexistent file must return Err" ); }