Files
storybook/src/resolve/links_prop_tests.rs
Sienna Meridian Satterwhite 16deb5d237 release: Storybook v0.2.0 - Major syntax and features update
BREAKING CHANGES:
- Relationship syntax now requires blocks for all participants
- Removed self/other perspective blocks from relationships
- Replaced 'guard' keyword with 'if' for behavior tree decorators

Language Features:
- Add tree-sitter grammar with improved if/condition disambiguation
- Add comprehensive tutorial and reference documentation
- Add SBIR v0.2.0 binary format specification
- Add resource linking system for behaviors and schedules
- Add year-long schedule patterns (day, season, recurrence)
- Add behavior tree enhancements (named nodes, decorators)

Documentation:
- Complete tutorial series (9 chapters) with baker family examples
- Complete reference documentation for all language features
- SBIR v0.2.0 specification with binary format details
- Added locations and institutions documentation

Examples:
- Convert all examples to baker family scenario
- Add comprehensive working examples

Tooling:
- Zed extension with LSP integration
- Tree-sitter grammar for syntax highlighting
- Build scripts and development tools

Version Updates:
- Main package: 0.1.0 → 0.2.0
- Tree-sitter grammar: 0.1.0 → 0.2.0
- Zed extension: 0.1.0 → 0.2.0
- Storybook editor: 0.1.0 → 0.2.0
2026-02-13 21:52:03 +00:00

470 lines
13 KiB
Rust

//! 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,
fields: vec![],
span: Span::new(0, 10),
})
}
#[allow(dead_code)]
fn valid_participant_with_blocks(name: String) -> impl Strategy<Value = Participant> {
(prop::option::of(valid_ident()), valid_field_list()).prop_map(move |(role, fields)| {
Participant {
name: vec![name.clone()],
role,
fields,
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,
fields: p1_self,
span: Span::new(0, 10),
};
let p2_in_p1_rel = Participant {
name: vec![p2_name.clone()],
role: None,
fields: vec![],
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,
fields: p2_self,
span: Span::new(20, 30),
};
let p1_in_p2_rel = Participant {
name: vec![p1_name],
role: None,
fields: vec![],
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,
fields: vec![],
span: Span::new(0, 10),
},
Participant {
name: vec![p2.clone()],
role: None,
fields: vec![],
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,
fields: vec![],
span: Span::new(20, 30),
},
Participant {
name: vec![p1.clone()],
role: None,
fields: vec![],
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,
fields: vec![],
span: Span::new(0, 10),
},
Participant {
name: vec![p2.clone()],
role: None,
fields: vec![],
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,
fields: vec![],
span: Span::new(20, 30),
},
Participant {
name: vec![p2],
role: None,
fields: vec![],
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,
fields: fields1,
span: Span::new(0, 10),
};
let participant1_again = Participant {
name: vec![p1.clone()],
role: None,
fields: fields2,
span: Span::new(20, 30),
};
let rel1 = Relationship {
name: name.clone(),
participants: vec![
participant1,
Participant {
name: vec![p2.clone()],
role: None,
fields: vec![],
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,
fields: vec![],
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,
species: None,
fields: vec![],
template: None,
uses_behaviors: None,
uses_schedule: None,
span: Span::new(0, 10),
})),
valid_ident().prop_map(|name| Declaration::Template(Template {
name,
fields: vec![],
strict: false,
includes: vec![],
uses_behaviors: None,
uses_schedule: None,
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);
}
}