//! Integration tests for the template schema system (tickets #163, #164, #165, #106). //! //! Tests YAML round-trips, validation logic, and ECS component interactions //! against the spec decisions: //! - D-023: three-tier content model //! - D-024: 10-axis NPC model, triangles as atomic unit //! - D-025: social site / single-ownership model //! - D-087: v0.1 triangle configuration //! - D-089: self-contained triangle forks, no cross-triangle cascade //! - D-010: determinism (no HashMap, FNV-1a IDs) use settled_reach_server::content::template::{ validate_role_schemas_no_duplicate_ids, ConflictType, NpcAxis, PrivacyLevel, RelationshipConstraint, RoleId, RoleSchema, SpaceSpec, TemplateId, TemplateOwnership, TemplateReference, TemplateReferenceMap, TemplateRoutineEntry, TrafficPattern, TriangleDef, TriangleId, TrustRange, }; use settled_reach_server::npc::{PersonalityTrait, RelationshipKind, Skill}; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn make_role_schema(id: &str) -> RoleSchema { RoleSchema { role_id: RoleId::new(id), required_traits: vec![], skill_focus: vec![], relationship_constraints: vec![], routine_template: vec![], } } fn make_triangle(roles: [&str; 3], conflict: ConflictType) -> TriangleDef { let role_arr = [ RoleId::new(roles[0]), RoleId::new(roles[1]), RoleId::new(roles[2]), ]; let triangle_id = TriangleId::from_seed_and_roles(42, &role_arr); TriangleDef { triangle_id, roles: role_arr, conflict_type: conflict, interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], relationship_constraints: vec![], } } // --------------------------------------------------------------------------- // #163: Role definition schema — YAML round-trips // --------------------------------------------------------------------------- #[test] fn role_schema_minimal_yaml_parse() { let yaml = r#" role_id: "guard" skill_focus: - Combat - Observation "#; let schema: RoleSchema = serde_yaml::from_str(yaml).expect("minimal schema must parse"); assert_eq!(schema.role_id, RoleId::new("guard")); assert_eq!(schema.skill_focus.len(), 2); assert!(schema.required_traits.is_empty()); assert!(schema.relationship_constraints.is_empty()); assert!(schema.routine_template.is_empty()); } #[test] fn role_schema_full_yaml_parse() { let yaml = r#" role_id: "dock-worker" required_traits: - Cautious - Honest skill_focus: - Technical - Observation relationship_constraints: - with_role: "ring-contact" kind: Colleague required_trust: min: -2 max: 2 routine_template: - phase: "morning" location: "terminal-cargo-bay" activity: "freight-handling" - phase: "evening" location: "bar-last-shift" "#; let schema: RoleSchema = serde_yaml::from_str(yaml).expect("full schema must parse"); assert_eq!(schema.role_id, RoleId::new("dock-worker")); assert_eq!(schema.required_traits.len(), 2); assert_eq!(schema.required_traits[0], PersonalityTrait::Cautious); assert_eq!(schema.skill_focus.len(), 2); assert_eq!(schema.relationship_constraints.len(), 1); assert_eq!( schema.relationship_constraints[0].with_role, RoleId::new("ring-contact") ); assert_eq!(schema.relationship_constraints[0].required_trust.min, -2); assert_eq!(schema.relationship_constraints[0].required_trust.max, 2); assert_eq!(schema.routine_template.len(), 2); assert_eq!(schema.routine_template[0].phase, "morning"); assert_eq!(schema.routine_template[0].activity, Some("freight-handling".to_string())); assert_eq!(schema.routine_template[1].activity, None); } #[test] fn role_schema_yaml_roundtrip_preserves_all_fields() { let schema = RoleSchema { role_id: RoleId::new("ring-contact"), required_traits: vec![PersonalityTrait::Deceptive, PersonalityTrait::Social], skill_focus: vec![Skill::Stealth, Skill::Persuasion], relationship_constraints: vec![ RelationshipConstraint { with_role: RoleId::new("dock-worker"), kind: RelationshipKind::Colleague, required_trust: TrustRange { min: 0, max: 4 }, }, RelationshipConstraint { with_role: RoleId::new("ring-leader"), kind: RelationshipKind::Superior, required_trust: TrustRange { min: 1, max: 4 }, }, ], routine_template: vec![ TemplateRoutineEntry { phase: "morning".into(), location: "terminal-cargo-bay".into(), activity: Some("oversight".into()), }, TemplateRoutineEntry { phase: "evening".into(), location: "maintenance-corridor".into(), activity: None, }, ], }; let yaml = serde_yaml::to_string(&schema).expect("serialize"); let restored: RoleSchema = serde_yaml::from_str(&yaml).expect("deserialize"); assert_eq!(restored.role_id, schema.role_id); assert_eq!(restored.required_traits, schema.required_traits); assert_eq!(restored.skill_focus, schema.skill_focus); assert_eq!( restored.relationship_constraints.len(), schema.relationship_constraints.len() ); assert_eq!( restored.relationship_constraints[0].required_trust, schema.relationship_constraints[0].required_trust ); assert_eq!(restored.routine_template.len(), schema.routine_template.len()); assert_eq!( restored.routine_template[0].activity, schema.routine_template[0].activity ); } // --------------------------------------------------------------------------- // #163: Role definition schema — validation // --------------------------------------------------------------------------- #[test] fn self_referential_constraint_rejected() { let schema = RoleSchema { role_id: RoleId::new("dock-worker"), required_traits: vec![], skill_focus: vec![], relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("dock-worker"), // same as role_id kind: RelationshipKind::Colleague, required_trust: TrustRange { min: 0, max: 4 }, }], routine_template: vec![], }; let result = schema.validate(); assert!(result.is_err(), "self-referential constraint must be rejected"); assert!( result.unwrap_err().contains("self-referential"), "error must mention self-referential" ); } #[test] fn invalid_trust_range_rejected() { let schema = RoleSchema { role_id: RoleId::new("guard"), required_traits: vec![], skill_focus: vec![], relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("captain"), kind: RelationshipKind::Superior, required_trust: TrustRange { min: 3, max: 1 }, // invalid: min > max }], routine_template: vec![], }; let result = schema.validate(); assert!(result.is_err(), "TrustRange min > max must be rejected"); assert!( result.unwrap_err().contains("trust min"), "error must mention trust min" ); } #[test] fn collection_with_duplicate_role_ids_rejected() { let schemas = vec![ make_role_schema("dock-worker"), make_role_schema("ring-contact"), make_role_schema("dock-worker"), // duplicate ]; let result = validate_role_schemas_no_duplicate_ids(&schemas); assert!(result.is_err(), "duplicate role_ids must be rejected"); let msg = result.unwrap_err(); assert!(msg.contains("dock-worker"), "error must name the duplicate: {}", msg); } #[test] fn collection_with_unique_role_ids_ok() { let schemas = vec![ make_role_schema("dock-worker"), make_role_schema("ring-contact"), make_role_schema("logistics-manager"), ]; assert!(validate_role_schemas_no_duplicate_ids(&schemas).is_ok()); } // --------------------------------------------------------------------------- // #164: Spatial requirement specification — YAML round-trips // --------------------------------------------------------------------------- #[test] fn space_spec_minimal_yaml_parse() { let yaml = r#" tile_count_min: 30 tile_count_max: 80 privacy_level: Public traffic_pattern: Thoroughfare "#; let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("minimal SpaceSpec must parse"); assert_eq!(spec.tile_count_min, 30); assert_eq!(spec.tile_count_max, 80); assert_eq!(spec.privacy_level, PrivacyLevel::Public); assert_eq!(spec.traffic_pattern, TrafficPattern::Thoroughfare); assert!(spec.sightline_zones.is_empty()); } #[test] fn space_spec_full_yaml_parse() { let yaml = r#" tile_count_min: 30 tile_count_max: 80 sightline_zones: - name: "bar-counter" radius: 4 - name: "corner-booth" radius: 2 privacy_level: SemiPrivate traffic_pattern: Destination "#; let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("full SpaceSpec must parse"); assert_eq!(spec.sightline_zones.len(), 2); assert_eq!(spec.sightline_zones[0].name, "bar-counter"); assert_eq!(spec.sightline_zones[0].radius, 4); assert_eq!(spec.sightline_zones[1].name, "corner-booth"); assert_eq!(spec.sightline_zones[1].radius, 2); assert_eq!(spec.privacy_level, PrivacyLevel::SemiPrivate); assert_eq!(spec.traffic_pattern, TrafficPattern::Destination); } /// D-025 scale assertion: 15-40 visual tiles = 30-80 sim tiles (D-066). #[test] fn space_spec_d025_tile_count_range() { let spec = SpaceSpec { tile_count_min: 30, tile_count_max: 80, sightline_zones: vec![], privacy_level: PrivacyLevel::Public, traffic_pattern: TrafficPattern::Destination, }; assert!(spec.validate().is_ok(), "D-025 tile range (30-80 sim) must be valid"); } #[test] fn space_spec_validation_min_gt_max_fails() { let spec = SpaceSpec { tile_count_min: 100, tile_count_max: 50, sightline_zones: vec![], privacy_level: PrivacyLevel::Private, traffic_pattern: TrafficPattern::Restricted, }; let result = spec.validate(); assert!(result.is_err(), "min > max must fail validation"); let msg = result.unwrap_err(); assert!(msg.contains("tile_count_min"), "error must mention tile_count_min: {}", msg); } #[test] fn all_privacy_levels_yaml_roundtrip() { for level in &[PrivacyLevel::Public, PrivacyLevel::SemiPrivate, PrivacyLevel::Private] { let yaml = serde_yaml::to_string(level).unwrap(); let decoded: PrivacyLevel = serde_yaml::from_str(&yaml).unwrap(); assert_eq!(level, &decoded, "{:?} must survive YAML round-trip", level); } } #[test] fn all_traffic_patterns_yaml_roundtrip() { for pattern in &[ TrafficPattern::Thoroughfare, TrafficPattern::Destination, TrafficPattern::Restricted, ] { let yaml = serde_yaml::to_string(pattern).unwrap(); let decoded: TrafficPattern = serde_yaml::from_str(&yaml).unwrap(); assert_eq!(pattern, &decoded, "{:?} must survive YAML round-trip", pattern); } } // --------------------------------------------------------------------------- // #165: Single-ownership model — TemplateId determinism // --------------------------------------------------------------------------- #[test] fn template_id_fnv1a_stable_across_calls() { let id = TemplateId::from_seed_and_slug(0, ""); assert_eq!( id, TemplateId::from_seed_and_slug(0, ""), "empty slug + seed 0 must be stable" ); let id2 = TemplateId::from_seed_and_slug(42, "the-terminal"); assert_eq!( id2, TemplateId::from_seed_and_slug(42, "the-terminal"), "non-empty slug must be stable" ); } #[test] fn template_ownership_component_single_owner_invariant() { // D-025: NPCs are owned by exactly one template, never reassigned. let seed = 1u64; let tid = TemplateId::from_seed_and_slug(seed, "terminal"); let rid = RoleId::new("dock-worker"); let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() }; assert_eq!(ownership.template_id, tid); assert_eq!(ownership.role_id, rid); // Clone (as would happen in save-state) must preserve values. let cloned = ownership.clone(); assert_eq!(cloned.template_id, ownership.template_id); assert_eq!(cloned.role_id, ownership.role_id); } #[test] fn template_reference_map_preserves_links_on_unload() { // D-025: reference links must be preserved when a template is unloaded. let mut map = TemplateReferenceMap::default(); let tid_a = TemplateId::from_seed_and_slug(1, "template-a"); let tid_b = TemplateId::from_seed_and_slug(1, "template-b"); map.add(TemplateReference { from_template: tid_a, to_template: tid_b, via_role: RoleId::new("ring-contact"), relationship_metadata: RelationshipKind::Colleague, }); // Simulate "unload template-a" by cloning (the save path). let preserved = map.clone(); assert_eq!(preserved.outgoing(tid_a).len(), 1); assert_eq!(preserved.outgoing(tid_a)[0].to_template, tid_b); } #[test] fn template_reference_map_btreemap_deterministic_ordering() { // D-010: BTreeMap ensures deterministic iteration order. let mut map = TemplateReferenceMap::default(); let tid_high = TemplateId(u64::MAX - 1); let tid_low = TemplateId(1); map.add(TemplateReference { from_template: tid_high, to_template: tid_low, via_role: RoleId::new("role-a"), relationship_metadata: RelationshipKind::Colleague, }); map.add(TemplateReference { from_template: tid_low, to_template: tid_high, via_role: RoleId::new("role-b"), relationship_metadata: RelationshipKind::Colleague, }); // Collect all references via all_references() (deterministic BTreeMap order). let all: Vec<&TemplateReference> = map.all_references().collect(); assert_eq!(all.len(), 2); // First entry's from_template must be the lower ID (BTreeMap key order). assert!( all[0].from_template <= all[1].from_template, "BTreeMap must iterate in ascending key order" ); } // --------------------------------------------------------------------------- // #106: Triangle definition schema — YAML round-trips // --------------------------------------------------------------------------- #[test] fn triangle_def_yaml_parse_with_computed_id() { // TriangleId is stored in YAML but computed at world-gen time. // Authors use 0 as placeholder; runtime overwrites with computed value. let yaml = r#" triangle_id: 0 roles: - "ring-smuggler" - "dock-worker" - "operations-manager" conflict_type: ResourceCompetition interest_axes: - Want - Secret - Relationships "#; let def: TriangleDef = serde_yaml::from_str(yaml).expect("TriangleDef must parse from YAML"); assert_eq!(def.triangle_id, TriangleId(0)); assert_eq!(def.roles[0], RoleId::new("ring-smuggler")); assert_eq!(def.conflict_type, ConflictType::ResourceCompetition); assert!(def.relationship_constraints.is_empty()); } #[test] fn triangle_def_yaml_parse_with_constraints() { let yaml = r#" triangle_id: 0 roles: - "ring-leader" - "dock-worker" - "logistics-manager" conflict_type: LoyaltyConflict interest_axes: - Relationships - Secret - Tolerance relationship_constraints: - with_role: "dock-worker" kind: Subordinate required_trust: min: -2 max: 2 "#; let def: TriangleDef = serde_yaml::from_str(yaml).expect("TriangleDef with constraints must parse"); assert_eq!(def.conflict_type, ConflictType::LoyaltyConflict); assert_eq!(def.relationship_constraints.len(), 1); assert_eq!(def.relationship_constraints[0].with_role, RoleId::new("dock-worker")); assert_eq!(def.relationship_constraints[0].kind, RelationshipKind::Subordinate); } #[test] fn triangle_def_all_conflict_types_yaml_roundtrip() { let conflict_types = [ ConflictType::ResourceCompetition, ConflictType::LoyaltyConflict, ConflictType::SecretExposure, ConflictType::AuthorityChallenge, ConflictType::LatentTension, ]; for ct in &conflict_types { let yaml = serde_yaml::to_string(ct).unwrap(); let decoded: ConflictType = serde_yaml::from_str(&yaml).unwrap(); assert_eq!(ct, &decoded, "{:?} must round-trip", ct); } } #[test] fn triangle_def_all_npc_axes_yaml_roundtrip() { let axes = [ NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships, NpcAxis::Tolerance, NpcAxis::Routine, NpcAxis::InformationInventory, NpcAxis::Contentment, NpcAxis::PersonalityTraits, NpcAxis::TellSystem, NpcAxis::SkillSet, ]; for axis in &axes { let yaml = serde_yaml::to_string(axis).unwrap(); let decoded: NpcAxis = serde_yaml::from_str(&yaml).unwrap(); assert_eq!(axis, &decoded, "{:?} must round-trip", axis); } } /// D-087: T1-T5 triangle configuration must be expressible in the schema. #[test] fn d087_v01_triangle_configurations_expressible() { // T1: Kael-Smuggler-Ring (ResourceCompetition, active fork) let t1 = make_triangle( ["kael-davan", "smuggler", "ring-contact"], ConflictType::ResourceCompetition, ); assert!(t1.validate().is_ok(), "T1 must be valid: {:?}", t1.validate()); // T2: Sera-Detective-Commission (SecretExposure, active fork) let t2 = make_triangle( ["sera-venn", "detective", "commission-inspector"], ConflictType::SecretExposure, ); assert!(t2.validate().is_ok(), "T2 must be valid: {:?}", t2.validate()); // T4: Drin-System-Ring (ResourceCompetition, active fork per D-087) let t4 = make_triangle( ["drin", "ring-system", "dock-supervisor"], ConflictType::ResourceCompetition, ); assert!(t4.validate().is_ok(), "T4 must be valid: {:?}", t4.validate()); // T3: passive tension (LatentTension variant per D-087) let t3 = make_triangle(["naia", "kael-davan", "hael"], ConflictType::LatentTension); assert!(t3.validate().is_ok(), "T3 passive tension must be valid: {:?}", t3.validate()); // T5: background worried partner (LatentTension variant) let t5 = make_triangle( ["worried-partner", "ring-member", "neighbor"], ConflictType::LatentTension, ); assert!(t5.validate().is_ok(), "T5 passive tension must be valid: {:?}", t5.validate()); } /// D-089: TriangleDef must not contain cross-triangle cascade state. #[test] fn d089_no_cross_triangle_cascade_fields() { let def = make_triangle(["role-a", "role-b", "role-c"], ConflictType::ResourceCompetition); let yaml = serde_yaml::to_string(&def).expect("serialize"); assert!(!yaml.contains("cascade"), "no cascade field should appear in serialized TriangleDef"); assert!(!yaml.contains("cross_triangle"), "no cross_triangle field should appear"); assert!(!yaml.contains("triggers"), "no triggers field should appear"); } // --------------------------------------------------------------------------- // #165: ECS integration — spawn two templates with cross-references // --------------------------------------------------------------------------- #[test] fn ecs_two_templates_with_cross_references_and_ownerships() { use bevy_ecs::world::World; let seed = 999u64; let tid_terminal = TemplateId::from_seed_and_slug(seed, "terminal-social-site"); let tid_bar = TemplateId::from_seed_and_slug(seed, "last-shift-bar"); let mut world = World::new(); world.init_resource::(); // Spawn 3 NPCs: 2 in terminal, 1 in bar. let npc_logistics = world .spawn(TemplateOwnership { template_id: tid_terminal, role_id: RoleId::new("logistics-manager"), }) .id(); let npc_dock = world .spawn(TemplateOwnership { template_id: tid_terminal, role_id: RoleId::new("dock-worker"), }) .id(); let npc_bar_regular = world .spawn(TemplateOwnership { template_id: tid_bar, role_id: RoleId::new("bar-regular"), }) .id(); // Add cross-template reference: dock-worker at terminal references bar-regular at bar. { let mut ref_map = world.resource_mut::(); ref_map.add(TemplateReference { from_template: tid_terminal, to_template: tid_bar, via_role: RoleId::new("dock-worker"), relationship_metadata: RelationshipKind::Colleague, }); } // Verify all TemplateOwnership components are correct. let own_logistics = world.get::(npc_logistics).unwrap(); assert_eq!( own_logistics.template_id, tid_terminal, "logistics-manager must be owned by terminal" ); assert_eq!(own_logistics.role_id, RoleId::new("logistics-manager")); let own_dock = world.get::(npc_dock).unwrap(); assert_eq!( own_dock.template_id, tid_terminal, "dock-worker must be owned by terminal" ); assert_eq!(own_dock.role_id, RoleId::new("dock-worker")); let own_bar = world.get::(npc_bar_regular).unwrap(); assert_eq!(own_bar.template_id, tid_bar, "bar-regular must be owned by bar"); // Verify TemplateReferenceMap entries. let ref_map = world.resource::(); let terminal_refs = ref_map.outgoing(tid_terminal); assert_eq!(terminal_refs.len(), 1, "terminal should have 1 cross-template reference"); assert_eq!(terminal_refs[0].to_template, tid_bar); assert_eq!(terminal_refs[0].via_role, RoleId::new("dock-worker")); // Bar template has no outgoing references. assert!( ref_map.outgoing(tid_bar).is_empty(), "bar template has no outgoing references" ); } #[test] fn template_ownership_survives_clone_for_save_state() { // D-026: TemplateOwnership must be preserved when tier drops to State-saved. let tid = TemplateId::from_seed_and_slug(42, "terminal"); let rid = RoleId::new("dock-worker"); let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() }; let saved = ownership.clone(); assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)"); }