Renamed AST value types for v0.3 naming convention: - Value::Int -> Value::Number - Value::Float -> Value::Decimal - Value::String -> Value::Text - Value::Bool -> Value::Boolean - Expr::IntLit -> Expr::NumberLit - Expr::FloatLit -> Expr::DecimalLit - Expr::StringLit -> Expr::TextLit - Expr::BoolLit -> Expr::BooleanLit Updated all references across parser, resolver, validator, LSP, query engine, tests, and editor.
362 lines
11 KiB
Rust
362 lines
11 KiB
Rust
//! 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<Value = String> {
|
|
"[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<Value = Value> {
|
|
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::<bool>().prop_map(Value::Boolean),
|
|
"[a-zA-Z0-9 ]{0,30}".prop_map(Value::Text),
|
|
]
|
|
}
|
|
|
|
fn valid_field() -> impl Strategy<Value = Field> {
|
|
(valid_ident(), valid_value()).prop_map(|(name, value)| Field {
|
|
name,
|
|
value,
|
|
span: Span::new(0, 10),
|
|
})
|
|
}
|
|
|
|
fn valid_unique_fields() -> impl Strategy<Value = Vec<Field>> {
|
|
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<Value = Character> {
|
|
(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::<bool>(),
|
|
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))
|
|
);
|
|
}
|
|
}
|
|
}
|