Skip to main content

wasm4pm/testing/
harness.rs

1//! Explicit stateful test harness.
2//!
3//! [`PowlTestHarness`] records test-time route evidence and can verify
4//! it against a declared POWL v2 route model. Designed to be used
5//! without proc-macros — macros are sugar added in Phase 9.
6//!
7//! # Example
8//!
9//! ```
10//! use wasm4pm::testing::harness::PowlTestHarness;
11//! use wasm4pm::testing::conformance::ExpectedConformance;
12//!
13//! let mut h = PowlTestHarness::new("example-route")
14//!     .expect(ExpectedConformance::exact());
15//!
16//! h.record_activity("test.started");
17//! h.record_activity("test.completed");
18//!
19//! assert_eq!(h.route_id(), "example-route");
20//! assert_eq!(h.event_count(), 2);
21//! ```
22
23use std::collections::HashMap;
24use std::path::PathBuf;
25
26use crate::testing::conformance::{
27    AndonPull, ConformanceVerdict, ExpectedConformance, ProofDimension,
28};
29
30// ── Evidence types ────────────────────────────────────────────────────────────
31
32/// A single object in an activity's evidence: id + BLAKE3 hex of its content.
33///
34/// The hash binds the activity receipt to the actual object state. An input
35/// hash that differs from the registered output hash is caught at
36/// [`PowlTestHarness::complete_activity`] time and the activity is rejected.
37#[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    /// Compute BLAKE3 of `data` and use it as the object hash.
52    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/// Evidence package for one activity: its input and output objects.
62///
63/// An activity without at least one input or one output is rejected with
64/// [`EvidenceError::NoObjectEvidence`].
65#[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/// Error from [`PowlTestHarness::complete_activity`] when evidence is invalid.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum EvidenceError {
97    /// An input object was referenced but never registered as any prior output.
98    InputObjectNotRegistered(String),
99    /// An input object was found but the provided hash does not match the
100    /// hash recorded when the object was created (tamper detection).
101    InputHashMismatch {
102        id: String,
103        expected: String,
104        actual: String,
105    },
106    /// An output object ID was already registered by a previous activity.
107    OutputObjectAlreadyRegistered(String),
108    /// No inputs and no outputs: the receipt cannot bind to nothing.
109    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// ── Private registry record ───────────────────────────────────────────────────
140
141#[derive(Debug, Clone)]
142struct ObjectRecord {
143    hash: String,
144    created_at: usize,
145}
146
147/// Captured stdout/stderr digest from a subprocess or command boundary.
148///
149/// Stored in the harness for OCEL evidence — the digest is included in
150/// the route receipt so the command output is cryptographically bound to
151/// the test run.
152#[derive(Debug, Clone, Default)]
153pub struct CapturedOutput {
154    pub stdout: Option<String>,
155    pub stderr: Option<String>,
156}
157
158impl CapturedOutput {
159    /// Wrap raw strings into a `CapturedOutput`.
160    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    /// Return true if both stdout and stderr are absent or empty.
178    pub fn is_empty(&self) -> bool {
179        self.stdout.is_none() && self.stderr.is_none()
180    }
181}
182
183/// A single recorded test-time activity event.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct TestEvent {
186    pub activity: String,
187    pub object_ids: Vec<String>,
188}
189
190/// Explicit stateful route-driven test harness.
191///
192/// Records test activities as OCEL evidence, then verifies the observed
193/// route against a declared POWL v2 model. Conformance must be exact
194/// (1.0) for the test to pass in admitted mode.
195#[derive(Debug, Clone)]
196pub struct PowlTestHarness {
197    route_id: String,
198    pub(crate) expected: ExpectedConformance,
199    events: Vec<TestEvent>,
200    /// BLAKE3 receipts, one per event (bound evidence or name-only).
201    receipts: Vec<String>,
202    /// Object registry: id → (hash, created_at event index).
203    object_registry: HashMap<String, ObjectRecord>,
204    /// Number of activities recorded via `complete_activity()` (i.e., with bound evidence).
205    /// `receipt_coverage = bound_evidence_count / events.len()`.
206    bound_evidence_count: usize,
207    model_path: Option<PathBuf>,
208    test_run_id: String,
209    captured_output: Vec<CapturedOutput>,
210}
211
212impl PowlTestHarness {
213    /// Create a new harness for a declared route.
214    ///
215    /// Defaults to [`ExpectedConformance::exact()`].
216    ///
217    /// # Example
218    ///
219    /// ```
220    /// use wasm4pm::testing::harness::PowlTestHarness;
221    ///
222    /// let h = PowlTestHarness::new("wrap-tool-to-part");
223    /// assert_eq!(h.route_id(), "wrap-tool-to-part");
224    /// ```
225    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    /// Return the route id under test.
240    pub fn route_id(&self) -> &str {
241        &self.route_id
242    }
243
244    /// Return the unique run id for this test execution.
245    pub fn test_run_id(&self) -> &str {
246        &self.test_run_id
247    }
248
249    /// Set the expected conformance contract.
250    ///
251    /// # Example
252    ///
253    /// ```
254    /// use wasm4pm::testing::harness::PowlTestHarness;
255    /// use wasm4pm::testing::conformance::ExpectedConformance;
256    ///
257    /// let h = PowlTestHarness::new("route").expect(ExpectedConformance::exact());
258    /// assert_eq!(h.route_id(), "route");
259    /// ```
260    pub fn expect(mut self, expected: ExpectedConformance) -> Self {
261        self.expected = expected;
262        self
263    }
264
265    /// Set the POWL route model path for replay-based verification.
266    ///
267    /// # Example
268    ///
269    /// ```
270    /// use wasm4pm::testing::harness::PowlTestHarness;
271    ///
272    /// let h = PowlTestHarness::new("route")
273    ///     .model("routes/test-harness/test-run.powl.json");
274    /// assert_eq!(h.route_id(), "route");
275    /// ```
276    pub fn model(mut self, path: impl Into<PathBuf>) -> Self {
277        self.model_path = Some(path.into());
278        self
279    }
280
281    /// Record a test-time activity event.
282    ///
283    /// # Example
284    ///
285    /// ```
286    /// use wasm4pm::testing::harness::PowlTestHarness;
287    ///
288    /// let mut h = PowlTestHarness::new("route");
289    /// h.record_activity("fixture.created");
290    /// assert_eq!(h.event_count(), 1);
291    /// ```
292    /// Record an activity by name only — **no object evidence**.
293    ///
294    /// The activity appears in the POWL replay trace and contributes to
295    /// `fitness` and `precision`, but it does **not** increment
296    /// `bound_evidence_count`. A route using only `record_activity` will have
297    /// `receipt_coverage = 0.0` and fail admission.
298    ///
299    /// Use [`complete_activity`] to provide material evidence and count toward
300    /// `receipt_coverage` and `object_lifecycle_validity`.
301    ///
302    /// [`complete_activity`]: Self::complete_activity
303    pub fn record_activity(&mut self, activity: impl Into<String>) {
304        let activity = activity.into();
305        // Name-only receipt — NOT counted as bound evidence.
306        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        // bound_evidence_count is NOT incremented — record_activity is not evidence.
319    }
320
321    /// Record an activity with bound object evidence.
322    ///
323    /// Validates:
324    /// - Every input object must be registered (created by a prior activity).
325    /// - Every input object hash must match the hash recorded at creation time
326    ///   (catches post-creation tampering).
327    /// - Every output object ID must be new (prevents double-creation).
328    /// - At least one input or output must be provided (receipts cannot bind to nothing).
329    ///
330    /// On success, chains a BLAKE3 receipt binding the previous receipt hash,
331    /// route id, activity name, and all input/output hashes. Increments
332    /// `bound_evidence_count` — this is what makes `receipt_coverage` real.
333    ///
334    /// On failure, the activity is NOT recorded and the event log is unchanged.
335    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        // Validate inputs: must exist in registry with matching hash.
343        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        // Validate outputs: must not already exist.
362        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        // Register output objects.
371        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        // Chain receipt: prev_hash + route_id + activity + inputs + outputs.
382        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    /// Return the number of recorded events.
420    pub fn event_count(&self) -> usize {
421        self.events.len()
422    }
423
424    /// Return all recorded events in order.
425    ///
426    /// # Example
427    ///
428    /// ```
429    /// use wasm4pm::testing::harness::{PowlTestHarness, TestEvent};
430    ///
431    /// let mut h = PowlTestHarness::new("route");
432    /// h.record_activity("a");
433    /// h.record_activity("b");
434    /// assert_eq!(h.events()[0], TestEvent { activity: "a".into(), object_ids: vec![] });
435    /// assert_eq!(h.events()[1], TestEvent { activity: "b".into(), object_ids: vec![] });
436    /// ```
437    pub fn events(&self) -> &[TestEvent] {
438        &self.events
439    }
440
441    /// Attach command boundary output to the harness for OCEL evidence.
442    ///
443    /// Call after each subprocess or command boundary to bind the output
444    /// to the route evidence. Multiple calls accumulate output in order.
445    pub fn capture_output(&mut self, output: CapturedOutput) {
446        self.captured_output.push(output);
447    }
448
449    /// Return all captured output in recording order.
450    pub fn captured_output(&self) -> &[CapturedOutput] {
451        &self.captured_output
452    }
453
454    /// Run `f` with `&mut self` and catch any panics.
455    ///
456    /// If `f` panics, records `"panic.caught"` in the event log and
457    /// returns [`ConformanceVerdict::Andon(AndonPull::UnhandledPanic)`].
458    /// If `f` completes normally, delegates to [`finish()`].
459    ///
460    /// [`finish()`]: Self::finish
461    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            // AssertUnwindSafe is safe here: if f panics, self may be
467            // partially mutated, but we only append "panic.caught" and
468            // return UnhandledPull — we never read inconsistent state.
469            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    /// Export recorded events as OCEL-compatible JSON.
484    pub fn export_ocel(&self) -> serde_json::Value {
485        crate::testing::ocel_exporter::export_ocel(&self.events, &self.route_id)
486    }
487
488    /// Verify route conformance. Returns [`AndonPull::TestRouteIncomplete`]
489    /// if no model path has been set.
490    ///
491    /// Phase 7 wires this to the POWL token replay engine.
492    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    /// Verify conformance against a specific POWL model file.
500    ///
501    /// When the `powl` feature is enabled, parses the model, builds a Petri net,
502    /// and replays the recorded events against it using token replay. Returns
503    /// [`AndonPull::TestRouteIncomplete`] when the `powl` feature is disabled or
504    /// when the model file cannot be loaded or parsed.
505    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    /// Replay recorded events against a POWL route model file.
523    ///
524    /// The model file must be JSON:
525    /// ```json
526    /// { "powl_expression": "PO=(nodes={A, B}, order={A->B})", "required_activities": ["A", "B"] }
527    /// ```
528    ///
529    /// Maps token-replay `FitnessResult` → [`ReplayReport`]:
530    /// - `fitness` ← `avg_trace_fitness`
531    /// - `precision` ← `avg_trace_precision`
532    /// - `required_stage_coverage` ← fraction of `required_activities` present in events
533    /// - `receipt_coverage` ← `bound_evidence_count / events.len()` — fraction of
534    ///   activities recorded via `complete_activity()` (with bound object evidence).
535    ///   Routes using only `record_activity()` produce 0.0.
536    /// - `object_lifecycle_validity` ← 1.0 if any bound evidence exists (violations
537    ///   are rejected at `complete_activity()` time and never enter the event log);
538    ///   0.0 if no bound evidence exists (cannot validate what was never recorded).
539    ///
540    /// [`ReplayReport`]: crate::testing::conformance::ReplayReport
541    #[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        // receipt_coverage: fraction of activities backed by bound evidence.
603        // record_activity() contributes 0; complete_activity() contributes 1.
604        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        // object_lifecycle_validity: lifecycle violations cause complete_activity() to
611        // return Err and the activity is never recorded. If any bound evidence exists,
612        // all admitted activities passed lifecycle validation. If no bound evidence
613        // exists, lifecycle cannot be validated — return 0.0 to fail admission.
614        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    // ─── Phase 11: CapturedOutput and panic capture ───────────────────────────
704
705    #[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        // No model set → TestRouteIncomplete
754        assert_eq!(
755            verdict,
756            ConformanceVerdict::Andon(AndonPull::TestRouteIncomplete)
757        );
758    }
759}