Skip to main content

trellis_testing/
resource_assertions.rs

1use std::collections::BTreeSet;
2
3use trellis_core::{Graph, ResourceCommandTrace, ResourceKey, Revision, ScopeId};
4
5use crate::{
6    HostStatusClass, HostStatusRecord, ResourceLedger, ResourceLedgerError, ResourceSnapshot,
7    ResourceStatusContext,
8};
9
10impl<C> ResourceLedger<C> {
11    /// Asserts the full applied resource command order.
12    pub fn assert_command_order(
13        &self,
14        expected: &[ResourceCommandTrace],
15    ) -> Result<(), ResourceLedgerError> {
16        if self.command_trace == expected {
17            Ok(())
18        } else {
19            Err(ResourceLedgerError::CommandOrderMismatch {
20                expected: expected.to_vec(),
21                actual: self.command_trace.clone(),
22            })
23        }
24    }
25
26    /// Asserts every tracked resource still has at least one owner.
27    pub fn assert_all_resources_have_owner(&self) -> Result<(), ResourceLedgerError> {
28        for (key, snapshot) in &self.resources {
29            if snapshot.owners.is_empty() {
30                return Err(ResourceLedgerError::Orphan {
31                    key: key.clone(),
32                    context: Some(snapshot.command_context()),
33                });
34            }
35        }
36        Ok(())
37    }
38
39    /// Asserts every tracked live resource has at least one owner.
40    pub fn assert_no_orphan_resources(&self) -> Result<(), ResourceLedgerError> {
41        self.assert_all_resources_have_owner()
42    }
43
44    /// Asserts the wrapped graph reports no graph-level orphan resources.
45    pub fn assert_graph_has_no_orphan_resources(
46        &self,
47        graph: &Graph<C>,
48    ) -> Result<(), ResourceLedgerError> {
49        if let Some(key) = graph.orphan_resources().into_iter().next() {
50            Err(ResourceLedgerError::Orphan {
51                context: self.context_for_key(&key),
52                key,
53            })
54        } else {
55            Ok(())
56        }
57    }
58
59    /// Asserts no duplicate close was observed.
60    pub fn assert_no_duplicate_close(&self) -> Result<(), ResourceLedgerError> {
61        if let Some(context) = self.duplicate_closes.first() {
62            Err(ResourceLedgerError::DuplicateClose {
63                key: context.key.clone(),
64                context: context.clone(),
65            })
66        } else {
67            Ok(())
68        }
69    }
70
71    /// Asserts every coalesced Open joined an already-open resource.
72    pub fn assert_no_unexplained_coalescing(&self) -> Result<(), ResourceLedgerError> {
73        if let Some(coalescence) = self.unexplained_coalescences.first() {
74            Err(ResourceLedgerError::UnexplainedCoalescence {
75                key: coalescence.key.clone(),
76                coalescence: coalescence.clone(),
77            })
78        } else {
79            Ok(())
80        }
81    }
82
83    /// Asserts no forbidden resource key was opened.
84    pub fn assert_no_forbidden_opened(&self) -> Result<(), ResourceLedgerError> {
85        if let Some(context) = self.forbidden_opened.first() {
86            Err(ResourceLedgerError::ForbiddenOpen {
87                key: context.key.clone(),
88                context: Some(context.clone()),
89            })
90        } else {
91            Ok(())
92        }
93    }
94
95    /// Asserts no explicitly forbidden wildcard resource key was opened.
96    pub fn assert_no_wildcard_resource_opened(&self) -> Result<(), ResourceLedgerError> {
97        self.assert_no_forbidden_opened()
98    }
99
100    /// Asserts a resource is no longer open.
101    pub fn assert_resource_not_open(&self, key: &ResourceKey) -> Result<(), ResourceLedgerError> {
102        if self.resources.contains_key(key) {
103            Err(ResourceLedgerError::StillOpen {
104                key: key.clone(),
105                context: self.context_for_key(key),
106            })
107        } else {
108            Ok(())
109        }
110    }
111
112    /// Asserts a closed scope owns no live resources.
113    pub fn assert_closed_scope_owns_no_resources(
114        &self,
115        scope: ScopeId,
116    ) -> Result<(), ResourceLedgerError> {
117        let resources = self
118            .resources
119            .iter()
120            .filter(|(_, snapshot)| snapshot.owners.contains(&scope))
121            .map(|(key, _)| key.clone())
122            .collect::<Vec<_>>();
123        if resources.is_empty() {
124            Ok(())
125        } else {
126            let contexts = resources
127                .iter()
128                .filter_map(|key| self.context_for_key(key))
129                .collect();
130            Err(ResourceLedgerError::ClosedScopeOwnsResources {
131                scope,
132                resources,
133                contexts,
134            })
135        }
136    }
137
138    /// Asserts a resource was opened exactly once.
139    pub fn assert_resource_opened_once(
140        &self,
141        key: &ResourceKey,
142    ) -> Result<(), ResourceLedgerError> {
143        self.assert_count(key, "open_count", 1, |snapshot| snapshot.open_count)
144    }
145
146    /// Asserts a resource was closed exactly once.
147    pub fn assert_resource_closed_once(
148        &self,
149        key: &ResourceKey,
150    ) -> Result<(), ResourceLedgerError> {
151        self.assert_count(key, "close_count", 1, |snapshot| snapshot.close_count)
152    }
153
154    /// Asserts a resource has the expected command generation.
155    pub fn assert_resource_generation(
156        &self,
157        key: &ResourceKey,
158        expected: u64,
159    ) -> Result<(), ResourceLedgerError> {
160        let actual = self
161            .history
162            .get(key)
163            .map_or(0, |snapshot| snapshot.generation);
164        if actual == expected {
165            Ok(())
166        } else {
167            Err(ResourceLedgerError::GenerationMismatch {
168                key: key.clone(),
169                expected,
170                actual,
171                context: self.context_for_key(key),
172            })
173        }
174    }
175
176    /// Asserts a resource is owned by the expected scopes.
177    pub fn assert_resource_shared_by(
178        &self,
179        key: &ResourceKey,
180        expected: BTreeSet<ScopeId>,
181    ) -> Result<(), ResourceLedgerError> {
182        let actual = self
183            .resources
184            .get(key)
185            .map(|snapshot| snapshot.owners.clone())
186            .unwrap_or_default();
187        if actual == expected {
188            Ok(())
189        } else {
190            Err(ResourceLedgerError::OwnerMismatch {
191                key: key.clone(),
192                expected,
193                actual,
194                context: self.context_for_key(key),
195            })
196        }
197    }
198
199    /// Asserts a status for a command revision was classified as stale.
200    pub fn assert_status_is_stale(
201        &self,
202        key: &ResourceKey,
203        command_revision: Revision,
204    ) -> Result<(), ResourceLedgerError> {
205        let Some(record) = self.status_records.iter().find(|record| {
206            record.status.resource_key == *key && record.status.command_revision == command_revision
207        }) else {
208            return Err(ResourceLedgerError::MissingStatus {
209                key: key.clone(),
210                command_revision,
211            });
212        };
213        if record.class == HostStatusClass::Stale {
214            Ok(())
215        } else {
216            Err(ResourceLedgerError::StatusClassMismatch {
217                context: status_context(record),
218                expected: HostStatusClass::Stale,
219            })
220        }
221    }
222
223    /// Asserts late statuses did not recreate ownership for a closed scope.
224    pub fn assert_status_did_not_resurrect_closed_scope(
225        &self,
226        scope: ScopeId,
227    ) -> Result<(), ResourceLedgerError> {
228        self.assert_closed_scope_owns_no_resources(scope)?;
229        self.assert_no_status_mutated_closed_scope()
230    }
231
232    /// Asserts status classification never mutated a closed scope's ownership.
233    pub fn assert_no_status_mutated_closed_scope(&self) -> Result<(), ResourceLedgerError> {
234        for record in &self.status_records {
235            if record.class == HostStatusClass::Late
236                && self.scope_owns_resource(record.status.scope, &record.status.resource_key)
237            {
238                return Err(ResourceLedgerError::StatusMutatedClosedScope {
239                    scope: record.status.scope,
240                    context: status_context(record),
241                });
242            }
243        }
244        Ok(())
245    }
246
247    fn assert_count(
248        &self,
249        key: &ResourceKey,
250        field: &'static str,
251        expected: usize,
252        count: impl FnOnce(&ResourceSnapshot<C>) -> usize,
253    ) -> Result<(), ResourceLedgerError> {
254        let actual = self.history.get(key).map_or(0, count);
255        if actual == expected {
256            Ok(())
257        } else {
258            Err(ResourceLedgerError::CountMismatch {
259                key: key.clone(),
260                field,
261                expected,
262                actual,
263                context: self.context_for_key(key),
264            })
265        }
266    }
267
268    fn scope_owns_resource(&self, scope: ScopeId, key: &ResourceKey) -> bool {
269        self.resources
270            .get(key)
271            .is_some_and(|snapshot| snapshot.owners.contains(&scope))
272    }
273}
274
275fn status_context(record: &HostStatusRecord) -> ResourceStatusContext {
276    ResourceStatusContext {
277        status: record.status.clone(),
278        class: record.class,
279        last_transaction_id: record.last_transaction_id,
280        last_command_revision: record.last_command_revision,
281    }
282}