Files
storybook/src/resolve/convert_prop_tests.rs
Sienna Meridian Satterwhite 8e4bdd3942 refactor(ast): rename value types to Number/Decimal/Text/Boolean
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.
2026-02-14 14:03:21 +00:00

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))
);
}
}
}