//! Integration tests for triangle validation and cross-template generation (#108, #109). //! //! Spec references: //! - D-024: NPC generation model — minimum 2 triangles per template, 1 cross-template //! - D-087: v0.1 triangle configuration — 3 active forks, 2 passive tensions //! - D-025: social site as atomic template unit — cross-template reference links //! //! Test naming follows the cargo test filter target: //! `cargo test -p settled-reach-server -- triangle_validation` use settled_reach_server::{ knowledge::{registry::StableEntityId, types::StableId}, npc::{Npc, RelationshipKind}, simulation::rng::SimRng, simulation::triangle::{ generate_cross_template_triangles, generate_intra_template_triangles, validate_triangle_def, ConflictType, NpcAxis, RelationshipConstraint, RoleId, TemplateId, TemplateOwnership, TriangleDef, TriangleId, TrianglePhase, TrustRange, ValidationError, }, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Minimal valid TriangleDef that passes all three validation checks. /// /// - interest_axes: [Want, Secret, Relationships] → all distinct, has Want /// - relationship_constraints: non-empty fn valid_triangle_def(id: u64) -> TriangleDef { TriangleDef { triangle_id: TriangleId(id), roles: [ RoleId::new("ops-manager"), RoleId::new("freight-handler"), RoleId::new("inspector"), ], conflict_type: ConflictType::LoyaltyConflict, interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("freight-handler"), kind: RelationshipKind::Colleague, required_trust: TrustRange { min: 1, max: 5 }, }], } } /// Spawn an NPC entity with TemplateOwnership in the given world. fn spawn_template_npc( world: &mut bevy_ecs::world::World, template_id: TemplateId, role: &str, stable_id: u64, ) { world.spawn(( Npc, TemplateOwnership { template_id, role_id: RoleId::new(role), }, StableEntityId(StableId(stable_id)), )); } // --------------------------------------------------------------------------- // #109 — Triangle validation: unit tests for each failure mode // --------------------------------------------------------------------------- /// A valid triangle passes all three validation checks. #[test] fn triangle_validation_valid_triangle_passes_all_checks() { let def = valid_triangle_def(1); assert!( validate_triangle_def(&def).is_ok(), "valid triangle must pass all checks: {:?}", validate_triangle_def(&def) ); } /// Conflict viability fails when no interest_axes entry is NpcAxis::Want. /// /// D-024: active conflict requires at least one role whose tension is Want-driven. #[test] fn triangle_validation_conflict_viability_fails_without_want_axis() { let def = TriangleDef { triangle_id: TriangleId(10), roles: [ RoleId::new("worker-a"), RoleId::new("worker-b"), RoleId::new("supervisor"), ], conflict_type: ConflictType::LatentTension, // No Want axis — all passive tensions interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine], relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("worker-b"), kind: RelationshipKind::Colleague, required_trust: TrustRange { min: 0, max: 3 }, }], }; let result = validate_triangle_def(&def); assert!( matches!( result, Err(ValidationError::ConflictViability { triangle_id: TriangleId(10) }) ), "expected ConflictViability error, got: {:?}", result ); } /// Relationship coherence fails when relationship_constraints is empty. /// /// A coherent triangle must document at least one social link among the three roles. #[test] fn triangle_validation_relationship_coherence_fails_without_constraints() { let def = TriangleDef { triangle_id: TriangleId(20), roles: [ RoleId::new("smuggler"), RoleId::new("detective"), RoleId::new("informant"), ], conflict_type: ConflictType::SecretExposure, // Has Want axis (passes conflict viability) interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], // Empty constraints — fails coherence relationship_constraints: vec![], }; let result = validate_triangle_def(&def); assert!( matches!( result, Err(ValidationError::RelationshipCoherence { triangle_id: TriangleId(20) }) ), "expected RelationshipCoherence error, got: {:?}", result ); } /// Interest divergence fails when two roles share the same interest_axis. /// /// All three axes must be distinct so each role brings a different tension. #[test] fn triangle_validation_interest_divergence_fails_with_duplicate_axes() { let def = TriangleDef { triangle_id: TriangleId(30), roles: [ RoleId::new("dock-worker"), RoleId::new("cargo-lead"), RoleId::new("port-officer"), ], conflict_type: ConflictType::ResourceCompetition, // Two roles both have Want — duplicate axis interest_axes: [NpcAxis::Want, NpcAxis::Want, NpcAxis::Secret], relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("cargo-lead"), kind: RelationshipKind::Colleague, required_trust: TrustRange { min: 0, max: 5 }, }], }; let result = validate_triangle_def(&def); assert!( matches!( result, Err(ValidationError::InterestDivergence { triangle_id: TriangleId(30), duplicate_axis: NpcAxis::Want, }) ), "expected InterestDivergence(Want) error, got: {:?}", result ); } /// Divergence check catches the third axis duplicating the first. /// /// Edge case: axes[0] == axes[2], but axes[1] is different. #[test] fn triangle_validation_interest_divergence_first_last_duplicate() { let def = TriangleDef { triangle_id: TriangleId(31), roles: [ RoleId::new("role-a"), RoleId::new("role-b"), RoleId::new("role-c"), ], conflict_type: ConflictType::AuthorityChallenge, interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Want], // 0 == 2 relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("role-b"), kind: RelationshipKind::Colleague, required_trust: TrustRange { min: 0, max: 5 }, }], }; let result = validate_triangle_def(&def); assert!( matches!( result, Err(ValidationError::InterestDivergence { triangle_id: TriangleId(31), duplicate_axis: NpcAxis::Want, }) ), "expected InterestDivergence(Want) for axes[0]==axes[2], got: {:?}", result ); } /// Validation checks are ordered: ConflictViability fires before Coherence. /// /// A def with no Want axis AND empty constraints should fail with /// ConflictViability, not RelationshipCoherence. #[test] fn triangle_validation_conflict_viability_checked_before_coherence() { let def = TriangleDef { triangle_id: TriangleId(40), roles: [RoleId::new("a"), RoleId::new("b"), RoleId::new("c")], conflict_type: ConflictType::LatentTension, interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine], relationship_constraints: vec![], // also fails coherence }; let result = validate_triangle_def(&def); assert!( matches!(result, Err(ValidationError::ConflictViability { .. })), "ConflictViability must be checked before RelationshipCoherence, got: {:?}", result ); } // --------------------------------------------------------------------------- // #108 — Cross-template triangle generation // --------------------------------------------------------------------------- /// Two instantiated templates produce 1 cross-template TriangleState /// with role assignments spanning both templates. /// /// Spec: D-024 ("1 cross-template" requirement), D-025 (ownership model) #[test] fn triangle_validation_cross_template_spans_two_templates() { let mut world = bevy_ecs::world::World::new(); let mut rng = SimRng::new(42); let hub_id = TemplateId::from_seed_and_slug(42, "logistics-hub"); let bar_id = TemplateId::from_seed_and_slug(42, "last-shift-bar"); // Logistics hub roles spawn_template_npc(&mut world, hub_id, "ops-manager", 1); spawn_template_npc(&mut world, hub_id, "freight-handler", 2); // Bar roles spawn_template_npc(&mut world, bar_id, "bartender", 3); // Cross-template triangle: ops-manager (hub) + freight-handler (hub) + bartender (bar) let def = valid_triangle_def(999); let overridden = TriangleDef { roles: [ RoleId::new("ops-manager"), RoleId::new("freight-handler"), RoleId::new("bartender"), ], ..def }; let result = generate_cross_template_triangles(&mut world, hub_id, bar_id, &[overridden], &mut rng); assert!( result.warnings.is_empty(), "no warnings expected for valid cross-template triangle: {:?}", result.warnings ); assert_eq!( result.triangles.len(), 1, "should generate exactly 1 cross-template triangle" ); let state = &result.triangles[0]; assert_eq!( state.template_id, hub_id, "cross-template triangle must be owned by template_a (hub)" ); assert_eq!(state.role_assignments.len(), 3); // Verify role assignments span both templates let ops = state.role_assignments[&RoleId::new("ops-manager")]; let freight = state.role_assignments[&RoleId::new("freight-handler")]; let bartender = state.role_assignments[&RoleId::new("bartender")]; assert_eq!(ops, StableId(1), "ops-manager must map to hub NPC 1"); assert_eq!( freight, StableId(2), "freight-handler must map to hub NPC 2" ); assert_eq!(bartender, StableId(3), "bartender must map to bar NPC 3"); assert_eq!(state.phase, TrianglePhase::Simmering); assert!( state.tension >= 5 && state.tension <= 25, "tension in seeded range" ); assert!( state.tension_rate >= 1 && state.tension_rate <= 5, "rate in seeded range" ); } /// Cross-template generation skips defs that fail validation, adding a warning. #[test] fn triangle_validation_cross_template_skips_invalid_defs() { let mut world = bevy_ecs::world::World::new(); let mut rng = SimRng::new(42); let hub_id = TemplateId::from_seed_and_slug(1, "hub"); let bar_id = TemplateId::from_seed_and_slug(1, "bar"); spawn_template_npc(&mut world, hub_id, "ops-manager", 1); spawn_template_npc(&mut world, hub_id, "freight-handler", 2); spawn_template_npc(&mut world, bar_id, "bartender", 3); // Invalid def: no Want axis (fails conflict viability) let invalid = TriangleDef { triangle_id: TriangleId(50), roles: [ RoleId::new("ops-manager"), RoleId::new("freight-handler"), RoleId::new("bartender"), ], conflict_type: ConflictType::LatentTension, interest_axes: [NpcAxis::Contentment, NpcAxis::Tolerance, NpcAxis::Routine], relationship_constraints: vec![RelationshipConstraint { with_role: RoleId::new("freight-handler"), kind: RelationshipKind::Colleague, required_trust: TrustRange { min: 0, max: 3 }, }], }; let result = generate_cross_template_triangles(&mut world, hub_id, bar_id, &[invalid], &mut rng); assert_eq!(result.triangles.len(), 0, "invalid def must be skipped"); assert_eq!( result.warnings.len(), 1, "exactly one warning for the skipped def" ); assert!( result.warnings[0].contains("validation failed"), "warning must mention validation failure: {}", result.warnings[0] ); } /// Cross-template generation is deterministic for the same seed (D-010). #[test] fn triangle_validation_cross_template_deterministic() { let hub_id = TemplateId::from_seed_and_slug(42, "hub"); let bar_id = TemplateId::from_seed_and_slug(42, "bar"); let def = valid_triangle_def(1); let make_world = || { let mut world = bevy_ecs::world::World::new(); spawn_template_npc(&mut world, hub_id, "ops-manager", 1); spawn_template_npc(&mut world, hub_id, "freight-handler", 2); spawn_template_npc(&mut world, bar_id, "inspector", 3); world }; let overridden = TriangleDef { roles: [ RoleId::new("ops-manager"), RoleId::new("freight-handler"), RoleId::new("inspector"), ], ..def.clone() }; let mut world1 = make_world(); let result1 = generate_cross_template_triangles( &mut world1, hub_id, bar_id, std::slice::from_ref(&overridden), &mut SimRng::new(42), ); let mut world2 = make_world(); let result2 = generate_cross_template_triangles( &mut world2, hub_id, bar_id, &[overridden], &mut SimRng::new(42), ); assert_eq!(result1.triangles.len(), 1); assert_eq!(result2.triangles.len(), 1); assert_eq!( result1.triangles[0].tension, result2.triangles[0].tension, "cross-template generation must be deterministic (D-010)" ); assert_eq!( result1.triangles[0].tension_rate, result2.triangles[0].tension_rate ); } /// D-024: cross-template generation is separate from intra-template generation. /// Intra-template only sees its own template's NPCs. #[test] fn triangle_validation_intra_template_does_not_see_other_template_npcs() { let mut world = bevy_ecs::world::World::new(); let mut rng = SimRng::new(42); let hub_id = TemplateId::from_seed_and_slug(1, "hub"); let bar_id = TemplateId::from_seed_and_slug(1, "bar"); // Only hub NPCs for roles ops-manager, freight-handler spawn_template_npc(&mut world, hub_id, "ops-manager", 1); spawn_template_npc(&mut world, hub_id, "freight-handler", 2); // Bar NPC exists but should NOT be used by intra-template hub generation spawn_template_npc(&mut world, bar_id, "inspector", 3); // Triangle requiring ops-manager + freight-handler + inspector // intra-template hub generation cannot find "inspector" in hub NPCs let def = TriangleDef { triangle_id: TriangleId(5), roles: [ RoleId::new("ops-manager"), RoleId::new("freight-handler"), RoleId::new("inspector"), ], conflict_type: ConflictType::LoyaltyConflict, interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], relationship_constraints: vec![], }; let result = generate_intra_template_triangles( &mut world, hub_id, &[def, valid_triangle_def(6)], &mut rng, ); // The def needing "inspector" should fall back (inspector is in bar, not hub) // At least one warning about the missing role assert!( !result.warnings.is_empty(), "intra-template must not find bar NPCs — warning expected for missing 'inspector' role" ); }