Skip to main content

trellis_testing/
scenario.rs

1use trellis_core::{
2    GraphError, OutputFrameTrace, ResourceCommandTrace, ResourceKey, Revision, TraceMismatch,
3    TransactionId, TransactionResult, TransactionTrace, assert_transaction_traces_match,
4};
5
6use crate::{
7    FullRecomputeOracle, OracleCheck, OracleMismatch, ResourceLedgerError,
8    assert_incremental_equals_full,
9};
10
11/// Named transaction trace captured by a scenario test.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct ScenarioStep {
14    /// Human-readable step name.
15    pub name: String,
16    /// Structural transaction trace for this step.
17    pub trace: TransactionTrace,
18}
19
20/// Deterministic scenario recorder for transaction scripts.
21#[derive(Clone, Debug, Default, Eq, PartialEq)]
22pub struct Scenario {
23    steps: Vec<ScenarioStep>,
24}
25
26/// Scenario assertion failure.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub enum ScenarioError {
29    /// The replay trace sequence differed.
30    ReplayMismatch(TraceMismatch),
31    /// A serialized script or trace used an unsupported format version.
32    TraceFormatVersionMismatch {
33        /// Format version supported by this crate.
34        expected: u32,
35        /// Format version found in the serialized artifact.
36        actual: u32,
37    },
38    /// The final deterministic graph dump differed after replay.
39    ReplayFinalStateMismatch {
40        /// Expected final graph dump.
41        expected: String,
42        /// Actual final graph dump.
43        actual: String,
44    },
45    /// The replayed typed ledger state differed.
46    ReplayLedgerMismatch {
47        /// Ledger field whose value differed.
48        field: &'static str,
49        /// Expected typed ledger state.
50        expected: String,
51        /// Actual typed ledger state.
52        actual: String,
53    },
54    /// A named step was not found.
55    MissingStep(String),
56    /// A step name was recorded more than once.
57    DuplicateStep {
58        /// Duplicate step name.
59        step: String,
60    },
61    /// A named step had different structural data.
62    StepMismatch {
63        /// Step whose assertion failed.
64        step: String,
65        /// Transaction that produced the mismatched structural value.
66        transaction_id: TransactionId,
67        /// Graph revision at the mismatched step.
68        revision: Revision,
69        /// Trace field whose value differed.
70        field: &'static str,
71        /// Expected structural value.
72        expected: String,
73        /// Actual structural value.
74        actual: String,
75    },
76    /// A scenario step failed while staging or committing a transaction.
77    StepCommitFailed {
78        /// Step whose transaction failed.
79        step: String,
80        /// Graph error returned by core.
81        error: GraphError,
82    },
83    /// A resource-ledger invariant failed after a committed step.
84    ResourceLedgerInvariantFailed {
85        /// Step whose transaction produced the failed invariant.
86        step: String,
87        /// Ledger error returned by the invariant.
88        error: Box<ResourceLedgerError>,
89    },
90    /// A step-level invariant hook failed.
91    InvariantFailed {
92        /// Step whose invariant failed.
93        step: String,
94        /// Stable invariant name.
95        invariant: String,
96        /// Transaction that produced the failure.
97        transaction_id: TransactionId,
98        /// Graph revision at the failed invariant.
99        revision: Revision,
100    },
101}
102
103/// Redaction hook for scenario debug and snapshot output.
104pub trait TraceRedactor {
105    /// Redacts a scenario step name.
106    fn step_name(&self, name: &str) -> String {
107        name.to_owned()
108    }
109
110    /// Redacts a resource key.
111    fn resource_key(&self, key: &ResourceKey) -> ResourceKey {
112        key.clone()
113    }
114
115    /// Redacts an invariant name.
116    fn invariant_name(&self, name: &str) -> String {
117        name.to_owned()
118    }
119}
120
121/// Redactor that preserves all trace data.
122#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
123pub struct NoRedaction;
124
125impl TraceRedactor for NoRedaction {}
126
127impl Scenario {
128    /// Creates an empty scenario recorder.
129    pub fn new() -> Self {
130        Self::default()
131    }
132
133    /// Records a committed transaction result under a stable step name.
134    pub fn record<C>(
135        &mut self,
136        name: impl Into<String>,
137        result: &TransactionResult<C>,
138    ) -> Result<(), ScenarioError> {
139        self.record_trace(name, result.trace())
140    }
141
142    /// Records an already-built structural transaction trace under a step name.
143    pub fn record_trace(
144        &mut self,
145        name: impl Into<String>,
146        trace: TransactionTrace,
147    ) -> Result<(), ScenarioError> {
148        let name = name.into();
149        self.ensure_step_name_available(&name)?;
150        self.steps.push(ScenarioStep { name, trace });
151        Ok(())
152    }
153
154    /// Returns all recorded steps in commit order.
155    pub fn steps(&self) -> &[ScenarioStep] {
156        &self.steps
157    }
158
159    /// Returns a named step.
160    pub fn step(&self, name: &str) -> Result<&ScenarioStep, ScenarioError> {
161        self.steps
162            .iter()
163            .find(|step| step.name == name)
164            .ok_or_else(|| ScenarioError::MissingStep(name.to_owned()))
165    }
166
167    pub(crate) fn ensure_step_name_available(&self, name: &str) -> Result<(), ScenarioError> {
168        if self.steps.iter().any(|step| step.name == name) {
169            Err(ScenarioError::DuplicateStep {
170                step: name.to_owned(),
171            })
172        } else {
173            Ok(())
174        }
175    }
176
177    /// Compares two scenario trace sequences structurally.
178    pub fn assert_replay_matches(&self, other: &Scenario) -> Result<(), ScenarioError> {
179        assert_transaction_traces_match(&self.traces(), &other.traces())
180            .map_err(ScenarioError::ReplayMismatch)
181    }
182
183    /// Returns all transaction traces in commit order.
184    pub fn traces(&self) -> Vec<TransactionTrace> {
185        self.steps
186            .iter()
187            .map(|step| step.trace.clone())
188            .collect::<Vec<_>>()
189    }
190
191    /// Returns all resource command traces in commit order.
192    pub fn resource_commands(&self) -> Vec<ResourceCommandTrace> {
193        self.steps
194            .iter()
195            .flat_map(|step| step.trace.resource_commands.iter().cloned())
196            .collect()
197    }
198
199    /// Returns all output frame traces in commit order.
200    pub fn output_frames(&self) -> Vec<OutputFrameTrace> {
201        self.steps
202            .iter()
203            .flat_map(|step| step.trace.output_frames.iter().cloned())
204            .collect()
205    }
206
207    /// Asserts a named step emitted the expected resource command trace.
208    pub fn assert_step_resource_commands(
209        &self,
210        name: &str,
211        expected: &[ResourceCommandTrace],
212    ) -> Result<(), ScenarioError> {
213        let step = self.step(name)?;
214        if step.trace.resource_commands == expected {
215            Ok(())
216        } else {
217            Err(ScenarioError::StepMismatch {
218                step: name.to_owned(),
219                transaction_id: step.trace.transaction_id,
220                revision: step.trace.revision,
221                field: "resource_commands",
222                expected: format!("{expected:#?}"),
223                actual: format!("{:#?}", step.trace.resource_commands),
224            })
225        }
226    }
227
228    /// Asserts a named step emitted the expected output frame trace.
229    pub fn assert_step_output_frames(
230        &self,
231        name: &str,
232        expected: &[OutputFrameTrace],
233    ) -> Result<(), ScenarioError> {
234        let step = self.step(name)?;
235        if step.trace.output_frames == expected {
236            Ok(())
237        } else {
238            Err(ScenarioError::StepMismatch {
239                step: name.to_owned(),
240                transaction_id: step.trace.transaction_id,
241                revision: step.trace.revision,
242                field: "output_frames",
243                expected: format!("{expected:#?}"),
244                actual: format!("{:#?}", step.trace.output_frames),
245            })
246        }
247    }
248
249    /// Runs an app-owned oracle check through the scenario harness.
250    pub fn assert_oracle<G, O>(
251        &self,
252        graph: &G,
253        inputs: &O::CanonicalInputs,
254    ) -> Result<OracleCheck<O::ExpectedState>, OracleMismatch<O::ExpectedState>>
255    where
256        O: FullRecomputeOracle<G>,
257    {
258        assert_incremental_equals_full::<G, O>(graph, inputs)
259    }
260
261    /// Returns a redacted copy of the scenario for snapshot/debug output.
262    pub fn redacted(&self, redactor: &impl TraceRedactor) -> Self {
263        let steps = self
264            .steps
265            .iter()
266            .map(|step| ScenarioStep {
267                name: redactor.step_name(&step.name),
268                trace: redact_trace(&step.trace, redactor),
269            })
270            .collect();
271        Self { steps }
272    }
273
274    /// Returns deterministic redacted debug output for snapshots.
275    pub fn to_redacted_debug_string(&self, redactor: &impl TraceRedactor) -> String {
276        format!("{:#?}", self.redacted(redactor))
277    }
278}
279
280fn redact_trace(trace: &TransactionTrace, redactor: &impl TraceRedactor) -> TransactionTrace {
281    let mut trace = trace.clone();
282    for command in &mut trace.resource_commands {
283        command.key = redactor.resource_key(&command.key);
284    }
285    for coalesced in &mut trace.resource_coalescences {
286        coalesced.key = redactor.resource_key(&coalesced.key);
287    }
288    for result in &mut trace.invariant_results {
289        result.name = redactor.invariant_name(&result.name);
290    }
291    trace
292}