Skip to main content

trellis_testing/
output_ledger.rs

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