diff --git a/Cargo.lock b/Cargo.lock index 5c96f56119..91f34c5102 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4699,6 +4699,7 @@ dependencies = [ "photocraft-color", "photocraft-geom", "photocraft-raster", + "photocraft-testkit", "rayon", "rustfft", "serde", @@ -4921,6 +4922,7 @@ dependencies = [ "photocraft-psd", "photocraft-raster", "photocraft-raw", + "photocraft-testkit", "photocraft-text", "photocraft-vector", "proptest", diff --git a/crates/algo/Cargo.toml b/crates/algo/Cargo.toml index c4f34b4b1d..af50c1d20e 100644 --- a/crates/algo/Cargo.toml +++ b/crates/algo/Cargo.toml @@ -21,4 +21,5 @@ rustfft = "6.4.1" rayon = { workspace = true } [dev-dependencies] +photocraft-testkit = { workspace = true } serde_json = { workspace = true } diff --git a/crates/algo/src/stylize2/tests.rs b/crates/algo/src/stylize2/tests.rs index 3d7f6b5235..c7139153af 100644 --- a/crates/algo/src/stylize2/tests.rs +++ b/crates/algo/src/stylize2/tests.rs @@ -29,14 +29,25 @@ const MODELS: [ColorMode; 8] = #[test] fn swept_blocks_match_the_pixel_search() { let bounds = Rect::new(-13, -9, 31, 26); + // Unless the heights are level-based, the colour mode reaches extrude only through the channel + // count and whether the samples are CMYK ink, so modes that agree on both repeat those cases. + // A run takes a quarter of the rest, a different quarter each day. + let mut seen = std::collections::HashSet::new(); + let sweep = photocraft_testkit::Sweep::new(4); + let mut case = 0; for mode in MODELS { for alpha in [false, true] { let n = PixelFormat::new(mode, SampleType::F32, alpha).channels(); let ctx = Ctx { bounds, mode, alpha }; + let repeat = !seen.insert((n, alpha, mode == ColorMode::Cmyk)); for kind in 0..5 { let src = image(bounds.inflate(20), n, kind); for (size, depth) in [(2.0, 0.0), (2.0, 255.0), (3.5, 190.0), (8.0, 64.0), (22.0, 255.0), (255.0, 100.0)] { - for flags in 0..8 { + for flags in (0..8).filter(|f| !repeat || f & 1 != 0) { + case += 1; + if !sweep.includes(case) { + continue; + } let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size, diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index eb8c0b00d7..261c16481c 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -54,4 +54,5 @@ photocraft-affinity = { workspace = true, features = ["synth"] } flate2 = { workspace = true } photocraft-psd = { workspace = true, features = ["testgen"] } photocraft-raw = { workspace = true, features = ["testgen"] } +photocraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/io/tests/it/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs index 382f45bc44..1bbe8e2f4b 100644 --- a/crates/io/tests/it/tiff_layers.rs +++ b/crates/io/tests/it/tiff_layers.rs @@ -333,21 +333,28 @@ fn damaged_layer_data_opens_flattened() { let r2 = import("x.tif", &bad).unwrap(); assert_eq!(r2.document.layers.len(), 1); assert!(r2.warnings.iter().any(|w| w.contains("could not be read")), "{:?}", r2.warnings); - // Every truncation and a sweep of bit flips through the whole file: never a panic. - for cut in (0..r.bytes.len()).step_by(11) { + // Truncations and bit flips through the whole file: never a panic. A run takes a quarter of + // them, a different quarter each day. + let sweep = photocraft_testkit::Sweep::new(4); + let mut case = 0; + let mut runs = || { + case += 1; + sweep.includes(case) + }; + for cut in (0..r.bytes.len()).step_by(11).filter(|_| runs()) { let _ = import("x.tif", &r.bytes[..cut]); } - for i in (0..r.bytes.len()).step_by(29) { + for i in (0..r.bytes.len()).step_by(29).filter(|_| runs()) { let mut b = r.bytes.clone(); b[i] ^= 0x5a; let _ = import("x.tif", &b); } for name in ["layered-le-8bit.tif", "layered-be-16bit.tif"] { let Some(bytes) = fixture(name) else { continue }; - for cut in (0..bytes.len()).step_by(7) { + for cut in (0..bytes.len()).step_by(7).filter(|_| runs()) { let _ = import(name, &bytes[..cut]); } - for i in (0..bytes.len()).step_by(5) { + for i in (0..bytes.len()).step_by(5).filter(|_| runs()) { let mut b = bytes.clone(); b[i] ^= 0xff; let _ = import(name, &b); diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 03af4b630f..06f70a4e2d 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -4,5 +4,7 @@ pub mod gpu; #[cfg(not(target_arch = "wasm32"))] pub mod perf; +pub mod sweep; pub use gpu::gpu_lock; +pub use sweep::Sweep; diff --git a/crates/testkit/src/sweep.rs b/crates/testkit/src/sweep.rs new file mode 100644 index 0000000000..1c4664bcb4 --- /dev/null +++ b/crates/testkit/src/sweep.rs @@ -0,0 +1,56 @@ +//! Long test sweeps spread over several days. + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The cases of a long sweep that this run covers. By default a run takes one of `parts` slices, +/// chosen by the UTC date, so every case runs at least once every `parts` days. +/// `PHOTOCRAFT_FULL_SWEEP=1` runs every case, and `PHOTOCRAFT_SWEEP_PART=` replays slice `n`; +/// [`Sweep::new`] prints the slice it picked. +#[derive(Debug, Clone, Copy)] +pub struct Sweep { + part: u64, + parts: u64, +} + +impl Sweep { + pub fn new(parts: u64) -> Self { + if std::env::var_os("PHOTOCRAFT_FULL_SWEEP").is_some_and(|v| v == "1") { + eprintln!("full sweep (PHOTOCRAFT_FULL_SWEEP=1)"); + return Self { part: 0, parts: 1 }; + } + let parts = parts.max(1); + let part = match std::env::var("PHOTOCRAFT_SWEEP_PART").ok().and_then(|v| v.parse::().ok()) { + Some(p) => p % parts, + None => SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() / 86_400).unwrap_or_default() % parts, + }; + eprintln!("sweep part {part} of {parts}: replay with PHOTOCRAFT_SWEEP_PART={part}, run every part with PHOTOCRAFT_FULL_SWEEP=1"); + Self { part, parts } + } + + /// Whether case `i` runs. Cases go to slices by a hash of `i`, so each slice mixes every + /// position of the sweep's inner loops instead of taking every `parts`-th one. + pub fn includes(&self, i: u64) -> bool { + self.parts == 1 || mix(i) % self.parts == self.part + } +} + +/// splitmix64's finalizer. +fn mix(mut x: u64) -> u64 { + x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + x ^ (x >> 31) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_parts_cover_every_case_once() { + let parts: Vec = (0..4).map(|part| Sweep { part, parts: 4 }).collect(); + for i in 0..1000 { + assert_eq!(parts.iter().filter(|s| s.includes(i)).count(), 1, "case {i}"); + } + assert!(parts.iter().all(|s| (0..1000).filter(|&i| s.includes(i)).count() > 200)); + } +} diff --git a/docs/development.md b/docs/development.md index f285c93e97..3ba7fc8ed6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -60,7 +60,7 @@ cargo nextest run --workspace # parallel across binaries (cargo ins cargo test --doc --workspace # nextest does not run doctests ``` -[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR. +[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. The longest sweeps (`photocraft-testkit`'s `Sweep`) run a quarter of their cases, a different quarter each UTC day; `PHOTOCRAFT_FULL_SWEEP=1` runs all of them, and `PHOTOCRAFT_SWEEP_PART=` replays the part a failing run printed. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR. ### Shortcut capture without a GPU, and native snapshots