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
470 lines
13 KiB
Rust
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);
|
|
}
|
|
}
|