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:
486
src/resolve/links_prop_tests.rs
Normal file
486
src/resolve/links_prop_tests.rs
Normal file
@@ -0,0 +1,486 @@
|
||||
//! Property tests for bidirectional relationship resolution
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::{
|
||||
resolve::links::resolve_relationships,
|
||||
syntax::ast::*,
|
||||
};
|
||||
|
||||
// ===== Generators =====
|
||||
|
||||
fn valid_ident() -> impl Strategy<Value = String> {
|
||||
"[a-zA-Z_][a-zA-Z0-9_]{0,15}".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_field() -> impl Strategy<Value = Field> {
|
||||
(valid_ident(), 0i64..100).prop_map(|(name, value)| Field {
|
||||
name,
|
||||
value: Value::Int(value),
|
||||
span: Span::new(0, 10),
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_field_list() -> impl Strategy<Value = Vec<Field>> {
|
||||
prop::collection::vec(valid_field(), 0..5)
|
||||
// Ensure unique field names
|
||||
.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_participant(name: String) -> impl Strategy<Value = Participant> {
|
||||
prop::option::of(valid_ident()).prop_map(move |role| Participant {
|
||||
name: vec![name.clone()],
|
||||
role,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(0, 10),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn valid_participant_with_blocks(name: String) -> impl Strategy<Value = Participant> {
|
||||
(
|
||||
prop::option::of(valid_ident()),
|
||||
prop::option::of(valid_field_list()),
|
||||
prop::option::of(valid_field_list()),
|
||||
)
|
||||
.prop_map(move |(role, self_block, other_block)| Participant {
|
||||
name: vec![name.clone()],
|
||||
role,
|
||||
self_block,
|
||||
other_block,
|
||||
span: Span::new(0, 10),
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_relationship() -> impl Strategy<Value = Relationship> {
|
||||
(
|
||||
valid_ident(),
|
||||
valid_ident(),
|
||||
valid_ident(),
|
||||
valid_field_list(),
|
||||
)
|
||||
.prop_flat_map(|(rel_name, person1, person2, fields)| {
|
||||
(
|
||||
Just(rel_name),
|
||||
valid_participant(person1.clone()),
|
||||
valid_participant(person2.clone()),
|
||||
Just(fields),
|
||||
)
|
||||
})
|
||||
.prop_map(|(name, p1, p2, fields)| Relationship {
|
||||
name,
|
||||
participants: vec![p1, p2],
|
||||
fields,
|
||||
span: Span::new(0, 10),
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_bidirectional_relationship() -> impl Strategy<Value = (Relationship, Relationship)> {
|
||||
(
|
||||
valid_ident(),
|
||||
valid_ident(),
|
||||
valid_ident(),
|
||||
valid_field_list(),
|
||||
valid_field_list(),
|
||||
)
|
||||
.prop_flat_map(|(rel_name, person1, person2, shared_fields, self_fields)| {
|
||||
let self_fields_clone = self_fields.clone();
|
||||
(
|
||||
Just(rel_name.clone()),
|
||||
Just(person1.clone()),
|
||||
Just(person2.clone()),
|
||||
Just(shared_fields.clone()),
|
||||
Just(self_fields),
|
||||
Just(self_fields_clone),
|
||||
)
|
||||
})
|
||||
.prop_map(|(name, p1_name, p2_name, shared, p1_self, p2_self)| {
|
||||
// First declaration from p1's perspective
|
||||
let p1 = Participant {
|
||||
name: vec![p1_name.clone()],
|
||||
role: None,
|
||||
self_block: Some(p1_self),
|
||||
other_block: None,
|
||||
span: Span::new(0, 10),
|
||||
};
|
||||
let p2_in_p1_rel = Participant {
|
||||
name: vec![p2_name.clone()],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(0, 10),
|
||||
};
|
||||
|
||||
let rel1 = Relationship {
|
||||
name: name.clone(),
|
||||
participants: vec![p1, p2_in_p1_rel],
|
||||
fields: shared.clone(),
|
||||
span: Span::new(0, 10),
|
||||
};
|
||||
|
||||
// Second declaration from p2's perspective
|
||||
let p2 = Participant {
|
||||
name: vec![p2_name],
|
||||
role: None,
|
||||
self_block: Some(p2_self),
|
||||
other_block: None,
|
||||
span: Span::new(20, 30),
|
||||
};
|
||||
let p1_in_p2_rel = Participant {
|
||||
name: vec![p1_name],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(20, 30),
|
||||
};
|
||||
|
||||
let rel2 = Relationship {
|
||||
name,
|
||||
participants: vec![p2, p1_in_p2_rel],
|
||||
fields: shared,
|
||||
span: Span::new(20, 30),
|
||||
};
|
||||
|
||||
(rel1, rel2)
|
||||
})
|
||||
}
|
||||
|
||||
// ===== Property Tests =====
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn test_single_relationship_always_resolves(rel in valid_relationship()) {
|
||||
let file = File {
|
||||
declarations: vec![Declaration::Relationship(rel)],
|
||||
};
|
||||
|
||||
let result = resolve_relationships(&file);
|
||||
assert!(result.is_ok(), "Single relationship should always resolve");
|
||||
|
||||
let resolved = result.unwrap();
|
||||
assert_eq!(resolved.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relationship_participant_count_preserved(rel in valid_relationship()) {
|
||||
let file = File {
|
||||
declarations: vec![Declaration::Relationship(rel.clone())],
|
||||
};
|
||||
|
||||
let resolved = resolve_relationships(&file).unwrap();
|
||||
assert_eq!(resolved[0].participants.len(), rel.participants.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relationship_fields_preserved(rel in valid_relationship()) {
|
||||
let file = File {
|
||||
declarations: vec![Declaration::Relationship(rel.clone())],
|
||||
};
|
||||
|
||||
let resolved = resolve_relationships(&file).unwrap();
|
||||
assert_eq!(resolved[0].fields.len(), rel.fields.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bidirectional_relationships_merge(
|
||||
(rel1, rel2) in valid_bidirectional_relationship()
|
||||
) {
|
||||
let file = File {
|
||||
declarations: vec![
|
||||
Declaration::Relationship(rel1),
|
||||
Declaration::Relationship(rel2),
|
||||
],
|
||||
};
|
||||
|
||||
let result = resolve_relationships(&file);
|
||||
assert!(result.is_ok(), "Bidirectional relationships should merge successfully");
|
||||
|
||||
let resolved = result.unwrap();
|
||||
// Should merge into single relationship
|
||||
assert_eq!(resolved.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_participant_order_doesnt_matter(
|
||||
name in valid_ident(),
|
||||
p1 in valid_ident(),
|
||||
p2 in valid_ident(),
|
||||
fields in valid_field_list()
|
||||
) {
|
||||
// Create two identical relationships with participants in different order
|
||||
let rel1 = Relationship {
|
||||
name: name.clone(),
|
||||
participants: vec![
|
||||
Participant {
|
||||
name: vec![p1.clone()],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(0, 10),
|
||||
},
|
||||
Participant {
|
||||
name: vec![p2.clone()],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(0, 10),
|
||||
},
|
||||
],
|
||||
fields: fields.clone(),
|
||||
span: Span::new(0, 10),
|
||||
};
|
||||
|
||||
let rel2 = Relationship {
|
||||
name: name.clone(),
|
||||
participants: vec![
|
||||
Participant {
|
||||
name: vec![p2.clone()],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(20, 30),
|
||||
},
|
||||
Participant {
|
||||
name: vec![p1.clone()],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(20, 30),
|
||||
},
|
||||
],
|
||||
fields,
|
||||
span: Span::new(20, 30),
|
||||
};
|
||||
|
||||
let file = File {
|
||||
declarations: vec![
|
||||
Declaration::Relationship(rel1),
|
||||
Declaration::Relationship(rel2),
|
||||
],
|
||||
};
|
||||
|
||||
let result = resolve_relationships(&file);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let resolved = result.unwrap();
|
||||
// Should recognize as same relationship despite order
|
||||
assert_eq!(resolved.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_relationships_stay_separate(
|
||||
name1 in valid_ident(),
|
||||
name2 in valid_ident(),
|
||||
p1 in valid_ident(),
|
||||
p2 in valid_ident()
|
||||
) {
|
||||
// Skip if names are the same
|
||||
if name1 == name2 {
|
||||
return Ok(());
|
||||
}
|
||||
let rel1 = Relationship {
|
||||
name: name1,
|
||||
participants: vec![
|
||||
Participant {
|
||||
name: vec![p1.clone()],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(0, 10),
|
||||
},
|
||||
Participant {
|
||||
name: vec![p2.clone()],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(0, 10),
|
||||
},
|
||||
],
|
||||
fields: vec![],
|
||||
span: Span::new(0, 10),
|
||||
};
|
||||
|
||||
let rel2 = Relationship {
|
||||
name: name2,
|
||||
participants: vec![
|
||||
Participant {
|
||||
name: vec![p1],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(20, 30),
|
||||
},
|
||||
Participant {
|
||||
name: vec![p2],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(20, 30),
|
||||
},
|
||||
],
|
||||
fields: vec![],
|
||||
span: Span::new(20, 30),
|
||||
};
|
||||
|
||||
let file = File {
|
||||
declarations: vec![
|
||||
Declaration::Relationship(rel1),
|
||||
Declaration::Relationship(rel2),
|
||||
],
|
||||
};
|
||||
|
||||
let result = resolve_relationships(&file);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let resolved = result.unwrap();
|
||||
// Different relationship names should stay separate
|
||||
assert_eq!(resolved.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_self_blocks_are_merged(
|
||||
name in valid_ident(),
|
||||
p1 in valid_ident(),
|
||||
p2 in valid_ident(),
|
||||
fields1 in valid_field_list(),
|
||||
fields2 in valid_field_list()
|
||||
) {
|
||||
let participant1 = Participant {
|
||||
name: vec![p1.clone()],
|
||||
role: None,
|
||||
self_block: Some(fields1),
|
||||
other_block: None,
|
||||
span: Span::new(0, 10),
|
||||
};
|
||||
|
||||
let participant1_again = Participant {
|
||||
name: vec![p1.clone()],
|
||||
role: None,
|
||||
self_block: Some(fields2),
|
||||
other_block: None,
|
||||
span: Span::new(20, 30),
|
||||
};
|
||||
|
||||
let rel1 = Relationship {
|
||||
name: name.clone(),
|
||||
participants: vec![
|
||||
participant1,
|
||||
Participant {
|
||||
name: vec![p2.clone()],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(0, 10),
|
||||
},
|
||||
],
|
||||
fields: vec![],
|
||||
span: Span::new(0, 10),
|
||||
};
|
||||
|
||||
let rel2 = Relationship {
|
||||
name: name.clone(),
|
||||
participants: vec![
|
||||
participant1_again,
|
||||
Participant {
|
||||
name: vec![p2],
|
||||
role: None,
|
||||
self_block: None,
|
||||
other_block: None,
|
||||
span: Span::new(20, 30),
|
||||
},
|
||||
],
|
||||
fields: vec![],
|
||||
span: Span::new(20, 30),
|
||||
};
|
||||
|
||||
let file = File {
|
||||
declarations: vec![
|
||||
Declaration::Relationship(rel1),
|
||||
Declaration::Relationship(rel2),
|
||||
],
|
||||
};
|
||||
|
||||
let result = resolve_relationships(&file);
|
||||
// Should succeed unless there are conflicting field values
|
||||
// (which is tested separately)
|
||||
if result.is_ok() {
|
||||
let resolved = result.unwrap();
|
||||
assert_eq!(resolved.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_file_gives_empty_result(
|
||||
decls in prop::collection::vec(
|
||||
prop_oneof![
|
||||
valid_ident().prop_map(|name| Declaration::Character(Character {
|
||||
name,
|
||||
fields: vec![],
|
||||
template: None,
|
||||
|
||||
span: Span::new(0, 10),
|
||||
})),
|
||||
valid_ident().prop_map(|name| Declaration::Template(Template {
|
||||
name,
|
||||
fields: vec![],
|
||||
strict: false,
|
||||
includes: vec![],
|
||||
span: Span::new(0, 10),
|
||||
})),
|
||||
],
|
||||
0..5
|
||||
)
|
||||
) {
|
||||
// File with no relationships
|
||||
let file = File { declarations: decls };
|
||||
|
||||
let result = resolve_relationships(&file);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let resolved = result.unwrap();
|
||||
assert_eq!(resolved.len(), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user