- Add ValidationError enum with three failure modes: ConflictViability (missing Want axis), RelationshipCoherence (empty constraints), InterestDivergence (duplicate interest axes) - Add validate_triangle_def() pure function enforcing all three checks in priority order (per D-087) - Add generate_cross_template_triangles() function that combines NPC pools from two templates, validates each TriangleDef before processing, and assigns ownership to template_a - 10 integration tests in tests/triangle_validation.rs covering all validation failure modes, ordering guarantees, cross-template span, invalid def skipping, determinism, and intra-template isolation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
443 lines
16 KiB
Rust
443 lines
16 KiB
Rust
//! 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::{
|
|
content::template::{
|
|
generate_cross_template_triangles, generate_intra_template_triangles,
|
|
validate_triangle_def, ConflictType, NpcAxis, RelationshipConstraint, RoleId,
|
|
TemplateId, TemplateOwnership, TriangleDef, TriangleId, TrianglePhase, TrustRange,
|
|
ValidationError,
|
|
},
|
|
knowledge::{registry::StableEntityId, types::StableId},
|
|
npc::{Npc, RelationshipKind},
|
|
simulation::rng::SimRng,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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,
|
|
&[overridden.clone()],
|
|
&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"
|
|
);
|
|
}
|