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:
546
tests/cli_integration.rs
Normal file
546
tests/cli_integration.rs
Normal file
@@ -0,0 +1,546 @@
|
||||
//! Integration tests for the CLI tool
|
||||
//!
|
||||
//! These tests verify that the `sb` command-line tool works correctly
|
||||
//! by testing it against real project files.
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Helper to get the path to the compiled sb binary
|
||||
fn sb_binary() -> PathBuf {
|
||||
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
path.push("target");
|
||||
path.push("debug");
|
||||
path.push("sb");
|
||||
path
|
||||
}
|
||||
|
||||
/// Helper to create a temporary test project
|
||||
fn create_test_project() -> TempDir {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Create a valid character file
|
||||
fs::write(
|
||||
dir.path().join("test.sb"),
|
||||
r#"
|
||||
character Martha {
|
||||
age: 34
|
||||
trust: 0.8
|
||||
}
|
||||
|
||||
character David {
|
||||
age: 42
|
||||
health: 0.9
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
/// Helper to create a project with errors
|
||||
fn create_invalid_project() -> TempDir {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("errors.sb"),
|
||||
r#"
|
||||
character Martha {
|
||||
age: 200
|
||||
trust: 1.5
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_valid_project() {
|
||||
let project = create_test_project();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(project.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Validation should succeed for valid project. Stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("✓ Validation successful"));
|
||||
assert!(stdout.contains("Characters: 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_invalid_project() {
|
||||
let project = create_invalid_project();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(project.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Validation should fail for invalid project"
|
||||
);
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("Found 2 errors"));
|
||||
assert!(stderr.contains("Trait 'age' has value 200"));
|
||||
assert!(stderr.contains("Trait 'trust' has value 1.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_single_file() {
|
||||
let project = create_test_project();
|
||||
let file_path = project.path().join("test.sb");
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(&file_path)
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Validation should succeed for valid file"
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("✓ Validation successful"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_nonexistent_path() {
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg("/nonexistent/path/to/project")
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(!output.status.success(), "Should fail for nonexistent path");
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("Path does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inspect_character() {
|
||||
let project = create_test_project();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("inspect")
|
||||
.arg("Martha")
|
||||
.arg("--path")
|
||||
.arg(project.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb inspect");
|
||||
|
||||
assert!(output.status.success(), "Inspect should succeed");
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("Character: Martha"));
|
||||
assert!(stdout.contains("age"));
|
||||
assert!(stdout.contains("34"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inspect_nonexistent_entity() {
|
||||
let project = create_test_project();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("inspect")
|
||||
.arg("NonExistent")
|
||||
.arg("--path")
|
||||
.arg(project.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb inspect");
|
||||
|
||||
assert!(output.status.success(), "Inspect runs even if not found");
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_empty_project() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(!output.status.success(), "Should fail for empty project");
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("No .sb files found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_shows_multiple_errors() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("multi_error.sb"),
|
||||
r#"
|
||||
character Alice {
|
||||
age: 200
|
||||
trust: 1.5
|
||||
bond: -0.2
|
||||
}
|
||||
|
||||
character Bob {
|
||||
age: -10
|
||||
love: 3.0
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Should fail with validation errors"
|
||||
);
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should show all 5 errors (non-fail-fast)
|
||||
assert!(stderr.contains("Found 5 errors"));
|
||||
assert!(stderr.contains("age")); // age: 200
|
||||
assert!(stderr.contains("trust")); // trust: 1.5
|
||||
assert!(stderr.contains("bond")); // bond: -0.2
|
||||
assert!(stderr.contains("-10")); // age: -10
|
||||
assert!(stderr.contains("love")); // love: 3.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_file_name_resolution() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Create multiple files with characters
|
||||
fs::write(
|
||||
dir.path().join("file1.sb"),
|
||||
r#"
|
||||
character Martha {
|
||||
age: 34
|
||||
trust: 0.8
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("file2.sb"),
|
||||
r#"
|
||||
character David {
|
||||
age: 42
|
||||
health: 0.9
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Should successfully load and validate multiple files"
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("✓ Validation successful"));
|
||||
assert!(stdout.contains("Characters: 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_file_duplicate_detection() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Create two files with the same character name
|
||||
fs::write(
|
||||
dir.path().join("file1.sb"),
|
||||
r#"
|
||||
character Martha {
|
||||
age: 34
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("file2.sb"),
|
||||
r#"
|
||||
character Martha {
|
||||
age: 42
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Should fail with duplicate definition error"
|
||||
);
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("Duplicate definition of 'Martha'"));
|
||||
}
|
||||
|
||||
// ===== Template Composition Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_template_composition_with_includes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("templates.sb"),
|
||||
r#"
|
||||
// Base template
|
||||
template Being {
|
||||
alive: true
|
||||
}
|
||||
|
||||
// Template that includes Being
|
||||
template Human {
|
||||
include Being
|
||||
kind: "human"
|
||||
}
|
||||
|
||||
// Character with template composition
|
||||
character Martha from Human {
|
||||
firstName: "Martha"
|
||||
age: 34
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Template composition with includes should succeed"
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("✓ Validation successful!"));
|
||||
assert!(stdout.contains("Characters: 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_composition_multiple_templates() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("templates.sb"),
|
||||
r#"
|
||||
template Physical {
|
||||
height: 0
|
||||
weight: 0
|
||||
}
|
||||
|
||||
template Mental {
|
||||
iq: 0
|
||||
}
|
||||
|
||||
character David from Physical, Mental {
|
||||
height: 180
|
||||
weight: 75
|
||||
iq: 120
|
||||
firstName: "David"
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Multiple template inheritance should succeed"
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("✓ Validation successful!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strict_template_validation_success() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("strict.sb"),
|
||||
r#"
|
||||
template Person strict {
|
||||
age: 18..100
|
||||
firstName: ""
|
||||
}
|
||||
|
||||
character Martha from Person {
|
||||
age: 34
|
||||
firstName: "Martha"
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Strict template with concrete values should succeed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strict_template_validation_failure() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("strict_fail.sb"),
|
||||
r#"
|
||||
template Person strict {
|
||||
age: 18..100
|
||||
}
|
||||
|
||||
character Martha from Person {
|
||||
firstName: "Martha"
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Strict template with range value should fail"
|
||||
);
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("strict template"));
|
||||
assert!(stderr.contains("range value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_chained_includes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("chained.sb"),
|
||||
r#"
|
||||
template Being {
|
||||
alive: true
|
||||
}
|
||||
|
||||
template Human {
|
||||
include Being
|
||||
kind: "human"
|
||||
}
|
||||
|
||||
template Person strict {
|
||||
include Human
|
||||
age: 18..100
|
||||
}
|
||||
|
||||
character Martha from Person {
|
||||
age: 34
|
||||
firstName: "Martha"
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Chained template includes should succeed"
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("✓ Validation successful!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reserved_keyword_field_name_fails_at_parse() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("reserved.sb"),
|
||||
r#"
|
||||
character Martha {
|
||||
species: "human"
|
||||
age: 34
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = Command::new(sb_binary())
|
||||
.arg("validate")
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("Failed to execute sb validate");
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Field with reserved keyword name should fail at parse time"
|
||||
);
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Parser catches this as UnrecognizedToken before validation
|
||||
assert!(stderr.contains("Parse error") || stderr.contains("UnrecognizedToken"));
|
||||
}
|
||||
Reference in New Issue
Block a user