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:
506
src/error_showcase_tests.rs
Normal file
506
src/error_showcase_tests.rs
Normal file
@@ -0,0 +1,506 @@
|
||||
//! Functional tests that showcase every error type with its helpful message
|
||||
//!
|
||||
//! These tests are designed to:
|
||||
//! 1. Ensure every error type can be triggered
|
||||
//! 2. Document what causes each error
|
||||
//! 3. Verify that error messages are helpful and clear
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{
|
||||
resolve::{
|
||||
convert::convert_file,
|
||||
names::NameTable,
|
||||
validate::{
|
||||
validate_behavior_tree_actions,
|
||||
validate_life_arc_transitions,
|
||||
validate_relationship_bonds,
|
||||
validate_schedule_overlaps,
|
||||
validate_trait_ranges,
|
||||
},
|
||||
ErrorCollector,
|
||||
ResolveError,
|
||||
},
|
||||
syntax::{
|
||||
ast::*,
|
||||
lexer::Lexer,
|
||||
FileParser,
|
||||
},
|
||||
Project,
|
||||
};
|
||||
|
||||
// ===== Parse Errors =====
|
||||
|
||||
#[test]
|
||||
fn test_unexpected_token_error() {
|
||||
let source = r#"
|
||||
character Martha {
|
||||
age 34
|
||||
}
|
||||
"#;
|
||||
// Missing colon after 'age' - should trigger UnexpectedToken
|
||||
|
||||
let lexer = Lexer::new(source);
|
||||
let result = FileParser::new().parse(lexer);
|
||||
|
||||
assert!(result.is_err(), "Should fail with unexpected token");
|
||||
println!("\n=== UnexpectedToken Error ===");
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unexpected_eof_error() {
|
||||
let source = r#"
|
||||
character Martha {
|
||||
age: 34
|
||||
"#;
|
||||
// Missing closing brace - should trigger UnexpectedEof
|
||||
|
||||
let lexer = Lexer::new(source);
|
||||
let result = FileParser::new().parse(lexer);
|
||||
|
||||
assert!(result.is_err(), "Should fail with unexpected EOF");
|
||||
println!("\n=== UnexpectedEof Error ===");
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_token_error() {
|
||||
let source = "character Martha { age: @#$ }";
|
||||
// Invalid character sequence - should trigger InvalidToken
|
||||
|
||||
let lexer = Lexer::new(source);
|
||||
let result = FileParser::new().parse(lexer);
|
||||
|
||||
assert!(result.is_err(), "Should fail with invalid token");
|
||||
println!("\n=== InvalidToken Error ===");
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unclosed_prose_block_error() {
|
||||
let source = r#"
|
||||
character Martha {
|
||||
backstory: ---backstory
|
||||
This is Martha's backstory.
|
||||
It goes on and on...
|
||||
But it never closes!
|
||||
}
|
||||
"#;
|
||||
// Prose block never closed - should trigger UnclosedProseBlock
|
||||
|
||||
let lexer = Lexer::new(source);
|
||||
let result = FileParser::new().parse(lexer);
|
||||
|
||||
assert!(result.is_err(), "Should fail with unclosed prose block");
|
||||
println!("\n=== UnclosedProseBlock Error ===");
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Resolution Errors =====
|
||||
|
||||
#[test]
|
||||
fn test_name_not_found_error() {
|
||||
let file = File {
|
||||
declarations: vec![],
|
||||
};
|
||||
|
||||
let table = NameTable::from_file(&file).unwrap();
|
||||
let result = table.lookup(&["NonExistent".to_string()]);
|
||||
|
||||
assert!(result.is_none(), "Should not find non-existent name");
|
||||
|
||||
// Create the actual error
|
||||
let error = ResolveError::NameNotFound {
|
||||
name: "NonExistent".to_string(),
|
||||
suggestion: table.find_suggestion("NonExistent"),
|
||||
};
|
||||
|
||||
println!("\n=== NameNotFound Error ===");
|
||||
println!("{:?}", error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_definition_error() {
|
||||
let file = File {
|
||||
declarations: vec![
|
||||
Declaration::Character(Character {
|
||||
name: "Martha".to_string(),
|
||||
fields: vec![],
|
||||
template: None,
|
||||
span: Span::new(0, 10),
|
||||
}),
|
||||
Declaration::Character(Character {
|
||||
name: "Martha".to_string(),
|
||||
fields: vec![],
|
||||
template: None,
|
||||
span: Span::new(20, 30),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
let result = NameTable::from_file(&file);
|
||||
|
||||
assert!(result.is_err(), "Should fail with duplicate definition");
|
||||
println!("\n=== DuplicateDefinition Error ===");
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_dependency_error() {
|
||||
// Manually create a circular dependency error for demonstration
|
||||
let error = ResolveError::CircularDependency {
|
||||
cycle: "Template A -> Template B -> Template A".to_string(),
|
||||
};
|
||||
|
||||
println!("\n=== CircularDependency Error ===");
|
||||
println!("{:?}", error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_field_access_error() {
|
||||
let error = ResolveError::InvalidFieldAccess {
|
||||
message: "Field 'nonexistent' does not exist on character 'Martha'".to_string(),
|
||||
};
|
||||
|
||||
println!("\n=== InvalidFieldAccess Error ===");
|
||||
println!("{:?}", error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_mismatch_error() {
|
||||
let error = ResolveError::TypeMismatch {
|
||||
message: "Expected number for field 'age', but got string \"thirty\"".to_string(),
|
||||
};
|
||||
|
||||
println!("\n=== TypeMismatch Error ===");
|
||||
println!("{:?}", error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_error_generic() {
|
||||
let error = ResolveError::ValidationError {
|
||||
message: "Cannot append field 'age': field already exists".to_string(),
|
||||
help: Some("The 'append' operation is used to add new fields. Use 'set' to update existing fields.".to_string()),
|
||||
};
|
||||
|
||||
println!("\n=== ValidationError Error ===");
|
||||
println!("{:?}", error);
|
||||
}
|
||||
|
||||
// ===== Validation Errors =====
|
||||
|
||||
#[test]
|
||||
fn test_unknown_life_arc_state_error() {
|
||||
let life_arc = LifeArc {
|
||||
name: "Growth".to_string(),
|
||||
states: vec![
|
||||
ArcState {
|
||||
name: "child".to_string(),
|
||||
transitions: vec![Transition {
|
||||
to: "adult".to_string(), // 'adult' exists
|
||||
condition: Expr::BoolLit(true),
|
||||
span: Span::new(0, 10),
|
||||
}],
|
||||
span: Span::new(0, 50),
|
||||
},
|
||||
ArcState {
|
||||
name: "adult".to_string(),
|
||||
transitions: vec![Transition {
|
||||
to: "senior".to_string(), // 'senior' doesn't exist!
|
||||
condition: Expr::BoolLit(true),
|
||||
span: Span::new(50, 60),
|
||||
}],
|
||||
span: Span::new(50, 100),
|
||||
},
|
||||
],
|
||||
span: Span::new(0, 100),
|
||||
};
|
||||
|
||||
let mut collector = ErrorCollector::new();
|
||||
validate_life_arc_transitions(&life_arc, &mut collector);
|
||||
|
||||
assert!(collector.has_errors(), "Should fail with unknown state");
|
||||
println!("\n=== UnknownLifeArcState Error ===");
|
||||
if collector.has_errors() {
|
||||
let result = collector.into_result(());
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trait_out_of_range_error_bond() {
|
||||
let fields = vec![Field {
|
||||
name: "bond".to_string(),
|
||||
value: Value::Float(1.5), // Out of range!
|
||||
span: Span::new(0, 10),
|
||||
}];
|
||||
|
||||
let mut collector = ErrorCollector::new();
|
||||
validate_trait_ranges(&fields, &mut collector);
|
||||
|
||||
assert!(
|
||||
collector.has_errors(),
|
||||
"Should fail with out of range trait"
|
||||
);
|
||||
println!("\n=== TraitOutOfRange Error (bond too high) ===");
|
||||
if collector.has_errors() {
|
||||
let result = collector.into_result(());
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trait_out_of_range_error_age() {
|
||||
let fields = vec![Field {
|
||||
name: "age".to_string(),
|
||||
value: Value::Int(200), // Out of range!
|
||||
span: Span::new(0, 10),
|
||||
}];
|
||||
|
||||
let mut collector = ErrorCollector::new();
|
||||
validate_trait_ranges(&fields, &mut collector);
|
||||
|
||||
assert!(collector.has_errors(), "Should fail with out of range age");
|
||||
println!("\n=== TraitOutOfRange Error (age too high) ===");
|
||||
if collector.has_errors() {
|
||||
let result = collector.into_result(());
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trait_out_of_range_negative() {
|
||||
let fields = vec![Field {
|
||||
name: "trust".to_string(),
|
||||
value: Value::Float(-0.2), // Negative!
|
||||
span: Span::new(0, 10),
|
||||
}];
|
||||
|
||||
let mut collector = ErrorCollector::new();
|
||||
validate_trait_ranges(&fields, &mut collector);
|
||||
|
||||
assert!(collector.has_errors(), "Should fail with negative trait");
|
||||
println!("\n=== TraitOutOfRange Error (negative value) ===");
|
||||
if collector.has_errors() {
|
||||
let result = collector.into_result(());
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schedule_overlap_error() {
|
||||
let schedule = Schedule {
|
||||
name: "DailyRoutine".to_string(),
|
||||
blocks: vec![
|
||||
ScheduleBlock {
|
||||
activity: "work".to_string(),
|
||||
start: Time {
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
},
|
||||
end: Time {
|
||||
hour: 12,
|
||||
minute: 30,
|
||||
second: 0,
|
||||
},
|
||||
span: Span::new(0, 50),
|
||||
},
|
||||
ScheduleBlock {
|
||||
activity: "lunch".to_string(),
|
||||
start: Time {
|
||||
hour: 12,
|
||||
minute: 0, // Overlaps with work!
|
||||
second: 0,
|
||||
},
|
||||
end: Time {
|
||||
hour: 13,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
},
|
||||
span: Span::new(50, 100),
|
||||
},
|
||||
],
|
||||
span: Span::new(0, 100),
|
||||
};
|
||||
|
||||
let mut collector = ErrorCollector::new();
|
||||
validate_schedule_overlaps(&schedule, &mut collector);
|
||||
|
||||
assert!(collector.has_errors(), "Should fail with schedule overlap");
|
||||
println!("\n=== ScheduleOverlap Error ===");
|
||||
if collector.has_errors() {
|
||||
let result = collector.into_result(());
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_behavior_action_error() {
|
||||
let tree = Behavior {
|
||||
name: "WorkDay".to_string(),
|
||||
root: BehaviorNode::Action("unknown_action".to_string(), vec![]),
|
||||
span: Span::new(0, 50),
|
||||
};
|
||||
|
||||
// Create a registry with some known actions (but not "unknown_action")
|
||||
let mut action_registry = HashSet::new();
|
||||
action_registry.insert("walk".to_string());
|
||||
action_registry.insert("work".to_string());
|
||||
action_registry.insert("eat".to_string());
|
||||
|
||||
let mut collector = ErrorCollector::new();
|
||||
validate_behavior_tree_actions(&tree, &action_registry, &mut collector);
|
||||
|
||||
assert!(collector.has_errors(), "Should fail with unknown action");
|
||||
println!("\n=== UnknownBehaviorAction Error ===");
|
||||
if collector.has_errors() {
|
||||
let result = collector.into_result(());
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relationship_bond_out_of_range() {
|
||||
let relationship = Relationship {
|
||||
name: "Test".to_string(),
|
||||
participants: vec![],
|
||||
fields: vec![Field {
|
||||
name: "bond".to_string(),
|
||||
value: Value::Float(2.5), // Way out of range!
|
||||
span: Span::new(0, 10),
|
||||
}],
|
||||
span: Span::new(0, 50),
|
||||
};
|
||||
|
||||
let mut collector = ErrorCollector::new();
|
||||
validate_relationship_bonds(&[relationship], &mut collector);
|
||||
|
||||
assert!(collector.has_errors(), "Should fail with bond out of range");
|
||||
println!("\n=== Relationship Bond Out of Range ===");
|
||||
if collector.has_errors() {
|
||||
let result = collector.into_result(());
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_field_in_convert() {
|
||||
let character = Character {
|
||||
name: "Martha".to_string(),
|
||||
fields: vec![
|
||||
Field {
|
||||
name: "age".to_string(),
|
||||
value: Value::Int(34),
|
||||
span: Span::new(0, 10),
|
||||
},
|
||||
Field {
|
||||
name: "age".to_string(), // Duplicate!
|
||||
value: Value::Int(35),
|
||||
span: Span::new(10, 20),
|
||||
},
|
||||
],
|
||||
template: None,
|
||||
span: Span::new(0, 50),
|
||||
};
|
||||
|
||||
let file = File {
|
||||
declarations: vec![Declaration::Character(character)],
|
||||
};
|
||||
|
||||
let result = convert_file(&file);
|
||||
|
||||
assert!(result.is_err(), "Should fail with duplicate field");
|
||||
println!("\n=== Duplicate Field Error (in conversion) ===");
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Project Errors =====
|
||||
|
||||
#[test]
|
||||
fn test_invalid_project_structure_no_directory() {
|
||||
let result = Project::load("/nonexistent/path/to/project");
|
||||
|
||||
assert!(result.is_err(), "Should fail with invalid structure");
|
||||
println!("\n=== InvalidStructure Error (directory doesn't exist) ===");
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_project_structure_not_directory() {
|
||||
// Try to load a file as if it were a directory
|
||||
let result = Project::load("Cargo.toml");
|
||||
|
||||
assert!(result.is_err(), "Should fail - file not directory");
|
||||
println!("\n=== InvalidStructure Error (not a directory) ===");
|
||||
if let Err(e) = result {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Showcase All Errors =====
|
||||
|
||||
#[test]
|
||||
#[ignore] // Run with: cargo test error_showcase -- --ignored --nocapture
|
||||
fn error_showcase_all() {
|
||||
println!("\n\n");
|
||||
println!("╔════════════════════════════════════════════════════════════════╗");
|
||||
println!("║ STORYBOOK ERROR MESSAGES SHOWCASE ║");
|
||||
println!("║ Every error type with helpful hints for users ║");
|
||||
println!("╚════════════════════════════════════════════════════════════════╝");
|
||||
|
||||
test_unexpected_token_error();
|
||||
test_unexpected_eof_error();
|
||||
test_invalid_token_error();
|
||||
test_unclosed_prose_block_error();
|
||||
test_name_not_found_error();
|
||||
test_duplicate_definition_error();
|
||||
test_circular_dependency_error();
|
||||
test_invalid_field_access_error();
|
||||
test_type_mismatch_error();
|
||||
test_validation_error_generic();
|
||||
test_unknown_life_arc_state_error();
|
||||
test_trait_out_of_range_error_bond();
|
||||
test_trait_out_of_range_error_age();
|
||||
test_trait_out_of_range_negative();
|
||||
test_schedule_overlap_error();
|
||||
test_unknown_behavior_action_error();
|
||||
test_relationship_bond_out_of_range();
|
||||
test_duplicate_field_in_convert();
|
||||
test_invalid_project_structure_no_directory();
|
||||
test_invalid_project_structure_not_directory();
|
||||
|
||||
println!("\n\n");
|
||||
println!("╔════════════════════════════════════════════════════════════════╗");
|
||||
println!("║ SHOWCASE COMPLETE ║");
|
||||
println!("╚════════════════════════════════════════════════════════════════╝");
|
||||
}
|
||||
Reference in New Issue
Block a user