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 no forbidden resource key was opened.
72    pub fn assert_no_forbidden_opened(&self) -> Result<(), ResourceLedgerError> {
73        if let Some(context) = self.forbidden_opened.first() {
74            Err(ResourceLedgerError::ForbiddenOpen {
75                key: context.key.clone(),
76                context: Some(context.clone()),
77            })
78        } else {
79            Ok(())
80        }
81    }
82
83    /// Asserts no explicitly forbidden wildcard resource key was opened.
84    pub fn assert_no_wildcard_resource_opened(&self) -> Result<(), ResourceLedgerError> {
85        self.assert_no_forbidden_opened()
86    }
87
88    /// Asserts a resource is no longer open.
89    pub fn assert_resource_not_open(&self, key: &ResourceKey) -> Result<(), ResourceLedgerError> {
90        if self.resources.contains_key(key) {
91            Err(ResourceLedgerError::StillOpen {
92                key: key.clone(),
93                context: self.context_for_key(key),
94            })
95        } else {
96            Ok(())
97        }
98    }
99
100    /// Asserts a closed scope owns no live resources.
101    pub fn assert_closed_scope_owns_no_resources(
102        &self,
103        scope: ScopeId,
104    ) -> Result<(), ResourceLedgerError> {
105        let resources = self
106            .resources
107            .iter()
108            .filter(|(_, snapshot)| snapshot.owners.contains(&scope))
109            .map(|(key, _)| key.clone())
110            .collect::<Vec<_>>();
111        if resources.is_empty() {
112            Ok(())
113        } else {
114            let contexts = resources
115                .iter()
116                .filter_map(|key| self.context_for_key(key))
117                .collect();
118            Err(ResourceLedgerError::ClosedScopeOwnsResources {
119                scope,
120                resources,
121                contexts,
122            })
123        }
124    }
125
126    /// Asserts a resource was opened exactly once.
127    pub fn assert_resource_opened_once(
128        &self,
129        key: &ResourceKey,
130    ) -> Result<(), ResourceLedgerError> {
131        self.assert_count(key, "open_count", 1, |snapshot| snapshot.open_count)
132    }
133
134    /// Asserts a resource was closed exactly once.
135    pub fn assert_resource_closed_once(
136        &self,
137        key: &ResourceKey,
138    ) -> Result<(), ResourceLedgerError> {
139        self.assert_count(key, "close_count", 1, |snapshot| snapshot.close_count)
140    }
141
142    /// Asserts a resource has the expected command generation.
143    pub fn assert_resource_generation(
144        &self,
145        key: &ResourceKey,
146        expected: u64,
147    ) -> Result<(), ResourceLedgerError> {
148        let actual = self
149            .history
150            .get(key)
151            .map_or(0, |snapshot| snapshot.generation);
152        if actual == expected {
153            Ok(())
154        } else {
155            Err(ResourceLedgerError::GenerationMismatch {
156                key: key.clone(),
157                expected,
158                actual,
159                context: self.context_for_key(key),
160            })
161        }
162    }
163
164    /// Asserts a resource is owned by the expected scopes.
165    pub fn assert_resource_shared_by(
166        &self,
167        key: &ResourceKey,
168        expected: BTreeSet<ScopeId>,
169    ) -> Result<(), ResourceLedgerError> {
170        let actual = self
171            .resources
172            .get(key)
173            .map(|snapshot| snapshot.owners.clone())
174            .unwrap_or_default();
175        if actual == expected {
176            Ok(())
177        } else {
178            Err(ResourceLedgerError::OwnerMismatch {
179                key: key.clone(),
180                expected,
181                actual,
182                context: self.context_for_key(key),
183            })
184        }
185    }
186
187    /// Asserts a status for a command revision was classified as stale.
188    pub fn assert_status_is_stale(
189        &self,
190        key: &ResourceKey,
191        command_revision: Revision,
192    ) -> Result<(), ResourceLedgerError> {
193        let Some(record) = self.status_records.iter().find(|record| {
194            record.status.resource_key == *key && record.status.command_revision == command_revision
195        }) else {
196            return Err(ResourceLedgerError::MissingStatus {
197                key: key.clone(),
198                command_revision,
199            });
200        };
201        if record.class == HostStatusClass::Stale {
202            Ok(())
203        } else {
204            Err(ResourceLedgerError::StatusClassMismatch {
205                context: status_context(record),
206                expected: HostStatusClass::Stale,
207            })
208        }
209    }
210
211    /// Asserts late statuses did not recreate ownership for a closed scope.
212    pub fn assert_status_did_not_resurrect_closed_scope(
213        &self,
214        scope: ScopeId,
215    ) -> Result<(), ResourceLedgerError> {
216        self.assert_closed_scope_owns_no_resources(scope)?;
217        self.assert_no_status_mutated_closed_scope()
218    }
219
220    /// Asserts status classification never mutated a closed scope's ownership.
221    pub fn assert_no_status_mutated_closed_scope(&self) -> Result<(), ResourceLedgerError> {
222        for record in &self.status_records {
223            if record.class == HostStatusClass::Late
224                && self.scope_owns_resource(record.status.scope, &record.status.resource_key)
225            {
226                return Err(ResourceLedgerError::StatusMutatedClosedScope {
227                    scope: record.status.scope,
228                    context: status_context(record),
229                });
230            }
231        }
232        Ok(())
233    }
234
235    fn assert_count(
236        &self,
237        key: &ResourceKey,
238        field: &'static str,
239        expected: usize,
240        count: impl FnOnce(&ResourceSnapshot<C>) -> usize,
241    ) -> Result<(), ResourceLedgerError> {
242        let actual = self.history.get(key).map_or(0, count);
243        if actual == expected {
244            Ok(())
245        } else {
246            Err(ResourceLedgerError::CountMismatch {
247                key: key.clone(),
248                field,
249                expected,
250                actual,
251                context: self.context_for_key(key),
252            })
253        }
254    }
255
256    fn scope_owns_resource(&self, scope: ScopeId, key: &ResourceKey) -> bool {
257        self.resources
258            .get(key)
259            .is_some_and(|snapshot| snapshot.owners.contains(&scope))
260    }
261}
262
263fn status_context(record: &HostStatusRecord) -> ResourceStatusContext {
264    ResourceStatusContext {
265        status: record.status.clone(),
266        class: record.class,
267        last_transaction_id: record.last_transaction_id,
268        last_command_revision: record.last_command_revision,
269    }
270}