//! Semantic validation for Storybook entities //! //! This module validates semantic constraints that can't be checked during //! parsing: //! - Reserved keyword conflicts in field names //! - Trait value ranges //! - Schedule time overlaps //! - Life arc transition validity //! - Behavior tree action registry checks //! - Relationship bond values (0.0 .. 1.0) use std::collections::HashSet; use crate::{ resolve::{ ErrorCollector, ResolveError, Result, }, syntax::ast::*, }; /// List of reserved keywords that cannot be used as field names const RESERVED_KEYWORDS: &[&str] = &[ // Top-level declaration keywords "character", "template", "life_arc", "schedule", "behavior", "institution", "relationship", "location", "species", "enum", // Statement keywords "use", "state", "on", "as", "remove", "append", "strict", "include", "from", // Expression keywords "self", "other", "forall", "exists", "in", "where", "and", "or", "not", "is", "true", "false", ]; /// Validate that field names don't conflict with reserved keywords pub fn validate_no_reserved_keywords(fields: &[Field], collector: &mut ErrorCollector) { for field in fields { if RESERVED_KEYWORDS.contains(&field.name.as_str()) { collector.add(ResolveError::ValidationError { message: format!( "Field name '{}' is a reserved keyword and cannot be used", field.name ), help: Some(format!( "The name '{}' is reserved by the Storybook language. Try using a different name like '{}Type', '{}Value', or 'my{}'.", field.name, field.name, field.name, field.name.chars().next().unwrap_or('x').to_uppercase().collect::() + &field.name[1..] )), }); } } } /// Validate trait values are within valid ranges pub fn validate_trait_ranges(fields: &[Field], collector: &mut ErrorCollector) { for field in fields { match &field.value { | Value::Decimal(f) => { // Normalized trait values should be 0.0 .. 1.0 if (field.name.ends_with("_normalized") || field.name == "bond" || field.name == "trust" || field.name == "love") && !(0.0..=1.0).contains(f) { collector.add(ResolveError::TraitOutOfRange { field: field.name.clone(), value: f.to_string(), min: 0.0, max: 1.0, }); } }, | Value::Number(i) => { // Age should be reasonable if field.name == "age" && (*i < 0 || *i > 150) { collector.add(ResolveError::TraitOutOfRange { field: "age".to_string(), value: i.to_string(), min: 0.0, max: 150.0, }); } }, | _ => {}, } } } /// Validate relationship bond values are in [0.0, 1.0] pub fn validate_relationship_bonds(relationships: &[Relationship], collector: &mut ErrorCollector) { for rel in relationships { for field in &rel.fields { if field.name == "bond" { if let Value::Decimal(f) = field.value { if !(0.0..=1.0).contains(&f) { collector.add(ResolveError::TraitOutOfRange { field: "bond".to_string(), value: f.to_string(), min: 0.0, max: 1.0, }); } } } } // Validate participant fields for participant in &rel.participants { validate_trait_ranges(&participant.fields, collector); } } } /// Validate schedule blocks don't overlap in time pub fn validate_schedule_overlaps(schedule: &Schedule, collector: &mut ErrorCollector) { // Sort blocks by start time let mut sorted_blocks: Vec<_> = schedule.blocks.iter().collect(); sorted_blocks.sort_by_key(|b| (b.start.hour as u32) * 60 + (b.start.minute as u32)); // Check for overlaps for i in 0..sorted_blocks.len() { for j in (i + 1)..sorted_blocks.len() { let block1 = sorted_blocks[i]; let block2 = sorted_blocks[j]; let end1 = (block1.end.hour as u32) * 60 + (block1.end.minute as u32); let start2 = (block2.start.hour as u32) * 60 + (block2.start.minute as u32); // Check if blocks overlap if end1 > start2 { collector.add(ResolveError::ScheduleOverlap { block1: format!( "{} ({}:{:02}-{}:{:02})", block1.name.as_ref().unwrap_or(&block1.activity), block1.start.hour, block1.start.minute, block1.end.hour, block1.end.minute ), block2: format!( "{} ({}:{:02}-{}:{:02})", block2.name.as_ref().unwrap_or(&block2.activity), block2.start.hour, block2.start.minute, block2.end.hour, block2.end.minute ), end1: format!("{}:{:02}", block1.end.hour, block1.end.minute), start2: format!("{}:{:02}", block2.start.hour, block2.start.minute), }); } } } } /// Validate life arc state machine has valid transitions pub fn validate_life_arc_transitions(life_arc: &LifeArc, collector: &mut ErrorCollector) { // Collect all state names let mut state_names = HashSet::new(); for state in &life_arc.states { state_names.insert(state.name.clone()); } // Validate all transitions point to valid states for state in &life_arc.states { for transition in &state.transitions { if !state_names.contains(&transition.to) { let available_states = state_names .iter() .map(|s| format!("'{}'", s)) .collect::>() .join(", "); collector.add(ResolveError::UnknownLifeArcState { life_arc: life_arc.name.clone(), state: state.name.clone(), target: transition.to.clone(), available_states, }); } } } // Warn if states have no outgoing transitions (terminal states) // This is not an error, just informational } /// Validate behavior tree actions are known /// /// If action_registry is empty, skips validation (no schema provided). pub fn validate_behavior_tree_actions( tree: &Behavior, action_registry: &HashSet, collector: &mut ErrorCollector, ) { // Skip validation if no action schema was provided if action_registry.is_empty() { return; } validate_tree_node_actions(&tree.root, action_registry, &tree.name, collector) } fn validate_tree_node_actions( node: &BehaviorNode, action_registry: &HashSet, tree_name: &str, collector: &mut ErrorCollector, ) { match node { | BehaviorNode::Sequence { children, .. } | BehaviorNode::Selector { children, .. } => { for child in children { validate_tree_node_actions(child, action_registry, tree_name, collector); } }, | BehaviorNode::Action(name, _params) => { if !action_registry.contains(name) { collector.add(ResolveError::UnknownBehaviorAction { tree: tree_name.to_string(), action: name.clone(), }); } }, | BehaviorNode::Condition(_) => { // Conditions are validated separately via expression validation }, | BehaviorNode::Decorator { child, .. } => { validate_tree_node_actions(child, action_registry, tree_name, collector); }, | BehaviorNode::SubTree(_path) => { // SubTree references validated separately }, } } /// Validate character resource linking /// /// Checks: /// 1. No duplicate behavior tree references /// 2. Priority values are valid (handled by type system) pub fn validate_character_resource_links(character: &Character, collector: &mut ErrorCollector) { // Check for duplicate behavior tree references if let Some(ref behavior_links) = character.uses_behaviors { let mut seen_trees: HashSet = HashSet::new(); for link in behavior_links { let tree_name = link.tree.join("::"); if seen_trees.contains(&tree_name) { collector.add(ResolveError::ValidationError { message: format!( "Character '{}' has duplicate behavior tree reference: {}", character.name, tree_name ), help: Some(format!( "The behavior tree '{}' is referenced multiple times in the uses behaviors list. Each behavior tree should only be referenced once. If you want different conditions or priorities, combine them into a single entry.", tree_name )), }); } seen_trees.insert(tree_name); } } // Check for duplicate schedule references if let Some(ref schedules) = character.uses_schedule { let mut seen_schedules: HashSet = HashSet::new(); for schedule in schedules { if seen_schedules.contains(schedule) { collector.add(ResolveError::ValidationError { message: format!( "Character '{}' has duplicate schedule reference: {}", character.name, schedule ), help: Some(format!( "The schedule '{}' is referenced multiple times. Each schedule should only be referenced once.", schedule )), }); } seen_schedules.insert(schedule.clone()); } } } /// Validate institution resource linking pub fn validate_institution_resource_links( institution: &Institution, collector: &mut ErrorCollector, ) { // Check for duplicate behavior tree references if let Some(ref behavior_links) = institution.uses_behaviors { let mut seen_trees: HashSet = HashSet::new(); for link in behavior_links { let tree_name = link.tree.join("::"); if seen_trees.contains(&tree_name) { collector.add(ResolveError::ValidationError { message: format!( "Institution '{}' has duplicate behavior tree reference: {}", institution.name, tree_name ), help: Some(format!( "The behavior tree '{}' is referenced multiple times. Each behavior tree should only be referenced once.", tree_name )), }); } seen_trees.insert(tree_name); } } // Check for duplicate schedule references if let Some(ref schedules) = institution.uses_schedule { let mut seen_schedules: HashSet = HashSet::new(); for schedule in schedules { if seen_schedules.contains(schedule) { collector.add(ResolveError::ValidationError { message: format!( "Institution '{}' has duplicate schedule reference: {}", institution.name, schedule ), help: Some(format!( "The schedule '{}' is referenced multiple times.", schedule )), }); } seen_schedules.insert(schedule.clone()); } } } /// Validate schedule composition requirements /// /// Checks: /// 1. All blocks in extended schedules have names /// 2. Override blocks reference valid block names (requires name resolution) pub fn validate_schedule_composition(schedule: &Schedule, collector: &mut ErrorCollector) { // If schedule extends another, all blocks must have names for override system if schedule.extends.is_some() { for block in &schedule.blocks { if block.name.is_none() && !block.activity.is_empty() { collector.add(ResolveError::ValidationError { message: format!( "Schedule '{}' extends another schedule but has unnamed blocks", schedule.name ), help: Some( "When a schedule extends another, all blocks must have names to support the override system. Use 'block name { ... }' syntax instead of 'time -> time : activity { ... }'.".to_string() ), }); } } } // Validate that new-style blocks have action references for block in &schedule.blocks { if block.name.is_some() && block.action.is_none() && block.activity.is_empty() { collector.add(ResolveError::ValidationError { message: format!( "Schedule '{}' block '{}' missing action reference", schedule.name, block.name.as_ref().unwrap() ), help: Some( "Named blocks should specify a behavior using 'action: BehaviorName'. Example: 'block work { 9:00 -> 17:00, action: WorkBehavior }'".to_string() ), }); } } } /// Validate that all sub_concepts reference existing parent concepts pub fn validate_sub_concept_parents(file: &File, collector: &mut ErrorCollector) { // Collect all concept names let concept_names: HashSet = file .declarations .iter() .filter_map(|decl| { if let Declaration::Concept(c) = decl { Some(c.name.clone()) } else { None } }) .collect(); // Check each sub_concept references an existing parent for decl in &file.declarations { if let Declaration::SubConcept(sc) = decl { if !concept_names.contains(&sc.parent_concept) { collector.add(ResolveError::ValidationError { message: format!( "Parent concept '{}' not found for sub_concept '{}.{}'", sc.parent_concept, sc.parent_concept, sc.name ), help: Some(format!( "Add 'concept {}' before defining sub_concepts for it.", sc.parent_concept )), }); } } } } /// Get the type name of a Value for error messages fn value_type_name(value: &Value) -> &'static str { match value { | Value::Number(_) => "Number", | Value::Decimal(_) => "Decimal", | Value::Text(_) => "Text", | Value::Boolean(_) => "Boolean", | Value::Range(_, _) => "Range", | Value::Time(_) => "Time", | Value::Duration(_) => "Duration", | Value::Identifier(_) => "Identifier", | Value::List(_) => "List", | Value::Object(_) => "Object", | Value::ProseBlock(_) => "ProseBlock", | Value::Override(_) => "Override", | Value::Any => "Any", } } /// Check if two values have compatible types fn values_type_compatible(a: &Value, b: &Value) -> bool { matches!( (a, b), (Value::Number(_), Value::Number(_)) | (Value::Decimal(_), Value::Decimal(_)) | (Value::Text(_), Value::Text(_)) | (Value::Boolean(_), Value::Boolean(_)) | (Value::Time(_), Value::Time(_)) | (Value::Duration(_), Value::Duration(_)) | (Value::Identifier(_), Value::Identifier(_)) | (Value::List(_), Value::List(_)) | (Value::Object(_), Value::Object(_)) | (Value::ProseBlock(_), Value::ProseBlock(_)) | (Value::Range(_, _), Value::Range(_, _)) | (Value::Range(_, _), Value::Number(_)) | (Value::Number(_), Value::Range(_, _)) | (Value::Range(_, _), Value::Decimal(_)) | (Value::Decimal(_), Value::Range(_, _)) | (Value::Any, _) | (_, Value::Any) ) } /// Validate type invariance in species inheritance /// /// When a template extends a species, any shared field names must have /// compatible types. For example, if species Human has `age: 0` (Number), /// then template Person: Human cannot have `age: "old"` (Text). pub fn validate_species_type_invariance(file: &File, collector: &mut ErrorCollector) { for decl in &file.declarations { if let Declaration::Template(template) = decl { if let Some(species_name) = &template.species_base { // Find the species declaration let species = file.declarations.iter().find_map(|d| { if let Declaration::Species(s) = d { if s.name == *species_name { return Some(s); } } None }); if let Some(species) = species { // Check each template field against species fields for template_field in &template.fields { if let Some(species_field) = species .fields .iter() .find(|f| f.name == template_field.name) { if !values_type_compatible(&species_field.value, &template_field.value) { collector.add(ResolveError::ValidationError { message: format!( "Field '{}' has type {} in species {} but {} in template {}", template_field.name, value_type_name(&species_field.value), species_name, value_type_name(&template_field.value), template.name, ), help: Some(format!( "Template '{}' inherits from species '{}'. Field '{}' must keep the same type ({}) as defined in the species.", template.name, species_name, template_field.name, value_type_name(&species_field.value), )), }); } } } } } } } } /// Validate concept_comparison patterns against the concept registry /// /// Checks: /// 1. The concept_comparison name references an existing concept /// 2. All variant names in patterns exist in the concept's enum sub-concepts /// 3. Patterns are exhaustive (all variants are covered) pub fn validate_concept_comparison_patterns( file: &File, registry: &crate::resolve::types::ConceptRegistry, collector: &mut ErrorCollector, ) { for decl in &file.declarations { if let Declaration::ConceptComparison(comp) = decl { // Check that the concept exists let concept = match registry.lookup_concept(&comp.name) { | Some(c) => c, | None => { collector.add(ResolveError::ValidationError { message: format!( "Concept '{}' not found for concept_comparison", comp.name ), help: Some(format!( "Add 'concept {}' before defining a concept_comparison for it.", comp.name )), }); continue; }, }; // Collect all enum variants from all enum sub-concepts let mut all_variants: HashSet = HashSet::new(); for sc_info in concept.sub_concepts.values() { if let SubConceptKind::Enum { variants } = &sc_info.kind { for variant in variants { all_variants.insert(variant.clone()); } } } // Check each variant pattern references valid variants let mut covered_variants: HashSet = HashSet::new(); for pattern in &comp.variants { // Check that the variant name is a known variant if !all_variants.contains(&pattern.name) { collector.add(ResolveError::ValidationError { message: format!( "Variant '{}' not found in concept '{}'", pattern.name, comp.name ), help: Some(format!( "Available variants for '{}': {}", comp.name, if all_variants.is_empty() { "(none - add sub_concept with enum variants)".to_string() } else { let mut sorted: Vec<_> = all_variants.iter().collect(); sorted.sort(); sorted .iter() .map(|s| s.as_str()) .collect::>() .join(", ") } )), }); } else { covered_variants.insert(pattern.name.clone()); } // Check field conditions reference valid fields in the Is values for condition in &pattern.conditions { if let Condition::Is(values) = &condition.condition { for value in values { // Check that the value references a valid variant if !all_variants.contains(value) { collector.add(ResolveError::ValidationError { message: format!( "Value '{}' in condition for variant '{}' not found in concept '{}'", value, pattern.name, comp.name ), help: Some(format!( "Available variants: {}", { let mut sorted: Vec<_> = all_variants.iter().collect(); sorted.sort(); sorted.iter().map(|s| s.as_str()).collect::>().join(", ") } )), }); } } } } } // Check exhaustiveness - all variants should be covered let uncovered: Vec<_> = all_variants.difference(&covered_variants).collect(); if !uncovered.is_empty() { let mut sorted: Vec<_> = uncovered.into_iter().collect(); sorted.sort(); collector.add(ResolveError::ValidationError { message: format!( "concept_comparison '{}' is not exhaustive: missing variants {}", comp.name, sorted .iter() .map(|s| format!("'{}'", s)) .collect::>() .join(", ") ), help: Some(format!( "Add patterns for all variants of concept '{}'. Missing: {}", comp.name, sorted .iter() .map(|s| s.as_str()) .collect::>() .join(", ") )), }); } } } } /// Validate an entire file /// /// Collects all validation errors and returns them together instead of failing /// fast. pub fn validate_file(file: &File, action_registry: &HashSet) -> Result<()> { let mut collector = ErrorCollector::new(); // Type system validation validate_sub_concept_parents(file, &mut collector); validate_species_type_invariance(file, &mut collector); let concept_registry = crate::resolve::types::ConceptRegistry::from_file(file); validate_concept_comparison_patterns(file, &concept_registry, &mut collector); for decl in &file.declarations { match decl { | Declaration::Character(c) => { validate_trait_ranges(&c.fields, &mut collector); validate_character_resource_links(c, &mut collector); }, | Declaration::Institution(i) => { validate_institution_resource_links(i, &mut collector); }, | Declaration::Relationship(r) => { validate_relationship_bonds(std::slice::from_ref(r), &mut collector); }, | Declaration::Schedule(s) => { validate_schedule_overlaps(s, &mut collector); validate_schedule_composition(s, &mut collector); }, | Declaration::LifeArc(la) => { validate_life_arc_transitions(la, &mut collector); }, | Declaration::Behavior(bt) => { validate_behavior_tree_actions(bt, action_registry, &mut collector); }, | _ => { // Other declarations don't need validation yet }, } } collector.into_result(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_valid_trait_ranges() { let fields = vec![ Field { name: "bond".to_string(), value: Value::Decimal(0.8), span: Span::new(0, 10), }, Field { name: "age".to_string(), value: Value::Number(30), span: Span::new(0, 10), }, ]; let mut collector = ErrorCollector::new(); validate_trait_ranges(&fields, &mut collector); assert!(!collector.has_errors()); } #[test] fn test_invalid_bond_value_too_high() { let fields = vec![Field { name: "bond".to_string(), value: Value::Decimal(1.5), span: Span::new(0, 10), }]; let mut collector = ErrorCollector::new(); validate_trait_ranges(&fields, &mut collector); assert!(collector.has_errors()); } #[test] fn test_invalid_bond_value_negative() { let fields = vec![Field { name: "bond".to_string(), value: Value::Decimal(-0.1), span: Span::new(0, 10), }]; let mut collector = ErrorCollector::new(); validate_trait_ranges(&fields, &mut collector); assert!(collector.has_errors()); } #[test] fn test_invalid_age_negative() { let fields = vec![Field { name: "age".to_string(), value: Value::Number(-5), span: Span::new(0, 10), }]; let mut collector = ErrorCollector::new(); validate_trait_ranges(&fields, &mut collector); assert!(collector.has_errors()); } #[test] fn test_invalid_age_too_high() { let fields = vec![Field { name: "age".to_string(), value: Value::Number(200), span: Span::new(0, 10), }]; let mut collector = ErrorCollector::new(); validate_trait_ranges(&fields, &mut collector); assert!(collector.has_errors()); } #[test] fn test_valid_relationship_bond() { let relationship = Relationship { name: "Test".to_string(), participants: vec![], fields: vec![Field { name: "bond".to_string(), value: Value::Decimal(0.9), span: Span::new(0, 10), }], span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_relationship_bonds(&[relationship], &mut collector); assert!(!collector.has_errors()); } #[test] fn test_invalid_relationship_bond() { let relationship = Relationship { name: "Test".to_string(), participants: vec![], fields: vec![Field { name: "bond".to_string(), value: Value::Decimal(1.2), span: Span::new(0, 10), }], span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_relationship_bonds(&[relationship], &mut collector); assert!(collector.has_errors()); } #[test] fn test_life_arc_valid_transitions() { let life_arc = LifeArc { name: "Test".to_string(), states: vec![ ArcState { name: "start".to_string(), on_enter: None, transitions: vec![Transition { to: "end".to_string(), condition: Expr::Identifier(vec!["ready".to_string()]), span: Span::new(0, 10), }], span: Span::new(0, 50), }, ArcState { name: "end".to_string(), on_enter: None, transitions: vec![], span: Span::new(50, 100), }, ], span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_life_arc_transitions(&life_arc, &mut collector); assert!(!collector.has_errors()); } #[test] fn test_life_arc_invalid_transition() { let life_arc = LifeArc { name: "Test".to_string(), states: vec![ArcState { name: "start".to_string(), on_enter: None, transitions: vec![Transition { to: "nonexistent".to_string(), condition: Expr::Identifier(vec!["ready".to_string()]), span: Span::new(0, 10), }], span: Span::new(0, 50), }], span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_life_arc_transitions(&life_arc, &mut collector); assert!(collector.has_errors()); } #[test] fn test_behavior_tree_valid_actions() { let mut registry = HashSet::new(); registry.insert("walk".to_string()); registry.insert("eat".to_string()); let tree = Behavior { name: "Test".to_string(), root: BehaviorNode::Sequence { label: None, children: vec![ BehaviorNode::Action("walk".to_string(), vec![]), BehaviorNode::Action("eat".to_string(), vec![]), ], }, span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_behavior_tree_actions(&tree, ®istry, &mut collector); assert!(!collector.has_errors()); } #[test] fn test_behavior_tree_invalid_action() { // Create a registry with some actions (but not "unknown_action") let mut registry = HashSet::new(); registry.insert("walk".to_string()); registry.insert("work".to_string()); let tree = Behavior { name: "Test".to_string(), root: BehaviorNode::Action("unknown_action".to_string(), vec![]), span: Span::new(0, 100), }; let mut collector = ErrorCollector::new(); validate_behavior_tree_actions(&tree, ®istry, &mut collector); assert!(collector.has_errors()); } #[test] fn test_sub_concept_with_valid_parent() { let file = File { declarations: vec![ Declaration::Concept(ConceptDecl { name: "Cup".to_string(), span: Span::new(0, 10), }), Declaration::SubConcept(SubConceptDecl { name: "Type".to_string(), parent_concept: "Cup".to_string(), kind: SubConceptKind::Enum { variants: vec![ "Small".to_string(), "Medium".to_string(), "Large".to_string(), ], }, span: Span::new(20, 60), }), ], }; let mut collector = ErrorCollector::new(); validate_sub_concept_parents(&file, &mut collector); assert!(!collector.has_errors()); } #[test] fn test_sub_concept_with_missing_parent() { let file = File { declarations: vec![Declaration::SubConcept(SubConceptDecl { name: "Type".to_string(), parent_concept: "Cup".to_string(), kind: SubConceptKind::Enum { variants: vec!["Small".to_string()], }, span: Span::new(0, 40), })], }; let mut collector = ErrorCollector::new(); validate_sub_concept_parents(&file, &mut collector); assert!(collector.has_errors()); } #[test] fn test_multiple_sub_concepts_same_valid_parent() { let file = File { declarations: vec![ Declaration::Concept(ConceptDecl { name: "Cup".to_string(), span: Span::new(0, 10), }), Declaration::SubConcept(SubConceptDecl { name: "Type".to_string(), parent_concept: "Cup".to_string(), kind: SubConceptKind::Enum { variants: vec!["Mug".to_string()], }, span: Span::new(20, 40), }), Declaration::SubConcept(SubConceptDecl { name: "Material".to_string(), parent_concept: "Cup".to_string(), kind: SubConceptKind::Enum { variants: vec!["Glass".to_string()], }, span: Span::new(50, 70), }), ], }; let mut collector = ErrorCollector::new(); validate_sub_concept_parents(&file, &mut collector); assert!(!collector.has_errors()); } #[test] fn test_sub_concept_wrong_parent_name() { let file = File { declarations: vec![ Declaration::Concept(ConceptDecl { name: "Plate".to_string(), span: Span::new(0, 10), }), Declaration::SubConcept(SubConceptDecl { name: "Type".to_string(), parent_concept: "Cup".to_string(), kind: SubConceptKind::Enum { variants: vec!["Small".to_string()], }, span: Span::new(20, 40), }), ], }; let mut collector = ErrorCollector::new(); validate_sub_concept_parents(&file, &mut collector); assert!(collector.has_errors()); } // ===== Type Invariance Tests ===== fn make_species(name: &str, fields: Vec) -> Species { Species { name: name.to_string(), includes: vec![], fields, span: Span::new(0, 10), } } fn make_template_decl(name: &str, species_base: Option<&str>, fields: Vec) -> Template { Template { name: name.to_string(), species_base: species_base.map(|s| s.to_string()), fields, strict: false, includes: vec![], uses_behaviors: None, uses_schedule: None, span: Span::new(0, 10), } } #[test] fn test_type_invariance_matching_types() { let file = File { declarations: vec![ Declaration::Species(make_species( "Human", vec![Field { name: "age".to_string(), value: Value::Number(0), span: Span::new(0, 10), }], )), Declaration::Template(make_template_decl( "Person", Some("Human"), vec![Field { name: "age".to_string(), value: Value::Number(30), span: Span::new(0, 10), }], )), ], }; let mut collector = ErrorCollector::new(); validate_species_type_invariance(&file, &mut collector); assert!(!collector.has_errors()); } #[test] fn test_type_invariance_mismatch() { let file = File { declarations: vec![ Declaration::Species(make_species( "Human", vec![Field { name: "age".to_string(), value: Value::Number(0), span: Span::new(0, 10), }], )), Declaration::Template(make_template_decl( "Person", Some("Human"), vec![Field { name: "age".to_string(), value: Value::Decimal(30.5), span: Span::new(0, 10), }], )), ], }; let mut collector = ErrorCollector::new(); validate_species_type_invariance(&file, &mut collector); assert!(collector.has_errors()); } #[test] fn test_type_invariance_number_to_text_error() { let file = File { declarations: vec![ Declaration::Species(make_species( "Human", vec![Field { name: "age".to_string(), value: Value::Number(0), span: Span::new(0, 10), }], )), Declaration::Template(make_template_decl( "Person", Some("Human"), vec![Field { name: "age".to_string(), value: Value::Text("old".to_string()), span: Span::new(0, 10), }], )), ], }; let mut collector = ErrorCollector::new(); validate_species_type_invariance(&file, &mut collector); assert!(collector.has_errors()); } #[test] fn test_type_invariance_range_compatible_with_number() { let file = File { declarations: vec![ Declaration::Species(make_species( "Human", vec![Field { name: "age".to_string(), value: Value::Number(0), span: Span::new(0, 10), }], )), Declaration::Template(make_template_decl( "Person", Some("Human"), vec![Field { name: "age".to_string(), value: Value::Range( Box::new(Value::Number(18)), Box::new(Value::Number(65)), ), span: Span::new(0, 10), }], )), ], }; let mut collector = ErrorCollector::new(); validate_species_type_invariance(&file, &mut collector); assert!(!collector.has_errors()); } #[test] fn test_type_invariance_no_species_base() { // Template without species_base should not trigger validation let file = File { declarations: vec![Declaration::Template(make_template_decl( "Person", None, vec![Field { name: "age".to_string(), value: Value::Text("old".to_string()), span: Span::new(0, 10), }], ))], }; let mut collector = ErrorCollector::new(); validate_species_type_invariance(&file, &mut collector); assert!(!collector.has_errors()); } // ===== Concept Comparison Validation Tests ===== use crate::resolve::types::ConceptRegistry; fn make_concept_comparison_file() -> File { File { declarations: vec![ Declaration::Concept(ConceptDecl { name: "Cup".to_string(), span: Span::new(0, 10), }), Declaration::SubConcept(SubConceptDecl { name: "Type".to_string(), parent_concept: "Cup".to_string(), kind: SubConceptKind::Enum { variants: vec![ "Small".to_string(), "Medium".to_string(), "Large".to_string(), ], }, span: Span::new(20, 60), }), ], } } #[test] fn test_concept_comparison_valid_exhaustive() { let mut file = make_concept_comparison_file(); file.declarations .push(Declaration::ConceptComparison(ConceptComparisonDecl { name: "Cup".to_string(), variants: vec![ VariantPattern { name: "Small".to_string(), conditions: vec![], span: Span::new(0, 10), }, VariantPattern { name: "Medium".to_string(), conditions: vec![], span: Span::new(0, 10), }, VariantPattern { name: "Large".to_string(), conditions: vec![], span: Span::new(0, 10), }, ], span: Span::new(0, 100), })); let registry = ConceptRegistry::from_file(&file); let mut collector = ErrorCollector::new(); validate_concept_comparison_patterns(&file, ®istry, &mut collector); assert!(!collector.has_errors()); } #[test] fn test_concept_comparison_not_exhaustive() { let mut file = make_concept_comparison_file(); file.declarations .push(Declaration::ConceptComparison(ConceptComparisonDecl { name: "Cup".to_string(), variants: vec![ VariantPattern { name: "Small".to_string(), conditions: vec![], span: Span::new(0, 10), }, // Missing Medium and Large ], span: Span::new(0, 100), })); let registry = ConceptRegistry::from_file(&file); let mut collector = ErrorCollector::new(); validate_concept_comparison_patterns(&file, ®istry, &mut collector); assert!(collector.has_errors()); } #[test] fn test_concept_comparison_unknown_variant() { let mut file = make_concept_comparison_file(); file.declarations .push(Declaration::ConceptComparison(ConceptComparisonDecl { name: "Cup".to_string(), variants: vec![ VariantPattern { name: "Small".to_string(), conditions: vec![], span: Span::new(0, 10), }, VariantPattern { name: "Medium".to_string(), conditions: vec![], span: Span::new(0, 10), }, VariantPattern { name: "Large".to_string(), conditions: vec![], span: Span::new(0, 10), }, VariantPattern { name: "Huge".to_string(), // Not a valid variant conditions: vec![], span: Span::new(0, 10), }, ], span: Span::new(0, 100), })); let registry = ConceptRegistry::from_file(&file); let mut collector = ErrorCollector::new(); validate_concept_comparison_patterns(&file, ®istry, &mut collector); assert!(collector.has_errors()); } #[test] fn test_concept_comparison_missing_concept() { let file = File { declarations: vec![Declaration::ConceptComparison(ConceptComparisonDecl { name: "NonExistent".to_string(), variants: vec![], span: Span::new(0, 100), })], }; let registry = ConceptRegistry::from_file(&file); let mut collector = ErrorCollector::new(); validate_concept_comparison_patterns(&file, ®istry, &mut collector); assert!(collector.has_errors()); } #[test] fn test_concept_comparison_with_field_conditions() { let mut file = make_concept_comparison_file(); file.declarations .push(Declaration::ConceptComparison(ConceptComparisonDecl { name: "Cup".to_string(), variants: vec![ VariantPattern { name: "Small".to_string(), conditions: vec![FieldCondition { field_name: "material".to_string(), condition: Condition::Is(vec!["Medium".to_string()]), // Valid variant ref span: Span::new(0, 10), }], span: Span::new(0, 10), }, VariantPattern { name: "Medium".to_string(), conditions: vec![FieldCondition { field_name: "material".to_string(), condition: Condition::Any, span: Span::new(0, 10), }], span: Span::new(0, 10), }, VariantPattern { name: "Large".to_string(), conditions: vec![], span: Span::new(0, 10), }, ], span: Span::new(0, 100), })); let registry = ConceptRegistry::from_file(&file); let mut collector = ErrorCollector::new(); validate_concept_comparison_patterns(&file, ®istry, &mut collector); assert!(!collector.has_errors()); } }