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, 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/// A suite of tests.
19#[derive(Debug, Clone)]
20pub struct Suite {
21    tests: BTreeMap<Id, Test>,
22    nested: BTreeMap<Id, Test>,
23}
24
25impl Suite {
26    /// Creates a new empty suite.
27    pub fn new() -> Self {
28        Self {
29            tests: BTreeMap::new(),
30            nested: BTreeMap::new(),
31        }
32    }
33
34    /// Recursively collects entries in the given directory.
35    #[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    /// Recursively collect tests in the given directory.
86    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    /// The unit tests in this suite.
118    pub fn tests(&self) -> &BTreeMap<Id, Test> {
119        &self.tests
120    }
121
122    /// The nested tests, those which contain other tests and need to be migrated.
123    ///
124    /// This is a temporary method and will be removed in a future release.
125    pub fn nested(&self) -> &BTreeMap<Id, Test> {
126        &self.nested
127    }
128}
129
130impl Suite {
131    /// Apply a filter to a suite.
132    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/// A filter used to restrict which tests in a suite should be run.
191#[derive(Debug, Clone)]
192pub enum Filter {
193    /// A test set expression filter.
194    TestSet(ExpressionFilter<Test>),
195
196    /// An explicit set of test identifiers, if any of these cannot be found the
197    /// filter fails.
198    Explicit(BTreeSet<Id>),
199}
200
201/// A suite of tests with a filter applied to it.
202#[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    /// The unfiltered inner suite.
212    pub fn suite(&self) -> &Suite {
213        &self.suite
214    }
215
216    /// The filter that was used to filter the tests.
217    pub fn filter(&self) -> &Filter {
218        &self.filter
219    }
220
221    /// The matched tests, i.e. those which _weren't_ filtered out.
222    pub fn matched(&self) -> &BTreeMap<Id, Test> {
223        &self.matched
224    }
225
226    /// The filtered tests, i.e. those which _were_ filtered out.
227    pub fn filtered(&self) -> &BTreeMap<Id, Test> {
228        &self.filtered
229    }
230}
231
232/// Returned by [`Suite::filter`].
233#[derive(Debug, Error)]
234pub enum FilterError {
235    /// An error occurred while evaluating an expresison filter.
236    #[error("an error occurred while evaluating an expresison filter")]
237    TestSet(#[from] eval::Error),
238
239    /// At least one test given by an explicit filter was missing.
240    #[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/// Returned by [`Suite::collect`].
249#[derive(Debug, Error)]
250pub enum Error {
251    /// An error occurred while trying to parse a test [`Id`].
252    #[error("an error occurred while collecting a test")]
253    Id(#[from] ParseIdError),
254
255    /// An error occurred while trying to collect a test.
256    #[error("an error occurred while collecting a test")]
257    Test(#[from] LoadError),
258
259    /// An io error occurred.
260    #[error("an io error occurred")]
261    Io(#[from] io::Error),
262}
263
264/// The result of a test suite run, this contains results for all tests in a
265/// suite, including filtered and not-yet-run tests, as well as cached values
266/// for the number of filtered, passed and failed tests.
267#[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    /// Create a fresh result for a suite, this will have pre-filled results for
281    /// all test set to cancelled, these results can be overridden while running
282    /// the suite.
283    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    /// The unique id of this run.
309    pub fn id(&self) -> Uuid {
310        self.id
311    }
312
313    /// The total number of tests in the suite, including filtered ones.
314    pub fn total(&self) -> usize {
315        self.total
316    }
317
318    /// The number of tests in the suite which were expected to run, i.e. the
319    /// number of tests which were _not_ filtered out.
320    pub fn expected(&self) -> usize {
321        self.total - self.filtered
322    }
323
324    /// The number of tests in the suite which were run, regardless of outcome.
325    pub fn run(&self) -> usize {
326        self.passed + self.failed
327    }
328
329    /// The number of tests in the suite which were filtered out.
330    pub fn filtered(&self) -> usize {
331        self.filtered
332    }
333
334    /// The number of tests in the suite which were _not_ run due to
335    /// cancellation.
336    pub fn skipped(&self) -> usize {
337        self.expected() - self.run()
338    }
339
340    /// The number of tests in the suite which passed.
341    pub fn passed(&self) -> usize {
342        self.passed
343    }
344
345    /// The number of tests in the suite which failed.
346    pub fn failed(&self) -> usize {
347        self.failed
348    }
349
350    /// The timestamp at which the suite run started.
351    pub fn timestamp(&self) -> Instant {
352        self.timestamp
353    }
354
355    /// The duration of the whole suite run.
356    pub fn duration(&self) -> Duration {
357        self.duration
358    }
359
360    /// The individual test results.
361    ///
362    /// This contains results for all tests in the a suite, not just those added
363    /// in [`SuiteResult::set_test_result`].
364    pub fn results(&self) -> &BTreeMap<Id, TestResult> {
365        &self.results
366    }
367
368    /// Whether this suite can be considered a complete pass.
369    pub fn is_complete_pass(&self) -> bool {
370        self.expected() == self.passed()
371    }
372}
373
374impl SuiteResult {
375    /// Sets the timestamp to [`Instant::now`].
376    ///
377    /// See [`SuiteResult::end`].
378    pub fn start(&mut self) {
379        self.timestamp = Instant::now();
380    }
381
382    /// Sets the duration to the time elapsed since [`SuiteResult::start`] was
383    /// called.
384    pub fn end(&mut self) {
385        self.duration = self.timestamp.elapsed();
386    }
387
388    /// Add a test result.
389    ///
390    /// - This should only add results for each test once, otherwise the test
391    ///   will be counted multiple times.
392    /// - The results should also only contain failures or passes, cancellations
393    ///   and filtered results are ignored, as these are pre-filled when the
394    ///   result is constructed.
395    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                    // compile only
423                    .setup_file("tests/compile-only/test.typ", "Hello World")
424                    // regular ephemeral
425                    .setup_file("tests/compare/ephemeral/test.typ", "Hello World")
426                    .setup_file("tests/compare/ephemeral/ref.typ", "Hello\nWorld")
427                    // ephemeral despite ref directory
428                    .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                    // persistent
432                    .setup_file("tests/compare/persistent/test.typ", "Hello World")
433                    .setup_file("tests/compare/persistent/ref", "Blah Blah")
434                    // not a test
435                    .setup_file_empty("tests/not-a-test/test.txt")
436                    // ignored test
437                    .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}