#[cfg(test)]
mod tests {
use etdl_parser::ast::EtlDocument;
use std::collections::BTreeMap;
use std::path::PathBuf;
fn fixture_path(filename: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(filename)
}
#[test]
fn test_parse_full_worked_example() {
let path = fixture_path("order-fulfillment.etdl");
let doc = etdl_parser::parse_document_from_file(&path).unwrap();
assert_eq!(doc.etdl, "1.0.0");
assert_eq!(doc.info.title, "Order Fulfillment Event Tree");
assert_eq!(doc.info.domain, "FulfillmentContext");
assert_eq!(doc.asyncapi_imports.len(), 2);
assert!(doc.asyncapi_imports.contains_key("orders_api"));
assert!(doc.asyncapi_imports.contains_key("payment_api"));
assert_eq!(doc.event_trees.len(), 1);
let tree = &doc.event_trees["OrderFulfillment"];
assert_eq!(tree.initiating_event.id, "OrderPlacedTrigger");
assert_eq!(tree.initiating_event.message.alias, "orders_api");
assert_eq!(
tree.initiating_event.message.pointer,
"#/components/messages/OrderPlaced"
);
assert_eq!(tree.nodes.len(), 5);
let barrier = match &tree.nodes["InventoryCheckBarrier"] {
etdl_parser::ast::Node::Barrier(b) => b,
_ => panic!("expected barrier"),
};
assert_eq!(barrier.branches.len(), 2);
assert_eq!(barrier.branches[0].outcome, "SUCCESS");
assert!(matches!(
&barrier.branches[0].condition,
etdl_parser::ast::Condition::Comparison(_)
));
assert!(matches!(
&barrier.branches[1].condition,
etdl_parser::ast::Condition::Default
));
let op = match &tree.nodes["ProcessPaymentOperation"] {
etdl_parser::ast::Node::Operation(op) => op,
_ => panic!("expected operation"),
};
assert_eq!(op.handler, "stripe_charge_handler");
assert!(op.retry_policy.is_some());
let retry = op.retry_policy.as_ref().unwrap();
assert_eq!(retry.max_attempts, 3);
assert_eq!(op.timeout_ms, Some(5000));
assert!(op.on_failure.is_some());
assert!(op.on_failure_probability_source.is_some());
let fault_trees = doc.fault_trees.as_ref().unwrap();
assert_eq!(fault_trees.len(), 1);
let ft = &fault_trees["PaymentGatewayFailure"];
assert_eq!(ft.top_event.id, "PaymentCaptureFailed");
let gates = ft.gates.as_ref().unwrap();
assert_eq!(gates.len(), 1);
let gate = &gates["GatewayUnavailableOrRejected"];
assert!(matches!(gate.gate_type, etdl_parser::ast::GateType::Or));
assert_eq!(gate.inputs.len(), 2);
assert_eq!(ft.basic_events.len(), 2);
let be1 = &ft.basic_events["GatewayUnreachable"];
assert_eq!(be1.probability, Some(0.008));
let be2 = &ft.basic_events["ChargeRejected"];
assert_eq!(be2.failure_rate, Some(0.00021));
assert_eq!(be2.mission_time, Some(24.0));
}
#[test]
fn test_asyncapi_loading() {
let base_dir = fixture_path("");
let doc_path = fixture_path("order-fulfillment.etdl");
let doc = etdl_parser::parse_document_from_file(&doc_path).unwrap();
let mut registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
for (alias, location) in &doc.asyncapi_imports {
registry.load(alias, location, &base_dir).unwrap();
}
let message_ref = &doc.event_trees["OrderFulfillment"].initiating_event.message;
let resolved = registry.resolve(message_ref).unwrap();
assert!(resolved.get("payload").is_some());
let payload = &resolved["payload"];
assert_eq!(payload["type"], "object");
}
#[test]
fn test_compile_worked_example() {
let base_dir = fixture_path("");
let doc_path = fixture_path("order-fulfillment.etdl");
let doc = etdl_parser::parse_document_from_file(&doc_path).unwrap();
let registry = etdl_parser::load_asyncapi_imports(&doc, &base_dir).unwrap();
let compiler = etdl_compiler::Compiler::new();
let result = compiler.compile(&doc, ®istry);
assert!(
result.rust_output.is_some(),
"Compilation should produce Rust code. Diagnostics: {:?}",
result.diagnostics
);
let rust_code = result.rust_output.unwrap();
println!("=== GENERATED RUST CODE ===\n{}\n=== END ===", rust_code);
assert!(rust_code.contains("AUTOGENERATED BY ETDL COMPILER"));
assert!(rust_code.contains("handle_order_placed_trigger"));
assert!(rust_code.contains("BranchMonitor::new"));
assert!(rust_code.contains("stripe_charge_handler"));
assert!(rust_code.contains("record_branch"));
assert!(
rust_code.contains("InventoryCheckBarrier"),
"Generated code should reference InventoryCheckBarrier"
);
let errors: Vec<_> = result.diagnostics.iter().filter(|d| d.is_error()).collect();
assert!(
errors.is_empty(),
"Compilation should have no errors, got: {:?}",
errors
);
}
#[test]
fn test_ecel_parsing() {
use etdl_parser::ecel::*;
let cond = parse_condition("message.payload.items[*].qty > 0").unwrap();
match cond {
Condition::Comparison(cmp) => {
assert_eq!(cmp.op, Comparator::Gt);
match &cmp.left {
Operand::Path(path) => {
assert_eq!(path.segments.len(), 5);
}
_ => panic!("expected path"),
}
match &cmp.right {
Operand::Literal(Literal::Number(n)) => assert_eq!(*n, 0.0),
_ => panic!("expected number literal"),
}
}
_ => panic!("expected comparison"),
}
}
#[test]
fn test_fault_tree_probability() {
let _base_dir = fixture_path("");
let doc_path = fixture_path("order-fulfillment.etdl");
let doc = etdl_parser::parse_document_from_file(&doc_path).unwrap();
let mut diagnostics = Vec::new();
let probs = etdl_compiler::fault_tree::resolve_fault_trees(&doc, &mut diagnostics);
assert!(
diagnostics.iter().all(|d| !d.is_error()),
"Fault tree resolution should have no errors: {:?}",
diagnostics
);
let payment_failure_prob = probs.get("PaymentGatewayFailure").unwrap();
let charge_rejected_prob = 1.0 - (-0.00021_f64 * 24.0).exp();
let expected = 1.0 - (1.0 - 0.008) * (1.0 - charge_rejected_prob);
assert!(
(payment_failure_prob - expected).abs() < 0.00001,
"Expected ~{:.6}, got {:.6}",
expected,
payment_failure_prob
);
}
#[test]
fn test_validation_detects_errors() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "Bad Tree"
version: "1.0.0"
domain: "Test"
asyncapi_imports: {}
eventTrees:
Bad:
initiatingEvent:
id: Test
message: "bad#/foo"
next: MissingNode
nodes: {}
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diagnostics = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diagnostics);
let errors: Vec<_> = diagnostics.iter().filter(|d| d.is_error()).collect();
assert!(!errors.is_empty(), "Should detect errors in bad document");
assert!(
errors
.iter()
.any(|d| d.code == "E-103" || d.code == "V-101"),
"Should have reference or structural errors, got: {:?}",
errors
);
}
#[test]
fn test_advanced_fault_tree_features() {
use etdl_parser::ast::{BasicEventType, GateType};
let path = fixture_path("advanced-fault-tree.etdl");
let doc = etdl_parser::parse_document_from_file(&path).unwrap();
let ft = &doc.fault_trees.as_ref().unwrap()["AdvancedTree"];
let gates = ft.gates.as_ref().unwrap();
assert_eq!(gates["InhibitedFailure"].gate_type, GateType::Inhibit);
assert_eq!(
gates["InhibitedFailure"].inhibit_condition.as_deref(),
Some("Control loop armed")
);
assert_eq!(gates["PriorityFailure"].gate_type, GateType::PriorityAnd);
assert_eq!(
ft.basic_events["ExternalCause"].event_type,
Some(BasicEventType::House)
);
assert_eq!(
ft.basic_events["UnderAnalyzed"].event_type,
Some(BasicEventType::Undeveloped)
);
assert_eq!(ft.basic_events["EventA"].event_type, None);
let transfers = ft.transfers.as_ref().unwrap();
assert_eq!(
transfers["SubAnalysis"].target,
"#/faultTrees/OtherTree/topEvent"
);
assert_eq!(
transfers["SubAnalysis"].label.as_deref(),
Some("See sub-analysis")
);
let mut diags = Vec::new();
let probs = etdl_compiler::fault_tree::resolve_fault_trees(&doc, &mut diags);
let advanced = probs["AdvancedTree"];
let inhibit = 0.1 * 0.5;
let priority = (0.2 * 0.3) / 2.0;
let expected = 1.0 - (1.0 - inhibit) * (1.0 - priority) * (1.0 - 0.01) * (1.0 - 0.02);
assert!(
(advanced - expected).abs() < 0.00001,
"Expected ~{:.6}, got {:.6}",
expected,
advanced
);
let errors: Vec<_> = diags.iter().filter(|d| d.is_error()).collect();
assert!(
errors.is_empty(),
"advanced fixture should have no errors: {:?}",
errors
);
}
#[test]
fn test_inhibit_gate_requires_condition() {
use etdl_parser::ast::GateType;
let yaml = r#"
etdl: "1.0.0"
info:
title: "Inhibit Missing Condition"
version: "1.0.0"
domain: "Test"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: Trig
message: "api#/m"
next: C
nodes:
C:
type: consequence
operation: terminate
faultTrees:
F:
topEvent:
id: Top
description: "top"
rootCause: G
gates:
G:
type: INHIBIT
inputs: [A, B]
basicEvents:
A:
description: "a"
probability: 0.1
B:
description: "b"
probability: 0.2
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
diags.iter().any(|d| d.code == "V-505"),
"INHIBIT without inhibitCondition should produce V-505, got {:?}",
diags
);
let ft = &doc.fault_trees.as_ref().unwrap()["F"];
assert_eq!(ft.gates.as_ref().unwrap()["G"].gate_type, GateType::Inhibit);
}
#[test]
fn test_validation_diagnostics_carry_span_keys() {
use etdl_parser::spanned::{build_span_index, SpanKey};
let yaml = r#"
etdl: "1.0.0"
info:
title: "Bad"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: MissingNode
nodes:
Present:
type: consequence
operation: terminate
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
let v101 = diags
.iter()
.find(|d| d.code == "V-101")
.expect("V-101 present");
let key = v101.key.as_ref().expect("diagnostic carries a span key");
assert_eq!(
key,
&SpanKey::InitiatingEvent {
tree: "T".to_string(),
field: "next",
}
);
let index = build_span_index(yaml).unwrap();
let el = index.resolve(key).expect("key resolves");
let span = el.key_span.unwrap_or(el.span);
assert_eq!(span.line, 12);
assert!(span.column > 0);
}
#[test]
fn test_duplicate_node_ids_warn() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "Dup"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: N
nodes:
N:
type: consequence
operation: terminate
N:
type: consequence
operation: terminate
"#;
let dups = etdl_parser::spanned::detect_duplicate_ids(yaml).unwrap();
assert_eq!(dups.len(), 1);
assert_eq!(dups[0].id, "N");
assert_eq!(dups[0].kind, "node");
assert_eq!(dups[0].span.line, 17);
}
#[test]
fn test_language_version_major_gate() {
let yaml = r#"
etdl: "2.0.0"
info:
title: "Future"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: N
nodes:
N:
type: consequence
operation: terminate
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
diags.iter().any(|d| d.code == "E-100"),
"future major version must be rejected with E-100, got {:?}",
diags
);
}
#[test]
fn test_handler_identifier_validation() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "Handler"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: O
nodes:
O:
type: operation
action: execute
handler: "not a valid id!"
next: C
C:
type: consequence
operation: terminate
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
diags.iter().any(|d| d.code == "V-301"),
"invalid handler should produce V-301, got {:?}",
diags
);
}
#[test]
fn test_non_terminating_path_is_v104() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "NoTerminal"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: O1
nodes:
O1:
type: operation
action: execute
handler: "h1"
next: O2
O2:
type: operation
action: execute
handler: "h2"
next: O2
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
diags.iter().any(|d| d.code == "V-104"),
"non-terminating path should produce V-104, got {:?}",
diags
);
}
#[test]
fn test_branch_probability_range_and_sum() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "BranchSum"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: B
nodes:
B:
type: barrier
branches:
- outcome: SUCCESS
condition: "message.payload.ok == true"
probability: 0.9
next: C
- outcome: FAILURE
condition: default
probability: 0.2
next: C
C:
type: consequence
operation: terminate
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
let mut diags = Vec::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
let resolved =
etdl_compiler::validate::resolve_probability_links(&doc, &BTreeMap::new(), &mut diags);
etdl_compiler::validate::validate_probability_sums(&doc, &resolved, &mut diags);
assert!(
diags.iter().any(|d| d.code == "V-203"),
"branch probabilities 0.9+0.2 must fail V-203, got {:?}",
diags
);
}
#[test]
fn test_consequence_revisit_not_cycle() {
let yaml = r#"
etdl: "1.0.0"
info:
title: "Revisit"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: B
nodes:
B:
type: barrier
branches:
- outcome: SUCCESS
condition: default
probability: 0.5
next: C
- outcome: FAILURE
condition: default
probability: 0.5
next: C
C:
type: consequence
operation: terminate
"#;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
!diags.iter().any(|d| d.code == "V-102"),
"revisiting a consequence must not be flagged as a cycle, got {:?}",
diags
);
}
#[test]
fn test_transfer_target_must_exist() {
let yaml = r##"
etdl: "1.0.0"
info:
title: "Transfer"
version: "1.0.0"
domain: "D"
asyncapi_imports: {}
eventTrees:
T:
initiatingEvent:
id: I
message: "a#/m"
next: C
nodes:
C:
type: consequence
operation: terminate
faultTrees:
F:
topEvent:
id: Top
description: "top"
rootCause: E
basicEvents:
E:
description: "e"
probability: 0.01
transfers:
Gone:
target: "#/faultTrees/DoesNotExist/topEvent"
label: "see other"
"##;
let doc: EtlDocument = serde_yaml::from_str(yaml).unwrap();
let mut diags = Vec::new();
let registry = etdl_parser::asyncapi::AsyncApiRegistry::new();
etdl_compiler::validate::validate_document(&doc, ®istry, &mut diags);
assert!(
diags.iter().any(|d| d.code == "V-506"),
"transfer to a missing fault tree should produce V-506, got {:?}",
diags
);
}
fn run_cli(args: &[&str]) -> (std::process::Output, String) {
let bin = env!("CARGO_BIN_EXE_etdl");
let out = std::process::Command::new(bin)
.args(args)
.output()
.expect("cli runs");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
(out, stdout)
}
#[test]
fn cli_validate_exit_zero_on_valid() {
let (out, _) = run_cli(&[
"validate",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
}
#[test]
fn cli_validate_exit_one_on_invalid() {
let dir = std::env::temp_dir();
let bad = dir.join("etdl_cli_bad_validate.etdl");
std::fs::write(&bad, "etdl: \"2.0.0\"\n").unwrap();
let (out, _) = run_cli(&["validate", bad.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(1));
let _ = std::fs::remove_file(&bad);
}
#[test]
fn cli_validate_json_output() {
let (out, stdout) = run_cli(&[
"validate",
"--json",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
assert_eq!(v["results"][0]["valid"], serde_json::json!(true));
}
#[test]
fn cli_analyze_json_output() {
let (out, stdout) = run_cli(&[
"analyze",
"--json",
fixture_path("order-fulfillment.etdl").to_str().unwrap(),
]);
assert_eq!(out.status.code(), Some(0));
let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
assert_eq!(v["eventTrees"], serde_json::json!(1));
assert_eq!(v["faultTrees"], serde_json::json!(1));
}
#[test]
fn cli_version() {
let (out, stdout) = run_cli(&["--version"]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout.contains("etdl "));
}
}