//! Integration tests for the template schema system (tickets #163, #164, #165, #106, #159). //! //! 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-028: dialogue tagged pools //! - 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, CrossTemplateLinkSpec, FullTemplateDef, NpcAxis, PrivacyLevel, RelationshipConstraint, RoleId, RoleSchema, SightlineZone, SpaceSpec, TemplateDialoguePoolRef, 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)"); } // --------------------------------------------------------------------------- // #159: Full Tier 2 template document — FullTemplateDef // --------------------------------------------------------------------------- /// Build a minimal valid FullTemplateDef with two roles and two triangles. fn minimal_full_template() -> FullTemplateDef { FullTemplateDef { slug: "test-site".to_string(), display_name: "Test Social Site".to_string(), description: None, roles: vec![ RoleSchema { role_id: RoleId::new("manager"), required_traits: vec![PersonalityTrait::Cautious], skill_focus: vec![Skill::Persuasion], relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("worker"), kind: RelationshipKind::Superior, required_trust: TrustRange { min: 0, max: 5 }, }], routine_template: vec![], }, RoleSchema { role_id: RoleId::new("worker"), required_traits: vec![PersonalityTrait::Honest], skill_focus: vec![Skill::Technical], relationship_constraints: vec![], routine_template: vec![], }, RoleSchema { role_id: RoleId::new("informant"), required_traits: vec![PersonalityTrait::Deceptive], skill_focus: vec![Skill::Stealth], relationship_constraints: vec![], routine_template: vec![], }, ], space: SpaceSpec { tile_count_min: 30, tile_count_max: 80, sightline_zones: vec![SightlineZone { name: "main-floor".to_string(), radius: 6, }], privacy_level: PrivacyLevel::SemiPrivate, traffic_pattern: TrafficPattern::Destination, }, triangles: vec![ TriangleDef { triangle_id: TriangleId(0), roles: [ RoleId::new("manager"), RoleId::new("worker"), RoleId::new("informant"), ], conflict_type: ConflictType::ResourceCompetition, interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], relationship_constraints: vec![], }, TriangleDef { triangle_id: TriangleId(0), roles: [ RoleId::new("manager"), RoleId::new("informant"), RoleId::new("worker"), ], conflict_type: ConflictType::LatentTension, interest_axes: [NpcAxis::Tolerance, NpcAxis::Contentment, NpcAxis::Routine], relationship_constraints: vec![], }, ], dialogue_pools: vec![TemplateDialoguePoolRef { location: "the-hub".to_string(), roles: vec!["manager".to_string(), "worker".to_string()], }], cross_template_links: vec![CrossTemplateLinkSpec { from_role: RoleId::new("worker"), to_template_slug: "other-site".to_string(), relationship: RelationshipKind::Colleague, }], } } #[test] fn full_template_def_yaml_roundtrip() { let template = minimal_full_template(); let yaml = serde_yaml::to_string(&template).expect("serialize FullTemplateDef"); let restored: FullTemplateDef = serde_yaml::from_str(&yaml).expect("deserialize FullTemplateDef"); assert_eq!(restored.slug, template.slug); assert_eq!(restored.display_name, template.display_name); assert_eq!(restored.roles.len(), template.roles.len()); assert_eq!(restored.space.tile_count_min, template.space.tile_count_min); assert_eq!(restored.triangles.len(), template.triangles.len()); assert_eq!(restored.dialogue_pools.len(), template.dialogue_pools.len()); assert_eq!(restored.cross_template_links.len(), template.cross_template_links.len()); // Role round-trip: traits, constraints, routine entries let role = &restored.roles[0]; assert_eq!(role.role_id, RoleId::new("manager")); assert_eq!(role.required_traits[0], PersonalityTrait::Cautious); assert_eq!(role.relationship_constraints[0].with_role, RoleId::new("worker")); // Triangle round-trip: roles, conflict type, axes let tri = &restored.triangles[0]; assert_eq!(tri.conflict_type, ConflictType::ResourceCompetition); assert_eq!(tri.roles[0], RoleId::new("manager")); assert_eq!(tri.interest_axes[1], NpcAxis::Secret); // Dialogue pool round-trip assert_eq!(restored.dialogue_pools[0].location, "the-hub"); assert_eq!(restored.dialogue_pools[0].roles.len(), 2); // Cross-template link round-trip assert_eq!( restored.cross_template_links[0].from_role, RoleId::new("worker") ); assert_eq!( restored.cross_template_links[0].to_template_slug, "other-site" ); } #[test] fn full_template_def_validation_passes_for_valid_template() { let template = minimal_full_template(); assert!( template.validate().is_ok(), "minimal valid template must pass: {:?}", template.validate() ); } #[test] fn full_template_def_validation_rejects_fewer_than_2_triangles() { let mut template = minimal_full_template(); template.triangles.truncate(1); let result = template.validate(); assert!(result.is_err(), "fewer than 2 triangles must fail"); assert!( result.unwrap_err().contains("fewer than 2 triangles"), "error must mention triangle count" ); } #[test] fn full_template_def_validation_rejects_undefined_triangle_role() { let mut template = minimal_full_template(); // Replace a triangle role with one not in the roles list template.triangles[0].roles[2] = RoleId::new("ghost-role"); let result = template.validate(); assert!(result.is_err(), "undefined triangle role must fail validation"); assert!( result.unwrap_err().contains("ghost-role"), "error must name the undefined role" ); } #[test] fn full_template_def_validation_rejects_duplicate_role_ids() { let mut template = minimal_full_template(); template.roles.push(RoleSchema { role_id: RoleId::new("manager"), // duplicate required_traits: vec![], skill_focus: vec![], relationship_constraints: vec![], routine_template: vec![], }); let result = template.validate(); assert!(result.is_err(), "duplicate role_id must fail validation"); } #[test] fn full_template_def_optional_fields_default_on_minimal_yaml() { // description, dialogue_pools, cross_template_links are all optional. let yaml = r#" slug: "bare-minimum" display_name: "Bare Minimum Site" roles: - role_id: "alpha" - role_id: "beta" - role_id: "gamma" space: tile_count_min: 30 tile_count_max: 80 privacy_level: Public traffic_pattern: Thoroughfare triangles: - triangle_id: 0 roles: - "alpha" - "beta" - "gamma" conflict_type: LatentTension interest_axes: - Contentment - Tolerance - Routine - triangle_id: 0 roles: - "alpha" - "gamma" - "beta" conflict_type: ResourceCompetition interest_axes: - Want - Secret - Relationships "#; let def: FullTemplateDef = serde_yaml::from_str(yaml).expect("minimal YAML must parse"); assert_eq!(def.slug, "bare-minimum"); assert!(def.description.is_none()); assert!(def.dialogue_pools.is_empty()); assert!(def.cross_template_links.is_empty()); assert!(def.validate().is_ok(), "minimal template must validate: {:?}", def.validate()); } /// Acceptance test: the authored logistics-hub.yaml round-trips through serde_yaml. /// /// The file lives at `server/data/templates/logistics-hub.yaml`. /// This test is the canonical acceptance criterion for ticket #159. #[test] fn logistics_hub_yaml_roundtrips_cleanly() { let path = concat!( env!("CARGO_MANIFEST_DIR"), "/data/templates/logistics-hub.yaml" ); let raw = std::fs::read_to_string(path) .unwrap_or_else(|e| panic!("could not read logistics-hub.yaml: {}", e)); let def: FullTemplateDef = serde_yaml::from_str(&raw) .unwrap_or_else(|e| panic!("logistics-hub.yaml failed to deserialize: {}", e)); // Structural assertions assert_eq!(def.slug, "logistics-hub"); assert_eq!(def.roles.len(), 4, "logistics hub must define 4 roles"); assert_eq!(def.triangles.len(), 2, "logistics hub must define 2 triangles"); assert!(!def.dialogue_pools.is_empty(), "dialogue_pools must be present"); assert!(!def.cross_template_links.is_empty(), "cross_template_links must be present"); // Spatial spec assertions (D-025: 30–80 sim tiles) assert!(def.space.validate().is_ok(), "space spec must validate"); assert_eq!(def.space.tile_count_min, 30); assert_eq!(def.space.tile_count_max, 80); // Validation must pass assert!( def.validate().is_ok(), "logistics-hub.yaml must pass full validation: {:?}", def.validate() ); // Round-trip: serialize back to YAML then deserialize again let reserialized = serde_yaml::to_string(&def).expect("re-serialize"); let restored: FullTemplateDef = serde_yaml::from_str(&reserialized).expect("re-deserialize after round-trip"); assert_eq!(def.slug, restored.slug); assert_eq!(def.roles.len(), restored.roles.len()); assert_eq!(def.triangles.len(), restored.triangles.len()); assert_eq!(def.dialogue_pools.len(), restored.dialogue_pools.len()); assert_eq!(def.cross_template_links.len(), restored.cross_template_links.len()); }