use std::collections::HashMap;
use std::path::PathBuf;
use crate::testing::conformance::{
AndonPull, ConformanceVerdict, ExpectedConformance, ProofDimension,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectEvidence {
pub id: String,
pub hash: String,
}
impl ObjectEvidence {
pub fn new(id: impl Into<String>, hash: impl Into<String>) -> Self {
Self {
id: id.into(),
hash: hash.into(),
}
}
pub fn from_data(id: impl Into<String>, data: &[u8]) -> Self {
let hash = blake3::hash(data).to_hex().to_string();
Self {
id: id.into(),
hash,
}
}
}
#[derive(Debug, Clone)]
pub struct ActivityEvidence {
pub activity: String,
pub inputs: Vec<ObjectEvidence>,
pub outputs: Vec<ObjectEvidence>,
}
impl ActivityEvidence {
pub fn new(activity: impl Into<String>) -> Self {
Self {
activity: activity.into(),
inputs: vec![],
outputs: vec![],
}
}
pub fn with_inputs(mut self, inputs: Vec<ObjectEvidence>) -> Self {
self.inputs = inputs;
self
}
pub fn with_outputs(mut self, outputs: Vec<ObjectEvidence>) -> Self {
self.outputs = outputs;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EvidenceError {
InputObjectNotRegistered(String),
InputHashMismatch {
id: String,
expected: String,
actual: String,
},
OutputObjectAlreadyRegistered(String),
NoObjectEvidence,
}
impl std::fmt::Display for EvidenceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InputObjectNotRegistered(id) => write!(
f,
"input object '{id}' not registered — used before creation"
),
Self::InputHashMismatch {
id,
expected,
actual,
} => write!(
f,
"input object '{id}' hash mismatch: expected {expected:.12}… got {actual:.12}…"
),
Self::OutputObjectAlreadyRegistered(id) => write!(
f,
"output object '{id}' already registered — double-creation"
),
Self::NoObjectEvidence => write!(
f,
"no object evidence — at least one input or output is required"
),
}
}
}
#[derive(Debug, Clone)]
struct ObjectRecord {
hash: String,
created_at: usize,
}
#[derive(Debug, Clone, Default)]
pub struct CapturedOutput {
pub stdout: Option<String>,
pub stderr: Option<String>,
}
impl CapturedOutput {
pub fn new(stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
let stdout = stdout.into();
let stderr = stderr.into();
Self {
stdout: if stdout.is_empty() {
None
} else {
Some(stdout)
},
stderr: if stderr.is_empty() {
None
} else {
Some(stderr)
},
}
}
pub fn is_empty(&self) -> bool {
self.stdout.is_none() && self.stderr.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TestEvent {
pub activity: String,
pub object_ids: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct PowlTestHarness {
route_id: String,
pub(crate) expected: ExpectedConformance,
events: Vec<TestEvent>,
receipts: Vec<String>,
object_registry: HashMap<String, ObjectRecord>,
bound_evidence_count: usize,
model_path: Option<PathBuf>,
test_run_id: String,
captured_output: Vec<CapturedOutput>,
}
impl PowlTestHarness {
pub fn new(route_id: impl Into<String>) -> Self {
Self {
route_id: route_id.into(),
expected: ExpectedConformance::exact(),
events: Vec::new(),
receipts: Vec::new(),
object_registry: HashMap::new(),
bound_evidence_count: 0,
model_path: None,
test_run_id: uuid::Uuid::new_v4().to_string(),
captured_output: Vec::new(),
}
}
pub fn route_id(&self) -> &str {
&self.route_id
}
pub fn test_run_id(&self) -> &str {
&self.test_run_id
}
pub fn expect(mut self, expected: ExpectedConformance) -> Self {
self.expected = expected;
self
}
pub fn model(mut self, path: impl Into<PathBuf>) -> Self {
self.model_path = Some(path.into());
self
}
pub fn record_activity(&mut self, activity: impl Into<String>) {
let activity = activity.into();
let receipt_input = format!(
"name-only:{}:{}:{}",
self.route_id,
activity,
self.events.len()
);
let receipt = blake3::hash(receipt_input.as_bytes()).to_hex().to_string();
self.events.push(TestEvent {
activity,
object_ids: vec![],
});
self.receipts.push(receipt);
}
pub fn complete_activity(&mut self, evidence: ActivityEvidence) -> Result<(), EvidenceError> {
if evidence.inputs.is_empty() && evidence.outputs.is_empty() {
return Err(EvidenceError::NoObjectEvidence);
}
let event_idx = self.events.len();
for input in &evidence.inputs {
match self.object_registry.get(&input.id) {
None => return Err(EvidenceError::InputObjectNotRegistered(input.id.clone())),
Some(record) => {
if record.created_at >= event_idx {
return Err(EvidenceError::InputObjectNotRegistered(input.id.clone()));
}
if record.hash != input.hash {
return Err(EvidenceError::InputHashMismatch {
id: input.id.clone(),
expected: record.hash.clone(),
actual: input.hash.clone(),
});
}
}
}
}
for output in &evidence.outputs {
if self.object_registry.contains_key(&output.id) {
return Err(EvidenceError::OutputObjectAlreadyRegistered(
output.id.clone(),
));
}
}
for output in &evidence.outputs {
self.object_registry.insert(
output.id.clone(),
ObjectRecord {
hash: output.hash.clone(),
created_at: event_idx,
},
);
}
let prev_hash = self.receipts.last().cloned().unwrap_or_default();
let input_part = evidence
.inputs
.iter()
.map(|i| format!("{}:{}", i.id, i.hash))
.collect::<Vec<_>>()
.join(",");
let output_part = evidence
.outputs
.iter()
.map(|o| format!("{}:{}", o.id, o.hash))
.collect::<Vec<_>>()
.join(",");
let receipt_data = format!(
"evidence:{}:{}:{}:in=[{}]:out=[{}]",
prev_hash, self.route_id, evidence.activity, input_part, output_part
);
let receipt = blake3::hash(receipt_data.as_bytes()).to_hex().to_string();
let mut object_ids = Vec::new();
for input in &evidence.inputs {
object_ids.push(input.id.clone());
}
for output in &evidence.outputs {
object_ids.push(output.id.clone());
}
self.events.push(TestEvent {
activity: evidence.activity,
object_ids,
});
self.receipts.push(receipt);
self.bound_evidence_count += 1;
Ok(())
}
pub fn event_count(&self) -> usize {
self.events.len()
}
pub fn events(&self) -> &[TestEvent] {
&self.events
}
pub fn capture_output(&mut self, output: CapturedOutput) {
self.captured_output.push(output);
}
pub fn captured_output(&self) -> &[CapturedOutput] {
&self.captured_output
}
pub fn run_catching_panic<F>(&mut self, f: F) -> ConformanceVerdict
where
F: FnOnce(&mut PowlTestHarness),
{
let result = std::panic::catch_unwind(
std::panic::AssertUnwindSafe(|| f(self)),
);
match result {
Ok(()) => self.finish(),
Err(_) => {
self.events.push(TestEvent {
activity: "panic.caught".into(),
object_ids: vec![],
});
ConformanceVerdict::andon(AndonPull::UnhandledPanic)
}
}
}
pub fn export_ocel(&self) -> serde_json::Value {
crate::testing::ocel_exporter::export_ocel(&self.events, &self.route_id)
}
pub fn finish(&self) -> ConformanceVerdict {
match &self.model_path {
Some(path) => self.finish_against_model(path.to_str().unwrap_or("")),
None => ConformanceVerdict::andon(AndonPull::TestRouteIncomplete),
}
}
pub fn finish_against_model(&self, model_path: &str) -> ConformanceVerdict {
#[cfg(feature = "powl")]
{
match self.replay_against_model(model_path) {
Ok(report) => {
crate::testing::conformance::classify_conformance(&report, self.expected)
}
Err(_) => ConformanceVerdict::andon(AndonPull::TestRouteIncomplete),
}
}
#[cfg(not(feature = "powl"))]
{
let _ = model_path;
ConformanceVerdict::andon(AndonPull::TestRouteIncomplete)
}
}
#[cfg(feature = "powl")]
pub fn replay_against_model(
&self,
model_path: &str,
) -> Result<crate::testing::conformance::ReplayReport, String> {
use crate::powl::conformance::token_replay::compute_fitness;
use crate::powl::conversion::to_petri_net;
use crate::powl_arena::PowlArena;
use crate::powl_event_log::{Event, EventLog, Trace};
use crate::powl_parser::parse_powl_model_string;
use crate::testing::conformance::ReplayReport;
#[derive(serde::Deserialize)]
struct RouteModelSpec {
powl_expression: String,
#[serde(default)]
required_activities: Vec<String>,
}
let json_str = std::fs::read_to_string(model_path)
.map_err(|e| format!("failed to read model '{}': {e}", model_path))?;
let spec: RouteModelSpec = serde_json::from_str(&json_str)
.map_err(|e| format!("failed to parse model JSON: {e}"))?;
let mut arena = PowlArena::new();
let root = parse_powl_model_string(&spec.powl_expression, &mut arena)
.map_err(|e| format!("failed to parse POWL expression: {e}"))?;
let pn = to_petri_net::apply(&arena, root);
let trace = Trace {
case_id: self.route_id.clone(),
events: self
.events
.iter()
.map(|e| Event {
name: e.activity.clone(),
timestamp: None,
lifecycle: None,
attributes: Default::default(),
})
.collect(),
};
let log = EventLog {
traces: vec![trace],
};
let fr = compute_fitness(&pn.net, &pn.initial_marking, &pn.final_marking, &log);
let required_stage_coverage = if spec.required_activities.is_empty() {
1.0
} else {
let present: std::collections::HashSet<&str> =
self.events.iter().map(|e| e.activity.as_str()).collect();
let covered = spec
.required_activities
.iter()
.filter(|a| present.contains(a.as_str()))
.count();
covered as f64 / spec.required_activities.len() as f64
};
let receipt_coverage = if self.events.is_empty() {
1.0
} else {
self.bound_evidence_count as f64 / self.events.len() as f64
};
let object_lifecycle_validity = if self.bound_evidence_count == 0 {
0.0
} else {
1.0
};
Ok(ReplayReport {
fitness: ProofDimension::Measured(fr.avg_trace_fitness),
precision: ProofDimension::Measured(fr.avg_trace_precision),
receipt_coverage: ProofDimension::Measured(receipt_coverage),
required_stage_coverage: ProofDimension::Measured(required_stage_coverage),
object_lifecycle_validity: ProofDimension::Measured(object_lifecycle_validity),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_sets_route_id() {
let h = PowlTestHarness::new("x");
assert_eq!(h.route_id(), "x");
}
#[test]
fn record_activity_increments_count() {
let mut h = PowlTestHarness::new("route");
assert_eq!(h.event_count(), 0);
h.record_activity("a");
assert_eq!(h.event_count(), 1);
h.record_activity("b");
assert_eq!(h.event_count(), 2);
}
#[test]
fn expect_stores_contract() {
let h = PowlTestHarness::new("route").expect(ExpectedConformance::exact());
assert_eq!(h.expected, ExpectedConformance::exact());
}
#[test]
fn events_preserves_order() {
let mut h = PowlTestHarness::new("route");
h.record_activity("first");
h.record_activity("second");
h.record_activity("third");
assert_eq!(h.events()[0].activity, "first");
assert_eq!(h.events()[1].activity, "second");
assert_eq!(h.events()[2].activity, "third");
}
#[test]
fn finish_without_model_returns_incomplete() {
let h = PowlTestHarness::new("route");
assert_eq!(
h.finish(),
ConformanceVerdict::Andon(AndonPull::TestRouteIncomplete)
);
}
#[test]
fn finish_with_missing_model_file_returns_incomplete() {
let h = PowlTestHarness::new("route").model("nonexistent-route-model.powl.json");
assert_eq!(
h.finish(),
ConformanceVerdict::Andon(AndonPull::TestRouteIncomplete)
);
}
#[test]
fn default_expected_is_exact() {
let h = PowlTestHarness::new("route");
assert_eq!(h.expected, ExpectedConformance::exact());
}
#[test]
fn test_run_id_is_non_empty() {
let h = PowlTestHarness::new("route");
assert!(!h.test_run_id().is_empty());
}
#[test]
fn model_builder_preserves_route_id() {
let h = PowlTestHarness::new("my-route").model("path.powl.json");
assert_eq!(h.route_id(), "my-route");
}
#[test]
fn captured_output_new_stores_non_empty_strings() {
let co = CapturedOutput::new("stdout data", "stderr data");
assert_eq!(co.stdout.as_deref(), Some("stdout data"));
assert_eq!(co.stderr.as_deref(), Some("stderr data"));
}
#[test]
fn captured_output_new_converts_empty_strings_to_none() {
let co = CapturedOutput::new("", "");
assert!(co.is_empty());
assert!(co.stdout.is_none());
assert!(co.stderr.is_none());
}
#[test]
fn harness_accumulates_captured_output() {
let mut h = PowlTestHarness::new("route");
assert_eq!(h.captured_output().len(), 0);
h.capture_output(CapturedOutput::new("line 1", ""));
h.capture_output(CapturedOutput::new("line 2", "err"));
assert_eq!(h.captured_output().len(), 2);
assert_eq!(h.captured_output()[0].stdout.as_deref(), Some("line 1"));
}
#[test]
fn run_catching_panic_returns_unhandled_panic_on_panic() {
let mut h = PowlTestHarness::new("route");
let verdict = h.run_catching_panic(|_| panic!("intentional test panic"));
assert_eq!(
verdict,
ConformanceVerdict::Andon(AndonPull::UnhandledPanic)
);
}
#[test]
fn run_catching_panic_appends_panic_caught_activity() {
let mut h = PowlTestHarness::new("route");
h.run_catching_panic(|_| panic!("boom"));
assert!(h.events().iter().any(|e| e.activity == "panic.caught"));
}
#[test]
fn run_catching_panic_without_panic_calls_finish() {
let mut h = PowlTestHarness::new("route");
let verdict = h.run_catching_panic(|h| {
h.record_activity("a");
});
assert_eq!(
verdict,
ConformanceVerdict::Andon(AndonPull::TestRouteIncomplete)
);
}
}