#![cfg(feature = "ocel")]
use std::collections::HashMap;
use std::path::PathBuf;
use serde_json::Value;
use wasm4pm_compat::ocel::flatten::flatten;
use wasm4pm_compat::ocel::validate::validate;
use wasm4pm_compat::ocel::{ObjectTypeCardinality, OCEL};
use wasm4pm::foundry::{field_net, field_powl};
use wasm4pm::models::PetriNet;
use wasm4pm::soundness::analyze_petri_net;
use wasm4pm::testing::conformance::AndonPull;
use wasm4pm::wf_to_powl::{powl_language, wf_net_language, PowlSpec};
fn negative_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root")
.join("fixtures")
.join("negative")
}
fn read_json(file: &str) -> Value {
let path = negative_dir().join(file);
let raw = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()));
serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse fixture {file}: {e}"))
}
fn trace_of(file: &str) -> Vec<String> {
let v = read_json(file);
v["trace"]
.as_array()
.unwrap_or_else(|| panic!("fixture {file} has no trace array"))
.iter()
.map(|s| s.as_str().expect("trace element is a string").to_string())
.collect()
}
fn ocel_of(file: &str) -> OCEL {
serde_json::from_value(read_json(file)).unwrap_or_else(|e| panic!("OCEL parse {file}: {e}"))
}
fn net_of(file: &str) -> PetriNet {
serde_json::from_value(read_json(file)).unwrap_or_else(|e| panic!("net parse {file}: {e}"))
}
fn declared_andon(file: &str) -> AndonPull {
let v = read_json(file);
let code = v["expected_refusal"].as_str().unwrap_or_else(|| {
panic!("fixture {file} missing expected_refusal");
});
map_andon(code)
}
fn map_andon(code: &str) -> AndonPull {
match code {
"RouteConformanceGap" => AndonPull::RouteConformanceGap,
"IllegalRouteMotion" => AndonPull::IllegalRouteMotion,
"LifecycleNotTerminated"
| "ObjectLifecycleViolation"
| "CardinalityViolation"
| "ReceiptSchemaViolation" => AndonPull::ObjectLifecycleViolation,
"MissingReceiptCoverage" | "InsufficientReceiptCoverage" => {
AndonPull::MissingReceiptCoverage
}
"MissingRequiredStages" | "MissingRouteActivity" => AndonPull::MissingRouteActivity,
other => panic!("unknown expected_refusal code {other}"),
}
}
fn assert_off_wf_language(file: &str) {
let net = field_net();
let lang = wf_net_language(&net).expect("field net has a defined language");
let t = trace_of(file);
assert!(
!lang.contains(&t),
"{file}: trace {t:?} must NOT be in the field WF-net language (must be refused)"
);
assert_eq!(
declared_andon(file),
AndonPull::RouteConformanceGap,
"{file}: declared refusal must be RouteConformanceGap"
);
}
#[test]
fn n01_missing_required_event_is_refused() {
assert_off_wf_language("n01-missing-required-event.trace.json");
}
#[test]
fn n02_event_out_of_order_is_refused() {
assert_off_wf_language("n02-event-out-of-order.trace.json");
}
#[test]
fn n03_duplicate_terminal_event_is_refused() {
assert_off_wf_language("n03-duplicate-terminal-event.trace.json");
}
#[test]
fn n04_receipt_before_gate_is_refused() {
let file = "n04-receipt-before-gate.trace.json";
let v = read_json(file);
let spec: PowlSpec =
serde_json::from_value(v["model"].clone()).expect("inline POWL model parses");
let lang = powl_language(&spec);
let t = trace_of(file);
let lawful = vec![
"collect_evidence".to_string(),
"verify_evidence".to_string(),
"emit_receipt".to_string(),
];
assert!(
lang.contains(&lawful),
"{file}: the in-order proof trace must be in the route language"
);
assert!(
!lang.contains(&t),
"{file}: emit_receipt before verify_evidence must NOT be in the route language"
);
assert_eq!(declared_andon(file), AndonPull::RouteConformanceGap);
}
#[test]
fn n09_nonconforming_powl_route_is_refused() {
let file = "n09-nonconforming-powl-route.trace.json";
let powl = field_powl();
let lang = powl_language(&powl.powl);
let t = trace_of(file);
assert!(
!lang.contains(&t),
"{file}: a trace with the undeclared 'Cancel Order' move must NOT be in the field POWL language"
);
let alphabet: std::collections::BTreeSet<String> = lang.iter().flatten().cloned().collect();
assert!(
!alphabet.contains("Cancel Order"),
"{file}: 'Cancel Order' must not appear anywhere in the field POWL language"
);
assert_eq!(declared_andon(file), AndonPull::IllegalRouteMotion);
}
fn cardinality_of(file: &str) -> HashMap<String, ObjectTypeCardinality> {
let v = read_json(file);
match v.get("cardinality") {
Some(c) => serde_json::from_value(c.clone()).expect("cardinality parses"),
None => HashMap::new(),
}
}
fn assert_validate_fails_with(file: &str, code: &str) {
let ocel = ocel_of(file);
let report = validate(&ocel, &cardinality_of(file));
assert!(
!report.valid,
"{file}: OCEL must be INVALID (was reported valid); errors: {:?}",
report.errors
);
assert!(
report.errors.iter().any(|e| e.code == code),
"{file}: expected error code {code}; got {:?}",
report.errors.iter().map(|e| &e.code).collect::<Vec<_>>()
);
assert_eq!(
declared_andon(file),
AndonPull::ObjectLifecycleViolation,
"{file}: meta-model defect must map to ObjectLifecycleViolation"
);
}
#[test]
fn n05_o2o_dangling_is_refused() {
assert_validate_fails_with("n05-o2o-dangling.ocel.json", "DANGLING_O2O");
}
#[test]
fn n10_cardinality_max_is_refused() {
assert_validate_fails_with("n10-cardinality-max.ocel.json", "CARDINALITY_MAX");
}
#[test]
fn n12_e2o_empty_is_refused() {
assert_validate_fails_with("n12-e2o-empty.ocel.json", "E2O_EMPTY");
}
#[test]
fn n13_duplicate_object_id_is_refused() {
assert_validate_fails_with("n13-duplicate-object-id.ocel.json", "DUPLICATE_OBJECT_ID");
}
#[test]
fn n14_undeclared_event_type_is_refused() {
assert_validate_fails_with(
"n14-undeclared-event-type.ocel.json",
"UNDECLARED_EVENT_TYPE",
);
}
#[test]
fn n06_flattening_loss_is_refused() {
let file = "n06-flattening-loss.ocel.json";
let ocel = ocel_of(file);
let v = read_json(file);
let object_type = v["flatten_to"].as_str().expect("flatten_to");
let raw_event_count = ocel.events.len();
let flat = flatten(&ocel, object_type).expect("flatten to declared type");
let flattened_pairs: usize = flat.cases.iter().map(|c| c.trace.len()).sum();
let referencing_events: std::collections::BTreeSet<&str> = ocel
.events
.iter()
.filter(|e| {
e.relationships.iter().any(|r| {
ocel.objects
.iter()
.any(|o| o.id == r.object_id && o.object_type == object_type)
})
})
.map(|e| e.id.as_str())
.collect();
assert!(
referencing_events.len() < raw_event_count,
"{file}: divergence — some event must reference no {object_type} and be dropped \
(referencing {} of {} raw events)",
referencing_events.len(),
raw_event_count
);
assert!(
flattened_pairs > referencing_events.len(),
"{file}: convergence — a multi-{object_type} event must be duplicated \
(flattened pairs {flattened_pairs} must exceed referencing events {})",
referencing_events.len()
);
assert!(
flat.cases.len() > 1,
"{file}: flattening must produce >1 case so convergence is observable; got {} case(s)",
flat.cases.len()
);
let dropped: Vec<String> = v["expected_loss"]["dropped_events"]
.as_array()
.unwrap()
.iter()
.map(|s| s.as_str().unwrap().to_string())
.collect();
for ev in &dropped {
assert!(
!referencing_events.contains(ev.as_str()),
"{file}: event {ev} is declared dropped but still references {object_type}"
);
}
assert_eq!(declared_andon(file), AndonPull::ObjectLifecycleViolation);
}
#[test]
fn n07_dead_transition_is_refused() {
let file = "n07-dead-transition.wf-net.json";
let net = net_of(file);
let report = analyze_petri_net(&net);
assert!(
!report.is_sound,
"{file}: net with a dead transition must be UNSOUND (reason: {})",
report.reason
);
assert!(
!report.dead_transitions.is_empty(),
"{file}: analyser must name the dead transition; got {:?}",
report.dead_transitions
);
let v = read_json(file);
let expected_label = v["soundness_assertion"]["dead_transition_label"]
.as_str()
.expect("dead_transition_label");
assert!(
report.dead_transitions.iter().any(|d| d == expected_label),
"{file}: expected dead transition '{expected_label}'; got {:?}",
report.dead_transitions
);
assert_eq!(declared_andon(file), AndonPull::ObjectLifecycleViolation);
}
#[test]
fn n08_unsafe_net_is_refused() {
let file = "n08-unsafe-net.wf-net.json";
let net = net_of(file);
let report = analyze_petri_net(&net);
assert!(
!report.is_safe,
"{file}: net that reaches a 2-token marking must be UNSAFE (reason: {})",
report.reason
);
assert!(
!report.is_sound_and_safe(),
"{file}: unsafe net cannot be in the admissible sound-and-safe class"
);
assert_eq!(declared_andon(file), AndonPull::ObjectLifecycleViolation);
}
#[test]
fn n11_lifecycle_not_terminated_is_refused() {
let file = "n11-lifecycle-not-terminated.ocel.json";
let ocel = ocel_of(file);
let v = read_json(file);
let object_type = v["flatten_to"].as_str().expect("flatten_to");
let card = cardinality_of(file);
let order_card = card
.get(object_type)
.unwrap_or_else(|| panic!("{file}: cardinality for {object_type} missing"));
let terminators = &order_card.terminated_by;
assert!(
!terminators.is_empty(),
"{file}: the object type must declare terminated_by to test termination"
);
let flat = flatten(&ocel, object_type).expect("flatten to the lifecycle type");
assert!(
!flat.cases.is_empty(),
"{file}: must have at least one case"
);
for case in &flat.cases {
let last = case
.trace
.last()
.unwrap_or_else(|| panic!("{file}: case {} has an empty trace", case.case_id));
assert!(
!terminators.contains(last),
"{file}: case {} ends in '{last}' which IS a terminator — fixture must leave the lifecycle open",
case.case_id
);
}
let lawful = wasm4pm::foundry::field_ocel();
let lawful_flat = flatten(&lawful, "Order").expect("flatten field OCEL to Order");
let lawful_last = lawful_flat.cases[0].trace.last().expect("non-empty");
let lawful_card = wasm4pm::foundry::field_cardinality();
assert!(
lawful_card["Order"].terminated_by.contains(lawful_last),
"sanity: the lawful field Order case must end in a terminating event (ends in '{lawful_last}')"
);
assert_eq!(declared_andon(file), AndonPull::ObjectLifecycleViolation);
assert_eq!(
map_andon("LifecycleNotTerminated"),
AndonPull::ObjectLifecycleViolation
);
}
#[test]
fn manifest_is_complete_and_every_entry_declares_a_refusal() {
let manifest = read_json("manifest.json");
let fixtures = manifest["fixtures"]
.as_array()
.expect("manifest.fixtures is an array");
assert!(
fixtures.len() >= 11,
"negative corpus must cover at least the 11 required categories; got {}",
fixtures.len()
);
for entry in fixtures {
let id = entry["id"].as_str().expect("entry id");
let file = entry["file"].as_str().expect("entry file");
let path = negative_dir().join(file);
assert!(path.exists(), "{id}: manifest names missing fixture {file}");
let code = entry["expected_refusal"]
.as_str()
.expect("expected_refusal");
let _ = map_andon(code); assert!(
entry["rejecting_primitive"].as_str().is_some(),
"{id}: must name the rejecting primitive"
);
assert!(
entry["minimal_counterexample"].as_str().is_some(),
"{id}: must give a minimal counterexample"
);
}
let categories: Vec<&str> = fixtures
.iter()
.map(|e| e["category"].as_str().unwrap())
.collect();
for required in [
"missing required event",
"event out of order",
"duplicate terminal event",
"receipt-before-gate",
"object relation missing/impossible",
"OCEL flattening loss",
"dead transition",
"unsafe net",
"non-conforming POWL route",
"OCEL O2O cardinality violation",
"lifecycle-not-terminated",
] {
assert!(
categories.iter().any(|c| *c == required),
"negative corpus is missing the required category: {required}"
);
}
}