1use 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
25pub const DEFAULT_TEST_INPUT: &str = include_str!("default-test.typ");
33
34pub const DEFAULT_TEST_OUTPUT: &[u8] = include_bytes!("default-test.png");
36
37#[derive(Debug, Clone)]
39pub enum Reference {
40 Ephemeral(EcoString),
43
44 Persistent {
46 doc: Document,
48
49 opt: Option<Box<oxipng::Options>>,
52 },
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub enum Kind {
58 Ephemeral,
61
62 Persistent,
65
66 CompileOnly,
68}
69
70impl Kind {
71 pub fn is_ephemeral(self) -> bool {
73 matches!(self, Kind::Ephemeral)
74 }
75
76 pub fn is_persistent(self) -> bool {
78 matches!(self, Kind::Persistent)
79 }
80
81 pub fn is_compile_only(self) -> bool {
83 matches!(self, Kind::CompileOnly)
84 }
85
86 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 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#[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 #[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 pub fn id(&self) -> &Id {
156 &self.id
157 }
158
159 pub fn kind(&self) -> Kind {
161 self.kind
162 }
163
164 pub fn annotations(&self) -> &[Annotation] {
166 &self.annotations
167 }
168
169 pub fn is_skip(&self) -> bool {
171 self.annotations.contains(&Annotation::Skip)
172 }
173}
174
175impl Test {
176 #[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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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#[derive(Debug, Error)]
451pub enum CreateError {
452 #[error("an error occurred while parsing a test annotation")]
454 Annotation(#[from] ParseAnnotationError),
455
456 #[error("an error occurred while saving test files")]
458 Save(#[from] doc::SaveError),
459
460 #[error("an io error occurred")]
462 Io(#[from] io::Error),
463}
464
465#[derive(Debug, Error)]
467pub enum LoadError {
468 #[error("an error occurred while parsing a test annotation")]
470 Annotation(#[from] ParseAnnotationError),
471
472 #[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}