tytanic_core/test/
unit.rs

1//! Test loading and on-disk manipulation.
2
3use std::fmt::Debug;
4use std::fs;
5use std::fs::File;
6use std::io;
7use std::io::Write;
8
9use ecow::EcoString;
10use ecow::EcoVec;
11use thiserror::Error;
12use typst::syntax::FileId;
13use typst::syntax::Source;
14use typst::syntax::VirtualPath;
15
16use super::Annotation;
17use super::Id;
18use super::ParseAnnotationError;
19use crate::doc;
20use crate::doc::Document;
21use crate::doc::SaveError;
22use crate::project::Project;
23use crate::project::Vcs;
24
25// NOTE(tinger): The order of ignoring and deleting/creating documents is not
26// random, this is specifically for VCS like jj with active watchman triggers
27// and auto snapshotting.
28//
29// This is currently untested though.
30
31/// The default test input as source code.
32pub const DEFAULT_TEST_INPUT: &str = include_str!("default-test.typ");
33
34/// The default test output as an encoded PNG.
35pub const DEFAULT_TEST_OUTPUT: &[u8] = include_bytes!("default-test.png");
36
37/// References for a test.
38#[derive(Debug, Clone)]
39pub enum Reference {
40    /// An ephemeral reference script used to compile the reference document on
41    /// the fly.
42    Ephemeral(EcoString),
43
44    /// Persistent references which are stored on disk.
45    Persistent {
46        /// The reference document.
47        doc: Document,
48
49        /// The optimization options to use when storing the document, `None`
50        /// disabled optimization.
51        opt: Option<Box<oxipng::Options>>,
52    },
53}
54
55/// The kind of a unit test.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub enum Kind {
58    /// Test is compared to ephemeral references, these are compiled on the fly
59    /// from a reference script.
60    Ephemeral,
61
62    /// Test is compared to persistent references, these are pre-compiled and
63    /// loaded for comparison.
64    Persistent,
65
66    /// Test is only compiled.
67    CompileOnly,
68}
69
70impl Kind {
71    /// Whether this kind is ephemeral.
72    pub fn is_ephemeral(self) -> bool {
73        matches!(self, Kind::Ephemeral)
74    }
75
76    /// Whether this kind is persistent.
77    pub fn is_persistent(self) -> bool {
78        matches!(self, Kind::Persistent)
79    }
80
81    /// Whether this kind is compile-only.
82    pub fn is_compile_only(self) -> bool {
83        matches!(self, Kind::CompileOnly)
84    }
85
86    /// Returns a kebab-case string representing this kind.
87    pub fn as_str(self) -> &'static str {
88        match self {
89            Kind::Ephemeral => "ephemeral",
90            Kind::Persistent => "persistent",
91            Kind::CompileOnly => "compile-only",
92        }
93    }
94}
95
96impl Reference {
97    /// The kind of this reference.
98    pub fn kind(&self) -> Kind {
99        match self {
100            Self::Ephemeral(_) => Kind::Ephemeral,
101            Self::Persistent { doc: _, opt: _ } => Kind::Persistent,
102        }
103    }
104}
105
106/// A standalone test script and its associated documents.
107#[derive(Debug, Clone, PartialEq)]
108pub struct Test {
109    id: Id,
110    kind: Kind,
111    annotations: EcoVec<Annotation>,
112}
113
114impl Test {
115    #[cfg(test)]
116    pub(crate) fn new_test(id: Id, kind: Kind) -> Self {
117        use ecow::eco_vec;
118
119        Self {
120            id,
121            kind,
122            annotations: eco_vec![],
123        }
124    }
125
126    /// Attempt to load a test, returns `None` if no test could be found.
127    #[tracing::instrument(skip(project))]
128    pub fn load(project: &Project, id: Id) -> Result<Option<Test>, LoadError> {
129        let test_script = project.unit_test_script(&id);
130
131        if !test_script.try_exists()? {
132            return Ok(None);
133        }
134
135        let kind = if project.unit_test_ref_script(&id).try_exists()? {
136            Kind::Ephemeral
137        } else if project.unit_test_ref_dir(&id).try_exists()? {
138            Kind::Persistent
139        } else {
140            Kind::CompileOnly
141        };
142
143        let annotations = Annotation::collect(&fs::read_to_string(test_script)?)?;
144
145        Ok(Some(Test {
146            id,
147            kind,
148            annotations,
149        }))
150    }
151}
152
153impl Test {
154    /// The id of this test.
155    pub fn id(&self) -> &Id {
156        &self.id
157    }
158
159    /// The kind of this test.
160    pub fn kind(&self) -> Kind {
161        self.kind
162    }
163
164    /// This test's annotations.
165    pub fn annotations(&self) -> &[Annotation] {
166        &self.annotations
167    }
168
169    /// Whether this test has a `skip` annotation.
170    pub fn is_skip(&self) -> bool {
171        self.annotations.contains(&Annotation::Skip)
172    }
173}
174
175impl Test {
176    /// Creates a new test on disk, the kind is inferred from the passed
177    /// reference and annotations are parsed from the test script.
178    ///
179    /// # Panics
180    /// Panics if the given id is equal to the unique template test id.
181    #[tracing::instrument(skip(project, vcs, source, reference))]
182    pub fn create(
183        project: &Project,
184        vcs: Option<&Vcs>,
185        id: Id,
186        source: &str,
187        reference: Option<Reference>,
188    ) -> Result<Test, CreateError> {
189        assert_ne!(id, Id::template());
190
191        let test_dir = project.unit_test_dir(&id);
192        tytanic_utils::fs::create_dir(test_dir, true)?;
193
194        let mut file = File::options()
195            .write(true)
196            .create_new(true)
197            .open(project.unit_test_script(&id))?;
198
199        file.write_all(source.as_bytes())?;
200
201        let kind = reference
202            .as_ref()
203            .map(Reference::kind)
204            .unwrap_or(Kind::CompileOnly);
205
206        let annotations = Annotation::collect(source)?;
207
208        let this = Self {
209            id,
210            kind,
211            annotations,
212        };
213
214        // Ignore temporaries before creating any.
215        if let Some(vcs) = vcs {
216            vcs.ignore(project, &this)?;
217        }
218
219        match reference {
220            Some(Reference::Ephemeral(reference)) => {
221                this.create_reference_script(project, reference.as_str())?;
222            }
223            Some(Reference::Persistent {
224                doc: reference,
225                opt: options,
226            }) => {
227                this.create_reference_document(project, &reference, options.as_deref())?;
228            }
229            None => {}
230        }
231
232        Ok(this)
233    }
234
235    /// Creates the temporary directories of this test.
236    #[tracing::instrument(skip(project))]
237    pub fn create_temporary_directories(&self, project: &Project) -> io::Result<()> {
238        if self.kind.is_ephemeral() {
239            tytanic_utils::fs::remove_dir(project.unit_test_ref_dir(&self.id), true)?;
240            tytanic_utils::fs::create_dir(project.unit_test_ref_dir(&self.id), true)?;
241        }
242
243        tytanic_utils::fs::create_dir(project.unit_test_out_dir(&self.id), true)?;
244
245        if !self.kind.is_compile_only() {
246            tytanic_utils::fs::create_dir(project.unit_test_diff_dir(&self.id), true)?;
247        }
248
249        Ok(())
250    }
251
252    /// Creates the test script of this test, this will truncate the file if it
253    /// already exists.
254    #[tracing::instrument(skip(project, source))]
255    pub fn create_script(&self, project: &Project, source: &str) -> io::Result<()> {
256        std::fs::write(project.unit_test_script(&self.id), source)?;
257        Ok(())
258    }
259
260    /// Creates reference script of this test, this will truncate the file if it
261    /// already exists.
262    #[tracing::instrument(skip(project, source))]
263    pub fn create_reference_script(&self, project: &Project, source: &str) -> io::Result<()> {
264        std::fs::write(project.unit_test_ref_script(&self.id), source)?;
265        Ok(())
266    }
267
268    /// Creates the persistent reference document of this test.
269    #[tracing::instrument(skip(project, reference, optimize_options))]
270    pub fn create_reference_document(
271        &self,
272        project: &Project,
273        reference: &Document,
274        optimize_options: Option<&oxipng::Options>,
275    ) -> Result<(), SaveError> {
276        // NOTE(tinger): if there are already more pages than we want to create,
277        // the surplus pages would persist and make every comparison fail due to
278        // a page count mismatch, so we clear them to be sure.
279        self.delete_reference_document(project)?;
280
281        let ref_dir = project.unit_test_ref_dir(&self.id);
282        tytanic_utils::fs::create_dir(&ref_dir, true)?;
283        reference.save(&ref_dir, optimize_options)?;
284
285        Ok(())
286    }
287
288    /// Deletes all directories and scripts of this test.
289    #[tracing::instrument(skip(project))]
290    pub fn delete(&self, project: &Project) -> io::Result<()> {
291        self.delete_reference_document(project)?;
292        self.delete_reference_script(project)?;
293        self.delete_temporary_directories(project)?;
294
295        tytanic_utils::fs::remove_file(project.unit_test_script(&self.id))?;
296        tytanic_utils::fs::remove_dir(project.unit_test_dir(&self.id), true)?;
297
298        Ok(())
299    }
300
301    /// Deletes the temporary directories of this test.
302    #[tracing::instrument(skip(project))]
303    pub fn delete_temporary_directories(&self, project: &Project) -> io::Result<()> {
304        if self.kind.is_ephemeral() {
305            tytanic_utils::fs::remove_dir(project.unit_test_ref_dir(&self.id), true)?;
306        }
307
308        tytanic_utils::fs::remove_dir(project.unit_test_out_dir(&self.id), true)?;
309        tytanic_utils::fs::remove_dir(project.unit_test_diff_dir(&self.id), true)?;
310        Ok(())
311    }
312
313    /// Deletes the test script of this test.
314    #[tracing::instrument(skip(project))]
315    pub fn delete_script(&self, project: &Project) -> io::Result<()> {
316        tytanic_utils::fs::remove_file(project.unit_test_script(&self.id))?;
317        Ok(())
318    }
319
320    /// Deletes reference script of this test.
321    #[tracing::instrument(skip(project))]
322    pub fn delete_reference_script(&self, project: &Project) -> io::Result<()> {
323        tytanic_utils::fs::remove_file(project.unit_test_ref_script(&self.id))?;
324        Ok(())
325    }
326
327    /// Deletes persistent reference document of this test.
328    #[tracing::instrument(skip(project))]
329    pub fn delete_reference_document(&self, project: &Project) -> io::Result<()> {
330        tytanic_utils::fs::remove_dir(project.unit_test_ref_dir(&self.id), true)?;
331        Ok(())
332    }
333
334    /// Removes any previous references, if they exist and creates a reference
335    /// script by copying the test script.
336    #[tracing::instrument(skip(project, vcs))]
337    pub fn make_ephemeral(&mut self, project: &Project, vcs: Option<&Vcs>) -> io::Result<()> {
338        self.kind = Kind::Ephemeral;
339
340        // Ensure deletion is recorded before ignore file is updated.
341        self.delete_reference_script(project)?;
342        self.delete_reference_document(project)?;
343
344        if let Some(vcs) = vcs {
345            vcs.ignore(project, self)?;
346        }
347
348        // Copy references after ignore file is updated.
349        std::fs::copy(
350            project.unit_test_script(&self.id),
351            project.unit_test_ref_script(&self.id),
352        )?;
353
354        Ok(())
355    }
356
357    /// Removes any previous references, if they exist and creates persistent
358    /// references from the given pages.
359    #[tracing::instrument(skip(project, vcs))]
360    pub fn make_persistent(
361        &mut self,
362        project: &Project,
363        vcs: Option<&Vcs>,
364        reference: &Document,
365        optimize_options: Option<&oxipng::Options>,
366    ) -> Result<(), SaveError> {
367        self.kind = Kind::Persistent;
368
369        // Ensure deletion/creation is recorded before ignore file is updated.
370        self.delete_reference_script(project)?;
371        self.create_reference_document(project, reference, optimize_options)?;
372
373        if let Some(vcs) = vcs {
374            vcs.ignore(project, self)?;
375        }
376
377        Ok(())
378    }
379
380    /// Removes any previous references, if they exist.
381    #[tracing::instrument(skip(project, vcs))]
382    pub fn make_compile_only(&mut self, project: &Project, vcs: Option<&Vcs>) -> io::Result<()> {
383        self.kind = Kind::CompileOnly;
384
385        // Ensure deletion is recorded before ignore file is updated.
386        self.delete_reference_document(project)?;
387        self.delete_reference_script(project)?;
388
389        if let Some(vcs) = vcs {
390            vcs.ignore(project, self)?;
391        }
392
393        Ok(())
394    }
395
396    /// Loads the test script source of this test.
397    #[tracing::instrument(skip(project))]
398    pub fn load_source(&self, project: &Project) -> io::Result<Source> {
399        let test_script = project.unit_test_script(&self.id);
400
401        Ok(Source::new(
402            FileId::new(
403                None,
404                VirtualPath::new(
405                    test_script
406                        .strip_prefix(project.root())
407                        .unwrap_or(&test_script),
408                ),
409            ),
410            std::fs::read_to_string(test_script)?,
411        ))
412    }
413
414    /// Loads the reference test script source of this test, if this test is
415    /// ephemeral.
416    #[tracing::instrument(skip(project))]
417    pub fn load_reference_source(&self, project: &Project) -> io::Result<Option<Source>> {
418        if !self.kind().is_ephemeral() {
419            return Ok(None);
420        }
421
422        let ref_script = project.unit_test_ref_script(&self.id);
423        Ok(Some(Source::new(
424            FileId::new(
425                None,
426                VirtualPath::new(
427                    ref_script
428                        .strip_prefix(project.root())
429                        .unwrap_or(&ref_script),
430                ),
431            ),
432            std::fs::read_to_string(ref_script)?,
433        )))
434    }
435
436    /// Loads the test document of this test.
437    #[tracing::instrument(skip(project))]
438    pub fn load_document(&self, project: &Project) -> Result<Document, doc::LoadError> {
439        Document::load(project.unit_test_out_dir(&self.id))
440    }
441
442    /// Loads the persistent reference document of this test.
443    #[tracing::instrument(skip(project))]
444    pub fn load_reference_document(&self, project: &Project) -> Result<Document, doc::LoadError> {
445        Document::load(project.unit_test_ref_dir(&self.id))
446    }
447}
448
449/// Returned by [`Test::create`].
450#[derive(Debug, Error)]
451pub enum CreateError {
452    /// An error occurred while parsing a test annotation.
453    #[error("an error occurred while parsing a test annotation")]
454    Annotation(#[from] ParseAnnotationError),
455
456    /// An error occurred while saving test files.
457    #[error("an error occurred while saving test files")]
458    Save(#[from] doc::SaveError),
459
460    /// An IO error occurred.
461    #[error("an io error occurred")]
462    Io(#[from] io::Error),
463}
464
465/// Returned by [`Test::load`].
466#[derive(Debug, Error)]
467pub enum LoadError {
468    /// An error occurred while parsing a test annotation.
469    #[error("an error occurred while parsing a test annotation")]
470    Annotation(#[from] ParseAnnotationError),
471
472    /// An IO error occurred.
473    #[error("an io error occurred")]
474    Io(#[from] io::Error),
475}
476
477#[cfg(test)]
478mod tests {
479    use tytanic_utils::fs::Setup;
480    use tytanic_utils::fs::TempTestEnv;
481
482    use super::*;
483
484    fn id(id: &str) -> Id {
485        Id::new(id).unwrap()
486    }
487
488    fn test(test_id: &str, kind: Kind) -> Test {
489        Test::new_test(id(test_id), kind)
490    }
491
492    fn setup_all(root: &mut Setup) -> &mut Setup {
493        root.setup_file("tests/compile-only/test.typ", "Hello World")
494            .setup_file("tests/ephemeral/test.typ", "Hello World")
495            .setup_file("tests/ephemeral/ref.typ", "Hello\nWorld")
496            .setup_file("tests/persistent/test.typ", "Hello World")
497            .setup_dir("tests/persistent/ref")
498    }
499
500    #[test]
501    fn test_create() {
502        TempTestEnv::run(
503            |root| root.setup_dir("tests"),
504            |root| {
505                let project = Project::new(root);
506                Test::create(&project, None, id("compile-only"), "Hello World", None).unwrap();
507
508                Test::create(
509                    &project,
510                    None,
511                    id("ephemeral"),
512                    "Hello World",
513                    Some(Reference::Ephemeral("Hello\nWorld".into())),
514                )
515                .unwrap();
516
517                Test::create(
518                    &project,
519                    None,
520                    id("persistent"),
521                    "Hello World",
522                    Some(Reference::Persistent {
523                        doc: Document::new(vec![]),
524                        opt: None,
525                    }),
526                )
527                .unwrap();
528            },
529            |root| {
530                root.expect_file_content("tests/compile-only/test.typ", "Hello World")
531                    .expect_file_content("tests/ephemeral/test.typ", "Hello World")
532                    .expect_file_content("tests/ephemeral/ref.typ", "Hello\nWorld")
533                    .expect_file_content("tests/persistent/test.typ", "Hello World")
534                    .expect_dir("tests/persistent/ref")
535            },
536        );
537    }
538
539    #[test]
540    fn test_make_ephemeral() {
541        TempTestEnv::run(
542            setup_all,
543            |root| {
544                let project = Project::new(root);
545                test("compile-only", Kind::CompileOnly)
546                    .make_ephemeral(&project, None)
547                    .unwrap();
548                test("ephemeral", Kind::Ephemeral)
549                    .make_ephemeral(&project, None)
550                    .unwrap();
551                test("persistent", Kind::Persistent)
552                    .make_ephemeral(&project, None)
553                    .unwrap();
554            },
555            |root| {
556                root.expect_file_content("tests/compile-only/test.typ", "Hello World")
557                    .expect_file_content("tests/compile-only/ref.typ", "Hello World")
558                    .expect_file_content("tests/ephemeral/test.typ", "Hello World")
559                    .expect_file_content("tests/ephemeral/ref.typ", "Hello World")
560                    .expect_file_content("tests/persistent/test.typ", "Hello World")
561                    .expect_file_content("tests/persistent/ref.typ", "Hello World")
562            },
563        );
564    }
565
566    #[test]
567    fn test_make_persistent() {
568        TempTestEnv::run(
569            setup_all,
570            |root| {
571                let project = Project::new(root);
572                test("compile-only", Kind::CompileOnly)
573                    .make_persistent(&project, None, &Document::new([]), None)
574                    .unwrap();
575
576                test("ephemeral", Kind::Ephemeral)
577                    .make_persistent(&project, None, &Document::new([]), None)
578                    .unwrap();
579
580                test("persistent", Kind::Persistent)
581                    .make_persistent(&project, None, &Document::new([]), None)
582                    .unwrap();
583            },
584            |root| {
585                root.expect_file_content("tests/compile-only/test.typ", "Hello World")
586                    .expect_dir("tests/compile-only/ref")
587                    .expect_file_content("tests/ephemeral/test.typ", "Hello World")
588                    .expect_dir("tests/ephemeral/ref")
589                    .expect_file_content("tests/persistent/test.typ", "Hello World")
590                    .expect_dir("tests/persistent/ref")
591            },
592        );
593    }
594
595    #[test]
596    fn test_make_compile_only() {
597        TempTestEnv::run(
598            setup_all,
599            |root| {
600                let project = Project::new(root);
601                test("compile-only", Kind::CompileOnly)
602                    .make_compile_only(&project, None)
603                    .unwrap();
604
605                test("ephemeral", Kind::Ephemeral)
606                    .make_compile_only(&project, None)
607                    .unwrap();
608
609                test("persistent", Kind::Persistent)
610                    .make_compile_only(&project, None)
611                    .unwrap();
612            },
613            |root| {
614                root.expect_file_content("tests/compile-only/test.typ", "Hello World")
615                    .expect_file_content("tests/ephemeral/test.typ", "Hello World")
616                    .expect_file_content("tests/persistent/test.typ", "Hello World")
617            },
618        );
619    }
620
621    #[test]
622    fn test_load_sources() {
623        TempTestEnv::run_no_check(
624            |root| {
625                root.setup_file("tests/fancy/test.typ", "Hello World")
626                    .setup_file("tests/fancy/ref.typ", "Hello\nWorld")
627            },
628            |root| {
629                let project = Project::new(root);
630
631                let mut test = test("fancy", Kind::Ephemeral);
632                test.kind = Kind::Ephemeral;
633
634                test.load_source(&project).unwrap();
635                test.load_reference_source(&project).unwrap().unwrap();
636            },
637        );
638    }
639
640    #[test]
641    fn test_sources_virtual() {
642        TempTestEnv::run_no_check(
643            |root| root.setup_file_empty("tests/fancy/test.typ"),
644            |root| {
645                let project = Project::new(root);
646
647                let test = test("fancy", Kind::CompileOnly);
648
649                let source = test.load_source(&project).unwrap();
650                assert_eq!(
651                    source.id().vpath().resolve(root).unwrap(),
652                    root.join("tests/fancy/test.typ")
653                );
654            },
655        );
656    }
657}