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:
303
src/resolve/prop_tests.rs
Normal file
303
src/resolve/prop_tests.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! Property-based tests for the resolution engine
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::{
|
||||
resolve::names::{
|
||||
DeclKind,
|
||||
NameTable,
|
||||
},
|
||||
syntax::ast::*,
|
||||
};
|
||||
|
||||
// ===== Generators =====
|
||||
|
||||
fn valid_ident() -> impl Strategy<Value = String> {
|
||||
"[a-zA-Z_][a-zA-Z0-9_]{0,20}".prop_filter("not a keyword", |s| {
|
||||
!matches!(
|
||||
s.as_str(),
|
||||
"use" |
|
||||
"character" |
|
||||
"template" |
|
||||
"life_arc" |
|
||||
"schedule" |
|
||||
"behavior" |
|
||||
"institution" |
|
||||
"relationship" |
|
||||
"location" |
|
||||
"species" |
|
||||
"enum" |
|
||||
"state" |
|
||||
"on" |
|
||||
"as" |
|
||||
"self" |
|
||||
"other" |
|
||||
"remove" |
|
||||
"append" |
|
||||
"forall" |
|
||||
"exists" |
|
||||
"in" |
|
||||
"where" |
|
||||
"and" |
|
||||
"or" |
|
||||
"not" |
|
||||
"is" |
|
||||
"true" |
|
||||
"false"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_character_decl() -> impl Strategy<Value = (String, Declaration)> {
|
||||
valid_ident().prop_map(|name| {
|
||||
let decl = Declaration::Character(Character {
|
||||
name: name.clone(),
|
||||
fields: vec![],
|
||||
template: None,
|
||||
|
||||
span: Span::new(0, 10),
|
||||
});
|
||||
(name, decl)
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_template_decl() -> impl Strategy<Value = (String, Declaration)> {
|
||||
valid_ident().prop_map(|name| {
|
||||
let decl = Declaration::Template(Template {
|
||||
name: name.clone(),
|
||||
fields: vec![],
|
||||
strict: false,
|
||||
includes: vec![],
|
||||
span: Span::new(0, 10),
|
||||
});
|
||||
(name, decl)
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_enum_decl() -> impl Strategy<Value = (String, Declaration)> {
|
||||
(valid_ident(), prop::collection::vec(valid_ident(), 1..5)).prop_map(|(name, variants)| {
|
||||
let decl = Declaration::Enum(EnumDecl {
|
||||
name: name.clone(),
|
||||
variants,
|
||||
span: Span::new(0, 10),
|
||||
});
|
||||
(name, decl)
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_use_single() -> impl Strategy<Value = Declaration> {
|
||||
(valid_ident(), valid_ident()).prop_map(|(module, name)| {
|
||||
Declaration::Use(UseDecl {
|
||||
path: vec![module, name],
|
||||
kind: UseKind::Single,
|
||||
span: Span::new(0, 10),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_use_grouped() -> impl Strategy<Value = Declaration> {
|
||||
(valid_ident(), prop::collection::vec(valid_ident(), 1..5)).prop_map(|(module, names)| {
|
||||
Declaration::Use(UseDecl {
|
||||
path: vec![module],
|
||||
kind: UseKind::Grouped(names),
|
||||
span: Span::new(0, 10),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_use_wildcard() -> impl Strategy<Value = Declaration> {
|
||||
valid_ident().prop_map(|module| {
|
||||
Declaration::Use(UseDecl {
|
||||
path: vec![module],
|
||||
kind: UseKind::Wildcard,
|
||||
span: Span::new(0, 10),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Property Tests =====
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn test_name_table_registers_all_declarations(
|
||||
chars in prop::collection::vec(valid_character_decl(), 0..10)
|
||||
) {
|
||||
let declarations: Vec<_> = chars.iter().map(|(_, decl)| decl.clone()).collect();
|
||||
let file = File { declarations };
|
||||
|
||||
let result = NameTable::from_file(&file);
|
||||
|
||||
if chars.is_empty() {
|
||||
// Empty file should succeed
|
||||
assert!(result.is_ok());
|
||||
} else {
|
||||
// Check if there are duplicates
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let has_duplicates = chars.iter().any(|(name, _)| !seen.insert(name));
|
||||
|
||||
if has_duplicates {
|
||||
// Should fail with duplicate error
|
||||
assert!(result.is_err());
|
||||
} else {
|
||||
// Should succeed and all names should be registered
|
||||
let table = result.unwrap();
|
||||
for (name, _) in &chars {
|
||||
assert!(table.lookup(std::slice::from_ref(name)).is_some(),
|
||||
"Name '{}' should be registered", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_detection_always_fails(
|
||||
name in valid_ident(),
|
||||
count in 2usize..5
|
||||
) {
|
||||
let declarations: Vec<_> = (0..count).map(|i| {
|
||||
Declaration::Character(Character {
|
||||
name: name.clone(),
|
||||
fields: vec![],
|
||||
template: None,
|
||||
|
||||
span: Span::new(i * 10, i * 10 + 10),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
let file = File { declarations };
|
||||
let result = NameTable::from_file(&file);
|
||||
|
||||
// Should always fail with duplicate error
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookup_is_case_sensitive(name in valid_ident()) {
|
||||
let file = File {
|
||||
declarations: vec![
|
||||
Declaration::Character(Character {
|
||||
name: name.clone(),
|
||||
fields: vec![],
|
||||
template: None,
|
||||
|
||||
span: Span::new(0, 10),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
let table = NameTable::from_file(&file).unwrap();
|
||||
|
||||
// Original name should be found
|
||||
assert!(table.lookup(std::slice::from_ref(&name)).is_some());
|
||||
|
||||
// Different case should not be found
|
||||
let uppercase = name.to_uppercase();
|
||||
if uppercase != name {
|
||||
assert!(table.lookup(&[uppercase]).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kind_filtering_works(
|
||||
chars in prop::collection::vec(valid_character_decl(), 0..5),
|
||||
templates in prop::collection::vec(valid_template_decl(), 0..5),
|
||||
enums in prop::collection::vec(valid_enum_decl(), 0..5)
|
||||
) {
|
||||
let mut declarations = vec![];
|
||||
declarations.extend(chars.iter().map(|(_, d)| d.clone()));
|
||||
declarations.extend(templates.iter().map(|(_, d)| d.clone()));
|
||||
declarations.extend(enums.iter().map(|(_, d)| d.clone()));
|
||||
|
||||
let file = File { declarations };
|
||||
|
||||
// Only proceed if no duplicates
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let has_duplicates = chars.iter().any(|(name, _)| !seen.insert(name))
|
||||
|| templates.iter().any(|(name, _)| !seen.insert(name))
|
||||
|| enums.iter().any(|(name, _)| !seen.insert(name));
|
||||
|
||||
if !has_duplicates {
|
||||
let table = NameTable::from_file(&file).unwrap();
|
||||
|
||||
let char_count = table.entries_of_kind(DeclKind::Character).count();
|
||||
let template_count = table.entries_of_kind(DeclKind::Template).count();
|
||||
let enum_count = table.entries_of_kind(DeclKind::Enum).count();
|
||||
|
||||
assert_eq!(char_count, chars.len());
|
||||
assert_eq!(template_count, templates.len());
|
||||
assert_eq!(enum_count, enums.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_use_statements_are_collected(
|
||||
uses in prop::collection::vec(
|
||||
prop_oneof![
|
||||
valid_use_single(),
|
||||
valid_use_grouped(),
|
||||
valid_use_wildcard(),
|
||||
],
|
||||
0..10
|
||||
)
|
||||
) {
|
||||
let file = File { declarations: uses.clone() };
|
||||
let table = NameTable::from_file(&file).unwrap();
|
||||
|
||||
assert_eq!(table.imports().len(), uses.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_matching_finds_close_names(
|
||||
name in valid_ident().prop_filter("long enough", |s| s.len() > 3)
|
||||
) {
|
||||
let file = File {
|
||||
declarations: vec![
|
||||
Declaration::Character(Character {
|
||||
name: name.clone(),
|
||||
fields: vec![],
|
||||
template: None,
|
||||
|
||||
span: Span::new(0, 10),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
let table = NameTable::from_file(&file).unwrap();
|
||||
|
||||
// Create a typo by swapping two adjacent characters
|
||||
if name.len() >= 2 {
|
||||
let mut chars: Vec<char> = name.chars().collect();
|
||||
chars.swap(0, 1);
|
||||
let typo: String = chars.into_iter().collect();
|
||||
|
||||
// Should suggest the original name
|
||||
if let Some(suggestion) = table.find_suggestion(&typo) {
|
||||
assert_eq!(suggestion, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_declarations_and_imports(
|
||||
chars in prop::collection::vec(valid_character_decl(), 1..5),
|
||||
uses in prop::collection::vec(valid_use_single(), 0..3)
|
||||
) {
|
||||
let mut declarations = uses;
|
||||
declarations.extend(chars.iter().map(|(_, d)| d.clone()));
|
||||
|
||||
let file = File { declarations };
|
||||
|
||||
// Check for duplicates
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let has_duplicates = chars.iter().any(|(name, _)| !seen.insert(name));
|
||||
|
||||
if !has_duplicates {
|
||||
let table = NameTable::from_file(&file).unwrap();
|
||||
|
||||
// All characters should be registered
|
||||
for (name, _) in &chars {
|
||||
assert!(table.lookup(std::slice::from_ref(name)).is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user