Add FullTemplateDef integration tests: round-trip YAML serialization, space spec validation, routine schedule, sightline zones, dialogue pool refs, cross-template link specs, and logistics-hub template fixture. Tests cover the full social site template document structure per D-023, D-024, D-025, D-028. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
907 lines
32 KiB
Rust
907 lines
32 KiB
Rust
//! 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::<TemplateReferenceMap>();
|
||
|
||
// 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::<TemplateReferenceMap>();
|
||
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::<TemplateOwnership>(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::<TemplateOwnership>(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::<TemplateOwnership>(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::<TemplateReferenceMap>();
|
||
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());
|
||
}
|