1use std::collections::{BTreeMap, BTreeSet};
6use std::path::Path;
7use std::time::{Duration, Instant};
8use std::{fs, io};
9
10use thiserror::Error;
11use tytanic_filter::{eval, ExpressionFilter};
12use tytanic_utils::fmt::Term;
13use uuid::Uuid;
14
15use crate::project::Project;
16use crate::test::{Id, LoadError, ParseIdError, Test, TestResult};
17
18#[derive(Debug, Clone)]
20pub struct Suite {
21 tests: BTreeMap<Id, Test>,
22 nested: BTreeMap<Id, Test>,
23}
24
25impl Suite {
26 pub fn new() -> Self {
28 Self {
29 tests: BTreeMap::new(),
30 nested: BTreeMap::new(),
31 }
32 }
33
34 #[tracing::instrument(skip(project), fields(test_root = ?project.unit_tests_root()))]
36 pub fn collect(project: &Project) -> Result<Self, Error> {
37 let root = project.unit_tests_root();
38
39 let mut this = Self::new();
40
41 match root.read_dir() {
42 Ok(read_dir) => {
43 tracing::debug!("collecting from test root directory");
44 for entry in read_dir {
45 let entry = entry?;
46
47 if entry.metadata()?.is_dir() {
48 let abs = entry.path();
49 let rel = abs
50 .strip_prefix(project.unit_tests_root())
51 .expect("entry must be in full");
52
53 this.collect_dir(project, rel)?;
54 }
55 }
56 }
57 Err(err) if err.kind() == io::ErrorKind::NotFound => {
58 tracing::debug!("test suite empty");
59 }
60 Err(err) => return Err(err.into()),
61 }
62
63 let without_leafs: BTreeSet<_> = this
64 .tests
65 .keys()
66 .flat_map(|test| test.ancestors().skip(1))
67 .map(|test| test.to_owned())
68 .collect();
69
70 let all: BTreeSet<_> = this
71 .tests
72 .keys()
73 .map(|test| test.as_str().to_owned())
74 .collect();
75
76 for id in all.intersection(&without_leafs) {
77 if let Some((id, test)) = this.tests.remove_entry(id.as_str()) {
78 this.nested.insert(id, test);
79 }
80 }
81
82 Ok(this)
83 }
84
85 fn collect_dir(&mut self, project: &Project, dir: &Path) -> Result<(), Error> {
87 let abs = project.unit_tests_root().join(dir);
88
89 tracing::trace!(?dir, "collecting directory");
90
91 let id = Id::new_from_path(dir)?;
92
93 if let Some(test) = Test::load(project, id.clone())? {
94 tracing::debug!(id = %test.id(), "collected test");
95 self.tests.insert(id, test);
96 }
97
98 for entry in fs::read_dir(&abs)? {
99 let entry = entry?;
100
101 if entry.metadata()?.is_dir() {
102 let abs = entry.path();
103 let rel = abs
104 .strip_prefix(project.unit_tests_root())
105 .expect("entry must be in full");
106
107 tracing::trace!(path = ?rel, "reading directory entry");
108 self.collect_dir(project, rel)?;
109 }
110 }
111
112 Ok(())
113 }
114}
115
116impl Suite {
117 pub fn tests(&self) -> &BTreeMap<Id, Test> {
119 &self.tests
120 }
121
122 pub fn nested(&self) -> &BTreeMap<Id, Test> {
126 &self.nested
127 }
128}
129
130impl Suite {
131 pub fn filter(self, filter: Filter) -> Result<FilteredSuite, FilterError> {
133 match &filter {
134 Filter::TestSet(expr) => {
135 let mut matched = BTreeMap::new();
136 let mut filtered = BTreeMap::new();
137
138 for (id, test) in &self.tests {
139 if expr.contains(test)? {
140 matched.insert(id.clone(), test.clone());
141 } else {
142 filtered.insert(id.clone(), test.clone());
143 }
144 }
145
146 Ok(FilteredSuite {
147 suite: self,
148 filter,
149 matched,
150 filtered,
151 })
152 }
153 Filter::Explicit(set) => {
154 let mut tests = self.tests.clone();
155 let mut matched = BTreeMap::new();
156 let mut missing = BTreeSet::new();
157
158 for id in set {
159 match tests.remove_entry(id) {
160 Some((id, test)) => {
161 matched.insert(id, test);
162 }
163 None => {
164 missing.insert(id.clone());
165 }
166 }
167 }
168
169 if !missing.is_empty() {
170 return Err(FilterError::Missing(missing));
171 }
172
173 Ok(FilteredSuite {
174 suite: self,
175 filter,
176 matched,
177 filtered: tests,
178 })
179 }
180 }
181 }
182}
183
184impl Default for Suite {
185 fn default() -> Self {
186 Self::new()
187 }
188}
189
190#[derive(Debug, Clone)]
192pub enum Filter {
193 TestSet(ExpressionFilter<Test>),
195
196 Explicit(BTreeSet<Id>),
199}
200
201#[derive(Debug, Clone)]
203pub struct FilteredSuite {
204 suite: Suite,
205 filter: Filter,
206 matched: BTreeMap<Id, Test>,
207 filtered: BTreeMap<Id, Test>,
208}
209
210impl FilteredSuite {
211 pub fn suite(&self) -> &Suite {
213 &self.suite
214 }
215
216 pub fn filter(&self) -> &Filter {
218 &self.filter
219 }
220
221 pub fn matched(&self) -> &BTreeMap<Id, Test> {
223 &self.matched
224 }
225
226 pub fn filtered(&self) -> &BTreeMap<Id, Test> {
228 &self.filtered
229 }
230}
231
232#[derive(Debug, Error)]
234pub enum FilterError {
235 #[error("an error occurred while evaluating an expresison filter")]
237 TestSet(#[from] eval::Error),
238
239 #[error(
241 "{} {} given by an explicit filter was missing",
242 .0.len(),
243 Term::simple("test").with(.0.len()),
244 )]
245 Missing(BTreeSet<Id>),
246}
247
248#[derive(Debug, Error)]
250pub enum Error {
251 #[error("an error occurred while collecting a test")]
253 Id(#[from] ParseIdError),
254
255 #[error("an error occurred while collecting a test")]
257 Test(#[from] LoadError),
258
259 #[error("an io error occurred")]
261 Io(#[from] io::Error),
262}
263
264#[derive(Debug, Clone)]
268pub struct SuiteResult {
269 id: Uuid,
270 total: usize,
271 filtered: usize,
272 passed: usize,
273 failed: usize,
274 timestamp: Instant,
275 duration: Duration,
276 results: BTreeMap<Id, TestResult>,
277}
278
279impl SuiteResult {
280 pub fn new(suite: &FilteredSuite) -> Self {
284 Self {
285 id: Uuid::new_v4(),
286 total: suite.suite().tests().len(),
287 filtered: suite.filtered().len(),
288 passed: 0,
289 failed: 0,
290 timestamp: Instant::now(),
291 duration: Duration::ZERO,
292 results: suite
293 .matched()
294 .keys()
295 .map(|id| (id.clone(), TestResult::skipped()))
296 .chain(
297 suite
298 .filtered()
299 .keys()
300 .map(|id| (id.clone(), TestResult::filtered())),
301 )
302 .collect(),
303 }
304 }
305}
306
307impl SuiteResult {
308 pub fn id(&self) -> Uuid {
310 self.id
311 }
312
313 pub fn total(&self) -> usize {
315 self.total
316 }
317
318 pub fn expected(&self) -> usize {
321 self.total - self.filtered
322 }
323
324 pub fn run(&self) -> usize {
326 self.passed + self.failed
327 }
328
329 pub fn filtered(&self) -> usize {
331 self.filtered
332 }
333
334 pub fn skipped(&self) -> usize {
337 self.expected() - self.run()
338 }
339
340 pub fn passed(&self) -> usize {
342 self.passed
343 }
344
345 pub fn failed(&self) -> usize {
347 self.failed
348 }
349
350 pub fn timestamp(&self) -> Instant {
352 self.timestamp
353 }
354
355 pub fn duration(&self) -> Duration {
357 self.duration
358 }
359
360 pub fn results(&self) -> &BTreeMap<Id, TestResult> {
365 &self.results
366 }
367
368 pub fn is_complete_pass(&self) -> bool {
370 self.expected() == self.passed()
371 }
372}
373
374impl SuiteResult {
375 pub fn start(&mut self) {
379 self.timestamp = Instant::now();
380 }
381
382 pub fn end(&mut self) {
385 self.duration = self.timestamp.elapsed();
386 }
387
388 pub fn set_test_result(&mut self, id: Id, result: TestResult) {
396 debug_assert!(self.results.contains_key(&id));
397 debug_assert!(result.is_pass() || result.is_fail());
398
399 if result.is_pass() {
400 self.passed += 1;
401 } else {
402 self.failed += 1;
403 }
404
405 self.results.insert(id, result);
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use ecow::eco_vec;
412 use tytanic_utils::fs::TempTestEnv;
413
414 use super::*;
415 use crate::test::{Annotation, Kind};
416
417 #[test]
418 fn test_collect() {
419 TempTestEnv::run_no_check(
420 |root| {
421 root
422 .setup_file("tests/compile-only/test.typ", "Hello World")
424 .setup_file("tests/compare/ephemeral/test.typ", "Hello World")
426 .setup_file("tests/compare/ephemeral/ref.typ", "Hello\nWorld")
427 .setup_file("tests/compare/ephemeral-store/test.typ", "Hello World")
429 .setup_file("tests/compare/ephemeral-store/ref.typ", "Hello\nWorld")
430 .setup_file("tests/compare/ephemeral-store/ref", "Blah Blah")
431 .setup_file("tests/compare/persistent/test.typ", "Hello World")
433 .setup_file("tests/compare/persistent/ref", "Blah Blah")
434 .setup_file_empty("tests/not-a-test/test.txt")
436 .setup_file("tests/ignored/test.typ", "/// [skip]\nHello World")
438 },
439 |root| {
440 let project = Project::new(root);
441 let suite = Suite::collect(&project).unwrap();
442
443 let tests = [
444 ("compile-only", Kind::CompileOnly, eco_vec![]),
445 ("compare/ephemeral", Kind::Ephemeral, eco_vec![]),
446 ("compare/ephemeral-store", Kind::Ephemeral, eco_vec![]),
447 ("compare/persistent", Kind::Persistent, eco_vec![]),
448 ("ignored", Kind::CompileOnly, eco_vec![Annotation::Skip]),
449 ];
450
451 for (key, kind, annotations) in tests {
452 let test = &suite.tests[key];
453 assert_eq!(test.annotations(), &annotations[..]);
454 assert_eq!(test.kind(), kind);
455 }
456 },
457 );
458 }
459}