Skip to main content

trellis_testing/
output_ledger.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use trellis_core::{
4    OutputFrame, OutputFrameKind, OutputFrameKindTrace, OutputFrameTrace, OutputKey, Revision,
5    ScopeId, TransactionId, TransactionResult,
6};
7
8/// Current ledger view for one materialized output.
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub struct OutputSnapshot<O> {
11    /// Scope that owns the output.
12    pub scope: ScopeId,
13    /// Last transaction that emitted a frame.
14    pub transaction_id: TransactionId,
15    /// Last revision observed for this output.
16    pub revision: Revision,
17    /// Current consumer state after applying frames.
18    pub state: Option<O>,
19    /// Whether a clear frame has been observed.
20    pub cleared: bool,
21    /// Last frame trace observed for this output.
22    pub frame: OutputFrameTrace,
23}
24
25/// Output ledger assertion failure.
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub enum OutputLedgerError {
28    /// A frame revision moved backward.
29    RevisionRegression {
30        /// Frame that regressed.
31        context: OutputFrameTrace,
32        /// Previous revision.
33        previous: Revision,
34    },
35    /// Output was not cleared.
36    NotCleared {
37        /// Output key.
38        key: OutputKey,
39        /// Last frame context for the output, if any.
40        context: Option<OutputFrameTrace>,
41    },
42    /// A closed scope emitted a non-terminal frame.
43    FrameAfterClosedScope {
44        /// Frame that targeted the closed scope.
45        context: OutputFrameTrace,
46    },
47    /// Outputs owned by a closed scope were not cleared.
48    ClosedScopeNotCleared {
49        /// Closed scope.
50        scope: ScopeId,
51        /// Output keys that remain uncleared.
52        outputs: Vec<OutputKey>,
53        /// Last frame contexts for uncleared outputs.
54        contexts: Vec<OutputFrameTrace>,
55    },
56    /// Current state differs from an expected baseline/rebaseline.
57    StateMismatch {
58        /// Output key.
59        key: OutputKey,
60        /// Last frame context for the output, if any.
61        context: Option<OutputFrameTrace>,
62    },
63}
64
65/// Fake output consumer ledger for materialized output frames.
66#[derive(Clone, Debug, Default, Eq, PartialEq)]
67pub struct OutputLedger<O> {
68    pub(crate) outputs: BTreeMap<OutputKey, OutputSnapshot<O>>,
69    pub(crate) closed_scopes: BTreeSet<ScopeId>,
70    pub(crate) frames: Vec<OutputFrameTrace>,
71    pub(crate) frame_records: Vec<OutputFrame<O>>,
72    pub(crate) errors: Vec<OutputLedgerError>,
73}
74
75impl<O: Clone + PartialEq> OutputLedger<O> {
76    /// Creates an empty output ledger.
77    pub fn new() -> Self {
78        Self {
79            outputs: BTreeMap::new(),
80            closed_scopes: BTreeSet::new(),
81            frames: Vec::new(),
82            frame_records: Vec::new(),
83            errors: Vec::new(),
84        }
85    }
86
87    /// Marks a scope closed for later frame validation.
88    pub fn close_scope(&mut self, scope: ScopeId) {
89        self.closed_scopes.insert(scope);
90    }
91
92    /// Applies all output frames from a transaction result.
93    pub fn apply_result<C>(&mut self, result: &TransactionResult<C, O>) {
94        for frame in &result.output_frames {
95            self.apply_frame(frame);
96        }
97    }
98
99    /// Applies a single output frame.
100    pub fn apply_frame(&mut self, frame: &OutputFrame<O>) {
101        let trace = output_frame_trace(frame);
102        self.frames.push(trace.clone());
103        self.frame_records.push(frame.clone());
104        if self.closed_scopes.contains(&frame.scope)
105            && !matches!(frame.kind, OutputFrameKind::Clear(_))
106        {
107            self.errors
108                .push(OutputLedgerError::FrameAfterClosedScope { context: trace });
109            return;
110        }
111
112        if let Some(previous) = self.outputs.get(&frame.output_key)
113            && frame.revision < previous.revision
114        {
115            self.errors.push(OutputLedgerError::RevisionRegression {
116                context: trace.clone(),
117                previous: previous.revision,
118            });
119        }
120
121        let state = match &frame.kind {
122            OutputFrameKind::Baseline(value)
123            | OutputFrameKind::Delta(value)
124            | OutputFrameKind::Rebaseline(value, _) => Some(value.clone()),
125            OutputFrameKind::Clear(_) => None,
126        };
127        self.outputs.insert(
128            frame.output_key,
129            OutputSnapshot {
130                scope: frame.scope,
131                transaction_id: frame.transaction_id,
132                revision: frame.revision,
133                state,
134                cleared: matches!(frame.kind, OutputFrameKind::Clear(_)),
135                frame: trace,
136            },
137        );
138    }
139
140    /// Returns current output state.
141    pub fn snapshot(&self, key: OutputKey) -> Option<&OutputSnapshot<O>> {
142        self.outputs.get(&key)
143    }
144
145    /// Returns structural ledger errors observed while applying frames.
146    pub fn errors(&self) -> &[OutputLedgerError] {
147        &self.errors
148    }
149
150    /// Returns frame traces in applied delivery order.
151    pub fn frame_trace(&self) -> &[OutputFrameTrace] {
152        &self.frames
153    }
154
155    /// Returns applied output frames including typed payloads in delivery order.
156    pub fn frame_records(&self) -> &[OutputFrame<O>] {
157        &self.frame_records
158    }
159
160    /// Asserts no revision regressions or closed-scope frame errors occurred.
161    pub fn assert_revision_monotonic(&self) -> Result<(), OutputLedgerError> {
162        self.errors
163            .iter()
164            .find(|error| matches!(error, OutputLedgerError::RevisionRegression { .. }))
165            .cloned()
166            .map_or(Ok(()), Err)
167    }
168
169    /// Asserts closed scopes emitted no non-terminal output frames.
170    pub fn assert_no_frame_for_closed_scope_except_terminal(
171        &self,
172    ) -> Result<(), OutputLedgerError> {
173        self.errors
174            .iter()
175            .find(|error| matches!(error, OutputLedgerError::FrameAfterClosedScope { .. }))
176            .cloned()
177            .map_or(Ok(()), Err)
178    }
179
180    /// Asserts every output owned by a closed scope has been cleared.
181    pub fn assert_closed_scope_cleared(&self, scope: ScopeId) -> Result<(), OutputLedgerError> {
182        let uncleared = self
183            .outputs
184            .iter()
185            .filter(|(_, snapshot)| snapshot.scope == scope && !snapshot.cleared)
186            .map(|(key, _)| *key)
187            .collect::<Vec<_>>();
188        if uncleared.is_empty() {
189            Ok(())
190        } else {
191            let contexts = uncleared
192                .iter()
193                .filter_map(|key| self.outputs.get(key).map(|snapshot| snapshot.frame.clone()))
194                .collect();
195            Err(OutputLedgerError::ClosedScopeNotCleared {
196                scope,
197                outputs: uncleared,
198                contexts,
199            })
200        }
201    }
202
203    /// Asserts an output key is currently cleared.
204    pub fn assert_cleared(&self, key: OutputKey) -> Result<(), OutputLedgerError> {
205        if self
206            .outputs
207            .get(&key)
208            .is_some_and(|snapshot| snapshot.cleared)
209        {
210            Ok(())
211        } else {
212            Err(OutputLedgerError::NotCleared {
213                key,
214                context: self
215                    .outputs
216                    .get(&key)
217                    .map(|snapshot| snapshot.frame.clone()),
218            })
219        }
220    }
221
222    /// Asserts the current consumer state equals an expected baseline.
223    pub fn assert_current_equals(
224        &self,
225        key: OutputKey,
226        expected: &O,
227    ) -> Result<(), OutputLedgerError> {
228        if self
229            .outputs
230            .get(&key)
231            .and_then(|snapshot| snapshot.state.as_ref())
232            == Some(expected)
233        {
234            Ok(())
235        } else {
236            Err(OutputLedgerError::StateMismatch {
237                key,
238                context: self
239                    .outputs
240                    .get(&key)
241                    .map(|snapshot| snapshot.frame.clone()),
242            })
243        }
244    }
245
246    /// Asserts the current delta-applied state matches a rebaseline value.
247    pub fn assert_delta_sequence_matches_rebaseline(
248        &self,
249        key: OutputKey,
250        rebaseline: &O,
251    ) -> Result<(), OutputLedgerError> {
252        self.assert_current_equals(key, rebaseline)
253    }
254
255    /// Asserts the ledger observed no structural frame errors.
256    pub fn assert_consumer_needs_no_hidden_graph_state(&self) -> Result<(), OutputLedgerError> {
257        self.errors.first().cloned().map_or(Ok(()), Err)
258    }
259}
260
261fn output_frame_trace<O>(frame: &OutputFrame<O>) -> OutputFrameTrace {
262    OutputFrameTrace {
263        output_key: frame.output_key,
264        scope: frame.scope,
265        transaction_id: frame.transaction_id,
266        revision: frame.revision,
267        kind: output_frame_kind(&frame.kind),
268    }
269}
270
271fn output_frame_kind<O>(kind: &OutputFrameKind<O>) -> OutputFrameKindTrace {
272    match kind {
273        OutputFrameKind::Baseline(_) => OutputFrameKindTrace::Baseline,
274        OutputFrameKind::Delta(_) => OutputFrameKindTrace::Delta,
275        OutputFrameKind::Clear(reason) => OutputFrameKindTrace::Clear(*reason),
276        OutputFrameKind::Rebaseline(_, reason) => OutputFrameKindTrace::Rebaseline(*reason),
277    }
278}