Files
vectorcraft/crates/testkit/src/lib.rs
T
dotCooCoo ba2531bc3f Tests: engine tests remove the temp folders they make
vectorcraft_testkit::temp_dir returns a bare path, so every engine test
that wrote into one left the folder in the system temporary folder.

- testkit: TempDir, a test's own folder made through temp_dir (same
  private creation and ownership checks) under a tag no other TempDir in
  the process uses, removed with its contents when dropped, also when
  the test panics.
- Engine unit and integration tests hold a TempDir for the whole test
  instead of calling temp_dir: graph, fileio, compare, journal, library,
  shortcut set, spelling, store, web export, cancel, containers, build,
  color settings, command sweep, font folder, removed font, model-based
  and the font search fuzz.
- The DCS and swatch library tests' own helpers and the PDF editing
  tests' shared vc-pdfedit-<pid> folder use a TempDir per test too.
- The font and link file search, store, color settings and font folder
  helpers no longer delete the private folder temp_dir checked and make
  it again with default permissions.
2026-11-13 09:33:36 -08:00

209 lines
9.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! VectorCraft test helpers.
//!
//! - [`fixtures`]: sessions and documents in known states (empty, single/multi selection, a "rich"
//! document touching most node kinds), plus a [`fixtures::DocBuilder`] for building documents
//! directly (render tests).
//! - [`strategies`]: proptest strategies for geometry and for engine command sequences ([`strategies::Op`]), and
//! junk-parameter generators for fuzzing the command registry.
//! - [`invariants`]: structural checks for documents and sessions, and round-trip checks
//! (`.vectorcraft`, SVG, `document.inspect`).
//! - [`raster`]: rendering helpers and image comparison with a perceptual tolerance.
//! - [`geom`]: geometry assertions (approximate equality, curve sampling, Hausdorff distance).
//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests.
//! - [`ai`]: hand-written Illustrator editing data, and the EPS and `.ai` files that carry it.
//! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library tests.
//! - [`tiff`]: hand-written TIFF files, chunky or planar, or compressed with CCITT Group 3, for
//! image import tests.
//! - [`fonts`]: font files made from the bundled fonts (a renamed family) for font tests.
//!
//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`).
// Test support only (a dev-dependency of every crate that uses it): a failed setup or assertion
// must panic, like `assert!`, so the shipped-code ban on panicking (AGENTS.md › Robustness) doesn't
// apply here.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#![forbid(unsafe_code)]
pub mod ai;
pub mod ase;
pub mod fixtures;
pub mod fonts;
pub mod geom;
pub mod invariants;
pub mod pdf;
pub mod raster;
pub mod strategies;
pub mod tiff;
pub use serde_json::{Value, json};
pub use vectorcraft_engine::{Session, doc::NodeId};
/// Re-exports so dependents without direct dependencies (e.g. app test crates) can use them.
pub use {vectorcraft_doc as doc, vectorcraft_format as format, vectorcraft_render as render, vectorcraft_svg as svg};
/// A per-process temporary directory for test output (created on first use). It is made with
/// `create_dir` (owner-only on Unix), and a directory that already exists is reused only when it is
/// a real directory, not a link, with no access for others and owned by this user: one pre-made in
/// a shared temp folder is refused.
/// The first call for a `tag` in a process empties a directory left by an earlier process of the
/// same id (Windows reuses ids soon); later calls share it.
pub fn temp_dir(tag: &str) -> std::path::PathBuf {
static SEEN: std::sync::Mutex<std::collections::BTreeSet<String>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
// Held to the end: another thread's first files must not land before the emptying.
let mut seen = SEEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let first = seen.insert(tag.to_string());
let dir = std::env::temp_dir().join(format!("vectorcraft-testkit-{tag}-{}", std::process::id()));
// Unix sets the mode on it; elsewhere it stays as made.
#[cfg_attr(not(unix), allow(unused_mut))]
let mut b = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
if let Err(e) = b.create(&dir) {
let m = std::fs::symlink_metadata(&dir).expect("temp dir");
assert!(e.kind() == std::io::ErrorKind::AlreadyExists && m.is_dir(), "create temp dir: {e}");
#[cfg(unix)]
assert!(std::os::unix::fs::PermissionsExt::mode(&m.permissions()) & 0o077 == 0, "{}: not private", dir.display());
// A directory made by another user may pass the mode check now and be opened up later: a
// probe file made new (never through a link left in its place) is this user's, and the
// directory must belong to the same user.
// Named for this call: tests running alongside probe the same directory.
static PROBES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let probe = dir.join(format!(".probe-{}", PROBES.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
let made = std::fs::OpenOptions::new().write(true).create_new(true).open(&probe).and_then(|f| f.metadata());
let _ = std::fs::remove_file(&probe);
let made = made.unwrap_or_else(|e| panic!("{}: not owned by this user ({e})", dir.display()));
#[cfg(unix)]
assert_eq!(std::os::unix::fs::MetadataExt::uid(&made), std::os::unix::fs::MetadataExt::uid(&m), "{}: not owned by this user", dir.display());
#[cfg(not(unix))]
let _ = made;
if first {
for entry in std::fs::read_dir(&dir).expect("temp dir").flatten() {
let path = entry.path();
let gone = if path.is_dir() { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) };
assert!(gone.is_ok(), "{}: left by an earlier run", path.display());
}
}
}
dir
}
/// A test's own temporary directory, removed with everything in it when dropped, also when the
/// test panics. [`temp_dir`] makes it, with the same checks, under a tag no other `TempDir` in this
/// process uses, so tests running alongside never share one. A removal that fails (a file still
/// held open) is ignored; the next process with the same id empties the directory before use.
pub struct TempDir(std::path::PathBuf);
impl TempDir {
/// A new empty directory `vectorcraft-testkit-<tag>-<n>-<process id>` in the system temporary
/// folder, where `n` counts the `TempDir`s made in this process.
pub fn new(tag: &str) -> Self {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Self(temp_dir(&format!("{tag}-{n}")))
}
}
impl std::ops::Deref for TempDir {
type Target = std::path::PathBuf;
fn deref(&self) -> &std::path::PathBuf {
&self.0
}
}
impl AsRef<std::path::Path> for TempDir {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// How many shapes an SVG draws: its `<path>` elements and the basic shapes SVG export writes
/// simple shapes as (`<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polyline>`, `<polygon>`).
pub fn svg_shapes(svg: &str) -> usize {
["<path", "<rect", "<circle", "<ellipse", "<line ", "<polyline", "<polygon"].iter().map(|t| svg.matches(t).count()).sum()
}
thread_local! {
static QUIET: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Run `f`, catching a panic without printing it (other threads' panics still print). Returns the
/// panic message on panic.
pub fn catch_quiet<T>(f: impl FnOnce() -> T) -> Result<T, String> {
static HOOK: std::sync::Once = std::sync::Once::new();
HOOK.call_once(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if !QUIET.with(|q| q.get()) {
prev(info);
}
}));
});
QUIET.with(|q| q.set(true));
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
QUIET.with(|q| q.set(false));
r.map_err(|p| p.downcast_ref::<String>().cloned().or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string())).unwrap_or_else(|| "panic".into()))
}
#[cfg(test)]
mod tests {
#[test]
fn a_temp_dir_starts_empty_in_a_process_and_stays_for_later_calls() {
let tag = "fresh-temp-dir";
let left = std::env::temp_dir().join(format!("vectorcraft-testkit-{tag}-{}", std::process::id()));
// Made private, as `temp_dir` makes it: a directory others can reach is refused, not reused.
#[cfg_attr(not(unix), allow(unused_mut))]
let mut b = std::fs::DirBuilder::new();
b.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
b.create(left.join("nested")).unwrap();
std::fs::write(left.join("left.txt"), b"an earlier process").unwrap();
let dir = super::temp_dir(tag);
assert_eq!(dir, left);
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "what an earlier process of this id left is gone");
std::fs::write(dir.join("mine.txt"), b"this process").unwrap();
assert!(super::temp_dir(tag).join("mine.txt").is_file(), "a later call keeps this process's files");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_test_dir_is_removed_with_its_contents_when_dropped() {
let dir = super::TempDir::new("dropped");
let path = dir.to_path_buf();
std::fs::create_dir_all(path.join("nested")).unwrap();
std::fs::write(path.join("nested").join("a.txt"), b"a").unwrap();
drop(dir);
assert!(!path.exists(), "{} is left", path.display());
}
#[test]
fn a_test_dir_is_removed_when_its_test_panics() {
let mut path = None;
let r = super::catch_quiet::<()>(|| {
let dir = super::TempDir::new("panicked");
std::fs::write(dir.join("a.txt"), b"a").unwrap();
path = Some(dir.to_path_buf());
panic!("the test fails");
});
assert_eq!(r.err().as_deref(), Some("the test fails"));
let path = path.unwrap();
assert!(!path.exists(), "{} is left", path.display());
}
#[test]
fn test_dirs_with_one_tag_are_separate_and_private() {
let (a, b) = (super::TempDir::new("shared-tag"), super::TempDir::new("shared-tag"));
assert_ne!(*a, *b);
std::fs::write(a.join("a.txt"), b"a").unwrap();
assert_eq!(std::fs::read_dir(&*b).unwrap().count(), 0, "one test's files are not in another's directory");
#[cfg(unix)]
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&*a).unwrap().permissions()) & 0o777, 0o700);
drop(a);
assert!(b.is_dir(), "dropping one leaves the other");
}
}