mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 15:57:49 +00:00
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.
209 lines
9.9 KiB
Rust
209 lines
9.9 KiB
Rust
//! 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");
|
||
}
|
||
}
|