//! Property tests for AST to resolved type conversion use proptest::prelude::*; use crate::{ resolve::convert::{ convert_character, convert_file, }, syntax::ast::*, }; // ===== Generators ===== // Reserved keywords that cannot be used as field names const RESERVED_KEYWORDS: &[&str] = &[ "character", "template", "life_arc", "schedule", "behavior", "institution", "relationship", "location", "species", "enum", "use", "state", "on", "as", "remove", "append", "strict", "include", "from", "self", "other", "forall", "exists", "in", "where", "and", "or", "not", "is", "true", "false", ]; fn valid_ident() -> impl Strategy { "[a-zA-Z_][a-zA-Z0-9_]{0,15}" .prop_filter("not reserved", |s| !RESERVED_KEYWORDS.contains(&s.as_str())) } fn valid_value() -> impl Strategy { prop_oneof![ (-1000i64..1000).prop_map(Value::Number), (-1000.0..1000.0) .prop_filter("finite", |f: &f64| f.is_finite()) .prop_map(Value::Decimal), any::().prop_map(Value::Boolean), "[a-zA-Z0-9 ]{0,30}".prop_map(Value::Text), ] } fn valid_field() -> impl Strategy { (valid_ident(), valid_value()).prop_map(|(name, value)| Field { name, value, span: Span::new(0, 10), }) } fn valid_unique_fields() -> impl Strategy> { prop::collection::vec(valid_field(), 0..10).prop_map(|fields| { let mut unique_fields = Vec::new(); let mut seen_names = std::collections::HashSet::new(); for field in fields { if seen_names.insert(field.name.clone()) { unique_fields.push(field); } } unique_fields }) } fn valid_character() -> impl Strategy { (valid_ident(), valid_unique_fields()).prop_map(|(name, fields)| Character { name, species: None, fields, template: None, uses_behaviors: None, uses_schedule: None, span: Span::new(0, 100), }) } // ===== Property Tests ===== proptest! { #[test] fn test_character_name_preserved(character in valid_character()) { let original_name = character.name.clone(); let resolved = convert_character(&character).unwrap(); assert_eq!(resolved.name, original_name); } #[test] fn test_character_field_count_preserved(character in valid_character()) { let original_count = character.fields.len(); let resolved = convert_character(&character).unwrap(); let total_count = resolved.fields.len() + resolved.prose_blocks.len(); assert_eq!(total_count, original_count); } #[test] fn test_character_field_values_preserved(character in valid_character()) { let resolved = convert_character(&character).unwrap(); for field in &character.fields { match &field.value { | Value::ProseBlock(_) => { assert!(resolved.prose_blocks.contains_key(&field.name)); }, | value => { assert_eq!(resolved.fields.get(&field.name), Some(value)); }, } } } #[test] fn test_convert_file_preserves_declaration_count( characters in prop::collection::vec(valid_character(), 0..5) ) { // Ensure unique names across all declarations to avoid duplicate definition errors let mut seen_names = std::collections::HashSet::new(); let mut declarations = Vec::new(); for char in characters { if seen_names.insert(char.name.clone()) { declarations.push(Declaration::Character(char)); } } let file = File { declarations: declarations.clone() }; let resolved = convert_file(&file).unwrap(); // Should have same count (excluding Use declarations) assert_eq!(resolved.len(), declarations.len()); } #[test] fn test_duplicate_field_names_rejected( name in valid_ident(), field_name in valid_ident(), val1 in valid_value(), val2 in valid_value() ) { let character = Character { name: name.clone(), species: None, fields: vec![ Field { name: field_name.clone(), value: val1, span: Span::new(0, 10), }, Field { name: field_name, value: val2, span: Span::new(10, 20), }, ], template: None, uses_behaviors: None, uses_schedule: None, span: Span::new(0, 50), }; let result = convert_character(&character); assert!(result.is_err(), "Duplicate field names should be rejected"); } #[test] fn test_field_lookup_is_efficient(character in valid_character()) { let resolved = convert_character(&character).unwrap(); // All fields should be directly accessible in O(1) for field in &character.fields { if matches!(field.value, Value::ProseBlock(_)) { assert!( resolved.prose_blocks.contains_key(&field.name), "Prose block {} should be in map", field.name ); } else { assert!( resolved.fields.contains_key(&field.name), "Field {} should be in map", field.name ); } } } #[test] fn test_empty_character_converts(name in valid_ident()) { let character = Character { name: name.clone(), species: None, fields: vec![], template: None, uses_behaviors: None, uses_schedule: None, span: Span::new(0, 10), }; let resolved = convert_character(&character).unwrap(); assert_eq!(resolved.name, name); assert_eq!(resolved.fields.len(), 0); assert_eq!(resolved.prose_blocks.len(), 0); } #[test] fn test_conversion_is_deterministic(character in valid_character()) { let resolved1 = convert_character(&character).unwrap(); let resolved2 = convert_character(&character).unwrap(); assert_eq!(resolved1.name, resolved2.name); assert_eq!(resolved1.fields.len(), resolved2.fields.len()); assert_eq!(resolved1.prose_blocks.len(), resolved2.prose_blocks.len()); // All fields should match for (key, value) in &resolved1.fields { assert_eq!(resolved2.fields.get(key), Some(value)); } } #[test] fn test_file_with_use_declarations_skips_them( char_count in 1usize..5, use_count in 0usize..5 ) { let mut declarations = vec![]; // Add some use declarations for i in 0..use_count { declarations.push(Declaration::Use(UseDecl { path: vec![format!("module{}", i)], kind: UseKind::Wildcard, span: Span::new(0, 10), })); } // Add characters with unique names to avoid duplicate definition errors for i in 0..char_count { declarations.push(Declaration::Character(Character { name: format!("Char{}", i), species: None, fields: vec![], template: None, uses_behaviors: None, uses_schedule: None, span: Span::new(0, 100), })); } let file = File { declarations }; let resolved = convert_file(&file).unwrap(); // Should only have characters, not use declarations assert_eq!(resolved.len(), char_count); } } #[cfg(test)] mod edge_cases { use super::*; proptest! { #[test] fn test_all_value_types_convert( int_val in -1000i64..1000, float_val in -1000.0..1000.0, bool_val in any::(), string_val in "[a-zA-Z0-9 ]{1,30}" ) { let character = Character { name: "Test".to_string(), species: None, fields: vec![ Field { name: "int_field".to_string(), value: Value::Number(int_val), span: Span::new(0, 10), }, Field { name: "float_field".to_string(), value: Value::Decimal(float_val), span: Span::new(10, 20), }, Field { name: "bool_field".to_string(), value: Value::Boolean(bool_val), span: Span::new(20, 30), }, Field { name: "string_field".to_string(), value: Value::Text(string_val.clone()), span: Span::new(30, 40), }, ], template: None, uses_behaviors: None, uses_schedule: None, span: Span::new(0, 50), }; let resolved = convert_character(&character).unwrap(); assert_eq!(resolved.fields.get("int_field"), Some(&Value::Number(int_val))); assert_eq!(resolved.fields.get("float_field"), Some(&Value::Decimal(float_val))); assert_eq!(resolved.fields.get("bool_field"), Some(&Value::Boolean(bool_val))); assert_eq!(resolved.fields.get("string_field"), Some(&Value::Text(string_val))); } #[test] fn test_unicode_in_names_and_values( name in "[a-zA-Z_\u{0080}-\u{00FF}]{1,20}", field_name in "[a-zA-Z_\u{0080}-\u{00FF}]{1,20}".prop_filter("not reserved", |s| { !RESERVED_KEYWORDS.contains(&s.as_str()) }), string_val in "[a-zA-Z0-9 \u{0080}-\u{00FF}]{0,30}" ) { let character = Character { name: name.clone(), species: None, fields: vec![Field { name: field_name.clone(), value: Value::Text(string_val.clone()), span: Span::new(0, 10), }], template: None, uses_behaviors: None, uses_schedule: None, span: Span::new(0, 50), }; let resolved = convert_character(&character).unwrap(); assert_eq!(resolved.name, name); assert_eq!( resolved.fields.get(&field_name), Some(&Value::Text(string_val)) ); } } }