1use 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#[derive(Debug, Clone)]
33pub struct Suite {
34 tests: BTreeMap<Id, Test>,
35 nested: BTreeMap<Id, Test>,
36}
37
38impl Suite {
39 pub fn new() -> Self {
41 Self {
42 tests: BTreeMap::new(),
43 nested: BTreeMap::new(),
44 }
45 }
46
47 #[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 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 pub fn tests(&self) -> Tests<'_> {
151 Tests {
152 iter: self.tests.values(),
153 }
154 }
155
156 pub fn unit_tests(&self) -> UnitTests<'_> {
158 UnitTests { iter: self.tests() }
159 }
160
161 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 pub fn nested(&self) -> &BTreeMap<Id, Test> {
174 &self.nested
175 }
176
177 pub fn get(&self, id: &Id) -> Option<&Test> {
179 self.tests.get(id)
180 }
181
182 pub fn contains(&self, id: &Id) -> bool {
184 self.tests.contains_key(id)
185 }
186
187 pub fn len(&self) -> usize {
189 self.tests.len()
190 }
191
192 pub fn is_empty(&self) -> bool {
194 self.tests.len() == 0
195 }
196}
197
198impl Suite {
199 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#[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#[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#[derive(Debug, Clone)]
313pub enum Filter {
314 TestSet(ExpressionFilter<Test>),
316
317 Explicit(BTreeSet<Id>),
320}
321
322#[derive(Debug, Clone)]
324pub struct FilteredSuite {
325 raw: Suite,
326 filter: Filter,
327 matched: Suite,
328 filtered: Suite,
329}
330
331impl FilteredSuite {
332 pub fn inner(&self) -> &Suite {
334 &self.raw
335 }
336
337 pub fn filter(&self) -> &Filter {
339 &self.filter
340 }
341
342 pub fn matched(&self) -> &Suite {
344 &self.matched
345 }
346
347 pub fn filtered(&self) -> &Suite {
349 &self.filtered
350 }
351}
352
353#[derive(Debug, Error)]
355pub enum FilterError {
356 #[error("an error occurred while evaluating an expressions filter")]
358 TestSet(#[from] eval::Error),
359
360 #[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#[derive(Debug, Error)]
371pub enum Error {
372 #[error("an error occurred while collecting a test")]
374 Id(#[from] ParseIdError),
375
376 #[error("an error occurred while collecting a test")]
378 Test(#[from] LoadError),
379
380 #[error("an io error occurred")]
382 Io(#[from] io::Error),
383}
384
385#[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 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 pub fn id(&self) -> Uuid {
431 self.id
432 }
433
434 pub fn total(&self) -> usize {
436 self.total
437 }
438
439 pub fn expected(&self) -> usize {
442 self.total - self.filtered
443 }
444
445 pub fn run(&self) -> usize {
447 self.passed + self.failed
448 }
449
450 pub fn filtered(&self) -> usize {
452 self.filtered
453 }
454
455 pub fn skipped(&self) -> usize {
458 self.expected() - self.run()
459 }
460
461 pub fn passed(&self) -> usize {
463 self.passed
464 }
465
466 pub fn failed(&self) -> usize {
468 self.failed
469 }
470
471 pub fn timestamp(&self) -> Instant {
473 self.timestamp
474 }
475
476 pub fn duration(&self) -> Duration {
478 self.duration
479 }
480
481 pub fn results(&self) -> &BTreeMap<Id, TestResult> {
486 &self.results
487 }
488
489 pub fn is_complete_pass(&self) -> bool {
491 self.expected() == self.passed()
492 }
493}
494
495impl SuiteResult {
496 pub fn start(&mut self) {
500 self.timestamp = Instant::now();
501 }
502
503 pub fn end(&mut self) {
506 self.duration = self.timestamp.elapsed();
507 }
508
509 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 .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 .setup_file("tests/compare/ephemeral/test.typ", "Hello World")
550 .setup_file("tests/compare/ephemeral/ref.typ", "Hello\nWorld")
551 .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 .setup_file("tests/compare/persistent/test.typ", "Hello World")
557 .setup_file("tests/compare/persistent/ref", "Blah Blah")
558 .setup_file_empty("tests/not-a-test/test.txt")
560 .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}