tytanic_core/
suite.rs

1//! Loading and filtering of test suites, suites contain test and supplementary
2//! fields for test templates, custom test set bindings and other information
3//! necessary for managing, filtering and running tests.
4
5use std::collections::BTreeMap;
6use std::collections::BTreeSet;
7use std::collections::btree_map;
8use std::fs;
9use std::io;
10use std::path::Path;
11use std::time::Duration;
12use std::time::Instant;
13
14use thiserror::Error;
15use tytanic_filter::ExpressionFilter;
16use tytanic_filter::eval;
17use tytanic_utils::fmt::Term;
18use tytanic_utils::result::ResultEx;
19use tytanic_utils::result::io_not_found;
20use uuid::Uuid;
21
22use crate::TemplateTest;
23use crate::project::Project;
24use crate::test::Id;
25use crate::test::ParseIdError;
26use crate::test::Test;
27use crate::test::TestResult;
28use crate::test::UnitTest;
29use crate::test::unit::LoadError;
30
31/// A suite of tests.
32#[derive(Debug, Clone)]
33pub struct Suite {
34    tests: BTreeMap<Id, Test>,
35    nested: BTreeMap<Id, Test>,
36}
37
38impl Suite {
39    /// Creates a new empty suite.
40    pub fn new() -> Self {
41        Self {
42            tests: BTreeMap::new(),
43            nested: BTreeMap::new(),
44        }
45    }
46
47    /// Recursively collects entries in the given directory.
48    #[tracing::instrument(skip_all)]
49    pub fn collect(project: &Project) -> Result<Self, Error> {
50        let mut this = Self::new();
51
52        if let Some(test) = TemplateTest::load(project) {
53            tracing::debug!("found template test");
54            this.tests.insert(test.id().clone(), Test::Template(test));
55        }
56
57        let root = project.unit_tests_root();
58        let Some(read_dir) = root.read_dir().ignore(io_not_found)? else {
59            tracing::debug!(?root, "test root not found, ignoring");
60            return Ok(this);
61        };
62
63        tracing::debug!(?root, "test root found, collecting top level entries");
64        for entry in read_dir {
65            let entry = entry?;
66
67            if entry.metadata()?.is_dir() {
68                let abs = entry.path();
69                let rel = abs
70                    .strip_prefix(project.unit_tests_root())
71                    .expect("entry must be in full");
72
73                this.collect_dir(project, rel)?;
74            }
75        }
76
77        let without_leaves: BTreeSet<_> = this
78            .tests
79            .keys()
80            .flat_map(|test| test.ancestors().skip(1))
81            .map(|test| test.to_owned())
82            .collect();
83
84        let all: BTreeSet<_> = this
85            .tests
86            .keys()
87            .map(|test| test.as_str().to_owned())
88            .collect();
89
90        for id in all.intersection(&without_leaves) {
91            if let Some((id, test)) = this.tests.remove_entry(id.as_str()) {
92                this.nested.insert(id, test);
93            }
94        }
95
96        if !this.nested.is_empty() {
97            tracing::trace!(nested = ?this.nested, "found nested tests");
98        }
99
100        Ok(this)
101    }
102
103    /// Recursively collect tests in the given directory.
104    fn collect_dir(&mut self, project: &Project, dir: &Path) -> Result<(), Error> {
105        let abs = project.unit_tests_root().join(dir);
106
107        if dir
108            .file_name()
109            .and_then(|p| p.to_str())
110            .is_some_and(|p| p.starts_with('.'))
111        {
112            tracing::debug!(?dir, "skipping hidden directory");
113            return Ok(());
114        }
115
116        let id = match Id::new_from_path(dir) {
117            Ok(id) => id,
118            Err(err) => {
119                tracing::error!(?dir, ?err, "ignoring test with invalid id");
120                return Ok(());
121            }
122        };
123
124        tracing::trace!(?dir, "checking for test");
125        if let Some(test) = UnitTest::load(project, id.clone())? {
126            tracing::debug!(id = %test.id(), "collected test");
127            self.tests.insert(id, Test::Unit(test));
128        }
129
130        tracing::trace!(?dir, "collecting sub directories");
131        for entry in fs::read_dir(&abs)? {
132            let entry = entry?;
133
134            if entry.metadata()?.is_dir() {
135                let abs = entry.path();
136                let rel = abs
137                    .strip_prefix(project.unit_tests_root())
138                    .expect("entry must be in full");
139
140                self.collect_dir(project, rel)?;
141            }
142        }
143
144        Ok(())
145    }
146}
147
148impl Suite {
149    /// The tests in this suite.
150    pub fn tests(&self) -> Tests<'_> {
151        Tests {
152            iter: self.tests.values(),
153        }
154    }
155
156    /// The unit tests in this suite.
157    pub fn unit_tests(&self) -> UnitTests<'_> {
158        UnitTests { iter: self.tests() }
159    }
160
161    /// The template test, if it exists.
162    pub fn template_test(&self) -> Option<&TemplateTest> {
163        self.tests.get(&Id::template()).map(|test| {
164            test.as_template_test()
165                .expect("Suite invariant ensures that this is a TemplateTest")
166        })
167    }
168
169    /// The nested tests, those which contain other tests and need to be
170    /// migrated.
171    ///
172    /// This is a temporary method and will be removed in a future release.
173    pub fn nested(&self) -> &BTreeMap<Id, Test> {
174        &self.nested
175    }
176
177    /// Returns the test with the given id.
178    pub fn get(&self, id: &Id) -> Option<&Test> {
179        self.tests.get(id)
180    }
181
182    /// Returns true if a test is contained in this suite.
183    pub fn contains(&self, id: &Id) -> bool {
184        self.tests.contains_key(id)
185    }
186
187    /// Returns the total number of tests in this suite.
188    pub fn len(&self) -> usize {
189        self.tests.len()
190    }
191
192    /// Whether this suite is empty.
193    pub fn is_empty(&self) -> bool {
194        self.tests.len() == 0
195    }
196}
197
198impl Suite {
199    /// Apply a filter to a suite.
200    pub fn filter(self, filter: Filter) -> Result<FilteredSuite, FilterError> {
201        tracing::warn!(
202            "ignoring {} nested tests while filtering",
203            self.nested.len()
204        );
205
206        let mut filtered = Suite::new();
207        let mut matched = Suite::new();
208
209        match &filter {
210            Filter::TestSet(expr) => {
211                for (id, test) in &self.tests {
212                    if expr.contains(test)? {
213                        matched.tests.insert(id.clone(), test.clone());
214                    } else {
215                        filtered.tests.insert(id.clone(), test.clone());
216                    }
217                }
218
219                tracing::trace!(?matched, ?filtered, "applied test set");
220
221                Ok(FilteredSuite {
222                    raw: self,
223                    filter,
224                    matched,
225                    filtered,
226                })
227            }
228            Filter::Explicit(set) => {
229                let mut tests = self.tests.clone();
230                let mut missing = BTreeSet::new();
231
232                for id in set {
233                    match tests.remove_entry(id) {
234                        Some((id, test)) => {
235                            matched.tests.insert(id, test);
236                        }
237                        None => {
238                            missing.insert(id.clone());
239                        }
240                    }
241                }
242
243                if !missing.is_empty() {
244                    return Err(FilterError::Missing(missing));
245                }
246
247                filtered.tests = tests;
248
249                tracing::trace!(?matched, ?filtered, "applied explicit filter");
250
251                Ok(FilteredSuite {
252                    raw: self,
253                    filter,
254                    matched,
255                    filtered,
256                })
257            }
258        }
259    }
260}
261
262impl Default for Suite {
263    fn default() -> Self {
264        Self::new()
265    }
266}
267
268impl<'s> IntoIterator for &'s Suite {
269    type IntoIter = Tests<'s>;
270    type Item = &'s Test;
271
272    fn into_iter(self) -> Self::IntoIter {
273        self.tests()
274    }
275}
276
277/// Returned by [`Suite::tests`].
278#[derive(Debug)]
279pub struct Tests<'s> {
280    iter: btree_map::Values<'s, Id, Test>,
281}
282
283impl<'s> Iterator for Tests<'s> {
284    type Item = &'s Test;
285
286    fn next(&mut self) -> Option<Self::Item> {
287        self.iter.next()
288    }
289}
290
291/// Returned by [`Suite::unit_tests`].
292#[derive(Debug)]
293pub struct UnitTests<'s> {
294    iter: Tests<'s>,
295}
296
297impl<'s> Iterator for UnitTests<'s> {
298    type Item = &'s UnitTest;
299
300    fn next(&mut self) -> Option<Self::Item> {
301        for test in self.iter.by_ref() {
302            if let Test::Unit(test) = test {
303                return Some(test);
304            }
305        }
306
307        None
308    }
309}
310
311/// A filter used to restrict which tests in a suite should be run.
312#[derive(Debug, Clone)]
313pub enum Filter {
314    /// A test set expression filter.
315    TestSet(ExpressionFilter<Test>),
316
317    /// An explicit set of test identifiers, if any of these cannot be found the
318    /// filter fails.
319    Explicit(BTreeSet<Id>),
320}
321
322/// A suite of tests with a filter applied to it.
323#[derive(Debug, Clone)]
324pub struct FilteredSuite {
325    raw: Suite,
326    filter: Filter,
327    matched: Suite,
328    filtered: Suite,
329}
330
331impl FilteredSuite {
332    /// The unfiltered inner suite.
333    pub fn inner(&self) -> &Suite {
334        &self.raw
335    }
336
337    /// The filter that was used to filter the tests.
338    pub fn filter(&self) -> &Filter {
339        &self.filter
340    }
341
342    /// The matched suite, contains only those test which _weren't_ filtered out.
343    pub fn matched(&self) -> &Suite {
344        &self.matched
345    }
346
347    /// The filtered suite, contains only those test which _were_ filtered out.
348    pub fn filtered(&self) -> &Suite {
349        &self.filtered
350    }
351}
352
353/// Returned by [`Suite::filter`].
354#[derive(Debug, Error)]
355pub enum FilterError {
356    /// An error occurred while evaluating an expression filter.
357    #[error("an error occurred while evaluating an expressions filter")]
358    TestSet(#[from] eval::Error),
359
360    /// At least one test given by an explicit filter was missing.
361    #[error(
362        "{} {} given by an explicit filter was missing",
363        .0.len(),
364        Term::simple("test").with(.0.len()),
365    )]
366    Missing(BTreeSet<Id>),
367}
368
369/// Returned by [`Suite::collect`].
370#[derive(Debug, Error)]
371pub enum Error {
372    /// An error occurred while trying to parse a test [`Id`].
373    #[error("an error occurred while collecting a test")]
374    Id(#[from] ParseIdError),
375
376    /// An error occurred while trying to collect a test.
377    #[error("an error occurred while collecting a test")]
378    Test(#[from] LoadError),
379
380    /// An IO error occurred.
381    #[error("an io error occurred")]
382    Io(#[from] io::Error),
383}
384
385/// The result of a test suite run, this contains results for all tests in a
386/// suite, including filtered and not-yet-run tests, as well as cached values
387/// for the number of filtered, passed and failed tests.
388#[derive(Debug, Clone)]
389pub struct SuiteResult {
390    id: Uuid,
391    total: usize,
392    filtered: usize,
393    passed: usize,
394    failed: usize,
395    timestamp: Instant,
396    duration: Duration,
397    results: BTreeMap<Id, TestResult>,
398}
399
400impl SuiteResult {
401    /// Create a fresh result for a suite, this will have pre-filled results for
402    /// all test set to canceled, these results can be overridden while running
403    /// the suite.
404    pub fn new(suite: &FilteredSuite) -> Self {
405        Self {
406            id: Uuid::new_v4(),
407            total: suite.inner().len(),
408            filtered: suite.filtered().len(),
409            passed: 0,
410            failed: 0,
411            timestamp: Instant::now(),
412            duration: Duration::ZERO,
413            results: suite
414                .matched()
415                .tests()
416                .map(|test| (test.id().clone(), TestResult::skipped()))
417                .chain(
418                    suite
419                        .filtered()
420                        .tests()
421                        .map(|test| (test.id().clone(), TestResult::filtered())),
422                )
423                .collect(),
424        }
425    }
426}
427
428impl SuiteResult {
429    /// The unique id of this run.
430    pub fn id(&self) -> Uuid {
431        self.id
432    }
433
434    /// The total number of tests in the suite, including filtered ones.
435    pub fn total(&self) -> usize {
436        self.total
437    }
438
439    /// The number of tests in the suite which were expected to run, i.e. the
440    /// number of tests which were _not_ filtered out.
441    pub fn expected(&self) -> usize {
442        self.total - self.filtered
443    }
444
445    /// The number of tests in the suite which were run, regardless of outcome.
446    pub fn run(&self) -> usize {
447        self.passed + self.failed
448    }
449
450    /// The number of tests in the suite which were filtered out.
451    pub fn filtered(&self) -> usize {
452        self.filtered
453    }
454
455    /// The number of tests in the suite which were _not_ run due to
456    /// cancellation.
457    pub fn skipped(&self) -> usize {
458        self.expected() - self.run()
459    }
460
461    /// The number of tests in the suite which passed.
462    pub fn passed(&self) -> usize {
463        self.passed
464    }
465
466    /// The number of tests in the suite which failed.
467    pub fn failed(&self) -> usize {
468        self.failed
469    }
470
471    /// The timestamp at which the suite run started.
472    pub fn timestamp(&self) -> Instant {
473        self.timestamp
474    }
475
476    /// The duration of the whole suite run.
477    pub fn duration(&self) -> Duration {
478        self.duration
479    }
480
481    /// The individual test results.
482    ///
483    /// This contains results for all tests in the a suite, not just those added
484    /// in [`SuiteResult::set_test_result`].
485    pub fn results(&self) -> &BTreeMap<Id, TestResult> {
486        &self.results
487    }
488
489    /// Whether this suite can be considered a complete pass.
490    pub fn is_complete_pass(&self) -> bool {
491        self.expected() == self.passed()
492    }
493}
494
495impl SuiteResult {
496    /// Sets the timestamp to [`Instant::now`].
497    ///
498    /// See [`SuiteResult::end`].
499    pub fn start(&mut self) {
500        self.timestamp = Instant::now();
501    }
502
503    /// Sets the duration to the time elapsed since [`SuiteResult::start`] was
504    /// called.
505    pub fn end(&mut self) {
506        self.duration = self.timestamp.elapsed();
507    }
508
509    /// Add a test result.
510    ///
511    /// - This should only add results for each test once, otherwise the test
512    ///   will be counted multiple times.
513    /// - The results should also only contain failures or passes, cancellations
514    ///   and filtered results are ignored, as these are pre-filled when the
515    ///   result is constructed.
516    pub fn set_test_result(&mut self, id: Id, result: TestResult) {
517        debug_assert!(self.results.contains_key(&id));
518        debug_assert!(result.is_pass() || result.is_fail());
519
520        if result.is_pass() {
521            self.passed += 1;
522        } else {
523            self.failed += 1;
524        }
525
526        self.results.insert(id, result);
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use ecow::eco_vec;
533    use tytanic_utils::fs::TempTestEnv;
534
535    use super::*;
536    use crate::test::Annotation;
537    use crate::test::unit::Kind;
538
539    #[test]
540    fn test_collect() {
541        TempTestEnv::run_no_check(
542            |root| {
543                root
544                    // compile only
545                    .setup_file("tests/.hidden/test.typ", "Not loaded")
546                    .setup_file("tests/ignored!/test.typ", "Ignored")
547                    .setup_file("tests/compile-only/test.typ", "Hello World")
548                    // regular ephemeral
549                    .setup_file("tests/compare/ephemeral/test.typ", "Hello World")
550                    .setup_file("tests/compare/ephemeral/ref.typ", "Hello\nWorld")
551                    // ephemeral despite ref directory
552                    .setup_file("tests/compare/ephemeral-store/test.typ", "Hello World")
553                    .setup_file("tests/compare/ephemeral-store/ref.typ", "Hello\nWorld")
554                    .setup_file("tests/compare/ephemeral-store/ref", "Blah Blah")
555                    // persistent
556                    .setup_file("tests/compare/persistent/test.typ", "Hello World")
557                    .setup_file("tests/compare/persistent/ref", "Blah Blah")
558                    // not a test
559                    .setup_file_empty("tests/not-a-test/test.txt")
560                    // ignored test
561                    .setup_file("tests/ignored/test.typ", "/// [skip]\nHello World")
562            },
563            |root| {
564                let project = Project::new(root);
565                let suite = Suite::collect(&project).unwrap();
566
567                let tests = [
568                    ("compile-only", Kind::CompileOnly, eco_vec![]),
569                    ("compare/ephemeral", Kind::Ephemeral, eco_vec![]),
570                    ("compare/ephemeral-store", Kind::Ephemeral, eco_vec![]),
571                    ("compare/persistent", Kind::Persistent, eco_vec![]),
572                    ("ignored", Kind::CompileOnly, eco_vec![Annotation::Skip]),
573                ];
574
575                for (key, kind, annotations) in tests {
576                    let Test::Unit(test) = &suite.tests[key] else {
577                        panic!("not testing template here");
578                    };
579                    assert_eq!(test.annotations(), &annotations[..]);
580                    assert_eq!(test.kind(), kind);
581                }
582            },
583        );
584    }
585
586    #[test]
587    fn test_collect_nested() {
588        TempTestEnv::run_no_check(
589            |root| {
590                root.setup_file("tests/foo/test.typ", "Hello World")
591                    .setup_file("tests/foo/bar/test.typ", "Hello World")
592                    .setup_file("tests/qux/test.typ", "Hello World")
593                    .setup_file("tests/qux/quux/quz/test.typ", "Hello World")
594            },
595            |root| {
596                let project = Project::new(root);
597                let suite = Suite::collect(&project).unwrap();
598
599                assert!(suite.nested.contains_key("foo"));
600                assert!(suite.nested.contains_key("qux"));
601
602                assert!(suite.tests.contains_key("foo/bar"));
603                assert!(suite.tests.contains_key("qux/quux/quz"));
604            },
605        );
606    }
607}