feat: implement storybook DSL with template composition and validation
Add complete domain-specific language for authoring narrative content for agent simulations. Features: - Complete parser using LALRPOP + logos lexer - Template composition (includes + multiple inheritance) - Strict mode validation for templates - Reserved keyword protection - Semantic validators (trait ranges, schedule overlaps, life arcs, behaviors) - Name resolution and cross-reference tracking - CLI tool (validate, inspect, query commands) - Query API with filtering - 260 comprehensive tests (unit, integration, property-based) Implementation phases: - Phase 1 (Parser): Complete - Phase 2 (Resolution + Validation): Complete - Phase 3 (Public API + CLI): Complete BREAKING CHANGE: Initial implementation
This commit is contained in:
371
src/resolve/convert_prop_tests.rs
Normal file
371
src/resolve/convert_prop_tests.rs
Normal file
@@ -0,0 +1,371 @@
|
||||
//! Property tests for AST to resolved type conversion
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::{
|
||||
resolve::convert::{
|
||||
convert_character,
|
||||
convert_enum,
|
||||
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::Int),
|
||||
(-1000.0..1000.0)
|
||||
.prop_filter("finite", |f: &f64| f.is_finite())
|
||||
.prop_map(Value::Float),
|
||||
any::<bool>().prop_map(Value::Bool),
|
||||
"[a-zA-Z0-9 ]{0,30}".prop_map(Value::String),
|
||||
]
|
||||
}
|
||||
|
||||
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,
|
||||
fields,
|
||||
template: None,
|
||||
|
||||
span: Span::new(0, 100),
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_enum() -> impl Strategy<Value = EnumDecl> {
|
||||
(valid_ident(), prop::collection::vec(valid_ident(), 1..10)).prop_map(|(name, variants)| {
|
||||
EnumDecl {
|
||||
name,
|
||||
variants,
|
||||
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_enum_name_preserved(enum_decl in valid_enum()) {
|
||||
let original_name = enum_decl.name.clone();
|
||||
let resolved = convert_enum(&enum_decl).unwrap();
|
||||
assert_eq!(resolved.name, original_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enum_variants_preserved(enum_decl in valid_enum()) {
|
||||
let resolved = convert_enum(&enum_decl).unwrap();
|
||||
assert_eq!(resolved.variants.len(), enum_decl.variants.len());
|
||||
for (i, variant) in enum_decl.variants.iter().enumerate() {
|
||||
assert_eq!(&resolved.variants[i], variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_file_preserves_declaration_count(
|
||||
characters in prop::collection::vec(valid_character(), 0..5),
|
||||
enums in prop::collection::vec(valid_enum(), 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));
|
||||
}
|
||||
}
|
||||
|
||||
for enum_decl in enums {
|
||||
if seen_names.insert(enum_decl.name.clone()) {
|
||||
declarations.push(Declaration::Enum(enum_decl));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
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(),
|
||||
fields: vec![],
|
||||
template: 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(
|
||||
characters in prop::collection::vec(valid_character(), 1..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
|
||||
let char_count = characters.len();
|
||||
declarations.extend(characters.into_iter().map(Declaration::Character));
|
||||
|
||||
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(),
|
||||
fields: vec![
|
||||
Field {
|
||||
name: "int_field".to_string(),
|
||||
value: Value::Int(int_val),
|
||||
span: Span::new(0, 10),
|
||||
},
|
||||
Field {
|
||||
name: "float_field".to_string(),
|
||||
value: Value::Float(float_val),
|
||||
span: Span::new(10, 20),
|
||||
},
|
||||
Field {
|
||||
name: "bool_field".to_string(),
|
||||
value: Value::Bool(bool_val),
|
||||
span: Span::new(20, 30),
|
||||
},
|
||||
Field {
|
||||
name: "string_field".to_string(),
|
||||
value: Value::String(string_val.clone()),
|
||||
span: Span::new(30, 40),
|
||||
},
|
||||
],
|
||||
template: None,
|
||||
span: Span::new(0, 50),
|
||||
};
|
||||
|
||||
let resolved = convert_character(&character).unwrap();
|
||||
assert_eq!(resolved.fields.get("int_field"), Some(&Value::Int(int_val)));
|
||||
assert_eq!(resolved.fields.get("float_field"), Some(&Value::Float(float_val)));
|
||||
assert_eq!(resolved.fields.get("bool_field"), Some(&Value::Bool(bool_val)));
|
||||
assert_eq!(resolved.fields.get("string_field"), Some(&Value::String(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(),
|
||||
fields: vec![Field {
|
||||
name: field_name.clone(),
|
||||
value: Value::String(string_val.clone()),
|
||||
span: Span::new(0, 10),
|
||||
}],
|
||||
template: 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::String(string_val))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user