Files
settled-reach/server/tests/triangle_validation.rs
jpmschweitzerandClaude Opus 4.7 8eb6c47f74 style(server): clear clippy-1.93 cfg(test)/test-target debt (#967)
Manual clippy-1.93 fixes that the prior machine-applicable sweep couldn't auto-
apply, all in cfg(test) modules and tests/ targets (invisible to the lib-only
pre-push clippy, hence accumulated unflagged):

- disallowed_types HashSet/HashMap → BTreeSet/BTreeMap (determinism rule):
  shadowcast_bench.rs (×8, (i32,i32) keys), mood.rs, sound.rs. SoundEventKind
  gains a PartialOrd/Ord derive (fieldless Copy enum) so it is BTree-usable.
- field_reassign_with_default → struct-init: disclosure.rs, monologue.rs (×2),
  save_io.rs (keeps `mut` for the deliberate last-write-wins overwrite).
- assertions_on_constants on the EAVESDROP_THRESHOLD invariant → compile-time
  `const _: () = assert!(...)`: listening.rs, cross_room_transitions.rs. This is
  stronger than the runtime assert and needs no #[allow].
- approx_constant: settings/types.rs round-trip literal 3.14 → 2.5 (the value is
  arbitrary test data, never meant to be PI — change avoids both the lint and a
  suppression).
- drop_non_drop: vision.rs early Mut<WalkabilityMap> release → scoped block.
- unnecessary_get_then_check → contains_key: information_boundaries.rs (×3).
- cloned_ref_to_slice_refs → std::slice::from_ref: triangle_validation.rs.
- unused_must_use: input.rs dropped the unused .id() on a spawn.

cargo clippy --all-targets -- -D warnings is clean; cargo test green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:03:15 +02:00

462 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::{
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"
);
}