1use std::collections::HashMap;
24use std::path::PathBuf;
25
26use crate::testing::conformance::{
27 AndonPull, ConformanceVerdict, ExpectedConformance, ProofDimension,
28};
29
30#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ObjectEvidence {
39 pub id: String,
40 pub hash: String,
41}
42
43impl ObjectEvidence {
44 pub fn new(id: impl Into<String>, hash: impl Into<String>) -> Self {
45 Self {
46 id: id.into(),
47 hash: hash.into(),
48 }
49 }
50
51 pub fn from_data(id: impl Into<String>, data: &[u8]) -> Self {
53 let hash = blake3::hash(data).to_hex().to_string();
54 Self {
55 id: id.into(),
56 hash,
57 }
58 }
59}
60
61#[derive(Debug, Clone)]
66pub struct ActivityEvidence {
67 pub activity: String,
68 pub inputs: Vec<ObjectEvidence>,
69 pub outputs: Vec<ObjectEvidence>,
70}
71
72impl ActivityEvidence {
73 pub fn new(activity: impl Into<String>) -> Self {
74 Self {
75 activity: activity.into(),
76 inputs: vec![],
77 outputs: vec![],
78 }
79 }
80
81 #[must_use]
82 pub fn with_inputs(mut self, inputs: Vec<ObjectEvidence>) -> Self {
83 self.inputs = inputs;
84 self
85 }
86
87 #[must_use]
88 pub fn with_outputs(mut self, outputs: Vec<ObjectEvidence>) -> Self {
89 self.outputs = outputs;
90 self
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum EvidenceError {
97 InputObjectNotRegistered(String),
99 InputHashMismatch {
102 id: String,
103 expected: String,
104 actual: String,
105 },
106 OutputObjectAlreadyRegistered(String),
108 NoObjectEvidence,
110}
111
112impl std::fmt::Display for EvidenceError {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 Self::InputObjectNotRegistered(id) => write!(
116 f,
117 "input object '{id}' not registered — used before creation"
118 ),
119 Self::InputHashMismatch {
120 id,
121 expected,
122 actual,
123 } => write!(
124 f,
125 "input object '{id}' hash mismatch: expected {expected:.12}… got {actual:.12}…"
126 ),
127 Self::OutputObjectAlreadyRegistered(id) => write!(
128 f,
129 "output object '{id}' already registered — double-creation"
130 ),
131 Self::NoObjectEvidence => write!(
132 f,
133 "no object evidence — at least one input or output is required"
134 ),
135 }
136 }
137}
138
139#[derive(Debug, Clone)]
142struct ObjectRecord {
143 hash: String,
144 created_at: usize,
145}
146
147#[derive(Debug, Clone, Default)]
153pub struct CapturedOutput {
154 pub stdout: Option<String>,
155 pub stderr: Option<String>,
156}
157
158impl CapturedOutput {
159 pub fn new(stdout: impl Into<String>, stderr: impl Into<String>) -> Self {
161 let stdout = stdout.into();
162 let stderr = stderr.into();
163 Self {
164 stdout: if stdout.is_empty() {
165 None
166 } else {
167 Some(stdout)
168 },
169 stderr: if stderr.is_empty() {
170 None
171 } else {
172 Some(stderr)
173 },
174 }
175 }
176
177 pub fn is_empty(&self) -> bool {
179 self.stdout.is_none() && self.stderr.is_none()
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct TestEvent {
186 pub activity: String,
187 pub object_ids: Vec<String>,
188}
189
190#[derive(Debug, Clone)]
196pub struct PowlTestHarness {
197 route_id: String,
198 pub(crate) expected: ExpectedConformance,
199 events: Vec<TestEvent>,
200 receipts: Vec<String>,
202 object_registry: HashMap<String, ObjectRecord>,
204 bound_evidence_count: usize,
207 model_path: Option<PathBuf>,
208 test_run_id: String,
209 captured_output: Vec<CapturedOutput>,
210}
211
212impl PowlTestHarness {
213 pub fn new(route_id: impl Into<String>) -> Self {
226 Self {
227 route_id: route_id.into(),
228 expected: ExpectedConformance::exact(),
229 events: Vec::new(),
230 receipts: Vec::new(),
231 object_registry: HashMap::new(),
232 bound_evidence_count: 0,
233 model_path: None,
234 test_run_id: uuid::Uuid::new_v4().to_string(),
235 captured_output: Vec::new(),
236 }
237 }
238
239 pub fn route_id(&self) -> &str {
241 &self.route_id
242 }
243
244 pub fn test_run_id(&self) -> &str {
246 &self.test_run_id
247 }
248
249 pub fn expect(mut self, expected: ExpectedConformance) -> Self {
261 self.expected = expected;
262 self
263 }
264
265 pub fn model(mut self, path: impl Into<PathBuf>) -> Self {
277 self.model_path = Some(path.into());
278 self
279 }
280
281 pub fn record_activity(&mut self, activity: impl Into<String>) {
304 let activity = activity.into();
305 let receipt_input = format!(
307 "name-only:{}:{}:{}",
308 self.route_id,
309 activity,
310 self.events.len()
311 );
312 let receipt = blake3::hash(receipt_input.as_bytes()).to_hex().to_string();
313 self.events.push(TestEvent {
314 activity,
315 object_ids: vec![],
316 });
317 self.receipts.push(receipt);
318 }
320
321 pub fn complete_activity(&mut self, evidence: ActivityEvidence) -> Result<(), EvidenceError> {
336 if evidence.inputs.is_empty() && evidence.outputs.is_empty() {
337 return Err(EvidenceError::NoObjectEvidence);
338 }
339
340 let event_idx = self.events.len();
341
342 for input in &evidence.inputs {
344 match self.object_registry.get(&input.id) {
345 None => return Err(EvidenceError::InputObjectNotRegistered(input.id.clone())),
346 Some(record) => {
347 if record.created_at >= event_idx {
348 return Err(EvidenceError::InputObjectNotRegistered(input.id.clone()));
349 }
350 if record.hash != input.hash {
351 return Err(EvidenceError::InputHashMismatch {
352 id: input.id.clone(),
353 expected: record.hash.clone(),
354 actual: input.hash.clone(),
355 });
356 }
357 }
358 }
359 }
360
361 for output in &evidence.outputs {
363 if self.object_registry.contains_key(&output.id) {
364 return Err(EvidenceError::OutputObjectAlreadyRegistered(
365 output.id.clone(),
366 ));
367 }
368 }
369
370 for output in &evidence.outputs {
372 self.object_registry.insert(
373 output.id.clone(),
374 ObjectRecord {
375 hash: output.hash.clone(),
376 created_at: event_idx,
377 },
378 );
379 }
380
381 let prev_hash = self.receipts.last().cloned().unwrap_or_default();
383 let input_part = evidence
384 .inputs
385 .iter()
386 .map(|i| format!("{}:{}", i.id, i.hash))
387 .collect::<Vec<_>>()
388 .join(",");
389 let output_part = evidence
390 .outputs
391 .iter()
392 .map(|o| format!("{}:{}", o.id, o.hash))
393 .collect::<Vec<_>>()
394 .join(",");
395 let receipt_data = format!(
396 "evidence:{}:{}:{}:in=[{}]:out=[{}]",
397 prev_hash, self.route_id, evidence.activity, input_part, output_part
398 );
399 let receipt = blake3::hash(receipt_data.as_bytes()).to_hex().to_string();
400
401 let mut object_ids = Vec::new();
402 for input in &evidence.inputs {
403 object_ids.push(input.id.clone());
404 }
405 for output in &evidence.outputs {
406 object_ids.push(output.id.clone());
407 }
408
409 self.events.push(TestEvent {
410 activity: evidence.activity,
411 object_ids,
412 });
413 self.receipts.push(receipt);
414 self.bound_evidence_count += 1;
415
416 Ok(())
417 }
418
419 pub fn event_count(&self) -> usize {
421 self.events.len()
422 }
423
424 pub fn events(&self) -> &[TestEvent] {
438 &self.events
439 }
440
441 pub fn capture_output(&mut self, output: CapturedOutput) {
446 self.captured_output.push(output);
447 }
448
449 pub fn captured_output(&self) -> &[CapturedOutput] {
451 &self.captured_output
452 }
453
454 pub fn run_catching_panic<F>(&mut self, f: F) -> ConformanceVerdict
462 where
463 F: FnOnce(&mut PowlTestHarness),
464 {
465 let result = std::panic::catch_unwind(
466 std::panic::AssertUnwindSafe(|| f(self)),
470 );
471 match result {
472 Ok(()) => self.finish(),
473 Err(_) => {
474 self.events.push(TestEvent {
475 activity: "panic.caught".into(),
476 object_ids: vec![],
477 });
478 ConformanceVerdict::andon(AndonPull::UnhandledPanic)
479 }
480 }
481 }
482
483 pub fn export_ocel(&self) -> serde_json::Value {
485 crate::testing::ocel_exporter::export_ocel(&self.events, &self.route_id)
486 }
487
488 pub fn finish(&self) -> ConformanceVerdict {
493 match &self.model_path {
494 Some(path) => self.finish_against_model(path.to_str().unwrap_or("")),
495 None => ConformanceVerdict::andon(AndonPull::TestRouteIncomplete),
496 }
497 }
498
499 pub fn finish_against_model(&self, model_path: &str) -> ConformanceVerdict {
506 #[cfg(feature = "powl")]
507 {
508 match self.replay_against_model(model_path) {
509 Ok(report) => {
510 crate::testing::conformance::classify_conformance(&report, self.expected)
511 }
512 Err(_) => ConformanceVerdict::andon(AndonPull::TestRouteIncomplete),
513 }
514 }
515 #[cfg(not(feature = "powl"))]
516 {
517 let _ = model_path;
518 ConformanceVerdict::andon(AndonPull::TestRouteIncomplete)
519 }
520 }
521
522 #[cfg(feature = "powl")]
542 pub fn replay_against_model(
543 &self,
544 model_path: &str,
545 ) -> Result<crate::testing::conformance::ReplayReport, String> {
546 use crate::powl::conformance::token_replay::compute_fitness;
547 use crate::powl::conversion::to_petri_net;
548 use crate::powl_arena::PowlArena;
549 use crate::powl_event_log::{Event, EventLog, Trace};
550 use crate::powl_parser::parse_powl_model_string;
551 use crate::testing::conformance::ReplayReport;
552
553 #[derive(serde::Deserialize)]
554 struct RouteModelSpec {
555 powl_expression: String,
556 #[serde(default)]
557 required_activities: Vec<String>,
558 }
559
560 let json_str = std::fs::read_to_string(model_path)
561 .map_err(|e| format!("failed to read model '{}': {e}", model_path))?;
562 let spec: RouteModelSpec = serde_json::from_str(&json_str)
563 .map_err(|e| format!("failed to parse model JSON: {e}"))?;
564
565 let mut arena = PowlArena::new();
566 let root = parse_powl_model_string(&spec.powl_expression, &mut arena)
567 .map_err(|e| format!("failed to parse POWL expression: {e}"))?;
568 let pn = to_petri_net::apply(&arena, root);
569
570 let trace = Trace {
571 case_id: self.route_id.clone(),
572 events: self
573 .events
574 .iter()
575 .map(|e| Event {
576 name: e.activity.clone(),
577 timestamp: None,
578 lifecycle: None,
579 attributes: Default::default(),
580 })
581 .collect(),
582 };
583 let log = EventLog {
584 traces: vec![trace],
585 };
586
587 let fr = compute_fitness(&pn.net, &pn.initial_marking, &pn.final_marking, &log);
588
589 let required_stage_coverage = if spec.required_activities.is_empty() {
590 1.0
591 } else {
592 let present: std::collections::HashSet<&str> =
593 self.events.iter().map(|e| e.activity.as_str()).collect();
594 let covered = spec
595 .required_activities
596 .iter()
597 .filter(|a| present.contains(a.as_str()))
598 .count();
599 covered as f64 / spec.required_activities.len() as f64
600 };
601
602 let receipt_coverage = if self.events.is_empty() {
605 1.0
606 } else {
607 self.bound_evidence_count as f64 / self.events.len() as f64
608 };
609
610 let object_lifecycle_validity = if self.bound_evidence_count == 0 {
615 0.0
616 } else {
617 1.0
618 };
619
620 Ok(ReplayReport {
621 fitness: ProofDimension::Measured(fr.avg_trace_fitness),
622 precision: ProofDimension::Measured(fr.avg_trace_precision),
623 receipt_coverage: ProofDimension::Measured(receipt_coverage),
624 required_stage_coverage: ProofDimension::Measured(required_stage_coverage),
625 object_lifecycle_validity: ProofDimension::Measured(object_lifecycle_validity),
626 })
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 #[test]
635 fn new_sets_route_id() {
636 let h = PowlTestHarness::new("x");
637 assert_eq!(h.route_id(), "x");
638 }
639
640 #[test]
641 fn record_activity_increments_count() {
642 let mut h = PowlTestHarness::new("route");
643 assert_eq!(h.event_count(), 0);
644 h.record_activity("a");
645 assert_eq!(h.event_count(), 1);
646 h.record_activity("b");
647 assert_eq!(h.event_count(), 2);
648 }
649
650 #[test]
651 fn expect_stores_contract() {
652 let h = PowlTestHarness::new("route").expect(ExpectedConformance::exact());
653 assert_eq!(h.expected, ExpectedConformance::exact());
654 }
655
656 #[test]
657 fn events_preserves_order() {
658 let mut h = PowlTestHarness::new("route");
659 h.record_activity("first");
660 h.record_activity("second");
661 h.record_activity("third");
662 assert_eq!(h.events()[0].activity, "first");
663 assert_eq!(h.events()[1].activity, "second");
664 assert_eq!(h.events()[2].activity, "third");
665 }
666
667 #[test]
668 fn finish_without_model_returns_incomplete() {
669 let h = PowlTestHarness::new("route");
670 assert_eq!(
671 h.finish(),
672 ConformanceVerdict::Andon(AndonPull::TestRouteIncomplete)
673 );
674 }
675
676 #[test]
677 fn finish_with_missing_model_file_returns_incomplete() {
678 let h = PowlTestHarness::new("route").model("nonexistent-route-model.powl.json");
679 assert_eq!(
680 h.finish(),
681 ConformanceVerdict::Andon(AndonPull::TestRouteIncomplete)
682 );
683 }
684
685 #[test]
686 fn default_expected_is_exact() {
687 let h = PowlTestHarness::new("route");
688 assert_eq!(h.expected, ExpectedConformance::exact());
689 }
690
691 #[test]
692 fn test_run_id_is_non_empty() {
693 let h = PowlTestHarness::new("route");
694 assert!(!h.test_run_id().is_empty());
695 }
696
697 #[test]
698 fn model_builder_preserves_route_id() {
699 let h = PowlTestHarness::new("my-route").model("path.powl.json");
700 assert_eq!(h.route_id(), "my-route");
701 }
702
703 #[test]
706 fn captured_output_new_stores_non_empty_strings() {
707 let co = CapturedOutput::new("stdout data", "stderr data");
708 assert_eq!(co.stdout.as_deref(), Some("stdout data"));
709 assert_eq!(co.stderr.as_deref(), Some("stderr data"));
710 }
711
712 #[test]
713 fn captured_output_new_converts_empty_strings_to_none() {
714 let co = CapturedOutput::new("", "");
715 assert!(co.is_empty());
716 assert!(co.stdout.is_none());
717 assert!(co.stderr.is_none());
718 }
719
720 #[test]
721 fn harness_accumulates_captured_output() {
722 let mut h = PowlTestHarness::new("route");
723 assert_eq!(h.captured_output().len(), 0);
724 h.capture_output(CapturedOutput::new("line 1", ""));
725 h.capture_output(CapturedOutput::new("line 2", "err"));
726 assert_eq!(h.captured_output().len(), 2);
727 assert_eq!(h.captured_output()[0].stdout.as_deref(), Some("line 1"));
728 }
729
730 #[test]
731 fn run_catching_panic_returns_unhandled_panic_on_panic() {
732 let mut h = PowlTestHarness::new("route");
733 let verdict = h.run_catching_panic(|_| panic!("intentional test panic"));
734 assert_eq!(
735 verdict,
736 ConformanceVerdict::Andon(AndonPull::UnhandledPanic)
737 );
738 }
739
740 #[test]
741 fn run_catching_panic_appends_panic_caught_activity() {
742 let mut h = PowlTestHarness::new("route");
743 h.run_catching_panic(|_| panic!("boom"));
744 assert!(h.events().iter().any(|e| e.activity == "panic.caught"));
745 }
746
747 #[test]
748 fn run_catching_panic_without_panic_calls_finish() {
749 let mut h = PowlTestHarness::new("route");
750 let verdict = h.run_catching_panic(|h| {
751 h.record_activity("a");
752 });
753 assert_eq!(
755 verdict,
756 ConformanceVerdict::Andon(AndonPull::TestRouteIncomplete)
757 );
758 }
759}