1use std::fmt::Debug;
4use std::fs::{self, File};
5use std::io::{self, Write};
6use std::time::{Duration, Instant};
7
8use ecow::{eco_vec, EcoString, EcoVec};
9use thiserror::Error;
10use typst::diag::SourceDiagnostic;
11use typst::syntax::{FileId, Source, VirtualPath};
12
13use crate::doc;
14use crate::doc::{compare, compile, Document, SaveError};
15use crate::project::{Project, Vcs};
16
17mod annotation;
18mod id;
19
20pub use self::annotation::{Annotation, ParseAnnotationError};
21pub use self::id::{Id, ParseIdError};
22
23pub const DEFAULT_TEST_INPUT: &str = include_str!("default-test.typ");
31
32pub const DEFAULT_TEST_OUTPUT: &[u8] = include_bytes!("default-test.png");
34
35#[derive(Debug, Clone)]
37pub enum Reference {
38 Ephemeral(EcoString),
41
42 Persistent {
44 doc: Document,
46
47 opt: Option<Box<oxipng::Options>>,
50 },
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
55pub enum Kind {
56 Ephemeral,
59
60 Persistent,
63
64 CompileOnly,
66}
67
68impl Kind {
69 pub fn is_ephemeral(self) -> bool {
71 matches!(self, Kind::Ephemeral)
72 }
73
74 pub fn is_persistent(self) -> bool {
76 matches!(self, Kind::Persistent)
77 }
78
79 pub fn is_compile_only(self) -> bool {
81 matches!(self, Kind::CompileOnly)
82 }
83
84 pub fn as_str(self) -> &'static str {
86 match self {
87 Kind::Ephemeral => "ephemeral",
88 Kind::Persistent => "persistent",
89 Kind::CompileOnly => "compile-only",
90 }
91 }
92}
93
94impl Reference {
95 pub fn kind(&self) -> Kind {
97 match self {
98 Self::Ephemeral(_) => Kind::Ephemeral,
99 Self::Persistent { doc: _, opt: _ } => Kind::Persistent,
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq)]
106pub struct Test {
107 id: Id,
108 kind: Kind,
109 annotations: EcoVec<Annotation>,
110}
111
112impl Test {
113 #[cfg(test)]
114 pub(crate) fn new_test(id: Id, kind: Kind) -> Self {
115 Self {
116 id,
117 kind,
118 annotations: eco_vec![],
119 }
120 }
121
122 pub fn load(project: &Project, id: Id) -> Result<Option<Test>, LoadError> {
124 let test_script = project.unit_test_script(&id);
125
126 if !test_script.try_exists()? {
127 return Ok(None);
128 }
129
130 let kind = if project.unit_test_ref_script(&id).try_exists()? {
131 Kind::Ephemeral
132 } else if project.unit_test_ref_dir(&id).try_exists()? {
133 Kind::Persistent
134 } else {
135 Kind::CompileOnly
136 };
137
138 let annotations = Annotation::collect(&fs::read_to_string(test_script)?)?;
139
140 Ok(Some(Test {
141 id,
142 kind,
143 annotations,
144 }))
145 }
146}
147
148impl Test {
149 pub fn id(&self) -> &Id {
151 &self.id
152 }
153
154 pub fn kind(&self) -> Kind {
156 self.kind
157 }
158
159 pub fn annotations(&self) -> &[Annotation] {
161 &self.annotations
162 }
163
164 pub fn is_skip(&self) -> bool {
166 self.annotations.contains(&Annotation::Skip)
167 }
168}
169
170impl Test {
171 pub fn create(
174 project: &Project,
175 vcs: Option<&Vcs>,
176 id: Id,
177 source: &str,
178 reference: Option<Reference>,
179 ) -> Result<Test, CreateError> {
180 let test_dir = project.unit_test_dir(&id);
181 tytanic_utils::fs::create_dir(test_dir, true)?;
182
183 let mut file = File::options()
184 .write(true)
185 .create_new(true)
186 .open(project.unit_test_script(&id))?;
187
188 file.write_all(source.as_bytes())?;
189
190 let kind = reference
191 .as_ref()
192 .map(Reference::kind)
193 .unwrap_or(Kind::CompileOnly);
194
195 let annotations = Annotation::collect(source)?;
196
197 let this = Self {
198 id,
199 kind,
200 annotations,
201 };
202
203 if let Some(vcs) = vcs {
205 vcs.ignore(project, &this)?;
206 }
207
208 match reference {
209 Some(Reference::Ephemeral(reference)) => {
210 this.create_reference_script(project, reference.as_str())?;
211 }
212 Some(Reference::Persistent {
213 doc: reference,
214 opt: options,
215 }) => {
216 this.create_reference_document(project, &reference, options.as_deref())?;
217 }
218 None => {}
219 }
220
221 Ok(this)
222 }
223
224 pub fn create_temporary_directories(&self, project: &Project) -> io::Result<()> {
226 if self.kind.is_ephemeral() {
227 tytanic_utils::fs::remove_dir(project.unit_test_ref_dir(&self.id), true)?;
228 tytanic_utils::fs::create_dir(project.unit_test_ref_dir(&self.id), true)?;
229 }
230
231 tytanic_utils::fs::create_dir(project.unit_test_out_dir(&self.id), true)?;
232 tytanic_utils::fs::create_dir(project.unit_test_diff_dir(&self.id), true)?;
233
234 Ok(())
235 }
236
237 pub fn create_script(&self, project: &Project, source: &str) -> io::Result<()> {
240 std::fs::write(project.unit_test_script(&self.id), source)?;
241 Ok(())
242 }
243
244 pub fn create_reference_script(&self, project: &Project, source: &str) -> io::Result<()> {
247 std::fs::write(project.unit_test_ref_script(&self.id), source)?;
248 Ok(())
249 }
250
251 pub fn create_reference_document(
253 &self,
254 project: &Project,
255 reference: &Document,
256 optimize_options: Option<&oxipng::Options>,
257 ) -> Result<(), SaveError> {
258 self.delete_reference_document(project)?;
262
263 let ref_dir = project.unit_test_ref_dir(&self.id);
264 tytanic_utils::fs::create_dir(&ref_dir, true)?;
265 reference.save(&ref_dir, optimize_options)?;
266
267 Ok(())
268 }
269
270 pub fn delete(&self, project: &Project) -> io::Result<()> {
272 self.delete_reference_document(project)?;
273 self.delete_reference_script(project)?;
274 self.delete_temporary_directories(project)?;
275
276 tytanic_utils::fs::remove_file(project.unit_test_script(&self.id))?;
277 tytanic_utils::fs::remove_dir(project.unit_test_dir(&self.id), true)?;
278
279 Ok(())
280 }
281
282 pub fn delete_temporary_directories(&self, project: &Project) -> io::Result<()> {
284 if !self.kind.is_persistent() {
285 tytanic_utils::fs::remove_dir(project.unit_test_ref_dir(&self.id), true)?;
286 }
287
288 tytanic_utils::fs::remove_dir(project.unit_test_out_dir(&self.id), true)?;
289 tytanic_utils::fs::remove_dir(project.unit_test_diff_dir(&self.id), true)?;
290 Ok(())
291 }
292
293 pub fn delete_script(&self, project: &Project) -> io::Result<()> {
295 tytanic_utils::fs::remove_file(project.unit_test_script(&self.id))?;
296 Ok(())
297 }
298
299 pub fn delete_reference_script(&self, project: &Project) -> io::Result<()> {
301 tytanic_utils::fs::remove_file(project.unit_test_ref_script(&self.id))?;
302 Ok(())
303 }
304
305 pub fn delete_reference_document(&self, project: &Project) -> io::Result<()> {
307 tytanic_utils::fs::remove_dir(project.unit_test_ref_dir(&self.id), true)?;
308 Ok(())
309 }
310
311 pub fn make_ephemeral(&mut self, project: &Project, vcs: Option<&Vcs>) -> io::Result<()> {
314 self.kind = Kind::Ephemeral;
315
316 self.delete_reference_script(project)?;
318 self.delete_reference_document(project)?;
319
320 if let Some(vcs) = vcs {
321 vcs.ignore(project, self)?;
322 }
323
324 std::fs::copy(
326 project.unit_test_script(&self.id),
327 project.unit_test_ref_script(&self.id),
328 )?;
329
330 Ok(())
331 }
332
333 pub fn make_persistent(
336 &mut self,
337 project: &Project,
338 vcs: Option<&Vcs>,
339 reference: &Document,
340 optimize_options: Option<&oxipng::Options>,
341 ) -> Result<(), SaveError> {
342 self.kind = Kind::Persistent;
343
344 self.delete_reference_script(project)?;
346 self.create_reference_document(project, reference, optimize_options)?;
347
348 if let Some(vcs) = vcs {
349 vcs.ignore(project, self)?;
350 }
351
352 Ok(())
353 }
354
355 pub fn make_compile_only(&mut self, project: &Project, vcs: Option<&Vcs>) -> io::Result<()> {
357 self.kind = Kind::CompileOnly;
358
359 self.delete_reference_document(project)?;
361 self.delete_reference_script(project)?;
362
363 if let Some(vcs) = vcs {
364 vcs.ignore(project, self)?;
365 }
366
367 Ok(())
368 }
369
370 pub fn load_source(&self, project: &Project) -> io::Result<Source> {
372 let test_script = project.unit_test_script(&self.id);
373
374 Ok(Source::new(
375 FileId::new(
376 None,
377 VirtualPath::new(
378 test_script
379 .strip_prefix(project.root())
380 .unwrap_or(&test_script),
381 ),
382 ),
383 std::fs::read_to_string(test_script)?,
384 ))
385 }
386
387 pub fn load_reference_source(&self, project: &Project) -> io::Result<Option<Source>> {
390 if !self.kind().is_ephemeral() {
391 return Ok(None);
392 }
393
394 let ref_script = project.unit_test_ref_script(&self.id);
395 Ok(Some(Source::new(
396 FileId::new(
397 None,
398 VirtualPath::new(
399 ref_script
400 .strip_prefix(project.root())
401 .unwrap_or(&ref_script),
402 ),
403 ),
404 std::fs::read_to_string(ref_script)?,
405 )))
406 }
407
408 pub fn load_document(&self, project: &Project) -> Result<Document, doc::LoadError> {
410 Document::load(project.unit_test_out_dir(&self.id))
411 }
412
413 pub fn load_reference_document(&self, project: &Project) -> Result<Document, doc::LoadError> {
415 Document::load(project.unit_test_ref_dir(&self.id))
416 }
417}
418
419#[derive(Debug, Error)]
421pub enum CreateError {
422 #[error("an error occurred while parsing a test annotation")]
424 Annotation(#[from] ParseAnnotationError),
425
426 #[error("an error occurred while saving test files")]
428 Save(#[from] doc::SaveError),
429
430 #[error("an io error occurred")]
432 Io(#[from] io::Error),
433}
434
435#[derive(Debug, Error)]
437pub enum LoadError {
438 #[error("an error occurred while parsing a test annotation")]
440 Annotation(#[from] ParseAnnotationError),
441
442 #[error("an io error occurred")]
444 Io(#[from] io::Error),
445}
446
447#[derive(Debug, Clone, Default)]
449pub enum Stage {
450 #[default]
452 Skipped,
453
454 Filtered,
458
459 FailedCompilation {
461 error: compile::Error,
463
464 reference: bool,
466 },
467
468 FailedComparison(compare::Error),
470
471 PassedCompilation,
473
474 PassedComparison,
476
477 Updated {
479 optimized: bool,
481 },
482}
483
484#[derive(Debug, Clone)]
486pub struct TestResult {
487 stage: Stage,
488 warnings: EcoVec<SourceDiagnostic>,
489 timestamp: Instant,
490 duration: Duration,
491}
492
493impl TestResult {
494 pub fn skipped() -> Self {
500 Self {
501 stage: Stage::Skipped,
502 warnings: eco_vec![],
503 timestamp: Instant::now(),
504 duration: Duration::ZERO,
505 }
506 }
507
508 pub fn filtered() -> Self {
511 Self {
512 stage: Stage::Filtered,
513 warnings: eco_vec![],
514 timestamp: Instant::now(),
515 duration: Duration::ZERO,
516 }
517 }
518}
519
520impl TestResult {
521 pub fn stage(&self) -> &Stage {
523 &self.stage
524 }
525
526 pub fn warnings(&self) -> &[SourceDiagnostic] {
528 &self.warnings
529 }
530
531 pub fn timestamp(&self) -> Instant {
533 self.timestamp
534 }
535
536 pub fn duration(&self) -> Duration {
538 self.duration
539 }
540
541 pub fn is_skipped(&self) -> bool {
543 matches!(&self.stage, Stage::Skipped)
544 }
545
546 pub fn is_filtered(&self) -> bool {
548 matches!(&self.stage, Stage::Filtered)
549 }
550
551 pub fn is_pass(&self) -> bool {
553 matches!(
554 &self.stage,
555 Stage::PassedCompilation | Stage::PassedComparison | Stage::Updated { .. }
556 )
557 }
558
559 pub fn is_fail(&self) -> bool {
561 matches!(
562 &self.stage,
563 Stage::FailedCompilation { .. } | Stage::FailedComparison(..),
564 )
565 }
566
567 pub fn errors(&self) -> Option<&[SourceDiagnostic]> {
569 match &self.stage {
570 Stage::FailedCompilation { error, .. } => Some(&error.0),
571 _ => None,
572 }
573 }
574}
575
576impl TestResult {
577 pub fn start(&mut self) {
581 self.timestamp = Instant::now();
582 }
583
584 pub fn end(&mut self) {
587 self.duration = self.timestamp.elapsed();
588 }
589
590 pub fn set_passed_compilation(&mut self) {
592 self.stage = Stage::PassedCompilation;
593 }
594
595 pub fn set_failed_reference_compilation(&mut self, error: compile::Error) {
597 self.stage = Stage::FailedCompilation {
598 error,
599 reference: true,
600 };
601 }
602
603 pub fn set_failed_test_compilation(&mut self, error: compile::Error) {
605 self.stage = Stage::FailedCompilation {
606 error,
607 reference: false,
608 };
609 }
610
611 pub fn set_passed_comparison(&mut self) {
613 self.stage = Stage::PassedComparison;
614 }
615
616 pub fn set_failed_comparison(&mut self, error: compare::Error) {
618 self.stage = Stage::FailedComparison(error);
619 }
620
621 pub fn set_updated(&mut self, optimized: bool) {
623 self.stage = Stage::Updated { optimized };
624 }
625
626 pub fn set_warnings<I>(&mut self, warnings: I)
628 where
629 I: Into<EcoVec<SourceDiagnostic>>,
630 {
631 self.warnings = warnings.into();
632 }
633}
634
635impl Default for TestResult {
636 fn default() -> Self {
637 Self::skipped()
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use tytanic_utils::fs::{Setup, TempTestEnv};
644
645 use super::*;
646
647 fn id(id: &str) -> Id {
648 Id::new(id).unwrap()
649 }
650
651 fn test(test_id: &str, kind: Kind) -> Test {
652 Test::new_test(id(test_id), kind)
653 }
654
655 fn setup_all(root: &mut Setup) -> &mut Setup {
656 root.setup_file("tests/compile-only/test.typ", "Hello World")
657 .setup_file("tests/ephemeral/test.typ", "Hello World")
658 .setup_file("tests/ephemeral/ref.typ", "Hello\nWorld")
659 .setup_file("tests/persistent/test.typ", "Hello World")
660 .setup_dir("tests/persistent/ref")
661 }
662
663 #[test]
664 fn test_create() {
665 TempTestEnv::run(
666 |root| root.setup_dir("tests"),
667 |root| {
668 let project = Project::new(root);
669 Test::create(&project, None, id("compile-only"), "Hello World", None).unwrap();
670
671 Test::create(
672 &project,
673 None,
674 id("ephemeral"),
675 "Hello World",
676 Some(Reference::Ephemeral("Hello\nWorld".into())),
677 )
678 .unwrap();
679
680 Test::create(
681 &project,
682 None,
683 id("persistent"),
684 "Hello World",
685 Some(Reference::Persistent {
686 doc: Document::new(vec![]),
687 opt: None,
688 }),
689 )
690 .unwrap();
691 },
692 |root| {
693 root.expect_file_content("tests/compile-only/test.typ", "Hello World")
694 .expect_file_content("tests/ephemeral/test.typ", "Hello World")
695 .expect_file_content("tests/ephemeral/ref.typ", "Hello\nWorld")
696 .expect_file_content("tests/persistent/test.typ", "Hello World")
697 .expect_dir("tests/persistent/ref")
698 },
699 );
700 }
701
702 #[test]
703 fn test_make_ephemeral() {
704 TempTestEnv::run(
705 setup_all,
706 |root| {
707 let project = Project::new(root);
708 test("compile-only", Kind::CompileOnly)
709 .make_ephemeral(&project, None)
710 .unwrap();
711 test("ephemeral", Kind::Ephemeral)
712 .make_ephemeral(&project, None)
713 .unwrap();
714 test("persistent", Kind::Persistent)
715 .make_ephemeral(&project, None)
716 .unwrap();
717 },
718 |root| {
719 root.expect_file_content("tests/compile-only/test.typ", "Hello World")
720 .expect_file_content("tests/compile-only/ref.typ", "Hello World")
721 .expect_file_content("tests/ephemeral/test.typ", "Hello World")
722 .expect_file_content("tests/ephemeral/ref.typ", "Hello World")
723 .expect_file_content("tests/persistent/test.typ", "Hello World")
724 .expect_file_content("tests/persistent/ref.typ", "Hello World")
725 },
726 );
727 }
728
729 #[test]
730 fn test_make_persistent() {
731 TempTestEnv::run(
732 setup_all,
733 |root| {
734 let project = Project::new(root);
735 test("compile-only", Kind::CompileOnly)
736 .make_persistent(&project, None, &Document::new([]), None)
737 .unwrap();
738
739 test("ephemeral", Kind::Ephemeral)
740 .make_persistent(&project, None, &Document::new([]), None)
741 .unwrap();
742
743 test("persistent", Kind::Persistent)
744 .make_persistent(&project, None, &Document::new([]), None)
745 .unwrap();
746 },
747 |root| {
748 root.expect_file_content("tests/compile-only/test.typ", "Hello World")
749 .expect_dir("tests/compile-only/ref")
750 .expect_file_content("tests/ephemeral/test.typ", "Hello World")
751 .expect_dir("tests/ephemeral/ref")
752 .expect_file_content("tests/persistent/test.typ", "Hello World")
753 .expect_dir("tests/persistent/ref")
754 },
755 );
756 }
757
758 #[test]
759 fn test_make_compile_only() {
760 TempTestEnv::run(
761 setup_all,
762 |root| {
763 let project = Project::new(root);
764 test("compile-only", Kind::CompileOnly)
765 .make_compile_only(&project, None)
766 .unwrap();
767
768 test("ephemeral", Kind::Ephemeral)
769 .make_compile_only(&project, None)
770 .unwrap();
771
772 test("persistent", Kind::Persistent)
773 .make_compile_only(&project, None)
774 .unwrap();
775 },
776 |root| {
777 root.expect_file_content("tests/compile-only/test.typ", "Hello World")
778 .expect_file_content("tests/ephemeral/test.typ", "Hello World")
779 .expect_file_content("tests/persistent/test.typ", "Hello World")
780 },
781 );
782 }
783
784 #[test]
785 fn test_load_sources() {
786 TempTestEnv::run_no_check(
787 |root| {
788 root.setup_file("tests/fancy/test.typ", "Hello World")
789 .setup_file("tests/fancy/ref.typ", "Hello\nWorld")
790 },
791 |root| {
792 let project = Project::new(root);
793
794 let mut test = test("fancy", Kind::Ephemeral);
795 test.kind = Kind::Ephemeral;
796
797 test.load_source(&project).unwrap();
798 test.load_reference_source(&project).unwrap().unwrap();
799 },
800 );
801 }
802
803 #[test]
804 fn test_sources_virtual() {
805 TempTestEnv::run_no_check(
806 |root| root.setup_file_empty("tests/fancy/test.typ"),
807 |root| {
808 let project = Project::new(root);
809
810 let test = test("fancy", Kind::CompileOnly);
811
812 let source = test.load_source(&project).unwrap();
813 assert_eq!(
814 source.id().vpath().resolve(root).unwrap(),
815 root.join("tests/fancy/test.typ")
816 );
817 },
818 );
819 }
820}