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