diff --git a/AGENTS.md b/AGENTS.md
index dbe7e172a9..172bc7bd70 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -70,6 +70,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
8. **Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log.
9. **Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green.
+10. **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## 4. Picking work
diff --git a/Cargo.lock b/Cargo.lock
index ba0e6baea0..98e1290c29 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4642,6 +4642,7 @@ version = "0.12.0"
dependencies = [
"arboard",
"async-io",
+ "chrono",
"core-graphics2",
"dispatch2",
"eframe",
@@ -4810,6 +4811,7 @@ name = "photocraft-engine"
version = "0.12.0"
dependencies = [
"blake3",
+ "chrono",
"craft-print",
"exr",
"flate2",
@@ -8713,6 +8715,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
name = "xtask"
version = "0.12.0"
dependencies = [
+ "chrono",
"serde",
"serde_json",
"toml",
diff --git a/Cargo.toml b/Cargo.toml
index c3fdd1e10c..b4a66136b8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -59,6 +59,8 @@ egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
criterion = { version = "0.5", default-features = false }
getrandom = "0.3"
diff --git a/README.md b/README.md
index 4285f69513..74a5c78f36 100644
--- a/README.md
+++ b/README.md
@@ -221,7 +221,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
-- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
+- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`).
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
diff --git a/ROADMAP.md b/ROADMAP.md
index 1d1174c504..829ff75af8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -137,7 +137,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) |
-| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
+| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/it/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/it/cli.rs
similarity index 100%
rename from apps/photocraft-cli/tests/cli.rs
rename to apps/photocraft-cli/tests/it/cli.rs
diff --git a/apps/photocraft-cli/tests/it/main.rs b/apps/photocraft-cli/tests/it/main.rs
new file mode 100644
index 0000000000..581c983b3c
--- /dev/null
+++ b/apps/photocraft-cli/tests/it/main.rs
@@ -0,0 +1,4 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod cli;
+mod orientation;
diff --git a/apps/photocraft-cli/tests/orientation.rs b/apps/photocraft-cli/tests/it/orientation.rs
similarity index 100%
rename from apps/photocraft-cli/tests/orientation.rs
rename to apps/photocraft-cli/tests/it/orientation.rs
diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml
index 61c43ae9da..4ba562db68 100644
--- a/apps/photocraft/Cargo.toml
+++ b/apps/photocraft/Cargo.toml
@@ -33,6 +33,7 @@ eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11",
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs
index 5c8f0697dd..6a0cc8f052 100644
--- a/apps/photocraft/src/logging.rs
+++ b/apps/photocraft/src/logging.rs
@@ -91,27 +91,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
+/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
+/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
- let secs = since.as_secs();
- let (days, rem) = (secs / 86_400, secs % 86_400);
- let (y, m, d) = civil_from_days(days);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
+ // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
+ // past `i64::MAX` seconds saturates rather than falling back to the epoch.
+ let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
+ let (y, t) = utc_parts(secs, since.subsec_nanos());
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
-/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
-/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
-fn civil_from_days(days: u64) -> (u64, u64, u64) {
- let z = days + 719_468;
- let era = z / 146_097;
- let doe = z % 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = yoe + era * 400 + u64::from(m <= 2);
- (y, m, d)
+/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
+/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
+/// so month, day and time of day come from chrono and only the year is carried, which keeps every
+/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
+fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
+ use chrono::Datelike;
+ let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -416,6 +425,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
+ // Past chrono's year range still converts via the 400-year cycle (the old code did too,
+ // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
+ // SystemTime can't hold this instant, so it is only checked where it exists.
+ // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
+ assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
+ }
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
+ // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
+ // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
+ if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
+ assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
+ }
}
#[test]
diff --git a/apps/photocraft/tests/command_line_usage.rs b/apps/photocraft/tests/it/command_line_usage.rs
similarity index 100%
rename from apps/photocraft/tests/command_line_usage.rs
rename to apps/photocraft/tests/it/command_line_usage.rs
diff --git a/apps/photocraft/tests/control_exit_code.rs b/apps/photocraft/tests/it/control_exit_code.rs
similarity index 100%
rename from apps/photocraft/tests/control_exit_code.rs
rename to apps/photocraft/tests/it/control_exit_code.rs
diff --git a/apps/photocraft/tests/it/main.rs b/apps/photocraft/tests/it/main.rs
new file mode 100644
index 0000000000..b5ece12621
--- /dev/null
+++ b/apps/photocraft/tests/it/main.rs
@@ -0,0 +1,5 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod command_line_usage;
+mod control_exit_code;
+mod startup_without_display;
diff --git a/apps/photocraft/tests/startup_without_display.rs b/apps/photocraft/tests/it/startup_without_display.rs
similarity index 100%
rename from apps/photocraft/tests/startup_without_display.rs
rename to apps/photocraft/tests/it/startup_without_display.rs
diff --git a/crates/affinity/README.md b/crates/affinity/README.md
index 535c3736c6..1be7fd954e 100644
--- a/crates/affinity/README.md
+++ b/crates/affinity/README.md
@@ -108,11 +108,11 @@ The same reader is shared, as an independent copy, with VectorCraft's `vectorcra
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
- mutations, here and in `photocraft-io`'s `tests/affinity.rs`.
+ mutations, here and in `photocraft-io`'s `tests/it/affinity.rs`.
* `cargo xtask corpus --affinity` fetches 21 public CC0/MIT documents, including the four
Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art)
- (CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs`
- parses them; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and
+ (CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/it/real_files.rs`
+ parses them; `photocraft-io`'s `tests/it/affinity_corpus.rs` imports and flattens each one and
compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a
ceiling per file).
* `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`)
diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs
index 2c68a064a3..a5517653de 100644
--- a/crates/affinity/src/tests.rs
+++ b/crates/affinity/src/tests.rs
@@ -1,4 +1,4 @@
-//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
+//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents.
mod preview {
use crate::container::header;
diff --git a/crates/affinity/tests/compound_limits.rs b/crates/affinity/tests/it/compound_limits.rs
similarity index 97%
rename from crates/affinity/tests/compound_limits.rs
rename to crates/affinity/tests/it/compound_limits.rs
index 76f2e6e4a1..6801f7f6d6 100644
--- a/crates/affinity/tests/compound_limits.rs
+++ b/crates/affinity/tests/it/compound_limits.rs
@@ -48,8 +48,10 @@ fn isolated(name: &str, check: impl FnOnce()) {
check();
return;
}
+ // The harness names a test by its module path below the crate root.
+ let test = module_path!().split_once("::").map_or(name.to_string(), |(_, m)| format!("{m}::{name}"));
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
- .args(["--exact", name, "--nocapture"])
+ .args(["--exact", &test, "--nocapture"])
.env("PHOTOCRAFT_COMPOUND_CASE", name)
.spawn()
.unwrap();
diff --git a/crates/affinity/tests/it/main.rs b/crates/affinity/tests/it/main.rs
new file mode 100644
index 0000000000..5d0a5740b8
--- /dev/null
+++ b/crates/affinity/tests/it/main.rs
@@ -0,0 +1,4 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod compound_limits;
+mod real_files;
diff --git a/crates/affinity/tests/real_files.rs b/crates/affinity/tests/it/real_files.rs
similarity index 100%
rename from crates/affinity/tests/real_files.rs
rename to crates/affinity/tests/it/real_files.rs
diff --git a/crates/algo/tests/liquify_source_footprint.rs b/crates/algo/tests/it/liquify_source_footprint.rs
similarity index 100%
rename from crates/algo/tests/liquify_source_footprint.rs
rename to crates/algo/tests/it/liquify_source_footprint.rs
diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs
new file mode 100644
index 0000000000..107626cfc2
--- /dev/null
+++ b/crates/algo/tests/it/main.rs
@@ -0,0 +1,5 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod liquify_source_footprint;
+mod retouch_blur_exact;
+mod warp_clipped;
diff --git a/crates/algo/tests/retouch_blur_exact.rs b/crates/algo/tests/it/retouch_blur_exact.rs
similarity index 100%
rename from crates/algo/tests/retouch_blur_exact.rs
rename to crates/algo/tests/it/retouch_blur_exact.rs
diff --git a/crates/algo/tests/warp_clipped.rs b/crates/algo/tests/it/warp_clipped.rs
similarity index 100%
rename from crates/algo/tests/warp_clipped.rs
rename to crates/algo/tests/it/warp_clipped.rs
diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/it/agent_tasks.rs
similarity index 100%
rename from crates/automation/tests/agent_tasks.rs
rename to crates/automation/tests/it/agent_tasks.rs
diff --git a/crates/automation/tests/agent_tasks_extended.rs b/crates/automation/tests/it/agent_tasks_extended.rs
similarity index 100%
rename from crates/automation/tests/agent_tasks_extended.rs
rename to crates/automation/tests/it/agent_tasks_extended.rs
diff --git a/crates/automation/tests/agent_tasks_more.rs b/crates/automation/tests/it/agent_tasks_more.rs
similarity index 100%
rename from crates/automation/tests/agent_tasks_more.rs
rename to crates/automation/tests/it/agent_tasks_more.rs
diff --git a/crates/automation/tests/bridge.rs b/crates/automation/tests/it/bridge.rs
similarity index 100%
rename from crates/automation/tests/bridge.rs
rename to crates/automation/tests/it/bridge.rs
diff --git a/crates/automation/tests/inactive_save.rs b/crates/automation/tests/it/inactive_save.rs
similarity index 100%
rename from crates/automation/tests/inactive_save.rs
rename to crates/automation/tests/it/inactive_save.rs
diff --git a/crates/automation/tests/krita.rs b/crates/automation/tests/it/krita.rs
similarity index 100%
rename from crates/automation/tests/krita.rs
rename to crates/automation/tests/it/krita.rs
diff --git a/crates/automation/tests/layered_save_dirty.rs b/crates/automation/tests/it/layered_save_dirty.rs
similarity index 100%
rename from crates/automation/tests/layered_save_dirty.rs
rename to crates/automation/tests/it/layered_save_dirty.rs
diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs
new file mode 100644
index 0000000000..f701bd705b
--- /dev/null
+++ b/crates/automation/tests/it/main.rs
@@ -0,0 +1,16 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod agent_tasks;
+mod agent_tasks_extended;
+mod agent_tasks_more;
+mod bridge;
+mod inactive_save;
+mod krita;
+mod layered_save_dirty;
+mod mcp;
+mod mcp_wire;
+mod missing_font_warnings;
+mod native_save_name;
+mod openraster;
+mod raw_defaults;
+mod smart_filter_authorization;
diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/it/mcp.rs
similarity index 100%
rename from crates/automation/tests/mcp.rs
rename to crates/automation/tests/it/mcp.rs
diff --git a/crates/automation/tests/mcp_wire.rs b/crates/automation/tests/it/mcp_wire.rs
similarity index 100%
rename from crates/automation/tests/mcp_wire.rs
rename to crates/automation/tests/it/mcp_wire.rs
diff --git a/crates/automation/tests/missing_font_warnings.rs b/crates/automation/tests/it/missing_font_warnings.rs
similarity index 100%
rename from crates/automation/tests/missing_font_warnings.rs
rename to crates/automation/tests/it/missing_font_warnings.rs
diff --git a/crates/automation/tests/native_save_name.rs b/crates/automation/tests/it/native_save_name.rs
similarity index 100%
rename from crates/automation/tests/native_save_name.rs
rename to crates/automation/tests/it/native_save_name.rs
diff --git a/crates/automation/tests/openraster.rs b/crates/automation/tests/it/openraster.rs
similarity index 100%
rename from crates/automation/tests/openraster.rs
rename to crates/automation/tests/it/openraster.rs
diff --git a/crates/automation/tests/raw_defaults.rs b/crates/automation/tests/it/raw_defaults.rs
similarity index 100%
rename from crates/automation/tests/raw_defaults.rs
rename to crates/automation/tests/it/raw_defaults.rs
diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/it/smart_filter_authorization.rs
similarity index 100%
rename from crates/automation/tests/smart_filter_authorization.rs
rename to crates/automation/tests/it/smart_filter_authorization.rs
diff --git a/crates/cms/README.md b/crates/cms/README.md
index 70b4ab3c9c..9109f48ed2 100644
--- a/crates/cms/README.md
+++ b/crates/cms/README.md
@@ -75,7 +75,7 @@ sampled gamut boundary. `profiles/photocraft-coated-cmyk.icc` (237 KB, AToB 11
21³ lut16) is that output, dedicated to the public domain (CC0-1.0). Regenerate with
```sh
-PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test regen
+PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test it -- regen::
```
It is a plausible coated-offset profile, not a measured characterisation: use your printer's
diff --git a/crates/cms/tests/bench.rs b/crates/cms/tests/it/bench.rs
similarity index 95%
rename from crates/cms/tests/bench.rs
rename to crates/cms/tests/it/bench.rs
index e0b9628a53..06789ff350 100644
--- a/crates/cms/tests/bench.rs
+++ b/crates/cms/tests/it/bench.rs
@@ -1,4 +1,4 @@
-//! Throughput check: `cargo test -p photocraft-cms --release --test bench -- --ignored --nocapture`.
+//! Throughput check: `cargo test -p photocraft-cms --release --test it -- bench:: --ignored --nocapture`.
use photocraft_cms::{Builtin, Intent, Transform};
diff --git a/crates/cms/tests/cms.rs b/crates/cms/tests/it/cms.rs
similarity index 100%
rename from crates/cms/tests/cms.rs
rename to crates/cms/tests/it/cms.rs
diff --git a/crates/cms/tests/gpu_matrix_shaper.rs b/crates/cms/tests/it/gpu_matrix_shaper.rs
similarity index 100%
rename from crates/cms/tests/gpu_matrix_shaper.rs
rename to crates/cms/tests/it/gpu_matrix_shaper.rs
diff --git a/crates/cms/tests/it/main.rs b/crates/cms/tests/it/main.rs
new file mode 100644
index 0000000000..127f08435b
--- /dev/null
+++ b/crates/cms/tests/it/main.rs
@@ -0,0 +1,6 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod bench;
+mod cms;
+mod gpu_matrix_shaper;
+mod regen;
diff --git a/crates/cms/tests/regen.rs b/crates/cms/tests/it/regen.rs
similarity index 98%
rename from crates/cms/tests/regen.rs
rename to crates/cms/tests/it/regen.rs
index 54db9b4e2e..05d4b696b3 100644
--- a/crates/cms/tests/regen.rs
+++ b/crates/cms/tests/it/regen.rs
@@ -1,7 +1,7 @@
//! Regenerates the shipped synthetic CMYK profile when `PHOTOCRAFT_REGEN_PROFILES=1`:
//!
//! ```sh
-//! PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test regen
+//! PHOTOCRAFT_REGEN_PROFILES=1 cargo test -p photocraft-cms --release --test it -- regen::
//! ```
//!
//! Without the variable the test only checks that the generator's model still matches the
diff --git a/crates/codecs/tests/camera_raw.rs b/crates/codecs/tests/it/camera_raw.rs
similarity index 100%
rename from crates/codecs/tests/camera_raw.rs
rename to crates/codecs/tests/it/camera_raw.rs
diff --git a/crates/codecs/tests/common/mod.rs b/crates/codecs/tests/it/common/mod.rs
similarity index 100%
rename from crates/codecs/tests/common/mod.rs
rename to crates/codecs/tests/it/common/mod.rs
diff --git a/crates/codecs/tests/common/tiffgen.rs b/crates/codecs/tests/it/common/tiffgen.rs
similarity index 100%
rename from crates/codecs/tests/common/tiffgen.rs
rename to crates/codecs/tests/it/common/tiffgen.rs
diff --git a/crates/codecs/tests/dds.rs b/crates/codecs/tests/it/dds.rs
similarity index 100%
rename from crates/codecs/tests/dds.rs
rename to crates/codecs/tests/it/dds.rs
diff --git a/crates/codecs/tests/dds_write.rs b/crates/codecs/tests/it/dds_write.rs
similarity index 99%
rename from crates/codecs/tests/dds_write.rs
rename to crates/codecs/tests/it/dds_write.rs
index 119101e4fd..e51e21ceda 100644
--- a/crates/codecs/tests/dds_write.rs
+++ b/crates/codecs/tests/it/dds_write.rs
@@ -348,7 +348,7 @@ fn compression_names_round_trip() {
/// Encode timings on a synthetic ~30 MP texture (6336×4752). Ignored by default; run in release:
///
/// ```sh
-/// cargo test --release -p photocraft-codecs --test dds_write -- --ignored --nocapture
+/// cargo test --release -p photocraft-codecs --test it -- dds_write:: --ignored --nocapture
/// ```
#[test]
#[ignore]
diff --git a/crates/codecs/tests/decode_warnings.rs b/crates/codecs/tests/it/decode_warnings.rs
similarity index 99%
rename from crates/codecs/tests/decode_warnings.rs
rename to crates/codecs/tests/it/decode_warnings.rs
index 5fc310104a..82bf75b57d 100644
--- a/crates/codecs/tests/decode_warnings.rs
+++ b/crates/codecs/tests/it/decode_warnings.rs
@@ -1,7 +1,7 @@
//! Decode warnings: a file that holds more than was decoded (#523: animations, multi-page
//! TIFF) or less than it should (#518: truncated JPEG) says so; complete single images don't.
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
diff --git a/crates/codecs/tests/deep_exr.rs b/crates/codecs/tests/it/deep_exr.rs
similarity index 100%
rename from crates/codecs/tests/deep_exr.rs
rename to crates/codecs/tests/it/deep_exr.rs
diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/it/detect_caps.rs
similarity index 99%
rename from crates/codecs/tests/detect_caps.rs
rename to crates/codecs/tests/it/detect_caps.rs
index 2f55d3cf58..8db608de12 100644
--- a/crates/codecs/tests/detect_caps.rs
+++ b/crates/codecs/tests/it/detect_caps.rs
@@ -1,7 +1,7 @@
//! Magic-number detection, extensions, and the capability table
//! (including the symmetric read/write guarantee).
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
diff --git a/crates/codecs/tests/dpx.rs b/crates/codecs/tests/it/dpx.rs
similarity index 100%
rename from crates/codecs/tests/dpx.rs
rename to crates/codecs/tests/it/dpx.rs
diff --git a/crates/codecs/tests/exr_cryptomatte.rs b/crates/codecs/tests/it/exr_cryptomatte.rs
similarity index 100%
rename from crates/codecs/tests/exr_cryptomatte.rs
rename to crates/codecs/tests/it/exr_cryptomatte.rs
diff --git a/crates/codecs/tests/exr_multipart.rs b/crates/codecs/tests/it/exr_multipart.rs
similarity index 100%
rename from crates/codecs/tests/exr_multipart.rs
rename to crates/codecs/tests/it/exr_multipart.rs
diff --git a/crates/codecs/tests/fidelity.rs b/crates/codecs/tests/it/fidelity.rs
similarity index 99%
rename from crates/codecs/tests/fidelity.rs
rename to crates/codecs/tests/it/fidelity.rs
index 93b0236fdf..2c584a559a 100644
--- a/crates/codecs/tests/fidelity.rs
+++ b/crates/codecs/tests/it/fidelity.rs
@@ -1,6 +1,6 @@
//! `fidelity_warnings` cases, and consistency with what `encode` does.
-mod common;
+use crate::common;
use FidelityWarning as W;
use common::*;
use photocraft_codecs::*;
diff --git a/crates/codecs/tests/gif_frames.rs b/crates/codecs/tests/it/gif_frames.rs
similarity index 100%
rename from crates/codecs/tests/gif_frames.rs
rename to crates/codecs/tests/it/gif_frames.rs
diff --git a/crates/codecs/tests/heif.rs b/crates/codecs/tests/it/heif.rs
similarity index 99%
rename from crates/codecs/tests/heif.rs
rename to crates/codecs/tests/it/heif.rs
index 6471a5ece6..bc1a185ae1 100644
--- a/crates/codecs/tests/heif.rs
+++ b/crates/codecs/tests/it/heif.rs
@@ -8,7 +8,7 @@
#![cfg(all(feature = "corpus", feature = "heif"))]
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
use std::sync::LazyLock;
diff --git a/crates/codecs/tests/limits.rs b/crates/codecs/tests/it/limits.rs
similarity index 99%
rename from crates/codecs/tests/limits.rs
rename to crates/codecs/tests/it/limits.rs
index 6969fea922..c1873771f6 100644
--- a/crates/codecs/tests/limits.rs
+++ b/crates/codecs/tests/it/limits.rs
@@ -1,6 +1,6 @@
//! Decompression-bomb guards.
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
diff --git a/crates/codecs/tests/it/main.rs b/crates/codecs/tests/it/main.rs
new file mode 100644
index 0000000000..3e77f071f0
--- /dev/null
+++ b/crates/codecs/tests/it/main.rs
@@ -0,0 +1,35 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod camera_raw;
+mod common;
+mod dds;
+mod dds_write;
+mod decode_warnings;
+mod deep_exr;
+mod detect_caps;
+mod dpx;
+mod exr_cryptomatte;
+mod exr_multipart;
+mod fidelity;
+mod gif_frames;
+mod heif;
+mod limits;
+mod malformed;
+mod metadata;
+mod orientation;
+mod pcx;
+mod png_jpeg;
+mod pnm_headers;
+mod resolution;
+mod roundtrip;
+mod source_settings;
+mod tga;
+mod tiff_bench;
+mod tiff_exif;
+mod tiff_lab;
+mod tiff_pages;
+mod tiff_photoshop;
+mod tiff_regressions;
+mod webp_frames;
+mod webp_lossy;
+mod xpm;
diff --git a/crates/codecs/tests/malformed.rs b/crates/codecs/tests/it/malformed.rs
similarity index 99%
rename from crates/codecs/tests/malformed.rs
rename to crates/codecs/tests/it/malformed.rs
index b6da84b072..4bb9ee7cb1 100644
--- a/crates/codecs/tests/malformed.rs
+++ b/crates/codecs/tests/it/malformed.rs
@@ -1,6 +1,6 @@
//! Malformed input must produce errors, never panics or huge allocations.
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
use proptest::prelude::*;
diff --git a/crates/codecs/tests/metadata.rs b/crates/codecs/tests/it/metadata.rs
similarity index 99%
rename from crates/codecs/tests/metadata.rs
rename to crates/codecs/tests/it/metadata.rs
index d8f4d80a9a..b33ae0a715 100644
--- a/crates/codecs/tests/metadata.rs
+++ b/crates/codecs/tests/it/metadata.rs
@@ -1,6 +1,6 @@
//! ICC / EXIF / XMP / DPI / text preservation, per format, per caps.
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
@@ -36,7 +36,7 @@ fn check_format(format: Format) {
}
if c.exif && format == Format::Tiff {
// TIFF writes the block's directories as its own and rebuilds a block from them on
- // reading (tests/tiff_exif.rs); IFD0's orientation here is the image's.
+ // reading (tests/it/tiff_exif.rs); IFD0's orientation here is the image's.
assert!(!warnings.contains(&FidelityWarning::ExifDropped), "{format:?}");
} else if c.exif {
assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif");
diff --git a/crates/codecs/tests/orientation.rs b/crates/codecs/tests/it/orientation.rs
similarity index 99%
rename from crates/codecs/tests/orientation.rs
rename to crates/codecs/tests/it/orientation.rs
index c1f176b87c..26eeae030c 100644
--- a/crates/codecs/tests/orientation.rs
+++ b/crates/codecs/tests/it/orientation.rs
@@ -1,7 +1,7 @@
//! EXIF / TIFF orientation (#285): files are opened upright, and every export
//! writes Orientation = 1 so the upright pixels are never rotated twice.
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
use proptest::prelude::{any, prop_assert, prop_assert_eq, proptest};
@@ -372,7 +372,7 @@ fn every_layout_and_depth_orients_and_inverts() {
}
/// #285 performance budget: turning a 24 MP photo upright on open adds ≤ 50 ms
-/// (release build: `cargo test --release -p photocraft-codecs --test orientation -- --ignored`).
+/// (release build: `cargo test --release -p photocraft-codecs --test it -- orientation:: --ignored`).
#[test]
#[ignore = "timing; run in release"]
fn rotating_24_mp_is_fast() {
diff --git a/crates/codecs/tests/pcx.rs b/crates/codecs/tests/it/pcx.rs
similarity index 100%
rename from crates/codecs/tests/pcx.rs
rename to crates/codecs/tests/it/pcx.rs
diff --git a/crates/codecs/tests/png_jpeg.rs b/crates/codecs/tests/it/png_jpeg.rs
similarity index 99%
rename from crates/codecs/tests/png_jpeg.rs
rename to crates/codecs/tests/it/png_jpeg.rs
index bd704e9a1a..0b8b42a7b7 100644
--- a/crates/codecs/tests/png_jpeg.rs
+++ b/crates/codecs/tests/it/png_jpeg.rs
@@ -1,7 +1,7 @@
//! Format-specific behaviour: Adam7 PNG, JPEG colour models, oracle
//! cross-checks against the `image` crate, and the optional PngSuite corpus.
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
diff --git a/crates/codecs/tests/pnm_headers.rs b/crates/codecs/tests/it/pnm_headers.rs
similarity index 100%
rename from crates/codecs/tests/pnm_headers.rs
rename to crates/codecs/tests/it/pnm_headers.rs
diff --git a/crates/codecs/tests/resolution.rs b/crates/codecs/tests/it/resolution.rs
similarity index 100%
rename from crates/codecs/tests/resolution.rs
rename to crates/codecs/tests/it/resolution.rs
diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/it/roundtrip.rs
similarity index 99%
rename from crates/codecs/tests/roundtrip.rs
rename to crates/codecs/tests/it/roundtrip.rs
index 9ba9423509..acbd16c5db 100644
--- a/crates/codecs/tests/roundtrip.rs
+++ b/crates/codecs/tests/it/roundtrip.rs
@@ -2,7 +2,7 @@
//! Lossless formats must reproduce the (planned) conversion of the input
//! exactly; lossy formats must stay above a PSNR threshold.
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
diff --git a/crates/codecs/tests/source_settings.rs b/crates/codecs/tests/it/source_settings.rs
similarity index 99%
rename from crates/codecs/tests/source_settings.rs
rename to crates/codecs/tests/it/source_settings.rs
index 9da82dbe79..0389e2fa8e 100644
--- a/crates/codecs/tests/source_settings.rs
+++ b/crates/codecs/tests/it/source_settings.rs
@@ -1,7 +1,7 @@
//! Reading back the settings a lossy file was saved with: a JPEG's quality from its
//! quantisation tables, a WebP's lossy or lossless encoding (and its quality).
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
diff --git a/crates/codecs/tests/tga.rs b/crates/codecs/tests/it/tga.rs
similarity index 100%
rename from crates/codecs/tests/tga.rs
rename to crates/codecs/tests/it/tga.rs
diff --git a/crates/codecs/tests/tiff_bench.rs b/crates/codecs/tests/it/tiff_bench.rs
similarity index 97%
rename from crates/codecs/tests/tiff_bench.rs
rename to crates/codecs/tests/it/tiff_bench.rs
index 357c10486e..e17f6466d4 100644
--- a/crates/codecs/tests/tiff_bench.rs
+++ b/crates/codecs/tests/it/tiff_bench.rs
@@ -2,10 +2,10 @@
//! release:
//!
//! ```sh
-//! cargo test --release -p photocraft-codecs --test tiff_bench -- --ignored --nocapture
+//! cargo test --release -p photocraft-codecs --test it -- tiff_bench:: --ignored --nocapture
//! ```
-mod common;
+use crate::common;
use common::tiffgen::*;
use photocraft_codecs::*;
use std::time::Instant;
diff --git a/crates/codecs/tests/tiff_exif.rs b/crates/codecs/tests/it/tiff_exif.rs
similarity index 100%
rename from crates/codecs/tests/tiff_exif.rs
rename to crates/codecs/tests/it/tiff_exif.rs
diff --git a/crates/codecs/tests/tiff_lab.rs b/crates/codecs/tests/it/tiff_lab.rs
similarity index 100%
rename from crates/codecs/tests/tiff_lab.rs
rename to crates/codecs/tests/it/tiff_lab.rs
diff --git a/crates/codecs/tests/tiff_pages.rs b/crates/codecs/tests/it/tiff_pages.rs
similarity index 99%
rename from crates/codecs/tests/tiff_pages.rs
rename to crates/codecs/tests/it/tiff_pages.rs
index 60885e527d..db432abe51 100644
--- a/crates/codecs/tests/tiff_pages.rs
+++ b/crates/codecs/tests/it/tiff_pages.rs
@@ -4,7 +4,7 @@
//! offsets past the end, absurd sizes) that must give `Ok` or a clean `Err`, never a panic.
//! Every file here is hand-built by `common::tiffgen`.
-mod common;
+use crate::common;
use common::Rng;
use common::tiffgen::*;
use photocraft_codecs::*;
diff --git a/crates/codecs/tests/tiff_photoshop.rs b/crates/codecs/tests/it/tiff_photoshop.rs
similarity index 99%
rename from crates/codecs/tests/tiff_photoshop.rs
rename to crates/codecs/tests/it/tiff_photoshop.rs
index 400b72eb0c..238710b68c 100644
--- a/crates/codecs/tests/tiff_photoshop.rs
+++ b/crates/codecs/tests/it/tiff_photoshop.rs
@@ -2,7 +2,7 @@
//! `Metadata` as opaque bytes: written when present, read from either byte order and from
//! BigTIFF, and never a panic or an allocation on malformed input.
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
diff --git a/crates/codecs/tests/tiff_regressions.rs b/crates/codecs/tests/it/tiff_regressions.rs
similarity index 99%
rename from crates/codecs/tests/tiff_regressions.rs
rename to crates/codecs/tests/it/tiff_regressions.rs
index d823f33f4a..8fcb1e570c 100644
--- a/crates/codecs/tests/tiff_regressions.rs
+++ b/crates/codecs/tests/it/tiff_regressions.rs
@@ -1,4 +1,4 @@
-mod common;
+use crate::common;
use common::tiffgen::*;
use photocraft_codecs::{Image, decode, exif_orientation, upright_exif};
diff --git a/crates/codecs/tests/webp_frames.rs b/crates/codecs/tests/it/webp_frames.rs
similarity index 100%
rename from crates/codecs/tests/webp_frames.rs
rename to crates/codecs/tests/it/webp_frames.rs
diff --git a/crates/codecs/tests/webp_lossy.rs b/crates/codecs/tests/it/webp_lossy.rs
similarity index 99%
rename from crates/codecs/tests/webp_lossy.rs
rename to crates/codecs/tests/it/webp_lossy.rs
index f0ff2cbbb4..079b9134fd 100644
--- a/crates/codecs/tests/webp_lossy.rs
+++ b/crates/codecs/tests/it/webp_lossy.rs
@@ -3,7 +3,7 @@
//! smaller than lossless on photographic content; alpha and metadata travel in the container;
//! odd sizes and the limits behave.
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::*;
diff --git a/crates/codecs/tests/xpm.rs b/crates/codecs/tests/it/xpm.rs
similarity index 100%
rename from crates/codecs/tests/xpm.rs
rename to crates/codecs/tests/it/xpm.rs
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 6b710eab93..ec5b4ac075 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -42,6 +42,7 @@ roxmltree = "0.20"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = "3"
+chrono = { workspace = true }
# `file_cmds::same_file` on Windows: the volume serial number and file index, so a hard link to a
# file is that file (std's `MetadataExt::file_index` is unstable).
diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs
index 92b630aa44..8932295d52 100644
--- a/crates/engine/src/analysis_cmds.rs
+++ b/crates/engine/src/analysis_cmds.rs
@@ -231,18 +231,7 @@ pub fn now_iso() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
- let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
- // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
- let z = days + 719_468;
- let era = z.div_euclid(146_097);
- let doe = z - era * 146_097;
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = yoe + era * 400 + i64::from(m <= 2);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
+ iso_from_unix(secs)
}
#[cfg(target_arch = "wasm32")]
{
@@ -250,6 +239,29 @@ pub fn now_iso() -> String {
}
}
+/// `YYYY-MM-DDTHH:MM:SSZ` for Unix seconds `secs` (UTC, proleptic Gregorian). A time outside
+/// chrono's year range still converts (the calendar repeats every 400 years, so the instant is
+/// moved into range by whole cycles and only the year is carried, matching the old hand-rolled
+/// code), never a silent reset to the epoch. chrono is the only calendar logic.
+#[cfg(not(target_arch = "wasm32"))]
+fn iso_from_unix(secs: i64) -> String {
+ use chrono::Datelike;
+ let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", d.and_time(t).format("%m-%dT%H:%M:%SZ"))
+ }
+ _ => "1970-01-01T00:00:00Z".to_string(), // unreachable
+ }
+}
+
fn round4(v: f64) -> f64 {
(v * 10_000.0).round() / 10_000.0
}
diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs
index f9138e8b65..c1184f2a4c 100644
--- a/crates/engine/src/analysis_cmds/tests.rs
+++ b/crates/engine/src/analysis_cmds/tests.rs
@@ -535,3 +535,20 @@ fn count_add_keeps_first_group_creation_and_targets_the_requested_group() {
let r = s.execute("count.add", json!({"x": 6, "y": 7})).unwrap();
assert_eq!(r["groups"][1]["points"], json!([[6.0, 7.0]]));
}
+
+#[test]
+#[cfg(not(target_arch = "wasm32"))]
+fn iso_from_unix_matches_the_old_cycle_conversion_including_beyond_chrono() {
+ use super::iso_from_unix;
+ assert_eq!(iso_from_unix(0), "1970-01-01T00:00:00Z");
+ assert_eq!(iso_from_unix(951_782_400), "2000-02-29T00:00:00Z");
+ assert_eq!(iso_from_unix(1_791_446_357), "2026-10-08T07:59:17Z");
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(iso_from_unix(253_402_300_800), "10000-01-01T00:00:00Z");
+ // Beyond chrono's year range the old hand-rolled code still converted; pin that output
+ // (never the epoch) in both directions and at the i64 extremes.
+ assert_eq!(iso_from_unix(10_000_000_000_000), "318857-05-20T17:46:40Z");
+ assert_eq!(iso_from_unix(-10_000_000_000_000), "-314918-08-13T06:13:20Z");
+ assert_eq!(iso_from_unix(i64::MAX), "292277026596-12-04T15:30:07Z");
+ assert_eq!(iso_from_unix(i64::MIN), "-292277022657-01-27T08:29:52Z");
+}
diff --git a/crates/engine/tests/affinity.rs b/crates/engine/tests/it/affinity.rs
similarity index 100%
rename from crates/engine/tests/affinity.rs
rename to crates/engine/tests/it/affinity.rs
diff --git a/crates/engine/tests/artboard_export_names.rs b/crates/engine/tests/it/artboard_export_names.rs
similarity index 100%
rename from crates/engine/tests/artboard_export_names.rs
rename to crates/engine/tests/it/artboard_export_names.rs
diff --git a/crates/engine/tests/atn_crystallize_cell_size.rs b/crates/engine/tests/it/atn_crystallize_cell_size.rs
similarity index 100%
rename from crates/engine/tests/atn_crystallize_cell_size.rs
rename to crates/engine/tests/it/atn_crystallize_cell_size.rs
diff --git a/crates/engine/tests/atn_ripple_amount.rs b/crates/engine/tests/it/atn_ripple_amount.rs
similarity index 100%
rename from crates/engine/tests/atn_ripple_amount.rs
rename to crates/engine/tests/it/atn_ripple_amount.rs
diff --git a/crates/engine/tests/atn_stroke_width.rs b/crates/engine/tests/it/atn_stroke_width.rs
similarity index 100%
rename from crates/engine/tests/atn_stroke_width.rs
rename to crates/engine/tests/it/atn_stroke_width.rs
diff --git a/crates/engine/tests/background_and_mask_depth.rs b/crates/engine/tests/it/background_and_mask_depth.rs
similarity index 100%
rename from crates/engine/tests/background_and_mask_depth.rs
rename to crates/engine/tests/it/background_and_mask_depth.rs
diff --git a/crates/engine/tests/background_layer_consistency.rs b/crates/engine/tests/it/background_layer_consistency.rs
similarity index 100%
rename from crates/engine/tests/background_layer_consistency.rs
rename to crates/engine/tests/it/background_layer_consistency.rs
diff --git a/crates/engine/tests/brush_smoothing.rs b/crates/engine/tests/it/brush_smoothing.rs
similarity index 100%
rename from crates/engine/tests/brush_smoothing.rs
rename to crates/engine/tests/it/brush_smoothing.rs
diff --git a/crates/engine/tests/camera_raw_exposure_clamp.rs b/crates/engine/tests/it/camera_raw_exposure_clamp.rs
similarity index 100%
rename from crates/engine/tests/camera_raw_exposure_clamp.rs
rename to crates/engine/tests/it/camera_raw_exposure_clamp.rs
diff --git a/crates/engine/tests/camera_raw_noise_clamp.rs b/crates/engine/tests/it/camera_raw_noise_clamp.rs
similarity index 100%
rename from crates/engine/tests/camera_raw_noise_clamp.rs
rename to crates/engine/tests/it/camera_raw_noise_clamp.rs
diff --git a/crates/engine/tests/clipping_semantics.rs b/crates/engine/tests/it/clipping_semantics.rs
similarity index 100%
rename from crates/engine/tests/clipping_semantics.rs
rename to crates/engine/tests/it/clipping_semantics.rs
diff --git a/crates/engine/tests/close_document.rs b/crates/engine/tests/it/close_document.rs
similarity index 100%
rename from crates/engine/tests/close_document.rs
rename to crates/engine/tests/it/close_document.rs
diff --git a/crates/engine/tests/command_lookup.rs b/crates/engine/tests/it/command_lookup.rs
similarity index 100%
rename from crates/engine/tests/command_lookup.rs
rename to crates/engine/tests/it/command_lookup.rs
diff --git a/crates/engine/tests/convert_keeps_references.rs b/crates/engine/tests/it/convert_keeps_references.rs
similarity index 100%
rename from crates/engine/tests/convert_keeps_references.rs
rename to crates/engine/tests/it/convert_keeps_references.rs
diff --git a/crates/engine/tests/corpus_atn.rs b/crates/engine/tests/it/corpus_atn.rs
similarity index 100%
rename from crates/engine/tests/corpus_atn.rs
rename to crates/engine/tests/it/corpus_atn.rs
diff --git a/crates/engine/tests/crop_content_aware.rs b/crates/engine/tests/it/crop_content_aware.rs
similarity index 100%
rename from crates/engine/tests/crop_content_aware.rs
rename to crates/engine/tests/it/crop_content_aware.rs
diff --git a/crates/engine/tests/current_affinity_artboards.rs b/crates/engine/tests/it/current_affinity_artboards.rs
similarity index 97%
rename from crates/engine/tests/current_affinity_artboards.rs
rename to crates/engine/tests/it/current_affinity_artboards.rs
index e3b27e856b..8348b4065f 100644
--- a/crates/engine/tests/current_affinity_artboards.rs
+++ b/crates/engine/tests/it/current_affinity_artboards.rs
@@ -1,7 +1,7 @@
//! Artboards recognized from current .af properties move with their children and undo together.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-#[path = "../../io/tests/support/current_affinity.rs"]
+#[path = "../../../io/tests/support/current_affinity.rs"]
mod fixture;
use fixture::{Marker, document};
diff --git a/crates/engine/tests/duplicate_layer_comps.rs b/crates/engine/tests/it/duplicate_layer_comps.rs
similarity index 100%
rename from crates/engine/tests/duplicate_layer_comps.rs
rename to crates/engine/tests/it/duplicate_layer_comps.rs
diff --git a/crates/engine/tests/duplicate_slices.rs b/crates/engine/tests/it/duplicate_slices.rs
similarity index 100%
rename from crates/engine/tests/duplicate_slices.rs
rename to crates/engine/tests/it/duplicate_slices.rs
diff --git a/crates/engine/tests/duplicate_variables.rs b/crates/engine/tests/it/duplicate_variables.rs
similarity index 100%
rename from crates/engine/tests/duplicate_variables.rs
rename to crates/engine/tests/it/duplicate_variables.rs
diff --git a/crates/engine/tests/empty_selection_combine.rs b/crates/engine/tests/it/empty_selection_combine.rs
similarity index 100%
rename from crates/engine/tests/empty_selection_combine.rs
rename to crates/engine/tests/it/empty_selection_combine.rs
diff --git a/crates/engine/tests/eraser_locked.rs b/crates/engine/tests/it/eraser_locked.rs
similarity index 100%
rename from crates/engine/tests/eraser_locked.rs
rename to crates/engine/tests/it/eraser_locked.rs
diff --git a/crates/engine/tests/explicit_layer_target.rs b/crates/engine/tests/it/explicit_layer_target.rs
similarity index 100%
rename from crates/engine/tests/explicit_layer_target.rs
rename to crates/engine/tests/it/explicit_layer_target.rs
diff --git a/crates/engine/tests/export_shape_strokes.rs b/crates/engine/tests/it/export_shape_strokes.rs
similarity index 100%
rename from crates/engine/tests/export_shape_strokes.rs
rename to crates/engine/tests/it/export_shape_strokes.rs
diff --git a/crates/engine/tests/fill_lock_transparency.rs b/crates/engine/tests/it/fill_lock_transparency.rs
similarity index 100%
rename from crates/engine/tests/fill_lock_transparency.rs
rename to crates/engine/tests/it/fill_lock_transparency.rs
diff --git a/crates/engine/tests/filter_locked.rs b/crates/engine/tests/it/filter_locked.rs
similarity index 100%
rename from crates/engine/tests/filter_locked.rs
rename to crates/engine/tests/it/filter_locked.rs
diff --git a/crates/engine/tests/finite_command_params.rs b/crates/engine/tests/it/finite_command_params.rs
similarity index 100%
rename from crates/engine/tests/finite_command_params.rs
rename to crates/engine/tests/it/finite_command_params.rs
diff --git a/crates/engine/tests/flatten_background.rs b/crates/engine/tests/it/flatten_background.rs
similarity index 100%
rename from crates/engine/tests/flatten_background.rs
rename to crates/engine/tests/it/flatten_background.rs
diff --git a/crates/engine/tests/gallery_colours.rs b/crates/engine/tests/it/gallery_colours.rs
similarity index 100%
rename from crates/engine/tests/gallery_colours.rs
rename to crates/engine/tests/it/gallery_colours.rs
diff --git a/crates/engine/tests/gauss_small_sigma.rs b/crates/engine/tests/it/gauss_small_sigma.rs
similarity index 100%
rename from crates/engine/tests/gauss_small_sigma.rs
rename to crates/engine/tests/it/gauss_small_sigma.rs
diff --git a/crates/engine/tests/gradient_overlay_strokes.rs b/crates/engine/tests/it/gradient_overlay_strokes.rs
similarity index 100%
rename from crates/engine/tests/gradient_overlay_strokes.rs
rename to crates/engine/tests/it/gradient_overlay_strokes.rs
diff --git a/crates/engine/tests/guide_layout_limit.rs b/crates/engine/tests/it/guide_layout_limit.rs
similarity index 100%
rename from crates/engine/tests/guide_layout_limit.rs
rename to crates/engine/tests/it/guide_layout_limit.rs
diff --git a/crates/engine/tests/guide_layout_preview.rs b/crates/engine/tests/it/guide_layout_preview.rs
similarity index 100%
rename from crates/engine/tests/guide_layout_preview.rs
rename to crates/engine/tests/it/guide_layout_preview.rs
diff --git a/crates/engine/tests/hdr_gamma_direction.rs b/crates/engine/tests/it/hdr_gamma_direction.rs
similarity index 100%
rename from crates/engine/tests/hdr_gamma_direction.rs
rename to crates/engine/tests/it/hdr_gamma_direction.rs
diff --git a/crates/engine/tests/huge_doc_flatten.rs b/crates/engine/tests/it/huge_doc_flatten.rs
similarity index 100%
rename from crates/engine/tests/huge_doc_flatten.rs
rename to crates/engine/tests/it/huge_doc_flatten.rs
diff --git a/crates/engine/tests/jpeg_exif_resolution.rs b/crates/engine/tests/it/jpeg_exif_resolution.rs
similarity index 100%
rename from crates/engine/tests/jpeg_exif_resolution.rs
rename to crates/engine/tests/it/jpeg_exif_resolution.rs
diff --git a/crates/engine/tests/keyboard_shortcut_bulk.rs b/crates/engine/tests/it/keyboard_shortcut_bulk.rs
similarity index 100%
rename from crates/engine/tests/keyboard_shortcut_bulk.rs
rename to crates/engine/tests/it/keyboard_shortcut_bulk.rs
diff --git a/crates/engine/tests/kra.rs b/crates/engine/tests/it/kra.rs
similarity index 96%
rename from crates/engine/tests/kra.rs
rename to crates/engine/tests/it/kra.rs
index 8bdf10531c..783c5fb28f 100644
--- a/crates/engine/tests/kra.rs
+++ b/crates/engine/tests/it/kra.rs
@@ -1,7 +1,7 @@
//! Krita documents opened through the session: read-only source, 16-bit layers, editable
//! layers with history, and saving as OpenRaster or native. The files come from
//! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus. Run with
-//! `KRITA_TEST_DIR=
cargo test -p photocraft-engine --test kra -- --ignored --nocapture`.
+//! `KRITA_TEST_DIR= cargo test -p photocraft-engine --test it -- kra:: --ignored --nocapture`.
use photocraft_engine::Session;
use serde_json::json;
@@ -24,7 +24,7 @@ fn krita_documents_open_read_only_and_stay_editable() {
assert!(session.execute("file.save", json!({})).is_err());
// Krita's merged image is the composite (the corpus files carry no ICC profile, so both
- // sides are sRGB); crates/io/tests/krita_corpus.rs compares every file.
+ // sides are sRGB); crates/io/tests/it/krita_corpus.rs compares every file.
let ours = photocraft_compose::flatten(&session.active().unwrap().doc);
let zip_bytes = std::fs::read(&path).unwrap();
let zip = photocraft_format::zip::ZipReader::new(&zip_bytes).unwrap();
diff --git a/crates/engine/tests/kys_import.rs b/crates/engine/tests/it/kys_import.rs
similarity index 100%
rename from crates/engine/tests/kys_import.rs
rename to crates/engine/tests/it/kys_import.rs
diff --git a/crates/engine/tests/layer_delete_last.rs b/crates/engine/tests/it/layer_delete_last.rs
similarity index 100%
rename from crates/engine/tests/layer_delete_last.rs
rename to crates/engine/tests/it/layer_delete_last.rs
diff --git a/crates/engine/tests/layer_mask_depth.rs b/crates/engine/tests/it/layer_mask_depth.rs
similarity index 100%
rename from crates/engine/tests/layer_mask_depth.rs
rename to crates/engine/tests/it/layer_mask_depth.rs
diff --git a/crates/engine/tests/layer_props_several.rs b/crates/engine/tests/it/layer_props_several.rs
similarity index 100%
rename from crates/engine/tests/layer_props_several.rs
rename to crates/engine/tests/it/layer_props_several.rs
diff --git a/crates/engine/tests/layer_range_panel_order.rs b/crates/engine/tests/it/layer_range_panel_order.rs
similarity index 100%
rename from crates/engine/tests/layer_range_panel_order.rs
rename to crates/engine/tests/it/layer_range_panel_order.rs
diff --git a/crates/engine/tests/live_line_transform.rs b/crates/engine/tests/it/live_line_transform.rs
similarity index 100%
rename from crates/engine/tests/live_line_transform.rs
rename to crates/engine/tests/it/live_line_transform.rs
diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs
new file mode 100644
index 0000000000..cd44a7a946
--- /dev/null
+++ b/crates/engine/tests/it/main.rs
@@ -0,0 +1,80 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod affinity;
+mod artboard_export_names;
+mod atn_crystallize_cell_size;
+mod atn_ripple_amount;
+mod atn_stroke_width;
+mod background_and_mask_depth;
+mod background_layer_consistency;
+mod brush_smoothing;
+mod camera_raw_exposure_clamp;
+mod camera_raw_noise_clamp;
+mod clipping_semantics;
+mod close_document;
+mod command_lookup;
+mod convert_keeps_references;
+mod corpus_atn;
+mod crop_content_aware;
+mod current_affinity_artboards;
+mod duplicate_layer_comps;
+mod duplicate_slices;
+mod duplicate_variables;
+mod empty_selection_combine;
+mod eraser_locked;
+mod explicit_layer_target;
+mod export_shape_strokes;
+mod fill_lock_transparency;
+mod filter_locked;
+mod finite_command_params;
+mod flatten_background;
+mod gallery_colours;
+mod gauss_small_sigma;
+mod gradient_overlay_strokes;
+mod guide_layout_limit;
+mod guide_layout_preview;
+mod hdr_gamma_direction;
+mod huge_doc_flatten;
+mod jpeg_exif_resolution;
+mod keyboard_shortcut_bulk;
+mod kra;
+mod kys_import;
+mod layer_delete_last;
+mod layer_mask_depth;
+mod layer_props_several;
+mod layer_range_panel_order;
+mod live_line_transform;
+mod merge_transparent_bounds;
+mod missing_layer_error;
+mod nonfinite_style;
+mod open_identity;
+mod path_blur_speed_clamp;
+mod path_transform_target;
+mod pdn;
+mod photoshop_oracles;
+mod pixel_lock_edits;
+mod precheck;
+mod prefs_usage;
+mod rasterize_psd_reopen;
+mod rasterize_selected_layers;
+mod rendering_preferences;
+mod save_keeps_encoding;
+mod selection_layer_commands;
+mod shortcut_sets;
+mod shortcuts_headless;
+mod smart_clipboard_sources;
+mod smart_duplicates;
+mod smart_filter_canvas_geometry;
+mod smart_object_tones;
+mod smart_psd;
+mod style_and_align;
+mod svg_smart_object;
+mod transform_budget_unclamped;
+mod type_format_targets;
+mod type_postscript_font;
+mod undo_keeps_active_layer;
+mod unicode_colors;
+mod unsupported_smart_filters;
+mod wand_transparent_copy;
+mod working_rgb_profile;
+mod xmp_charref;
diff --git a/crates/engine/tests/merge_transparent_bounds.rs b/crates/engine/tests/it/merge_transparent_bounds.rs
similarity index 100%
rename from crates/engine/tests/merge_transparent_bounds.rs
rename to crates/engine/tests/it/merge_transparent_bounds.rs
diff --git a/crates/engine/tests/missing_layer_error.rs b/crates/engine/tests/it/missing_layer_error.rs
similarity index 100%
rename from crates/engine/tests/missing_layer_error.rs
rename to crates/engine/tests/it/missing_layer_error.rs
diff --git a/crates/engine/tests/nonfinite_style.rs b/crates/engine/tests/it/nonfinite_style.rs
similarity index 100%
rename from crates/engine/tests/nonfinite_style.rs
rename to crates/engine/tests/it/nonfinite_style.rs
diff --git a/crates/engine/tests/open_identity.rs b/crates/engine/tests/it/open_identity.rs
similarity index 100%
rename from crates/engine/tests/open_identity.rs
rename to crates/engine/tests/it/open_identity.rs
diff --git a/crates/engine/tests/path_blur_speed_clamp.rs b/crates/engine/tests/it/path_blur_speed_clamp.rs
similarity index 100%
rename from crates/engine/tests/path_blur_speed_clamp.rs
rename to crates/engine/tests/it/path_blur_speed_clamp.rs
diff --git a/crates/engine/tests/path_transform_target.rs b/crates/engine/tests/it/path_transform_target.rs
similarity index 100%
rename from crates/engine/tests/path_transform_target.rs
rename to crates/engine/tests/it/path_transform_target.rs
diff --git a/crates/engine/tests/pdn.rs b/crates/engine/tests/it/pdn.rs
similarity index 100%
rename from crates/engine/tests/pdn.rs
rename to crates/engine/tests/it/pdn.rs
diff --git a/crates/engine/tests/photoshop_oracles.rs b/crates/engine/tests/it/photoshop_oracles.rs
similarity index 98%
rename from crates/engine/tests/photoshop_oracles.rs
rename to crates/engine/tests/it/photoshop_oracles.rs
index b1f0a133a1..88e63b67fc 100644
--- a/crates/engine/tests/photoshop_oracles.rs
+++ b/crates/engine/tests/it/photoshop_oracles.rs
@@ -3,7 +3,7 @@
//! `cargo xtask corpus --all`. Feature `corpus` (run with `cargo xtask test-corpus`); a missing
//! corpus fails.
//!
-//! The io corpus test (`crates/io/tests/corpus.rs`) flattens imported documents, which composite
+//! The io corpus test (`crates/io/tests/it/corpus.rs`) flattens imported documents, which composite
//! Photoshop's own cached pixels for smart objects and type layers. This test throws those caches
//! away: every smart object is re-rendered from its embedded source through our smart-filter
//! stack, and every type layer is re-laid-out and re-rasterised by our text engine; the flatten is
diff --git a/crates/engine/tests/pixel_lock_edits.rs b/crates/engine/tests/it/pixel_lock_edits.rs
similarity index 100%
rename from crates/engine/tests/pixel_lock_edits.rs
rename to crates/engine/tests/it/pixel_lock_edits.rs
diff --git a/crates/engine/tests/precheck.rs b/crates/engine/tests/it/precheck.rs
similarity index 100%
rename from crates/engine/tests/precheck.rs
rename to crates/engine/tests/it/precheck.rs
diff --git a/crates/engine/tests/prefs_usage.rs b/crates/engine/tests/it/prefs_usage.rs
similarity index 100%
rename from crates/engine/tests/prefs_usage.rs
rename to crates/engine/tests/it/prefs_usage.rs
diff --git a/crates/engine/tests/rasterize_psd_reopen.rs b/crates/engine/tests/it/rasterize_psd_reopen.rs
similarity index 100%
rename from crates/engine/tests/rasterize_psd_reopen.rs
rename to crates/engine/tests/it/rasterize_psd_reopen.rs
diff --git a/crates/engine/tests/rasterize_selected_layers.rs b/crates/engine/tests/it/rasterize_selected_layers.rs
similarity index 100%
rename from crates/engine/tests/rasterize_selected_layers.rs
rename to crates/engine/tests/it/rasterize_selected_layers.rs
diff --git a/crates/engine/tests/rendering_preferences.rs b/crates/engine/tests/it/rendering_preferences.rs
similarity index 100%
rename from crates/engine/tests/rendering_preferences.rs
rename to crates/engine/tests/it/rendering_preferences.rs
diff --git a/crates/engine/tests/save_keeps_encoding.rs b/crates/engine/tests/it/save_keeps_encoding.rs
similarity index 100%
rename from crates/engine/tests/save_keeps_encoding.rs
rename to crates/engine/tests/it/save_keeps_encoding.rs
diff --git a/crates/engine/tests/selection_layer_commands.rs b/crates/engine/tests/it/selection_layer_commands.rs
similarity index 100%
rename from crates/engine/tests/selection_layer_commands.rs
rename to crates/engine/tests/it/selection_layer_commands.rs
diff --git a/crates/engine/tests/shortcut_sets.rs b/crates/engine/tests/it/shortcut_sets.rs
similarity index 100%
rename from crates/engine/tests/shortcut_sets.rs
rename to crates/engine/tests/it/shortcut_sets.rs
diff --git a/crates/engine/tests/shortcuts_headless.rs b/crates/engine/tests/it/shortcuts_headless.rs
similarity index 100%
rename from crates/engine/tests/shortcuts_headless.rs
rename to crates/engine/tests/it/shortcuts_headless.rs
diff --git a/crates/engine/tests/smart_clipboard_sources.rs b/crates/engine/tests/it/smart_clipboard_sources.rs
similarity index 100%
rename from crates/engine/tests/smart_clipboard_sources.rs
rename to crates/engine/tests/it/smart_clipboard_sources.rs
diff --git a/crates/engine/tests/smart_duplicates.rs b/crates/engine/tests/it/smart_duplicates.rs
similarity index 100%
rename from crates/engine/tests/smart_duplicates.rs
rename to crates/engine/tests/it/smart_duplicates.rs
diff --git a/crates/engine/tests/smart_filter_canvas_geometry.rs b/crates/engine/tests/it/smart_filter_canvas_geometry.rs
similarity index 100%
rename from crates/engine/tests/smart_filter_canvas_geometry.rs
rename to crates/engine/tests/it/smart_filter_canvas_geometry.rs
diff --git a/crates/engine/tests/smart_object_tones.rs b/crates/engine/tests/it/smart_object_tones.rs
similarity index 100%
rename from crates/engine/tests/smart_object_tones.rs
rename to crates/engine/tests/it/smart_object_tones.rs
diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/it/smart_psd.rs
similarity index 99%
rename from crates/engine/tests/smart_psd.rs
rename to crates/engine/tests/it/smart_psd.rs
index 5bba21f122..4aeda1519b 100644
--- a/crates/engine/tests/smart_psd.rs
+++ b/crates/engine/tests/it/smart_psd.rs
@@ -331,7 +331,7 @@ fn photoshop_samples() {
}
/// PSD export of a 24 MP smart object versus the same layer as pixels (release:
-/// `cargo test --release -p photocraft-engine --test smart_psd -- --ignored --nocapture perf`).
+/// `cargo test --release -p photocraft-engine --test it -- --ignored --nocapture smart_psd::perf`).
#[test]
#[ignore = "benchmark"]
fn perf_24mp_smart_object_export() {
diff --git a/crates/engine/tests/style_and_align.rs b/crates/engine/tests/it/style_and_align.rs
similarity index 100%
rename from crates/engine/tests/style_and_align.rs
rename to crates/engine/tests/it/style_and_align.rs
diff --git a/crates/engine/tests/svg_smart_object.rs b/crates/engine/tests/it/svg_smart_object.rs
similarity index 100%
rename from crates/engine/tests/svg_smart_object.rs
rename to crates/engine/tests/it/svg_smart_object.rs
diff --git a/crates/engine/tests/transform_budget_unclamped.rs b/crates/engine/tests/it/transform_budget_unclamped.rs
similarity index 100%
rename from crates/engine/tests/transform_budget_unclamped.rs
rename to crates/engine/tests/it/transform_budget_unclamped.rs
diff --git a/crates/engine/tests/type_format_targets.rs b/crates/engine/tests/it/type_format_targets.rs
similarity index 100%
rename from crates/engine/tests/type_format_targets.rs
rename to crates/engine/tests/it/type_format_targets.rs
diff --git a/crates/engine/tests/type_postscript_font.rs b/crates/engine/tests/it/type_postscript_font.rs
similarity index 100%
rename from crates/engine/tests/type_postscript_font.rs
rename to crates/engine/tests/it/type_postscript_font.rs
diff --git a/crates/engine/tests/undo_keeps_active_layer.rs b/crates/engine/tests/it/undo_keeps_active_layer.rs
similarity index 100%
rename from crates/engine/tests/undo_keeps_active_layer.rs
rename to crates/engine/tests/it/undo_keeps_active_layer.rs
diff --git a/crates/engine/tests/unicode_colors.rs b/crates/engine/tests/it/unicode_colors.rs
similarity index 100%
rename from crates/engine/tests/unicode_colors.rs
rename to crates/engine/tests/it/unicode_colors.rs
diff --git a/crates/engine/tests/unsupported_smart_filters.rs b/crates/engine/tests/it/unsupported_smart_filters.rs
similarity index 100%
rename from crates/engine/tests/unsupported_smart_filters.rs
rename to crates/engine/tests/it/unsupported_smart_filters.rs
diff --git a/crates/engine/tests/wand_transparent_copy.rs b/crates/engine/tests/it/wand_transparent_copy.rs
similarity index 100%
rename from crates/engine/tests/wand_transparent_copy.rs
rename to crates/engine/tests/it/wand_transparent_copy.rs
diff --git a/crates/engine/tests/working_rgb_profile.rs b/crates/engine/tests/it/working_rgb_profile.rs
similarity index 100%
rename from crates/engine/tests/working_rgb_profile.rs
rename to crates/engine/tests/it/working_rgb_profile.rs
diff --git a/crates/engine/tests/xmp_charref.rs b/crates/engine/tests/it/xmp_charref.rs
similarity index 100%
rename from crates/engine/tests/xmp_charref.rs
rename to crates/engine/tests/it/xmp_charref.rs
diff --git a/crates/format/tests/analysis.rs b/crates/format/tests/it/analysis.rs
similarity index 100%
rename from crates/format/tests/analysis.rs
rename to crates/format/tests/it/analysis.rs
diff --git a/crates/format/tests/autosave.rs b/crates/format/tests/it/autosave.rs
similarity index 99%
rename from crates/format/tests/autosave.rs
rename to crates/format/tests/it/autosave.rs
index a1954682a1..24d61d50d3 100644
--- a/crates/format/tests/autosave.rs
+++ b/crates/format/tests/it/autosave.rs
@@ -1,6 +1,6 @@
//! Background autosave and crash recovery.
-mod common;
+use crate::common;
use std::sync::Arc;
use common::*;
diff --git a/crates/format/tests/common/mod.rs b/crates/format/tests/it/common/mod.rs
similarity index 100%
rename from crates/format/tests/common/mod.rs
rename to crates/format/tests/it/common/mod.rs
diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/it/corruption.rs
similarity index 99%
rename from crates/format/tests/corruption.rs
rename to crates/format/tests/it/corruption.rs
index 727b37cb70..fd2d0602f6 100644
--- a/crates/format/tests/corruption.rs
+++ b/crates/format/tests/it/corruption.rs
@@ -1,6 +1,6 @@
//! Damaged or hostile bundles must fail cleanly, never panic.
-mod common;
+use crate::common;
use common::*;
use photocraft_color::{ColorMode, SampleType};
use photocraft_format::*;
diff --git a/crates/format/tests/directory_symlinks.rs b/crates/format/tests/it/directory_symlinks.rs
similarity index 99%
rename from crates/format/tests/directory_symlinks.rs
rename to crates/format/tests/it/directory_symlinks.rs
index edeebbdc3d..0a8d7d2462 100644
--- a/crates/format/tests/directory_symlinks.rs
+++ b/crates/format/tests/it/directory_symlinks.rs
@@ -1,6 +1,6 @@
//! Directory cleanup only owns content-addressed objects; unrelated files survive.
-mod common;
+use crate::common;
use common::temp_dir;
use photocraft_color::{ColorMode, SampleType};
diff --git a/crates/format/tests/float_roundtrip.rs b/crates/format/tests/it/float_roundtrip.rs
similarity index 100%
rename from crates/format/tests/float_roundtrip.rs
rename to crates/format/tests/it/float_roundtrip.rs
diff --git a/crates/format/tests/layer_labels.rs b/crates/format/tests/it/layer_labels.rs
similarity index 100%
rename from crates/format/tests/layer_labels.rs
rename to crates/format/tests/it/layer_labels.rs
diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs
new file mode 100644
index 0000000000..6c822f4282
--- /dev/null
+++ b/crates/format/tests/it/main.rs
@@ -0,0 +1,11 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod analysis;
+mod autosave;
+mod common;
+mod corruption;
+mod directory_symlinks;
+mod float_roundtrip;
+mod layer_labels;
+mod roundtrip;
+mod smart_contents;
diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/it/roundtrip.rs
similarity index 99%
rename from crates/format/tests/roundtrip.rs
rename to crates/format/tests/it/roundtrip.rs
index 0a419884df..a199e15f13 100644
--- a/crates/format/tests/roundtrip.rs
+++ b/crates/format/tests/it/roundtrip.rs
@@ -1,6 +1,6 @@
//! doc → .pcraft → doc equality, incremental saves, ids, previews.
-mod common;
+use crate::common;
use common::*;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::text::{CharStyle, ParagraphStyle, TextRun};
diff --git a/crates/format/tests/smart_contents.rs b/crates/format/tests/it/smart_contents.rs
similarity index 100%
rename from crates/format/tests/smart_contents.rs
rename to crates/format/tests/it/smart_contents.rs
diff --git a/crates/gpu/tests/device_loss.rs b/crates/gpu/tests/it/device_loss.rs
similarity index 98%
rename from crates/gpu/tests/device_loss.rs
rename to crates/gpu/tests/it/device_loss.rs
index 294961ee8d..666e434da4 100644
--- a/crates/gpu/tests/device_loss.rs
+++ b/crates/gpu/tests/it/device_loss.rs
@@ -8,8 +8,7 @@ use photocraft_geom::{Rect, Size};
use photocraft_gpu::{Compositor, DeviceHealth, Fault, render_to_vec};
use photocraft_raster::Surface;
-/// Concurrent wgpu instances in one process segfault on some drivers (see `parity.rs`).
-static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+use crate::GPU_LOCK;
fn device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::default();
diff --git a/crates/gpu/tests/it/main.rs b/crates/gpu/tests/it/main.rs
new file mode 100644
index 0000000000..f71972c5b9
--- /dev/null
+++ b/crates/gpu/tests/it/main.rs
@@ -0,0 +1,11 @@
+//! The crate's integration tests, linked into one test binary.
+
+/// Concurrent wgpu instances in one process segfault on some drivers (RADV), so every test that
+/// creates a device takes this lock and holds it until the device is dropped.
+static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+mod device_loss;
+mod parity;
+mod timing;
+mod uniform_limits;
+mod vector_mask;
diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/it/parity.rs
similarity index 99%
rename from crates/gpu/tests/parity.rs
rename to crates/gpu/tests/it/parity.rs
index 400c5a03b7..464ad1f1a2 100644
--- a/crates/gpu/tests/parity.rs
+++ b/crates/gpu/tests/it/parity.rs
@@ -12,9 +12,7 @@ use photocraft_gpu::{Compositor, render_to_vec};
/// lands on that side at the Hue/Saturation pixel in `adjustment_layers`).
const TOL: f32 = 1.0 / 255.0 + 4.0 * f32::EPSILON;
-/// Concurrent wgpu instances in one process segfault on some drivers (RADV), so the GPU tests
-/// take this lock and hold it until their device is dropped.
-static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+use crate::GPU_LOCK;
struct Gpu {
device: wgpu::Device,
diff --git a/crates/gpu/tests/timing.rs b/crates/gpu/tests/it/timing.rs
similarity index 96%
rename from crates/gpu/tests/timing.rs
rename to crates/gpu/tests/it/timing.rs
index 24c2057253..9761a1d90d 100644
--- a/crates/gpu/tests/timing.rs
+++ b/crates/gpu/tests/it/timing.rs
@@ -111,6 +111,7 @@ fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, do
#[test]
fn gpu_time_tracks_execution_not_submission() {
+ let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((device, queue, mut comp)) = gpu() else { return };
let size = Size::new(3000, 2000);
let doc = document(size);
@@ -164,6 +165,7 @@ fn gpu_time_tracks_execution_not_submission() {
#[test]
fn an_unmeasured_compositor_reports_nothing() {
+ let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((device, queue, _)) = gpu() else { return };
let mut comp = Compositor::new(&device);
assert!(!comp.measures_gpu_time(), "timing is off until it is asked for");
@@ -175,6 +177,7 @@ fn an_unmeasured_compositor_reports_nothing() {
#[test]
fn a_refresh_that_submits_several_times_is_one_measurement() {
+ let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((device, queue, mut comp)) = gpu() else { return };
// A budget far below the document's layer pages makes the refresh evict as it goes, which is
// what makes it submit in batches: the measurement must span all of them, not just the last.
@@ -208,6 +211,7 @@ fn a_refresh_that_submits_several_times_is_one_measurement() {
#[test]
fn refreshes_faster_than_the_readback_stay_valid() {
+ let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((device, queue, mut comp)) = gpu() else { return };
// Refreshes arriving before the last measurement has been read back must simply go
// unmeasured. Mapping the readback buffer again while its first map is still pending is a
@@ -232,6 +236,7 @@ fn refreshes_faster_than_the_readback_stay_valid() {
/// wrote no timestamps itself — and the compositor would silently never measure again.
#[test]
fn a_failed_refresh_that_recorded_nothing_keeps_the_timer_alive() {
+ let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some((device, queue, _)) = gpu() else { return };
let mut timer = timing::GpuTimer::new(&device, &queue).expect("the device has timestamp queries");
diff --git a/crates/gpu/tests/uniform_limits.rs b/crates/gpu/tests/it/uniform_limits.rs
similarity index 99%
rename from crates/gpu/tests/uniform_limits.rs
rename to crates/gpu/tests/it/uniform_limits.rs
index 994dbd7508..90dfa57aed 100644
--- a/crates/gpu/tests/uniform_limits.rs
+++ b/crates/gpu/tests/it/uniform_limits.rs
@@ -5,7 +5,7 @@ use photocraft_doc::{Document, Fill, Layer, LayerContent};
use photocraft_geom::Size;
use photocraft_gpu::{Compositor, DeviceHealth, plan, render_to_vec};
-static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+use crate::GPU_LOCK;
fn fills(count: usize) -> Document {
fills_size(count, Size::new(1, 1))
diff --git a/crates/gpu/tests/vector_mask.rs b/crates/gpu/tests/it/vector_mask.rs
similarity index 98%
rename from crates/gpu/tests/vector_mask.rs
rename to crates/gpu/tests/it/vector_mask.rs
index 43d3d081e3..d0d7e7a012 100644
--- a/crates/gpu/tests/vector_mask.rs
+++ b/crates/gpu/tests/it/vector_mask.rs
@@ -47,6 +47,7 @@ fn painted(name: &str, fmt: PixelFormat, rgba: [f32; 4]) -> Layer {
#[test]
fn vector_masks_match_the_cpu() {
+ let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(mut g) = gpu() else { return };
for depth in [SampleType::U8, SampleType::U16] {
for (inverted, density) in [(false, 1.0), (true, 1.0), (false, 0.6)] {
diff --git a/crates/heif/README.md b/crates/heif/README.md
index 8915129443..9d859e8401 100644
--- a/crates/heif/README.md
+++ b/crates/heif/README.md
@@ -30,5 +30,5 @@ inside it (fuzzing found two in 0.1.1) becomes `Error::Malformed`.
The crate depends on no other PhotoCraft crate and knows nothing of `photocraft-codecs`' types;
`codecs` → `heif` is the one allowed dependency between standalone crates (`cargo xtask layers`).
-Tests on real files live in `photocraft-codecs` (`tests/heif.rs`, features `corpus,heif`, files
+Tests on real files live in `photocraft-codecs` (`tests/it/heif.rs`, features `corpus,heif`, files
fetched by `cargo xtask corpus --heif`).
diff --git a/crates/io/examples/big_psb_check.rs b/crates/io/examples/big_psb_check.rs
index 3d0f407583..3b1c21034b 100644
--- a/crates/io/examples/big_psb_check.rs
+++ b/crates/io/examples/big_psb_check.rs
@@ -2,7 +2,7 @@
//!
//! `cargo run --release -p photocraft-io --example big_psb_check -- ...`
//!
-//! The opt-in tests in `tests/large_files.rs` generate and check the same files.
+//! The opt-in tests in `tests/it/large_files.rs` generate and check the same files.
use photocraft_psd::fixtures::sample;
diff --git a/crates/io/examples/oracle_diff.rs b/crates/io/examples/oracle_diff.rs
index a7f295d463..fddef567a9 100644
--- a/crates/io/examples/oracle_diff.rs
+++ b/crates/io/examples/oracle_diff.rs
@@ -437,7 +437,7 @@ fn main() {
}
}
-/// The corpus oracle table (as `tests/corpus.rs`), files in parallel, without a test build.
+/// The corpus oracle table (as `tests/it/corpus.rs`), files in parallel, without a test build.
fn corpus_summary(root: &std::path::Path) {
fn collect(dir: &std::path::Path, out: &mut Vec) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
diff --git a/crates/io/tests/adjust_blend_roundtrip.rs b/crates/io/tests/it/adjust_blend_roundtrip.rs
similarity index 99%
rename from crates/io/tests/adjust_blend_roundtrip.rs
rename to crates/io/tests/it/adjust_blend_roundtrip.rs
index 4e8b727af2..67085abee3 100644
--- a/crates/io/tests/adjust_blend_roundtrip.rs
+++ b/crates/io/tests/it/adjust_blend_roundtrip.rs
@@ -4,7 +4,7 @@
//! document flattens to the same composite (within 1/255) in every case, so nothing a user made
//! in PhotoCraft silently disappears or changes when Photoshop opens the file.
-mod common;
+use crate::common;
use std::sync::Arc;
diff --git a/crates/io/tests/adjust_warnings.rs b/crates/io/tests/it/adjust_warnings.rs
similarity index 100%
rename from crates/io/tests/adjust_warnings.rs
rename to crates/io/tests/it/adjust_warnings.rs
diff --git a/crates/io/tests/affinity.rs b/crates/io/tests/it/affinity.rs
similarity index 100%
rename from crates/io/tests/affinity.rs
rename to crates/io/tests/it/affinity.rs
diff --git a/crates/io/tests/affinity_artboards.rs b/crates/io/tests/it/affinity_artboards.rs
similarity index 98%
rename from crates/io/tests/affinity_artboards.rs
rename to crates/io/tests/it/affinity_artboards.rs
index 67e3739043..26a9a3c7e5 100644
--- a/crates/io/tests/affinity_artboards.rs
+++ b/crates/io/tests/it/affinity_artboards.rs
@@ -1,7 +1,7 @@
//! Current .af artboards stay separate, editable and clipped through native/PSD saves.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-#[path = "support/current_affinity.rs"]
+#[path = "../support/current_affinity.rs"]
mod fixture;
use fixture::{Marker, document};
diff --git a/crates/io/tests/affinity_corpus.rs b/crates/io/tests/it/affinity_corpus.rs
similarity index 100%
rename from crates/io/tests/affinity_corpus.rs
rename to crates/io/tests/it/affinity_corpus.rs
diff --git a/crates/io/tests/annotations.rs b/crates/io/tests/it/annotations.rs
similarity index 100%
rename from crates/io/tests/annotations.rs
rename to crates/io/tests/it/annotations.rs
diff --git a/crates/io/tests/artboard_guide_order.rs b/crates/io/tests/it/artboard_guide_order.rs
similarity index 100%
rename from crates/io/tests/artboard_guide_order.rs
rename to crates/io/tests/it/artboard_guide_order.rs
diff --git a/crates/io/tests/banded.rs b/crates/io/tests/it/banded.rs
similarity index 100%
rename from crates/io/tests/banded.rs
rename to crates/io/tests/it/banded.rs
diff --git a/crates/io/tests/cancel.rs b/crates/io/tests/it/cancel.rs
similarity index 100%
rename from crates/io/tests/cancel.rs
rename to crates/io/tests/it/cancel.rs
diff --git a/crates/io/tests/channels.rs b/crates/io/tests/it/channels.rs
similarity index 100%
rename from crates/io/tests/channels.rs
rename to crates/io/tests/it/channels.rs
diff --git a/crates/io/tests/cmyk_composite_export.rs b/crates/io/tests/it/cmyk_composite_export.rs
similarity index 100%
rename from crates/io/tests/cmyk_composite_export.rs
rename to crates/io/tests/it/cmyk_composite_export.rs
diff --git a/crates/io/tests/color.rs b/crates/io/tests/it/color.rs
similarity index 99%
rename from crates/io/tests/color.rs
rename to crates/io/tests/it/color.rs
index c5d4479904..b553097ae4 100644
--- a/crates/io/tests/color.rs
+++ b/crates/io/tests/it/color.rs
@@ -1,7 +1,7 @@
//! ICC profiles through import/export: byte-exact PSD round trips with real profiles, PNG/JPEG
//! embedding and extraction, colour-managed CMYK → RGB for formats without CMYK.
-mod common;
+use crate::common;
use std::sync::Arc;
diff --git a/crates/io/tests/common/mod.rs b/crates/io/tests/it/common/mod.rs
similarity index 100%
rename from crates/io/tests/common/mod.rs
rename to crates/io/tests/it/common/mod.rs
diff --git a/crates/io/tests/composite.rs b/crates/io/tests/it/composite.rs
similarity index 99%
rename from crates/io/tests/composite.rs
rename to crates/io/tests/it/composite.rs
index fbb9e5ab3d..cef80e6a08 100644
--- a/crates/io/tests/composite.rs
+++ b/crates/io/tests/it/composite.rs
@@ -1,7 +1,7 @@
//! Composite oracle: the merged image stored in a PSD must match our
//! compositor's rendering of the imported document.
-mod common;
+use crate::common;
use common::*;
use photocraft_color::{BlendMode, ColorMode, SampleType};
diff --git a/crates/io/tests/comps_artboards.rs b/crates/io/tests/it/comps_artboards.rs
similarity index 100%
rename from crates/io/tests/comps_artboards.rs
rename to crates/io/tests/it/comps_artboards.rs
diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/it/corpus.rs
similarity index 99%
rename from crates/io/tests/corpus.rs
rename to crates/io/tests/it/corpus.rs
index 831529d115..b7695d451a 100644
--- a/crates/io/tests/corpus.rs
+++ b/crates/io/tests/it/corpus.rs
@@ -44,7 +44,7 @@
//! asserts import + flatten return (Ok or Err) without panicking.
#![cfg(feature = "corpus")]
-mod common;
+use crate::common;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
diff --git a/crates/io/tests/corpus_regressions.rs b/crates/io/tests/it/corpus_regressions.rs
similarity index 99%
rename from crates/io/tests/corpus_regressions.rs
rename to crates/io/tests/it/corpus_regressions.rs
index b335642cc9..26266c9f07 100644
--- a/crates/io/tests/corpus_regressions.rs
+++ b/crates/io/tests/it/corpus_regressions.rs
@@ -1,5 +1,5 @@
//! Synthetic reproductions of failures found by the psd-tools corpus
-//! (`cargo xtask corpus --psd-tools`; see `tests/corpus.rs`). The corpus
+//! (`cargo xtask corpus --psd-tools`; see `tests/it/corpus.rs`). The corpus
//! files themselves are never committed: each test rebuilds the relevant
//! structure from our own documents.
diff --git a/crates/io/tests/effects_multi.rs b/crates/io/tests/it/effects_multi.rs
similarity index 100%
rename from crates/io/tests/effects_multi.rs
rename to crates/io/tests/it/effects_multi.rs
diff --git a/crates/io/tests/exif_resolution.rs b/crates/io/tests/it/exif_resolution.rs
similarity index 100%
rename from crates/io/tests/exif_resolution.rs
rename to crates/io/tests/it/exif_resolution.rs
diff --git a/crates/io/tests/export_progress.rs b/crates/io/tests/it/export_progress.rs
similarity index 100%
rename from crates/io/tests/export_progress.rs
rename to crates/io/tests/it/export_progress.rs
diff --git a/crates/io/tests/exr_part_profile.rs b/crates/io/tests/it/exr_part_profile.rs
similarity index 100%
rename from crates/io/tests/exr_part_profile.rs
rename to crates/io/tests/it/exr_part_profile.rs
diff --git a/crates/io/tests/flat.rs b/crates/io/tests/it/flat.rs
similarity index 99%
rename from crates/io/tests/flat.rs
rename to crates/io/tests/it/flat.rs
index 98833d45d5..353e2bbb7a 100644
--- a/crates/io/tests/flat.rs
+++ b/crates/io/tests/it/flat.rs
@@ -1,6 +1,6 @@
//! Flat formats through photocraft-codecs, and export warnings.
-mod common;
+use crate::common;
use common::*;
use photocraft_color::{ColorMode, SampleType};
diff --git a/crates/io/tests/gif_frames.rs b/crates/io/tests/it/gif_frames.rs
similarity index 100%
rename from crates/io/tests/gif_frames.rs
rename to crates/io/tests/it/gif_frames.rs
diff --git a/crates/io/tests/guides_v2.rs b/crates/io/tests/it/guides_v2.rs
similarity index 100%
rename from crates/io/tests/guides_v2.rs
rename to crates/io/tests/it/guides_v2.rs
diff --git a/crates/io/tests/heif.rs b/crates/io/tests/it/heif.rs
similarity index 100%
rename from crates/io/tests/heif.rs
rename to crates/io/tests/it/heif.rs
diff --git a/crates/io/tests/heif_unsupported.rs b/crates/io/tests/it/heif_unsupported.rs
similarity index 100%
rename from crates/io/tests/heif_unsupported.rs
rename to crates/io/tests/it/heif_unsupported.rs
diff --git a/crates/io/tests/krita_corpus.rs b/crates/io/tests/it/krita_corpus.rs
similarity index 97%
rename from crates/io/tests/krita_corpus.rs
rename to crates/io/tests/it/krita_corpus.rs
index b0b3a01d63..ab2c56c143 100644
--- a/crates/io/tests/krita_corpus.rs
+++ b/crates/io/tests/it/krita_corpus.rs
@@ -2,7 +2,7 @@
//! Krita's own rendering of the layer stack (`mergedimage.png`). The files are generated by
//! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus and are
//! not bundled. Run with
-//! `KRITA_TEST_DIR= cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture`.
+//! `KRITA_TEST_DIR= cargo test -p photocraft-io --test it -- krita_corpus:: --ignored --nocapture`.
use std::path::{Path, PathBuf};
diff --git a/crates/io/tests/lab16.rs b/crates/io/tests/it/lab16.rs
similarity index 100%
rename from crates/io/tests/lab16.rs
rename to crates/io/tests/it/lab16.rs
diff --git a/crates/io/tests/large_files.rs b/crates/io/tests/it/large_files.rs
similarity index 98%
rename from crates/io/tests/large_files.rs
rename to crates/io/tests/it/large_files.rs
index f492dd2d65..b0a2669480 100644
--- a/crates/io/tests/large_files.rs
+++ b/crates/io/tests/it/large_files.rs
@@ -5,7 +5,7 @@
//!
//! The two layered documents run in memory; the streamed files are written to
//! `$PHOTOCRAFT_FIXTURE_DIR` (default: the system temp dir), removed afterwards, and are opt-in:
-//! `cargo test --release -p photocraft-io --test large_files -- --ignored`.
+//! `cargo test --release -p photocraft-io --test it -- large_files:: --ignored`.
use std::path::PathBuf;
diff --git a/crates/io/tests/large_psb.rs b/crates/io/tests/it/large_psb.rs
similarity index 96%
rename from crates/io/tests/large_psb.rs
rename to crates/io/tests/it/large_psb.rs
index bcde955bd0..da9f6ca9a1 100644
--- a/crates/io/tests/large_psb.rs
+++ b/crates/io/tests/it/large_psb.rs
@@ -25,7 +25,7 @@ fn undecodable_channel_leaves_the_layer_empty() {
/// A flattened 29000² RGB PSB: its merged image decodes to 2.52 GB, past the old 2 GiB cap, which
/// opened it as a document with no layers. Needs about 12 GB of memory, so it is opt-in:
-/// `cargo test --release -p photocraft-io --test large_psb -- --ignored`.
+/// `cargo test --release -p photocraft-io --test it -- large_psb:: --ignored`.
#[test]
#[ignore = "allocates ~12 GB; run in release"]
fn flattened_psb_with_a_merged_image_over_2_gib_opens() {
diff --git a/crates/io/tests/layer_labels.rs b/crates/io/tests/it/layer_labels.rs
similarity index 100%
rename from crates/io/tests/layer_labels.rs
rename to crates/io/tests/it/layer_labels.rs
diff --git a/crates/io/tests/layer_xmp.rs b/crates/io/tests/it/layer_xmp.rs
similarity index 100%
rename from crates/io/tests/layer_xmp.rs
rename to crates/io/tests/it/layer_xmp.rs
diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs
new file mode 100644
index 0000000000..662d37c307
--- /dev/null
+++ b/crates/io/tests/it/main.rs
@@ -0,0 +1,76 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod adjust_blend_roundtrip;
+mod adjust_warnings;
+mod affinity;
+mod affinity_artboards;
+mod affinity_corpus;
+mod annotations;
+mod artboard_guide_order;
+mod banded;
+mod cancel;
+mod channels;
+mod cmyk_composite_export;
+mod color;
+mod common;
+mod composite;
+mod comps_artboards;
+mod corpus;
+mod corpus_regressions;
+mod effects_multi;
+mod exif_resolution;
+mod export_progress;
+mod exr_part_profile;
+mod flat;
+mod gif_frames;
+mod guides_v2;
+mod heif;
+mod heif_unsupported;
+mod krita_corpus;
+mod lab16;
+mod large_files;
+mod large_psb;
+mod layer_labels;
+mod layer_xmp;
+mod modes;
+mod multichannel;
+mod orientation;
+mod pattern_fill_psd;
+mod pcraft_manifest_name;
+mod pdn_corpus;
+mod png_text_metadata;
+mod psd16_merged_transparency;
+mod psd32_export;
+mod psd_background;
+mod psd_block_alignment;
+mod psd_block_layout;
+mod psd_damaged_merged;
+mod psd_empty_channels;
+mod psd_font_identity;
+mod psd_kerning;
+mod psd_layer_block_length;
+mod psd_maximize_compatibility;
+mod psd_noise_fill_roundtrip;
+mod psd_roundtrip_bytes;
+mod psd_structure;
+mod psd_style_sheets;
+mod psd_thumbnail;
+mod psd_type_bounds;
+mod psd_type_photoshop_bytes;
+mod psd_unknown_adjustment;
+mod psd_vertical_type;
+mod raw;
+mod resolution_axes;
+mod roundtrip;
+mod smart_contents_identity;
+mod smart_corpus;
+mod svg;
+mod text_corpus;
+mod text_styles;
+mod tiff_corpus;
+mod tiff_layers;
+mod tiff_pages;
+mod tiff_type_geometry;
+mod vector;
+mod xcf;
+mod xmp_export;
diff --git a/crates/io/tests/modes.rs b/crates/io/tests/it/modes.rs
similarity index 100%
rename from crates/io/tests/modes.rs
rename to crates/io/tests/it/modes.rs
diff --git a/crates/io/tests/multichannel.rs b/crates/io/tests/it/multichannel.rs
similarity index 100%
rename from crates/io/tests/multichannel.rs
rename to crates/io/tests/it/multichannel.rs
diff --git a/crates/io/tests/orientation.rs b/crates/io/tests/it/orientation.rs
similarity index 100%
rename from crates/io/tests/orientation.rs
rename to crates/io/tests/it/orientation.rs
diff --git a/crates/io/tests/pattern_fill_psd.rs b/crates/io/tests/it/pattern_fill_psd.rs
similarity index 100%
rename from crates/io/tests/pattern_fill_psd.rs
rename to crates/io/tests/it/pattern_fill_psd.rs
diff --git a/crates/io/tests/pcraft_manifest_name.rs b/crates/io/tests/it/pcraft_manifest_name.rs
similarity index 98%
rename from crates/io/tests/pcraft_manifest_name.rs
rename to crates/io/tests/it/pcraft_manifest_name.rs
index 285fa02203..fffb88fa8b 100644
--- a/crates/io/tests/pcraft_manifest_name.rs
+++ b/crates/io/tests/it/pcraft_manifest_name.rs
@@ -1,7 +1,7 @@
//! A `.pcraft` bundle's manifest reports the file it is in (#3312): writing a document to a new
//! name must not leave the previous one inside, on every path that produces `.pcraft` bytes.
-mod common;
+use crate::common;
use common::*;
use photocraft_color::{ColorMode, SampleType};
diff --git a/crates/io/tests/pdn_corpus.rs b/crates/io/tests/it/pdn_corpus.rs
similarity index 98%
rename from crates/io/tests/pdn_corpus.rs
rename to crates/io/tests/it/pdn_corpus.rs
index 1e67b3d22b..c85b87f3ee 100644
--- a/crates/io/tests/pdn_corpus.rs
+++ b/crates/io/tests/it/pdn_corpus.rs
@@ -1,5 +1,5 @@
//! Local PDN oracles are not bundled. Set PDN_TEST_DIR to a directory containing PDNs and their
-//! exported PNGs, then run `cargo test -p photocraft-io --test pdn_corpus -- --ignored --nocapture`.
+//! exported PNGs, then run `cargo test -p photocraft-io --test it -- pdn_corpus:: --ignored --nocapture`.
use std::path::{Path, PathBuf};
diff --git a/crates/io/tests/png_text_metadata.rs b/crates/io/tests/it/png_text_metadata.rs
similarity index 100%
rename from crates/io/tests/png_text_metadata.rs
rename to crates/io/tests/it/png_text_metadata.rs
diff --git a/crates/io/tests/psd16_merged_transparency.rs b/crates/io/tests/it/psd16_merged_transparency.rs
similarity index 99%
rename from crates/io/tests/psd16_merged_transparency.rs
rename to crates/io/tests/it/psd16_merged_transparency.rs
index 02d6c0e6b3..82b054e873 100644
--- a/crates/io/tests/psd16_merged_transparency.rs
+++ b/crates/io/tests/it/psd16_merged_transparency.rs
@@ -5,7 +5,7 @@
//! Photoshop's 16-bit PSB in the report has four channels, a positive layer count in `Lr16`, no
//! `Mt16` block, and one alpha channel name, "Transparency". Re-saving it wrote five channels.
-mod common;
+use crate::common;
use common::raster;
use photocraft_color::{ColorMode, SampleType};
diff --git a/crates/io/tests/psd32_export.rs b/crates/io/tests/it/psd32_export.rs
similarity index 99%
rename from crates/io/tests/psd32_export.rs
rename to crates/io/tests/it/psd32_export.rs
index b4707fadb0..f9a297a7f6 100644
--- a/crates/io/tests/psd32_export.rs
+++ b/crates/io/tests/it/psd32_export.rs
@@ -6,7 +6,7 @@
//! resources, layer info in an `Lr32` global block with an empty classic layer info, channel
//! compression codes valid for 32-bit data, and a raw big-endian float merged image.
-mod common;
+use crate::common;
use common::Features;
use photocraft_color::{ColorMode, SampleType};
diff --git a/crates/io/tests/psd_background.rs b/crates/io/tests/it/psd_background.rs
similarity index 100%
rename from crates/io/tests/psd_background.rs
rename to crates/io/tests/it/psd_background.rs
diff --git a/crates/io/tests/psd_block_alignment.rs b/crates/io/tests/it/psd_block_alignment.rs
similarity index 100%
rename from crates/io/tests/psd_block_alignment.rs
rename to crates/io/tests/it/psd_block_alignment.rs
diff --git a/crates/io/tests/psd_block_layout.rs b/crates/io/tests/it/psd_block_layout.rs
similarity index 99%
rename from crates/io/tests/psd_block_layout.rs
rename to crates/io/tests/it/psd_block_layout.rs
index bb858633b5..d081bfbc43 100644
--- a/crates/io/tests/psd_block_layout.rs
+++ b/crates/io/tests/it/psd_block_layout.rs
@@ -3,7 +3,7 @@
//! to even, a layer info body not padded to 4 and, in PSB, `cinf`/`lnkE` read and written
//! with 4-byte lengths; psd-tools rejected or misaligned all of them.
-mod common;
+use crate::common;
use photocraft_io::*;
use photocraft_psd::descriptor::{Descriptor, VersionedDescriptor};
diff --git a/crates/io/tests/psd_damaged_merged.rs b/crates/io/tests/it/psd_damaged_merged.rs
similarity index 100%
rename from crates/io/tests/psd_damaged_merged.rs
rename to crates/io/tests/it/psd_damaged_merged.rs
diff --git a/crates/io/tests/psd_empty_channels.rs b/crates/io/tests/it/psd_empty_channels.rs
similarity index 99%
rename from crates/io/tests/psd_empty_channels.rs
rename to crates/io/tests/it/psd_empty_channels.rs
index 285f03cfcd..610bb145cc 100644
--- a/crates/io/tests/psd_empty_channels.rs
+++ b/crates/io/tests/it/psd_empty_channels.rs
@@ -1,4 +1,4 @@
-mod common;
+use crate::common;
use common::Features;
use photocraft_color::{ColorMode, SampleType};
use photocraft_io::{ExportOptions, export};
diff --git a/crates/io/tests/psd_font_identity.rs b/crates/io/tests/it/psd_font_identity.rs
similarity index 100%
rename from crates/io/tests/psd_font_identity.rs
rename to crates/io/tests/it/psd_font_identity.rs
diff --git a/crates/io/tests/psd_kerning.rs b/crates/io/tests/it/psd_kerning.rs
similarity index 100%
rename from crates/io/tests/psd_kerning.rs
rename to crates/io/tests/it/psd_kerning.rs
diff --git a/crates/io/tests/psd_layer_block_length.rs b/crates/io/tests/it/psd_layer_block_length.rs
similarity index 99%
rename from crates/io/tests/psd_layer_block_length.rs
rename to crates/io/tests/it/psd_layer_block_length.rs
index acb4a6b15c..71f8e2552d 100644
--- a/crates/io/tests/psd_layer_block_length.rs
+++ b/crates/io/tests/it/psd_layer_block_length.rs
@@ -4,7 +4,7 @@
//! Photoshop writes these blocks this way. Readers that check the block length exactly (Unity's
//! PSD Importer, "Incorrect length for InfoLayers") reject a block whose length counts the padding.
-mod common;
+use crate::common;
use common::Features;
use photocraft_color::{ColorMode, SampleType};
diff --git a/crates/io/tests/psd_maximize_compatibility.rs b/crates/io/tests/it/psd_maximize_compatibility.rs
similarity index 100%
rename from crates/io/tests/psd_maximize_compatibility.rs
rename to crates/io/tests/it/psd_maximize_compatibility.rs
diff --git a/crates/io/tests/psd_noise_fill_roundtrip.rs b/crates/io/tests/it/psd_noise_fill_roundtrip.rs
similarity index 100%
rename from crates/io/tests/psd_noise_fill_roundtrip.rs
rename to crates/io/tests/it/psd_noise_fill_roundtrip.rs
diff --git a/crates/io/tests/psd_roundtrip_bytes.rs b/crates/io/tests/it/psd_roundtrip_bytes.rs
similarity index 100%
rename from crates/io/tests/psd_roundtrip_bytes.rs
rename to crates/io/tests/it/psd_roundtrip_bytes.rs
diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/it/psd_structure.rs
similarity index 100%
rename from crates/io/tests/psd_structure.rs
rename to crates/io/tests/it/psd_structure.rs
diff --git a/crates/io/tests/psd_style_sheets.rs b/crates/io/tests/it/psd_style_sheets.rs
similarity index 100%
rename from crates/io/tests/psd_style_sheets.rs
rename to crates/io/tests/it/psd_style_sheets.rs
diff --git a/crates/io/tests/psd_thumbnail.rs b/crates/io/tests/it/psd_thumbnail.rs
similarity index 100%
rename from crates/io/tests/psd_thumbnail.rs
rename to crates/io/tests/it/psd_thumbnail.rs
diff --git a/crates/io/tests/psd_type_bounds.rs b/crates/io/tests/it/psd_type_bounds.rs
similarity index 99%
rename from crates/io/tests/psd_type_bounds.rs
rename to crates/io/tests/it/psd_type_bounds.rs
index a80bc9b7ab..b87d4f4a3a 100644
--- a/crates/io/tests/psd_type_bounds.rs
+++ b/crates/io/tests/it/psd_type_bounds.rs
@@ -10,7 +10,7 @@
//!
//! Fonts: the bundled Inter (deterministic), so our render is the "Photoshop" render here.
-mod common;
+use crate::common;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::text::TextShape;
diff --git a/crates/io/tests/psd_type_photoshop_bytes.rs b/crates/io/tests/it/psd_type_photoshop_bytes.rs
similarity index 100%
rename from crates/io/tests/psd_type_photoshop_bytes.rs
rename to crates/io/tests/it/psd_type_photoshop_bytes.rs
diff --git a/crates/io/tests/psd_unknown_adjustment.rs b/crates/io/tests/it/psd_unknown_adjustment.rs
similarity index 100%
rename from crates/io/tests/psd_unknown_adjustment.rs
rename to crates/io/tests/it/psd_unknown_adjustment.rs
diff --git a/crates/io/tests/psd_vertical_type.rs b/crates/io/tests/it/psd_vertical_type.rs
similarity index 100%
rename from crates/io/tests/psd_vertical_type.rs
rename to crates/io/tests/it/psd_vertical_type.rs
diff --git a/crates/io/tests/raw.rs b/crates/io/tests/it/raw.rs
similarity index 100%
rename from crates/io/tests/raw.rs
rename to crates/io/tests/it/raw.rs
diff --git a/crates/io/tests/resolution_axes.rs b/crates/io/tests/it/resolution_axes.rs
similarity index 99%
rename from crates/io/tests/resolution_axes.rs
rename to crates/io/tests/it/resolution_axes.rs
index f7f8cbf006..fb2bd209e2 100644
--- a/crates/io/tests/resolution_axes.rs
+++ b/crates/io/tests/it/resolution_axes.rs
@@ -1,7 +1,7 @@
//! A file whose horizontal and vertical resolutions differ opens at the horizontal one, and the
//! import says the vertical one is not kept (#1018). Equal axes, the common case, open silently.
-mod common;
+use crate::common;
use common::*;
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, Metadata, SampleType as CSample};
diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/it/roundtrip.rs
similarity index 99%
rename from crates/io/tests/roundtrip.rs
rename to crates/io/tests/it/roundtrip.rs
index dcb7bce62a..1031b5504a 100644
--- a/crates/io/tests/roundtrip.rs
+++ b/crates/io/tests/it/roundtrip.rs
@@ -1,6 +1,6 @@
//! Document → PSD → Document round trips.
-mod common;
+use crate::common;
use common::*;
use photocraft_color::{ColorMode, SampleType};
diff --git a/crates/io/tests/smart_contents_identity.rs b/crates/io/tests/it/smart_contents_identity.rs
similarity index 100%
rename from crates/io/tests/smart_contents_identity.rs
rename to crates/io/tests/it/smart_contents_identity.rs
diff --git a/crates/io/tests/smart_corpus.rs b/crates/io/tests/it/smart_corpus.rs
similarity index 99%
rename from crates/io/tests/smart_corpus.rs
rename to crates/io/tests/it/smart_corpus.rs
index 494a43071c..f258c01103 100644
--- a/crates/io/tests/smart_corpus.rs
+++ b/crates/io/tests/it/smart_corpus.rs
@@ -10,7 +10,7 @@
//! ones dropped).
#![cfg(feature = "corpus")]
-mod common;
+use crate::common;
use std::path::{Path, PathBuf};
use std::sync::Arc;
diff --git a/crates/io/tests/svg.rs b/crates/io/tests/it/svg.rs
similarity index 100%
rename from crates/io/tests/svg.rs
rename to crates/io/tests/it/svg.rs
diff --git a/crates/io/tests/text_corpus.rs b/crates/io/tests/it/text_corpus.rs
similarity index 100%
rename from crates/io/tests/text_corpus.rs
rename to crates/io/tests/it/text_corpus.rs
diff --git a/crates/io/tests/text_styles.rs b/crates/io/tests/it/text_styles.rs
similarity index 100%
rename from crates/io/tests/text_styles.rs
rename to crates/io/tests/it/text_styles.rs
diff --git a/crates/io/tests/tiff_corpus.rs b/crates/io/tests/it/tiff_corpus.rs
similarity index 100%
rename from crates/io/tests/tiff_corpus.rs
rename to crates/io/tests/it/tiff_corpus.rs
diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs
similarity index 99%
rename from crates/io/tests/tiff_layers.rs
rename to crates/io/tests/it/tiff_layers.rs
index 56e51bf9f9..382f45bc44 100644
--- a/crates/io/tests/tiff_layers.rs
+++ b/crates/io/tests/it/tiff_layers.rs
@@ -2,7 +2,7 @@
//! trips in every mode and depth, equivalence with the PSD path, the flat fall-backs, files
//! written by an independent implementation (psdtags, both byte orders), and malformed input.
-mod common;
+use crate::common;
use common::*;
use photocraft_color::{ColorMode, SampleType};
diff --git a/crates/io/tests/tiff_pages.rs b/crates/io/tests/it/tiff_pages.rs
similarity index 100%
rename from crates/io/tests/tiff_pages.rs
rename to crates/io/tests/it/tiff_pages.rs
diff --git a/crates/io/tests/tiff_type_geometry.rs b/crates/io/tests/it/tiff_type_geometry.rs
similarity index 100%
rename from crates/io/tests/tiff_type_geometry.rs
rename to crates/io/tests/it/tiff_type_geometry.rs
diff --git a/crates/io/tests/vector.rs b/crates/io/tests/it/vector.rs
similarity index 100%
rename from crates/io/tests/vector.rs
rename to crates/io/tests/it/vector.rs
diff --git a/crates/io/tests/xcf.rs b/crates/io/tests/it/xcf.rs
similarity index 100%
rename from crates/io/tests/xcf.rs
rename to crates/io/tests/it/xcf.rs
diff --git a/crates/io/tests/xmp_export.rs b/crates/io/tests/it/xmp_export.rs
similarity index 100%
rename from crates/io/tests/xmp_export.rs
rename to crates/io/tests/it/xmp_export.rs
diff --git a/crates/psd/README.md b/crates/psd/README.md
index b26a33dff6..421d706ab7 100644
--- a/crates/psd/README.md
+++ b/crates/psd/README.md
@@ -145,7 +145,7 @@ allocations for padded channels return an error on failure.
* Builder → `rgba8` correctness.
* Unknown blend modes and unknown blocks pass through unchanged.
-`tests/corpus.rs` walks `corpus/psd/**/*.{psd,psb}` at the workspace root when that directory exists. For each file it asserts that parsing succeeds and the file round-trips byte for byte, and it prints a result per file. Without the directory it skips silently.
+`tests/it/corpus.rs` walks `corpus/psd/**/*.{psd,psb}` at the workspace root when that directory exists. For each file it asserts that parsing succeeds and the file round-trips byte for byte, and it prints a result per file. Without the directory it skips silently.
A `cargo-fuzz` skeleton lives in `fuzz/`. It is its own workspace and is excluded from photocraft's. Run it with `cargo +nightly fuzz run parse` (or `descriptor`) from `crates/psd`.
diff --git a/crates/psd/fuzz/fuzz_targets/grd.rs b/crates/psd/fuzz/fuzz_targets/grd.rs
index 2779a96846..16ab26a137 100644
--- a/crates/psd/fuzz/fuzz_targets/grd.rs
+++ b/crates/psd/fuzz/fuzz_targets/grd.rs
@@ -3,7 +3,7 @@
//!
//! The cap assertions below are invariants, not fast checks: at libFuzzer default `-max_len`
//! of 4096 no input can carry 10 001 gradients or 1001 stops, so they only fire on long runs with
-//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/swatch_malformed.rs`.
+//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/it/swatch_malformed.rs`.
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_psd::grd::{self, GrdColor};
diff --git a/crates/psd/tests/builder.rs b/crates/psd/tests/it/builder.rs
similarity index 100%
rename from crates/psd/tests/builder.rs
rename to crates/psd/tests/it/builder.rs
diff --git a/crates/psd/tests/corpus.rs b/crates/psd/tests/it/corpus.rs
similarity index 100%
rename from crates/psd/tests/corpus.rs
rename to crates/psd/tests/it/corpus.rs
diff --git a/crates/psd/tests/corpus_atn.rs b/crates/psd/tests/it/corpus_atn.rs
similarity index 100%
rename from crates/psd/tests/corpus_atn.rs
rename to crates/psd/tests/it/corpus_atn.rs
diff --git a/crates/psd/tests/layer_block_order.rs b/crates/psd/tests/it/layer_block_order.rs
similarity index 100%
rename from crates/psd/tests/layer_block_order.rs
rename to crates/psd/tests/it/layer_block_order.rs
diff --git a/crates/psd/tests/it/main.rs b/crates/psd/tests/it/main.rs
new file mode 100644
index 0000000000..ee6879aa66
--- /dev/null
+++ b/crates/psd/tests/it/main.rs
@@ -0,0 +1,12 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod builder;
+mod corpus;
+mod corpus_atn;
+mod layer_block_order;
+mod malformed;
+mod merged_recovery;
+mod proptests;
+mod roundtrip;
+mod swatch_malformed;
+mod tree;
diff --git a/crates/psd/tests/malformed.rs b/crates/psd/tests/it/malformed.rs
similarity index 100%
rename from crates/psd/tests/malformed.rs
rename to crates/psd/tests/it/malformed.rs
diff --git a/crates/psd/tests/merged_recovery.rs b/crates/psd/tests/it/merged_recovery.rs
similarity index 100%
rename from crates/psd/tests/merged_recovery.rs
rename to crates/psd/tests/it/merged_recovery.rs
diff --git a/crates/psd/tests/proptests.rs b/crates/psd/tests/it/proptests.rs
similarity index 100%
rename from crates/psd/tests/proptests.rs
rename to crates/psd/tests/it/proptests.rs
diff --git a/crates/psd/tests/roundtrip.rs b/crates/psd/tests/it/roundtrip.rs
similarity index 100%
rename from crates/psd/tests/roundtrip.rs
rename to crates/psd/tests/it/roundtrip.rs
diff --git a/crates/psd/tests/swatch_malformed.rs b/crates/psd/tests/it/swatch_malformed.rs
similarity index 100%
rename from crates/psd/tests/swatch_malformed.rs
rename to crates/psd/tests/it/swatch_malformed.rs
diff --git a/crates/psd/tests/tree.rs b/crates/psd/tests/it/tree.rs
similarity index 100%
rename from crates/psd/tests/tree.rs
rename to crates/psd/tests/it/tree.rs
diff --git a/crates/raw/examples/inspect.rs b/crates/raw/examples/inspect.rs
index ca7b136a90..4dd6e5709a 100644
--- a/crates/raw/examples/inspect.rs
+++ b/crates/raw/examples/inspect.rs
@@ -1,5 +1,5 @@
//! Prints the raw metadata of files: `cargo run -p photocraft-raw --example inspect -- a.cr2 b.nef`
-//! (handy while adding corpus files; the oracle expectations in `tests/raw_corpus.rs` come from it).
+//! (handy while adding corpus files; the oracle expectations in `tests/it/raw_corpus.rs` come from it).
use std::env;
fn main() {
diff --git a/crates/raw/tests/cr3.rs b/crates/raw/tests/it/cr3.rs
similarity index 100%
rename from crates/raw/tests/cr3.rs
rename to crates/raw/tests/it/cr3.rs
diff --git a/crates/raw/tests/development_limits.rs b/crates/raw/tests/it/development_limits.rs
similarity index 100%
rename from crates/raw/tests/development_limits.rs
rename to crates/raw/tests/it/development_limits.rs
diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/it/dng.rs
similarity index 100%
rename from crates/raw/tests/dng.rs
rename to crates/raw/tests/it/dng.rs
diff --git a/crates/raw/tests/hostile.rs b/crates/raw/tests/it/hostile.rs
similarity index 100%
rename from crates/raw/tests/hostile.rs
rename to crates/raw/tests/it/hostile.rs
diff --git a/crates/raw/tests/it/main.rs b/crates/raw/tests/it/main.rs
new file mode 100644
index 0000000000..27a1af788b
--- /dev/null
+++ b/crates/raw/tests/it/main.rs
@@ -0,0 +1,12 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod cr3;
+mod development_limits;
+mod dng;
+mod hostile;
+mod nef_packed;
+mod nikon_d4_depth;
+mod raf;
+mod raw_corpus;
+mod vendor_codecs;
+mod vendors;
diff --git a/crates/raw/tests/nef_packed.rs b/crates/raw/tests/it/nef_packed.rs
similarity index 100%
rename from crates/raw/tests/nef_packed.rs
rename to crates/raw/tests/it/nef_packed.rs
diff --git a/crates/raw/tests/nikon_d4_depth.rs b/crates/raw/tests/it/nikon_d4_depth.rs
similarity index 100%
rename from crates/raw/tests/nikon_d4_depth.rs
rename to crates/raw/tests/it/nikon_d4_depth.rs
diff --git a/crates/raw/tests/raf.rs b/crates/raw/tests/it/raf.rs
similarity index 100%
rename from crates/raw/tests/raf.rs
rename to crates/raw/tests/it/raf.rs
diff --git a/crates/raw/tests/raw_corpus.rs b/crates/raw/tests/it/raw_corpus.rs
similarity index 100%
rename from crates/raw/tests/raw_corpus.rs
rename to crates/raw/tests/it/raw_corpus.rs
diff --git a/crates/raw/tests/vendor_codecs.rs b/crates/raw/tests/it/vendor_codecs.rs
similarity index 100%
rename from crates/raw/tests/vendor_codecs.rs
rename to crates/raw/tests/it/vendor_codecs.rs
diff --git a/crates/raw/tests/vendors.rs b/crates/raw/tests/it/vendors.rs
similarity index 100%
rename from crates/raw/tests/vendors.rs
rename to crates/raw/tests/it/vendors.rs
diff --git a/crates/ui-egui/tests/adjust_preview_gpu.rs b/crates/ui-egui/tests/it/adjust_preview_gpu.rs
similarity index 99%
rename from crates/ui-egui/tests/adjust_preview_gpu.rs
rename to crates/ui-egui/tests/it/adjust_preview_gpu.rs
index c7104f72ee..d3880b04fd 100644
--- a/crates/ui-egui/tests/adjust_preview_gpu.rs
+++ b/crates/ui-egui/tests/it/adjust_preview_gpu.rs
@@ -1,7 +1,7 @@
//! GPU parity of the Image › Adjustments dialog preview (`adjust_preview`): the document with
//! the temporary clipped adjustment layer must composite on the wgpu compositor like on the CPU
//! reference, and like the destructive command's result, for every kind with a dialog. Skips
-//! without an adapter that renders 32-bit float targets (like `crates/gpu/tests/parity.rs`).
+//! without an adapter that renders 32-bit float targets (like `crates/gpu/tests/it/parity.rs`).
use eframe::wgpu;
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
diff --git a/crates/ui-egui/tests/arbitrary_rotation_preview.rs b/crates/ui-egui/tests/it/arbitrary_rotation_preview.rs
similarity index 100%
rename from crates/ui-egui/tests/arbitrary_rotation_preview.rs
rename to crates/ui-egui/tests/it/arbitrary_rotation_preview.rs
diff --git a/crates/ui-egui/tests/batch_export_options.rs b/crates/ui-egui/tests/it/batch_export_options.rs
similarity index 100%
rename from crates/ui-egui/tests/batch_export_options.rs
rename to crates/ui-egui/tests/it/batch_export_options.rs
diff --git a/crates/ui-egui/tests/camera_raw_histogram.rs b/crates/ui-egui/tests/it/camera_raw_histogram.rs
similarity index 100%
rename from crates/ui-egui/tests/camera_raw_histogram.rs
rename to crates/ui-egui/tests/it/camera_raw_histogram.rs
diff --git a/crates/ui-egui/tests/camera_raw_localization.rs b/crates/ui-egui/tests/it/camera_raw_localization.rs
similarity index 100%
rename from crates/ui-egui/tests/camera_raw_localization.rs
rename to crates/ui-egui/tests/it/camera_raw_localization.rs
diff --git a/crates/ui-egui/tests/camera_raw_scope.rs b/crates/ui-egui/tests/it/camera_raw_scope.rs
similarity index 100%
rename from crates/ui-egui/tests/camera_raw_scope.rs
rename to crates/ui-egui/tests/it/camera_raw_scope.rs
diff --git a/crates/ui-egui/tests/camera_raw_zoom.rs b/crates/ui-egui/tests/it/camera_raw_zoom.rs
similarity index 100%
rename from crates/ui-egui/tests/camera_raw_zoom.rs
rename to crates/ui-egui/tests/it/camera_raw_zoom.rs
diff --git a/crates/ui-egui/tests/canvas_16f.rs b/crates/ui-egui/tests/it/canvas_16f.rs
similarity index 100%
rename from crates/ui-egui/tests/canvas_16f.rs
rename to crates/ui-egui/tests/it/canvas_16f.rs
diff --git a/crates/ui-egui/tests/checker_canvas.rs b/crates/ui-egui/tests/it/checker_canvas.rs
similarity index 100%
rename from crates/ui-egui/tests/checker_canvas.rs
rename to crates/ui-egui/tests/it/checker_canvas.rs
diff --git a/crates/ui-egui/tests/color_managed_canvas.rs b/crates/ui-egui/tests/it/color_managed_canvas.rs
similarity index 98%
rename from crates/ui-egui/tests/color_managed_canvas.rs
rename to crates/ui-egui/tests/it/color_managed_canvas.rs
index 4267a2cf53..ec58a4faa1 100644
--- a/crates/ui-egui/tests/color_managed_canvas.rs
+++ b/crates/ui-egui/tests/it/color_managed_canvas.rs
@@ -1,7 +1,7 @@
//! The canvas is colour-managed (#46): a Display P3 document shows its colours converted to
//! the (sRGB) monitor on the GPU canvas (display LUT) and on the CPU canvas (flipped views draw
//! through the CPU path); sRGB documents are shown unchanged. Skips when no GPU adapter with
-//! 32-bit float render targets exists (like `crates/gpu/tests/parity.rs`).
+//! 32-bit float render targets exists (like `crates/gpu/tests/it/parity.rs`).
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
diff --git a/crates/ui-egui/tests/crop_beyond_canvas.rs b/crates/ui-egui/tests/it/crop_beyond_canvas.rs
similarity index 100%
rename from crates/ui-egui/tests/crop_beyond_canvas.rs
rename to crates/ui-egui/tests/it/crop_beyond_canvas.rs
diff --git a/crates/ui-egui/tests/crop_default_mode.rs b/crates/ui-egui/tests/it/crop_default_mode.rs
similarity index 100%
rename from crates/ui-egui/tests/crop_default_mode.rs
rename to crates/ui-egui/tests/it/crop_default_mode.rs
diff --git a/crates/ui-egui/tests/crop_popup_keys.rs b/crates/ui-egui/tests/it/crop_popup_keys.rs
similarity index 100%
rename from crates/ui-egui/tests/crop_popup_keys.rs
rename to crates/ui-egui/tests/it/crop_popup_keys.rs
diff --git a/crates/ui-egui/tests/custom_print_paper.rs b/crates/ui-egui/tests/it/custom_print_paper.rs
similarity index 100%
rename from crates/ui-egui/tests/custom_print_paper.rs
rename to crates/ui-egui/tests/it/custom_print_paper.rs
diff --git a/crates/ui-egui/tests/display_shadows.rs b/crates/ui-egui/tests/it/display_shadows.rs
similarity index 100%
rename from crates/ui-egui/tests/display_shadows.rs
rename to crates/ui-egui/tests/it/display_shadows.rs
diff --git a/crates/ui-egui/tests/drag_preview_canvas.rs b/crates/ui-egui/tests/it/drag_preview_canvas.rs
similarity index 100%
rename from crates/ui-egui/tests/drag_preview_canvas.rs
rename to crates/ui-egui/tests/it/drag_preview_canvas.rs
diff --git a/crates/ui-egui/tests/export_as_scaling.rs b/crates/ui-egui/tests/it/export_as_scaling.rs
similarity index 100%
rename from crates/ui-egui/tests/export_as_scaling.rs
rename to crates/ui-egui/tests/it/export_as_scaling.rs
diff --git a/crates/ui-egui/tests/full_res_preview.rs b/crates/ui-egui/tests/it/full_res_preview.rs
similarity index 100%
rename from crates/ui-egui/tests/full_res_preview.rs
rename to crates/ui-egui/tests/it/full_res_preview.rs
diff --git a/crates/ui-egui/tests/gpu_device_loss.rs b/crates/ui-egui/tests/it/gpu_device_loss.rs
similarity index 100%
rename from crates/ui-egui/tests/gpu_device_loss.rs
rename to crates/ui-egui/tests/it/gpu_device_loss.rs
diff --git a/crates/ui-egui/tests/layout_perf.rs b/crates/ui-egui/tests/it/layout_perf.rs
similarity index 99%
rename from crates/ui-egui/tests/layout_perf.rs
rename to crates/ui-egui/tests/it/layout_perf.rs
index 1afb0f36cf..2b5cf73bf1 100644
--- a/crates/ui-egui/tests/layout_perf.rs
+++ b/crates/ui-egui/tests/it/layout_perf.rs
@@ -4,7 +4,7 @@
//! nothing and hiding one recomposites only its area; and the GPU compositor (which composites
//! small layers over their bounds only) matches the CPU reference on the whole document.
-#[path = "support/layout_doc.rs"]
+#[path = "../support/layout_doc.rs"]
mod layout_doc;
use egui::{Event, Modifiers, PointerButton, Pos2};
diff --git a/crates/ui-egui/tests/live_stroke_canvas.rs b/crates/ui-egui/tests/it/live_stroke_canvas.rs
similarity index 100%
rename from crates/ui-egui/tests/live_stroke_canvas.rs
rename to crates/ui-egui/tests/it/live_stroke_canvas.rs
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
new file mode 100644
index 0000000000..a65e094315
--- /dev/null
+++ b/crates/ui-egui/tests/it/main.rs
@@ -0,0 +1,33 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod adjust_preview_gpu;
+mod arbitrary_rotation_preview;
+mod batch_export_options;
+mod camera_raw_histogram;
+mod camera_raw_localization;
+mod camera_raw_scope;
+mod camera_raw_zoom;
+mod canvas_16f;
+mod checker_canvas;
+mod color_managed_canvas;
+mod crop_beyond_canvas;
+mod crop_default_mode;
+mod crop_popup_keys;
+mod custom_print_paper;
+mod display_shadows;
+mod drag_preview_canvas;
+mod export_as_scaling;
+mod full_res_preview;
+mod gpu_device_loss;
+mod layout_perf;
+mod live_stroke_canvas;
+mod marching_ants_pixels;
+mod marquee_autoscroll;
+mod paint_bucket_all_layers;
+mod pattern_preview_canvas;
+mod radial_blur_center;
+mod remove_channel_target;
+mod system_locale;
+mod ui_scale_canvas;
+mod variables_data_sets;
+mod workspace_presets;
diff --git a/crates/ui-egui/tests/marching_ants_pixels.rs b/crates/ui-egui/tests/it/marching_ants_pixels.rs
similarity index 100%
rename from crates/ui-egui/tests/marching_ants_pixels.rs
rename to crates/ui-egui/tests/it/marching_ants_pixels.rs
diff --git a/crates/ui-egui/tests/marquee_autoscroll.rs b/crates/ui-egui/tests/it/marquee_autoscroll.rs
similarity index 100%
rename from crates/ui-egui/tests/marquee_autoscroll.rs
rename to crates/ui-egui/tests/it/marquee_autoscroll.rs
diff --git a/crates/ui-egui/tests/paint_bucket_all_layers.rs b/crates/ui-egui/tests/it/paint_bucket_all_layers.rs
similarity index 100%
rename from crates/ui-egui/tests/paint_bucket_all_layers.rs
rename to crates/ui-egui/tests/it/paint_bucket_all_layers.rs
diff --git a/crates/ui-egui/tests/pattern_preview_canvas.rs b/crates/ui-egui/tests/it/pattern_preview_canvas.rs
similarity index 100%
rename from crates/ui-egui/tests/pattern_preview_canvas.rs
rename to crates/ui-egui/tests/it/pattern_preview_canvas.rs
diff --git a/crates/ui-egui/tests/radial_blur_center.rs b/crates/ui-egui/tests/it/radial_blur_center.rs
similarity index 100%
rename from crates/ui-egui/tests/radial_blur_center.rs
rename to crates/ui-egui/tests/it/radial_blur_center.rs
diff --git a/crates/ui-egui/tests/remove_channel_target.rs b/crates/ui-egui/tests/it/remove_channel_target.rs
similarity index 100%
rename from crates/ui-egui/tests/remove_channel_target.rs
rename to crates/ui-egui/tests/it/remove_channel_target.rs
diff --git a/crates/ui-egui/tests/system_locale.rs b/crates/ui-egui/tests/it/system_locale.rs
similarity index 100%
rename from crates/ui-egui/tests/system_locale.rs
rename to crates/ui-egui/tests/it/system_locale.rs
diff --git a/crates/ui-egui/tests/ui_scale_canvas.rs b/crates/ui-egui/tests/it/ui_scale_canvas.rs
similarity index 100%
rename from crates/ui-egui/tests/ui_scale_canvas.rs
rename to crates/ui-egui/tests/it/ui_scale_canvas.rs
diff --git a/crates/ui-egui/tests/variables_data_sets.rs b/crates/ui-egui/tests/it/variables_data_sets.rs
similarity index 100%
rename from crates/ui-egui/tests/variables_data_sets.rs
rename to crates/ui-egui/tests/it/variables_data_sets.rs
diff --git a/crates/ui-egui/tests/workspace_presets.rs b/crates/ui-egui/tests/it/workspace_presets.rs
similarity index 100%
rename from crates/ui-egui/tests/workspace_presets.rs
rename to crates/ui-egui/tests/it/workspace_presets.rs
diff --git a/docs/development.md b/docs/development.md
index 034127ad53..fd5512b710 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -292,7 +292,7 @@ PSD placed layer, the `Idnt` uuid its duplicates share), shared contents (`smart
every copy of the same contents has, and the number of `instances`; Replace Contents changes them
all and lists them in its result), type
text, adjustment settings, channels and history, so agents can verify what they did without a
-screenshot. `crates/automation/tests/agent_tasks.rs` is the reference: ten realistic edit tasks
+screenshot. `crates/automation/tests/it/agent_tasks.rs` is the reference: ten realistic edit tasks
(title card, colour grade, undo/redo, editable smart blur, masks, saved selections, align,
capability-scoped export, resize/crop, CMYK + native save) driven purely over MCP.
@@ -384,7 +384,7 @@ cargo xtask perf --update-baseline # also write perf/baseline.json from this r
- `perf/baseline.json`: the numbers the scorecard shows, written only by
`cargo xtask perf --update-baseline`, with the machine (CPU, RAM, GPU adapter, OS), its machine
class, commit, date and load average.
-- `crates/io/tests/corpus.rs`: the corpus floors (every `Source { .. }` constant).
+- `crates/io/tests/it/corpus.rs`: the corpus floors (every `Source { .. }` constant).
- The prefs audit: `Preferences` fields that no code reads (target 0, #204), plus counts taken
from the tree (never-crash attribute coverage, `docs/parity-checklist.md`).
@@ -634,12 +634,12 @@ per source with its own floors: `corpus/psd`, the psd-tools set (also a truncati
over every file that must never panic) and our Photoshop set `corpus/photoshop` (per-feature-group
totals: `smart-filters`, `effects`, `text`, `adjustments/`). Smart objects and type
layers composite Photoshop's cached pixels there; `cargo xtask test-corpus -p engine -- --nocapture`
-(`crates/engine/tests/photoshop_oracles.rs`) re-renders them with our smart-filter stack and text
+(`crates/engine/tests/it/photoshop_oracles.rs`) re-renders them with our smart-filter stack and text
engine and is the failure map for both. A panic is reported as `CRASH` and fails the run. Files without a real merged image (Maximize Compatibility off)
are judged against their embedded thumbnail instead (`PASS (thumbnail)`, a strict low-resolution
-check); a file only SKIPs when it has no oracle at all. Raise the floors in `crates/io/tests/corpus.rs` when they
+check); a file only SKIPs when it has no oracle at all. Raise the floors in `crates/io/tests/it/corpus.rs` when they
improve; never lower them. Synthetic reproductions of corpus findings live in
-`crates/io/tests/corpus_regressions.rs` (corpus files are never committed). To dig into one file:
+`crates/io/tests/it/corpus_regressions.rs` (corpus files are never committed). To dig into one file:
```sh
cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd/.psd 0 png /tmp/diff.png
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 2970b0edb8..e2c9420674 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -22,7 +22,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
| Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests |
|---|---|---|---|---|
| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2 with their warp: the bend presets, a custom mesh and, since #4112, Cylinder; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (53 mapped, among them Invert, the Distort filters but Shear, Clouds, Difference Clouds, Fibers and Lens Flare, plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending, each layer's XMP packet (`layer.xmpMetadata`). Oracle floors: Photoshop-authored 190/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) |
-| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 |
+| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 |
| Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) |
| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders, including Photoshop mixed-order type blocks (#4758); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7: extra alpha channels not written) |
| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview |
@@ -63,7 +63,7 @@ supports many more, including CR3).
| Camera format | PhotoCraft | Evidence |
|---|---|---|
-| DNG (incl. LJPEG, lossy JPEG and JPEG XL tiles) | yes | 2026-10-13: lossy DNG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, CFA and LinearRaw, partial edge tiles) decodes to the stored samples, which go through LinearizationTable / BlackLevel / WhiteLevel (default 255) like every other depth; `crates/raw/tests/dng.rs` `lossy_jpeg_*` (FILE-215-11, lossy DNG part; no lossy file in the raw corpus yet) |
+| DNG (incl. LJPEG, lossy JPEG and JPEG XL tiles) | yes | 2026-10-13: lossy DNG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, CFA and LinearRaw, partial edge tiles) decodes to the stored samples, which go through LinearizationTable / BlackLevel / WhiteLevel (default 255) like every other depth; `crates/raw/tests/it/dng.rs` `lossy_jpeg_*` (FILE-215-11, lossy DNG part; no lossy file in the raw corpus yet) |
| Canon CR2 | yes | |
| Canon CR3 | detected, unsupported | `crates/raw/src/lib.rs:219` |
| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50; D5100 and D90 image areas measured (14-bit NEFs); D4 profile levels follow the sample depth (12 and 14 bit); [validation](nikon-nef-development.md) |
@@ -171,7 +171,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 |
| 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) |
| 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 |
-| 2026-10-13 | minor | Large-file fixtures generated at test time (20 MP layered, 150-layer layout, 2.02 GB PSD, 4.38 GB PSB), opened, saved and re-opened by io tests/large_files.rs; FILE-216-2 done; P20/P63 open times measurable (#5107) |
+| 2026-10-13 | minor | Large-file fixtures generated at test time (20 MP layered, 150-layer layout, 2.02 GB PSD, 4.38 GB PSB), opened, saved and re-opened by io tests/it/large_files.rs; FILE-216-2 done; P20/P63 open times measurable (#5107) |
| 2026-10-13 | minor | PSD smart filters: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag and Invert open as editable filters and are saved instead of dropped (checked against a Photoshop 27 file); a scaled Wave, a Wave with more than 64 generators and Shear stay verbatim |
| 2026-10-13 | minor | CMYK Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with black kept, on the inks for display, export and destructive edits; cmyk8/16 hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25% |
| 2026-10-13 | minor | CMYK Posterize works on the stored values (1 − ink), as Photoshop does: psd-tools posterize_cmyk passes (13.8 % → 0 of pixels off on the ink path); psd-tools floor → 242 of 309 |
diff --git a/docs/gaps.md b/docs/gaps.md
index 614a91beb5..2111315257 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -46,13 +46,13 @@ what an agent can't finish alone.
constant and transparency is disabled. Variable-range synthesis, HSB/Lab modes and
transparency remain unsupported and are preserved verbatim; the five visual examples do not
establish Photoshop's exact noise algorithm or include this degenerate constant case.
-- **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored
+- **Evidence:** corpus floors (`crates/io/tests/it/corpus.rs`, scorecard): Photoshop-authored
oracles 190/258 (74%; 32-bit Hue/Saturation 2026-10-22; smart filter masks 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100%
within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers
re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not
opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object
blank (#1764), perspective placed layers flattened (#579, fixed by #426), PSB > 4 GB fails on Windows (#375; a generated 4.38 GB PSB opens, saves and re-opens
- through io on Windows, io tests/large_files.rs, 2026-10-13).
+ through io on Windows, io tests/it/large_files.rs, 2026-10-13).
No test opens our exports in a third-party editor (FILE-216-4). Mockups whose smart object
uses Transform Warp's Cylinder re-rendered flat after Replace Contents (#4112, fixed 2026-10-14;
vertical cylinders and ones with perspective are still unread).
@@ -174,7 +174,7 @@ what an agent can't finish alone.
sample depth (15520 at 14 bits, 3880 at 12), so a 12-bit D4 NEF opens as bright as the same
scene at 14 bits instead of 2.6 stops darker (#5978). Real CC0 pair, mean linear luminance
against the 14-bit file: 12-bit lossy +0.02, uncompressed -0.02, lossless +0.01 stop;
- 14-bit output unchanged (`cameras.rs`, `tests/nikon_d4_depth.rs`).
+ 14-bit output unchanged (`cameras.rs`, `tests/it/nikon_d4_depth.rs`).
- **Estimate:** 120–200 h.
### G15. AI and generative features
@@ -378,7 +378,7 @@ what an agent can't finish alone.
| 2026-10-14 | minor | G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E); gap remains open |
| 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open |
| 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 |
-| 2026-10-13 | minor | G1: generated large-file fixtures (20 MP, 150 layers, 2 GB PSD, 4.38 GB PSB) round-trip in io tests/large_files.rs; the 4.38 GB PSB opens on Windows through io (#375 evidence); FILE-216-2 done (#5107) |
+| 2026-10-13 | minor | G1: generated large-file fixtures (20 MP, 150 layers, 2 GB PSD, 4.38 GB PSB) round-trip in io tests/it/large_files.rs; the 4.38 GB PSB opens on Windows through io (#375 evidence); FILE-216-2 done (#5107) |
| 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged |
| 2026-10-13 | minor | G1: CMYK Posterize works on the stored values (1 − ink); psd-tools posterize_cmyk passes; psd-tools floor → 242 of 309 |
| 2026-10-13 | minor | G1: CMYK stacks with Invert, Posterize, Threshold, Brightness/Contrast, Levels or Curves layers composite on the document's own inks (14 CMYK oracles pass); Photoshop-oracle floor 151 → 165 of 258 |
diff --git a/docs/kra.md b/docs/kra.md
index 847a1f32d2..19e39bcf1e 100644
--- a/docs/kra.md
+++ b/docs/kra.md
@@ -54,8 +54,8 @@ corrupted files are covered by tests.
Unit tests (`crates/io/src/kra/tests.rs`) build Krita archives in code (raw and LZF tiles) and
check layers, groups, pass-through, offsets, opacity, visibility, locks, masks, default pixels,
16-bit and gray conversion, the merged-image fallback, LZF edge cases and malformed input.
-`crates/automation/tests/krita.rs` opens a `.kra` through the agent surface, edits it and saves
-it as OpenRaster. `crates/engine/tests/kra.rs` (opt-in) opens a Krita-authored 16-bit file in a
+`crates/automation/tests/it/krita.rs` opens a `.kra` through the agent surface, edits it and saves
+it as OpenRaster. `crates/engine/tests/it/kra.rs` (opt-in) opens a Krita-authored 16-bit file in a
session, checks it is read-only, edits with undo and saves as `.ora` and `.pcraft`.
Real-file checks use the Krita-authored oracles in
@@ -64,8 +64,8 @@ Real-file checks use the Krita-authored oracles in
the files are sRGB):
```sh
-KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture
-KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-engine --test kra -- --ignored
+KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test it -- krita_corpus:: --ignored --nocapture
+KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-engine --test it -- kra:: --ignored
```
2026-10-09, Krita 5.2.9: 31 of 35 `.kra` files render within 3/255 of Krita's merged image; the
diff --git a/docs/ora.md b/docs/ora.md
index 2bf4f5112d..61125f060b 100644
--- a/docs/ora.md
+++ b/docs/ora.md
@@ -61,7 +61,7 @@ Unit tests (`crates/io/src/ora/tests.rs`) round-trip layers, groups, pass-throug
opacity, visibility, locks and every blend mode at 8 and 16 bit, check masks, rendered layer
kinds, the archive layout and malformed input. Synthetic 8/16-bit pixel fixtures check modern
and legacy group-isolation defaults, explicit isolation and pass-through.
-`crates/automation/tests/openraster.rs` saves,
+`crates/automation/tests/it/openraster.rs` saves,
reopens, edits and saves in place through the agent surface, and checks that a document with an
adjustment layer never overwrites its `.ora`; the CLI test converts both ways.
@@ -71,11 +71,11 @@ Real-file checks use Krita-authored oracles from
`mergedimage.png`:
```sh
-KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture
+KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test it -- krita_corpus:: --ignored --nocapture
```
2026-10-09, Krita 5.2.9: 32 of 35 `.ora` files render within 3/255 of Krita's merged image; the
-other three differ for known reasons listed in `crates/io/tests/krita_corpus.rs` (Krita's
+other three differ for known reasons listed in `crates/io/tests/it/krita_corpus.rs` (Krita's
tie-break in Darker and Lighter Color, and Hard Mix at a channel sum of exactly 1). In the other
direction, Krita opened every PhotoCraft re-export of the first 34 of those files and rendered 33
of them identically to its original; the exception is Photoshop's Soft Light, which is written
diff --git a/docs/pdn.md b/docs/pdn.md
index dea41eb143..1a4e10940f 100644
--- a/docs/pdn.md
+++ b/docs/pdn.md
@@ -32,8 +32,8 @@ padding, reversed chunks, old/new mode records, native saves, cancellation and m
The GPU parity tests include the additional modes. Local real-file checks are opt-in:
```sh
-PDN_TEST_DIR=/path/to/pdn-and-png-exports cargo test -p photocraft-io --test pdn_corpus -- --ignored --nocapture
-PDN_TEST_FILE=/path/to/layered.pdn cargo test -p photocraft-engine --test pdn -- --ignored
+PDN_TEST_DIR=/path/to/pdn-and-png-exports cargo test -p photocraft-io --test it -- pdn_corpus:: --ignored --nocapture
+PDN_TEST_FILE=/path/to/layered.pdn cargo test -p photocraft-engine --test it -- pdn:: --ignored
```
Each PDN in `PDN_TEST_DIR` needs a corresponding `.png` export with the same basename. The
diff --git a/docs/roadmap.md b/docs/roadmap.md
index bca91616c7..85c355bc5f 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -31,7 +31,7 @@ that blocks the workflow, would make PhotoCraft pre-alpha.
| Core workflow | Works end to end? | Evidence | Hours to pass |
|---|---|---|---:|
-| Photo retouch: open JPEG/raw, heal/clone/remove, adjustment layers, export JPEG, save and reopen | yes | Spot Healing, Healing, Clone, Patch, Remove tools; 16 adjustment layers; raw for DNG/CR2/NEF/ARW/RAF; JPEG export; MCP workflow tests (`automation/tests/agent_tasks.rs`). Quality complaints (#1092, #2104) and no CR3 don't stop the workflow | 0 |
+| Photo retouch: open JPEG/raw, heal/clone/remove, adjustment layers, export JPEG, save and reopen | yes | Spot Healing, Healing, Clone, Patch, Remove tools; 16 adjustment layers; raw for DNG/CR2/NEF/ARW/RAF; JPEG export; MCP workflow tests (`automation/tests/it/agent_tasks.rs`). Quality complaints (#1092, #2104) and no CR3 don't stop the workflow | 0 |
| Composite: layers, masks, selections, blend modes, smart objects; save as PSD and `.pcraft` and reopen | yes | PSD round trip ≥ 169/170, 308/309, 258/256 corpus files; `.pcraft` atomic save and crash recovery. Multi-layer Smart Object conversion (#2604) and mask targeting (#2458) are partial but have workarounds | 0 |
| Cut-out: select a subject, refine the edge, mask, export PNG with transparency | partial (not blocking) | Object/Quick Selection, Select Subject (classical), Refine Edge dialog, layer masks, PNG export. Select and Mask is a dialog, not the workspace; no Refine Hair (#2671) | 0 (depth: 80–140 h, G7) |
| Design: type, shapes, layer styles, artboards; export PNG/JPEG; save and reopen | partial (not blocking) | Point/paragraph type, shapes with stroke controls, all 10 layer effects, artboards, Export As. One macOS report of a 60 s freeze on choosing the Type tool (#2518, not reproduced); no type on a path | 0 (fix #2518: 2–6 h) |
@@ -57,7 +57,7 @@ real work is ~45%, inside the alpha band (~40–75%), but close to its floor.
| M8 PSD v2 | 🟡 | adjustments (incl. Selective Color, Color Lookup), fills, effects, patterns, text, shapes, smart objects, alpha channels; oracle floors 146/170 (io), 236/309 (psd-tools), 133/256 (Photoshop-authored) |
| M9 Text, vector, styles | 🟡 | type engine + Warp Text, shapes / pen / paths, all 10 effects on CPU and GPU (parity ≤1/255, 30/31 corpus effect files on the GPU) |
| M10 Smart features | 🟡 | classical Select Subject / Object, content-aware fill and scale, healing, auto-align / auto-blend; ML backend not started |
-| M11 Automation + formats | 🟡 | MCP (headless + live bridge), batch, Image Processor, prefs over MCP; DoD test passes (10 agent tasks over MCP, `automation/tests/agent_tasks.rs`); since 2026-10-07 the CLI rejects unknown flags and answers ` --help`, batch runs report same-name outputs instead of overwriting them, MCP `command_batch` steps can start background jobs, and headless saves never flatten over the opened file; JP2 / DICOM / DPX / C2PA pending |
+| M11 Automation + formats | 🟡 | MCP (headless + live bridge), batch, Image Processor, prefs over MCP; DoD test passes (10 agent tasks over MCP, `automation/tests/it/agent_tasks.rs`); since 2026-10-07 the CLI rejects unknown flags and answers ` --help`, batch runs report same-name outputs instead of overwriting them, MCP `command_batch` steps can start background jobs, and headless saves never flatten over the opened file; JP2 / DICOM / DPX / C2PA pending |
| M12 Pro parity | 🟡 | CMYK / Lab / Indexed / Bitmap / Duotone, ICC + soft proofing, channels + Quick Mask, smart-object stack modes, artboards, layer comps; print, HDR, timeline pending; Photomerge is implemented, with real-world quality validation still open |
Releases: 0.1.0 (2026-10-02), 0.2.0 (2026-10-05, first signed and notarized), 0.3.0 (10-07),
diff --git a/docs/scorecard.md b/docs/scorecard.md
index 026d286d70..771898e01c 100644
--- a/docs/scorecard.md
+++ b/docs/scorecard.md
@@ -1,6 +1,6 @@
# PhotoCraft scorecard
-Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.json`, `crates/io/tests/corpus.rs`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and regenerate (CI runs `cargo xtask scorecard --check`). Checklist statuses are verified against the code; numbers here supersede estimates elsewhere.
+Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.json`, `crates/io/tests/it/corpus.rs`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and regenerate (CI runs `cargo xtask scorecard --check`). Checklist statuses are verified against the code; numbers here supersede estimates elsewhere.
## Summary
@@ -92,7 +92,7 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr
## File compatibility
-Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus tests (cargo feature `corpus`, `cargo xtask test-corpus`, always run in CI) fail below them.
+Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpus tests (cargo feature `corpus`, `cargo xtask test-corpus`, always run in CI) fail below them.
| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |
|---|---|---:|---:|---:|
@@ -102,8 +102,8 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|
-| FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded |
-| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 49 filters plus Hue/Saturation, Perspective Warp, Filter Gallery (Chrome) and a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning |
+| FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/it/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded |
+| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 49 filters plus Hue/Saturation, Perspective Warp, Filter Gallery (Chrome) and a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/it/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning |
| FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection |
| FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work |
| FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu |
@@ -114,21 +114,21 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | partial | [#215](https://github.com/storytold/photocraft/issues/215) | io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain |
| FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp |
| FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open |
-| FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping |
+| FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/it/roundtrip.rs:282 persist frames; no PSD video mapping |
| FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches |
| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file |
| FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ |
| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only |
-| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks |
+| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks |
| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. Missing: Matte, progressive Scans (3-5) and the size preview |
| FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only |
| FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) |
| FILE-216-1 | Every corpus fetched and enforced in CI, cached by pin | done | [#216](https://github.com/storytold/photocraft/issues/216) | ci.yml has a corpus job (name: corpus tests) on every PR and on main: the corpora are pinned in xtask/src/corpus_pins.rs, sha256-verified after both a cache restore and a download, and a missing corpus fails the tests. The cache key follows the pins, so it is cached by pin as this row asks (#190) |
-| FILE-216-2 | Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests | done | [#216](https://github.com/storytold/photocraft/issues/216) | Generated at test time by psd fixtures.rs (feature testgen; never committed). io tests/large_files.rs: layered_20_mp_psd_opens_and_saves (5472x3648, 5 layers in 2 groups, mask, drop shadow) and layout_with_150_layers_opens_and_saves (A4 300 ppi, 150 layers in 15 groups, masks, hidden layers, closed groups, drop shadows) run in every cargo test (~2 s each); opt-in psd_just_under_2_gb_opens_and_saves (17,000 px, 2.02 GB PSD) and psb_of_4_gb_opens_and_saves (25,000 px, 4.38 GB PSB, data past 4 GiB), each opened, pixel-checked, saved and re-opened; plus large_psb.rs (2.5 GB merged image). Open times: ui-egui large_file_bench (P20, P63). The 2 GB and 4 GB files are not run in CI (6.4 GB on disk; ~12 GB of memory for the PSD test, ~25 GB for the PSB test) (#5107) |
-| FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | partial | [#216](https://github.com/storytold/photocraft/issues/216) | EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report |
+| FILE-216-2 | Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests | done | [#216](https://github.com/storytold/photocraft/issues/216) | Generated at test time by psd fixtures.rs (feature testgen; never committed). io tests/it/large_files.rs: layered_20_mp_psd_opens_and_saves (5472x3648, 5 layers in 2 groups, mask, drop shadow) and layout_with_150_layers_opens_and_saves (A4 300 ppi, 150 layers in 15 groups, masks, hidden layers, closed groups, drop shadows) run in every cargo test (~2 s each); opt-in psd_just_under_2_gb_opens_and_saves (17,000 px, 2.02 GB PSD) and psb_of_4_gb_opens_and_saves (25,000 px, 4.38 GB PSB, data past 4 GiB), each opened, pixel-checked, saved and re-opened; plus large_psb.rs (2.5 GB merged image). Open times: ui-egui large_file_bench (P20, P63). The 2 GB and 4 GB files are not run in CI (6.4 GB on disk; ~12 GB of memory for the PSD test, ~25 GB for the PSB test) (#5107) |
+| FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | partial | [#216](https://github.com/storytold/photocraft/issues/216) | EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/it/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report |
| FILE-216-4 | Exports re-open in a third-party oracle editor | missing | [#216](https://github.com/storytold/photocraft/issues/216) | The round trip is our export -> our import |
-| FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | partial | [#216](https://github.com/storytold/photocraft/issues/216) | Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_the_platform_backend decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist |
-| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs, agent_tasks_extended.rs and agent_tasks_more.rs: 36 MCP tasks |
+| FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | partial | [#216](https://github.com/storytold/photocraft/issues/216) | Every save path that exists is tested by saving and reading back: layered TIFF io tests/it/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_the_platform_backend decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/it/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist |
+| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/it/agent_tasks.rs, agent_tasks_extended.rs and agent_tasks_more.rs: 36 MCP tasks |
## Tools
@@ -220,7 +220,7 @@ Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.
| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) |
| TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet |
| TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks |
-| TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) |
+| TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/it/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) |
| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | done | [#218](https://github.com/storytold/photocraft/issues/218) | Type > Warp Text opens with the shown layer's warp, or None, never remembered values (filter_dialog.rs:503 warp_defaults; warp_text_dialog_tests.rs:80 warp_text_opens_with_the_type_layers_current_warp, :99 warp_text_opens_at_none_for_a_layer_without_warp, :108 remembered_warp_values_never_override_the_layers_warp). The dialog previews live without editing the document, Cancel leaves no step and OK is one undoable step (:138 warp_text_dialog_previews_the_warp_without_editing_the_document, :160 cancelling_warp_text_leaves_the_document_and_history_untouched, :179 confirming_warp_text_is_one_undoable_step); the reduced-proxy preview matches the full-size result (filter_dialog.rs:2150 warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy) |
| TYPE-218-8 | Text editing: select all, double/triple/quadruple-click selection, paste, with tests | done | [#218](https://github.com/storytold/photocraft/issues/218) | Like Photoshop, 2 clicks select the word, 3 the laid-out line (a column in vertical type), 4 the paragraph up to the hard return, 5 all text, and the next quick click starts over (#3493; type_tool.rs next_click_count, click_selection, multi_click). Tested through real canvas clicks in point, paragraph and vertical type, with paste replacing the selection and Cmd+A selecting all (type_tool_tests.rs multi_click_selects_word_line_paragraph_and_all_in_point_text, multi_click_separates_wrapped_lines_from_paragraphs, click_counts_cycle_and_restart, click_selection_without_layout_and_on_hostile_input, multi_click_without_a_session_does_nothing) |
| TYPE-218-9 | Distort/Perspective/Warp on live type as in Photoshop (was: offer 'Rasterize and continue?'; Photoshop doesn't prompt) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Photoshop 2026 prompts for none of these: Edit › Transform › Warp on live type stays enabled with no rasterize prompt and offers only the warp styles (the mesh can't be dragged), so the type stays editable; Distort, Perspective and the split warps are greyed. PhotoCraft does the same: Warp on type commits the layer's Warp Text in one step (transform_tool.rs enter_type_warp, warp_cmds.rs warp_type_layer, #3004; warp_on_type_takes_a_style_and_keeps_the_type_editable), Distort/Perspective greyed (enable_rules.rs, #2701) |
@@ -242,7 +242,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T
| AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency |
| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check |
| AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform |
-| AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP |
+| AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP |
## Reliability
@@ -289,17 +289,17 @@ Never-crash attribute on 30 of 30 crate roots.
| BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields are in prefs::HIDDEN_UNTIL_IMPLEMENTED |
| BUG-204-4 | Recent files persisted through prefs across restarts | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/ui-egui/src/lib.rs::push_recent stores the list in preferences; prefs_ui::load restores it and recent_files_survive_a_restart_and_honour_the_count verifies reload |
| BUG-204-5 | Recent-files list honours recent_file_count (default 20) | done | [#204](https://github.com/storytold/photocraft/issues/204) | lib.rs push_recent caps the list with recent_cap (recent_file_count); menus.rs covers default and custom limits |
-| BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/engine/tests/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both |
+| BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/engine/tests/it/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both |
| BUG-205-1 | Free Transform with a selection moves only the selected part of a linked mask | done | [#205](https://github.com/storytold/photocraft/issues/205) | linked_mask_with_a_selection_moves_only_the_selected_region (transform_cmds.rs; 5a5facc, #242) |
| BUG-205-2 | Live transform preview draws the layer with its mask applied | done | [#205](https://github.com/storytold/photocraft/issues/205) | transform_tool.rs preview_mask + preview_applies_the_linked_mask (5a5facc, #242) |
| BUG-205-3 | edit.transform honours target mask: transforms only a targeted, unlinked mask | done | [#205](https://github.com/storytold/photocraft/issues/205) | targeted_unlinked_mask_transforms_alone and targeted_linked_mask_moves_with_its_layer (transform_cmds.rs) plus the through-the-box tests (transform_tool.rs; 5a5facc, #242) |
| BUG-205-4 | Test: a linked raster mask with no selection moves with the pixels | done | [#205](https://github.com/storytold/photocraft/issues/205) | linked_raster_mask_moves_with_the_pixels (transform_cmds.rs): a reveal-all mask with a hidden half, linked, no selection; asserts the hidden half travels with the pixels and nothing hidden is left behind |
| BUG-205-5 | Tests: linked mask + selection stays aligned; unlinked stays; targeted mask only | done | [#205](https://github.com/storytold/photocraft/issues/205) | All three in transform_cmds.rs: linked_mask_with_a_selection_moves_only_the_selected_region checks the mask value under each moved pixel, unlinked_mask_stays_put covers it with and without a selection, targeted_unlinked_mask_transforms_alone moves the mask while the layer bounds stay |
| BUG-206-1 | CharStyle models per-character manual kerning separately from the auto-kern mode | done | [#206](https://github.com/storytold/photocraft/issues/206) | doc/src/text.rs CharStyle carries both: kerning: Kerning for the automatic mode (Metrics/Optical/Off) and kern: f32 for manual kerning in 1/1000 em between this character and the next, added on top of the pair's automatic kerning |
-| BUG-206-2 | PSD import keeps Optical and manual Kerning values | done | [#206](https://github.com/storytold/photocraft/issues/206) | io/tests/psd_kerning.rs manual_and_off_kerning_round_trip_through_psd and optical_kerning_round_trips_through_psd import an exported layer and compare every character's (mode, kern) pair; the optical mode travels in the document Txt2 block (#1348) |
-| BUG-206-3 | PSD export writes the real AutoKerning mode and Kerning values | done | [#206](https://github.com/storytold/photocraft/issues/206) | manual_and_off_kerning_round_trip_through_psd (io/tests/psd_kerning.rs) reads the AutoKerning flag and Kerning value of every character back out of the written TySh: (false, 100) after A, (true, -50) after T, and automatic kerning off where the style says so |
+| BUG-206-2 | PSD import keeps Optical and manual Kerning values | done | [#206](https://github.com/storytold/photocraft/issues/206) | io/tests/it/psd_kerning.rs manual_and_off_kerning_round_trip_through_psd and optical_kerning_round_trips_through_psd import an exported layer and compare every character's (mode, kern) pair; the optical mode travels in the document Txt2 block (#1348) |
+| BUG-206-3 | PSD export writes the real AutoKerning mode and Kerning values | done | [#206](https://github.com/storytold/photocraft/issues/206) | manual_and_off_kerning_round_trip_through_psd (io/tests/it/psd_kerning.rs) reads the AutoKerning flag and Kerning value of every character back out of the written TySh: (false, 100) after A, (true, -50) after T, and automatic kerning off where the style says so |
| BUG-206-4 | Alt+Left/Right at a collapsed caret adjusts pair kerning; Character panel field | done | [#206](https://github.com/storytold/photocraft/issues/206) | type_tool.rs: Alt+Left/Right at a collapsed caret calls kern_pair (20 per step, 100 with Cmd); the Character panel field takes Metrics, Optical or a manual value (kerning_label / parse_kerning). Covered by type_tool_alt_arrows_kern_the_pair and kerning_field_values (input_tests.rs) |
-| BUG-206-5 | Tests: PSD round trip of manual and Optical kerning, a real file in the oracle corpus | partial | [#206](https://github.com/storytold/photocraft/issues/206) | Round trips covered by io/tests/psd_kerning.rs (manual, Off and Optical, plus hostile_kerning_values and txt2_extras_survive_a_resave_of_unchanged_text); corpus_text_layers reads real PSDs and applies their Txt2 optical kerning, but no oracle file is dedicated to kerning values |
+| BUG-206-5 | Tests: PSD round trip of manual and Optical kerning, a real file in the oracle corpus | partial | [#206](https://github.com/storytold/photocraft/issues/206) | Round trips covered by io/tests/it/psd_kerning.rs (manual, Off and Optical, plus hostile_kerning_values and txt2_extras_survive_a_resave_of_unchanged_text); corpus_text_layers reads real PSDs and applies their Txt2 optical kerning, but no oracle file is dedicated to kerning values |
| BUG-207-1 | Retouch tools respect the paint target (mask, alpha channel, Quick Mask) | done | [#207](https://github.com/storytold/photocraft/issues/207) | run_stroke paints channel_cmds::target_surface; retouch_strokes_paint_the_targeted_mask (retouch_ui.rs) and engine retouch_cmds/tests.rs (e2444ce, #234) |
| BUG-207-2 | Sample All Layers applies to Blur, Sharpen and Smudge | done | [#207](https://github.com/storytold/photocraft/issues/207) | retouch_ui.rs passes sampleAllLayers to paint.blur/sharpen/smudge; sample_all_layers_reaches_the_command (e2444ce, #234) |
| BUG-207-3 | Smudge mixes premultiplied RGBA (no dark fringe at transparent edges) | done | [#207](https://github.com/storytold/photocraft/issues/207) | paint/src/retouch.rs premultiplies; smudge_into_transparency_leaves_no_dark_fringe (e2444ce, #234) |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index d9e3a3d6ea..1a4c5b4468 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -438,10 +438,10 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s
|---|---|---|---|
| Menu wiring | 626/626 menu items dispatch a command (`parity-checklist.md`) | high but shallow | Says nothing about behaviour. |
| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error. 2026-10-07: io corpus 146/170, psd-tools corpus 236/309 (was 229: Advanced Blending knockouts), Photoshop oracles 132/258 | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). |
-| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). |
+| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/it/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). |
| OpenRaster read/write | 2026-10-09: 34 Krita 5.2.9-authored `.ora` files (27 blend modes, groups, pass-through, offsets, opacity, visibility, masks, 16-bit, gray) render within 3/255 of Krita's merged image (30) or differ for known reasons (4: Krita's lighter/darker-colour tie-break, Hard Mix at exactly 1, a linear-light 16-bit document); Krita re-opens our exports and renders them identically to its originals (33/34; Soft Light is written as `svg:soft-light`) | medium (one authoring app) | MyPaint and GIMP files untested. Masks are applied to pixels and layer styles dropped on save (reported). See [OpenRaster](ora.md). |
| Paint.NET import | 2026-10-09: 14/14 local PDN3 textures preserve editable layers exactly through `.pcraft` and render within 2.142/255 of full-size previews; a supplied 5.x document (1280×720, 11 layers) passes editing/history/native saves and matches its thumbnail within 0.418/255 mean; all 14 blend modes exercised synthetically | medium (limited corpus) | Import only; metadata omitted. More current-version files and full-size exports needed. See [PDN support](pdn.md). |
-| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. |
+| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/it/corpus.rs` and `crates/engine/tests/it/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. |
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. |
| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. The only tool still missing is Art History Brush: Frame landed (#2914), Red Eye is a live toolbar tool (`paint.redEye`, TOOL-213-17) and Perspective Crop landed (2026-10-10, #1043, `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is done (`TOOL-213-4`): Move/Extend, Structure/Color, Sample All Layers, Transform On Drop, and a live result preview for every selection the command takes (coarse first for large ones, then exact; 2026-10-16, #6680). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. |
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 22b4dff89c..61d9f5b7cc 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -116,7 +116,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Single-pixel rows and columns kept on release | partial | #2391 |
| Esc deselects / ⌘Z undoes the last selection | partial | #2674, #1432 |
| Select › Modify live preview | missing | #1752 |
-| Marching ants as Photoshop draws them: device pixels, every edge equally thick (1 px at 100 %, 2 px up to 200 %), black/white dashes 4× the thickness, slanted dash ends, same look for drag previews and committed selections | done | `ants.rs`, `tests/marching_ants_pixels.rs` (#7489) |
+| Marching ants as Photoshop draws them: device pixels, every edge equally thick (1 px at 100 %, 2 px up to 200 %), black/white dashes 4× the thickness, slanted dash ends, same look for drag previews and committed selections | done | `ants.rs`, `tests/it/marching_ants_pixels.rs` (#7489) |
## Pen, paths and shapes
@@ -141,16 +141,16 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
|---|---|---|
| Drag outside rotates (⇧ 15°), angle readout; ↵ rotates and crops in one undo step | done | TOOL-214-18 |
| Straighten button and ⌘-drag line | done | TOOL-214-21 |
-| Default-mode resize snaps handles without camera feedback; image and frame use the same camera in each rendered frame | done | `crop_mode.rs::a_live_resize_snaps_the_handle_without_feedback_from_the_camera`, `tests/crop_default_mode.rs::a_resize_renders_the_frame_and_image_with_the_same_camera` (2026-10-10) |
-| Ratio presets and W × H changes immediately fit the current frame around its centre; Swap button and X share the fitted ratio, Enter needs no resize | done | `crop_ui.rs::choosing_a_ratio_fits_and_commits_without_a_resize`, `crop_size.rs::choosing_a_size_with_the_canvas_ratio_commits_without_a_resize`, `tests/crop_default_mode.rs` (2026-10-10) |
+| Default-mode resize snaps handles without camera feedback; image and frame use the same camera in each rendered frame | done | `crop_mode.rs::a_live_resize_snaps_the_handle_without_feedback_from_the_camera`, `tests/it/crop_default_mode.rs::a_resize_renders_the_frame_and_image_with_the_same_camera` (2026-10-10) |
+| Ratio presets and W × H changes immediately fit the current frame around its centre; Swap button and X share the fitted ratio, Enter needs no resize | done | `crop_ui.rs::choosing_a_ratio_fits_and_commits_without_a_resize`, `crop_size.rs::choosing_a_size_with_the_canvas_ratio_commits_without_a_resize`, `tests/it/crop_default_mode.rs` (2026-10-10) |
| Roomy Crop options bar does not scroll; narrow tool bars retain horizontal scrolling | done | `panels.rs::a_wide_crop_options_bar_does_not_scroll`, `panels.rs::narrow_tool_options_can_scroll_to_the_last_control` (2026-10-10) |
| Overlays (thirds, grid, diagonal, triangle, golden ratio/spiral), O / ⇧O | done | TOOL-214-20 |
| Crop shield, Show Cropped Area (H) | done | TOOL-214-22 |
| Default mode: the image turns and moves behind an upright box; Auto Center Preview; Use Classic Mode | done | TOOL-214-23 |
| W × H × Resolution with units, built-in size presets, Front Image | done | TOOL-214-9 (`crop_size.rs`) |
| Saved custom crop presets (New / Delete Crop Preset, kept with the preferences; moving them to another computer is not offered) | done | TOOL-214-9 (`crop_presets.rs`, `tool.presets.*` with tool `crop`, #2723) |
-| Fill: Content-Aware Fill: a turned or extending crop fills the uncovered corners and strips from the image (each piece with its own sampling window), on the Background or else the active pixel layer, in the crop's one undo step; Generative Expand not offered | done | `tests/crop_content_aware.rs`, `crop_ui.rs::commit_passes_the_fill_choice` (#3823, 2026-10-12) |
-| Delete Cropped Pixels off: the crop only hides the pixels outside the frame; editing the frame again shows them past the canvas and a recrop handle snaps to their edge | done | `image.crop` `deleteCroppedPixels`, the options-bar checkbox (`panels.rs`), `tests/crop_beyond_canvas.rs::editing_a_crop_frame_shows_the_pixels_past_the_canvas`, `crop_mode.rs::a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge` (#3638, #3640) |
+| Fill: Content-Aware Fill: a turned or extending crop fills the uncovered corners and strips from the image (each piece with its own sampling window), on the Background or else the active pixel layer, in the crop's one undo step; Generative Expand not offered | done | `tests/it/crop_content_aware.rs`, `crop_ui.rs::commit_passes_the_fill_choice` (#3823, 2026-10-12) |
+| Delete Cropped Pixels off: the crop only hides the pixels outside the frame; editing the frame again shows them past the canvas and a recrop handle snaps to their edge | done | `image.crop` `deleteCroppedPixels`, the options-bar checkbox (`panels.rs`), `tests/it/crop_beyond_canvas.rs::editing_a_crop_frame_shows_the_pixels_past_the_canvas`, `crop_mode.rs::a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge` (#3638, #3640) |
## Snapping, guides, rulers, precision
diff --git a/docs/xcf.md b/docs/xcf.md
index 5717ae5b28..afa4e2a42d 100644
--- a/docs/xcf.md
+++ b/docs/xcf.md
@@ -86,7 +86,7 @@ channels, the colour map, parasites, limits and malformed input.
Real files come from GIMP 3.2.6 itself: `gimp/` of [photocraft-corpus](https://github.com/storytold/photocraft-corpus)
(generator `tools/gimp-xcf/xcf_fixtures.py`, run inside GIMP), fetched into `corpus/gimp/` by
`cargo xtask corpus --gimp` (28 files, 251 KB): procedural layers the tests regenerate sample for
-sample, and for each file GIMP's own merged rendering as a PNG. `crates/io/tests/xcf.rs` (feature
+sample, and for each file GIMP's own merged rendering as a PNG. `crates/io/tests/it/xcf.rs` (feature
`corpus`, run by `cargo xtask test-corpus -p io`) checks the layer data against the formulas and our
composite against GIMP's:
diff --git a/scorecard/automation.toml b/scorecard/automation.toml
index ed3839e73f..862de726f2 100644
--- a/scorecard/automation.toml
+++ b/scorecard/automation.toml
@@ -79,4 +79,4 @@ id = "AUTO-219-10"
target = "Agent acceptance suite grown to 30-50 workflows"
status = "partial"
issue = 219
-note = "automation/tests/agent_tasks.rs runs 10 workflows over MCP"
+note = "automation/tests/it/agent_tasks.rs runs 10 workflows over MCP"
diff --git a/scorecard/bugs.toml b/scorecard/bugs.toml
index 48c5e3e7a8..6b7e9a3ee2 100644
--- a/scorecard/bugs.toml
+++ b/scorecard/bugs.toml
@@ -88,7 +88,7 @@ id = "BUG-204-6"
target = "Test: every Prefs field is read somewhere or on a hidden allowlist"
status = "done"
issue = 204
-note = "crates/engine/tests/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both"
+note = "crates/engine/tests/it/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both"
[[item]]
id = "BUG-205-1"
@@ -137,14 +137,14 @@ id = "BUG-206-2"
target = "PSD import keeps Optical and manual Kerning values"
status = "done"
issue = 206
-note = "io/tests/psd_kerning.rs manual_and_off_kerning_round_trip_through_psd and optical_kerning_round_trips_through_psd import an exported layer and compare every character's (mode, kern) pair; the optical mode travels in the document Txt2 block (#1348)"
+note = "io/tests/it/psd_kerning.rs manual_and_off_kerning_round_trip_through_psd and optical_kerning_round_trips_through_psd import an exported layer and compare every character's (mode, kern) pair; the optical mode travels in the document Txt2 block (#1348)"
[[item]]
id = "BUG-206-3"
target = "PSD export writes the real AutoKerning mode and Kerning values"
status = "done"
issue = 206
-note = "manual_and_off_kerning_round_trip_through_psd (io/tests/psd_kerning.rs) reads the AutoKerning flag and Kerning value of every character back out of the written TySh: (false, 100) after A, (true, -50) after T, and automatic kerning off where the style says so"
+note = "manual_and_off_kerning_round_trip_through_psd (io/tests/it/psd_kerning.rs) reads the AutoKerning flag and Kerning value of every character back out of the written TySh: (false, 100) after A, (true, -50) after T, and automatic kerning off where the style says so"
[[item]]
id = "BUG-206-4"
@@ -158,7 +158,7 @@ id = "BUG-206-5"
target = "Tests: PSD round trip of manual and Optical kerning, a real file in the oracle corpus"
status = "partial"
issue = 206
-note = "Round trips covered by io/tests/psd_kerning.rs (manual, Off and Optical, plus hostile_kerning_values and txt2_extras_survive_a_resave_of_unchanged_text); corpus_text_layers reads real PSDs and applies their Txt2 optical kerning, but no oracle file is dedicated to kerning values"
+note = "Round trips covered by io/tests/it/psd_kerning.rs (manual, Off and Optical, plus hostile_kerning_values and txt2_extras_survive_a_resave_of_unchanged_text); corpus_text_layers reads real PSDs and applies their Txt2 optical kerning, but no oracle file is dedicated to kerning values"
[[item]]
id = "BUG-207-1"
diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml
index e103eab90a..acdb75326a 100644
--- a/scorecard/file_compat.toml
+++ b/scorecard/file_compat.toml
@@ -1,5 +1,5 @@
# Scorecard checklist: file compatibility (#215 formats, #216 measurement).
-# Corpus numbers come from crates/io/tests/corpus.rs, not from here.
+# Corpus numbers come from crates/io/tests/it/corpus.rs, not from here.
# Statuses must be true on main. Verified against 4b53f62 on 2026-10-05 (smart objects: #215 PR).
area = "file"
title = "File compatibility"
@@ -10,14 +10,14 @@ id = "FILE-215-1"
target = "Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels"
status = "done"
issue = 215
-note = "psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded"
+note = "psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/it/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded"
[[item]]
id = "FILE-215-2"
target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)"
status = "partial"
issue = 215
-note = "smart_map.rs: 49 filters plus Hue/Saturation, Perspective Warp, Filter Gallery (Chrome) and a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning"
+note = "smart_map.rs: 49 filters plus Hue/Saturation, Perspective Warp, Filter Gallery (Chrome) and a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/it/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning"
[[item]]
id = "FILE-215-3"
@@ -94,7 +94,7 @@ id = "FILE-215-13"
target = "Video layer frames persist in .pcraft; PSD video layers mapped"
status = "partial"
issue = 215
-note = "format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping"
+note = "format convert.rs:89 + tests/it/roundtrip.rs:282 persist frames; no PSD video mapping"
[[item]]
id = "FILE-215-14"
@@ -129,7 +129,7 @@ id = "FILE-215-18"
target = "Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling"
status = "done"
issue = 215
-note = "Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks"
+note = "Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks"
[[item]]
id = "FILE-215-19"
@@ -164,14 +164,14 @@ id = "FILE-216-2"
target = "Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests"
status = "done"
issue = 216
-note = "Generated at test time by psd fixtures.rs (feature testgen; never committed). io tests/large_files.rs: layered_20_mp_psd_opens_and_saves (5472x3648, 5 layers in 2 groups, mask, drop shadow) and layout_with_150_layers_opens_and_saves (A4 300 ppi, 150 layers in 15 groups, masks, hidden layers, closed groups, drop shadows) run in every cargo test (~2 s each); opt-in psd_just_under_2_gb_opens_and_saves (17,000 px, 2.02 GB PSD) and psb_of_4_gb_opens_and_saves (25,000 px, 4.38 GB PSB, data past 4 GiB), each opened, pixel-checked, saved and re-opened; plus large_psb.rs (2.5 GB merged image). Open times: ui-egui large_file_bench (P20, P63). The 2 GB and 4 GB files are not run in CI (6.4 GB on disk; ~12 GB of memory for the PSD test, ~25 GB for the PSB test) (#5107)"
+note = "Generated at test time by psd fixtures.rs (feature testgen; never committed). io tests/it/large_files.rs: layered_20_mp_psd_opens_and_saves (5472x3648, 5 layers in 2 groups, mask, drop shadow) and layout_with_150_layers_opens_and_saves (A4 300 ppi, 150 layers in 15 groups, masks, hidden layers, closed groups, drop shadows) run in every cargo test (~2 s each); opt-in psd_just_under_2_gb_opens_and_saves (17,000 px, 2.02 GB PSD) and psb_of_4_gb_opens_and_saves (25,000 px, 4.38 GB PSB, data past 4 GiB), each opened, pixel-checked, saved and re-opened; plus large_psb.rs (2.5 GB merged image). Open times: ui-egui large_file_bench (P20, P63). The 2 GB and 4 GB files are not run in CI (6.4 GB on disk; ~12 GB of memory for the PSD test, ~25 GB for the PSB test) (#5107)"
[[item]]
id = "FILE-216-3"
target = "TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported"
status = "partial"
issue = 216
-note = "EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report"
+note = "EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/it/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report"
[[item]]
id = "FILE-216-4"
@@ -185,11 +185,11 @@ id = "FILE-216-5"
target = "Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure"
status = "partial"
issue = 216
-note = "Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_the_platform_backend decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist"
+note = "Every save path that exists is tested by saving and reading back: layered TIFF io tests/it/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_the_platform_backend decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/it/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist"
[[item]]
id = "FILE-216-6"
target = "30-50 end-to-end workflow acceptance tasks driven through MCP or commands"
status = "partial"
issue = 216
-note = "automation/tests/agent_tasks.rs, agent_tasks_extended.rs and agent_tasks_more.rs: 36 MCP tasks"
+note = "automation/tests/it/agent_tasks.rs, agent_tasks_extended.rs and agent_tasks_more.rs: 36 MCP tasks"
diff --git a/scorecard/type.toml b/scorecard/type.toml
index aa9587f15d..6ef3a90f89 100644
--- a/scorecard/type.toml
+++ b/scorecard/type.toml
@@ -44,7 +44,7 @@ id = "TYPE-218-6"
target = "Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206)"
status = "done"
issue = 218
-note = "Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning)"
+note = "Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/it/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning)"
[[item]]
id = "TYPE-218-7"
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
index 25305d5435..b879946a6e 100644
--- a/xtask/Cargo.toml
+++ b/xtask/Cargo.toml
@@ -11,5 +11,6 @@ workspace = true
[dependencies]
serde_json = { workspace = true }
serde = { workspace = true }
+chrono = { workspace = true }
# perf/budgets.toml and scorecard/*.toml (MIT OR Apache-2.0).
toml = "1"
diff --git a/xtask/src/perf.rs b/xtask/src/perf.rs
index 3e49514c1c..c1890d1cbd 100644
--- a/xtask/src/perf.rs
+++ b/xtask/src/perf.rs
@@ -541,20 +541,27 @@ pub fn machine() -> Value {
json!({"os": os, "os_version": os_version, "arch": arch, "cpu": cpu, "cores": cores, "ram_bytes": ram})
}
-/// `YYYY-MM-DDTHH:MM:SSZ` for a Unix time (civil-from-days, proleptic Gregorian).
+/// `YYYY-MM-DDTHH:MM:SSZ` for a Unix time (proleptic Gregorian, UTC). A time past chrono's year
+/// range still converts (the calendar repeats every 400 years, so the instant is moved into range
+/// by whole cycles and only the year is carried, matching the old hand-rolled `iso_utc`), never a
+/// silent reset to the epoch. Accepts the full `u64` range the old code did.
pub fn iso_utc(secs: u64) -> String {
- let days = (secs / 86_400) as i64;
- let rem = secs % 86_400;
- let z = days + 719_468;
- let era = z.div_euclid(146_097);
- let doe = z.rem_euclid(146_097);
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = yoe + era * 400 + i64::from(m <= 2);
- format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
+ use chrono::Datelike;
+ // `secs / 86_400` is at most 213_503_982_334_601, so `days` always fits in `i64` (old code too).
+ let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
+ format!("{y:04}-{}", d.and_time(t).format("%m-%dT%H:%M:%SZ"))
+ }
+ _ => "1970-01-01T00:00:00Z".to_string(), // unreachable
+ }
}
fn now_iso() -> String {
@@ -927,6 +934,18 @@ pub fn render_summary(results: &Value, outcomes: &[Outcome], baseline_note: Opti
mod tests {
use super::*;
+ #[test]
+ fn iso_utc_handles_boundaries() {
+ assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
+ assert_eq!(iso_utc(1_791_446_357), "2026-10-08T07:59:17Z");
+ // Years past 9999 keep the plain zero-padded year (no `+`).
+ assert_eq!(iso_utc(253_402_300_800), "10000-01-01T00:00:00Z");
+ // A value past chrono's range still converts via the 400-year cycle (the old hand-rolled
+ // `iso_utc` did too): pin that output, never the epoch.
+ assert_eq!(iso_utc(10_000_000_000_000), "318857-05-20T17:46:40Z");
+ assert_eq!(iso_utc(u64::MAX), "584554051223-11-09T07:00:15Z");
+ }
+
const TOML: &str = r#"
[settings]
regression_pct = 15.0
diff --git a/xtask/src/scorecard.rs b/xtask/src/scorecard.rs
index f7f270bd00..e33b3eec48 100644
--- a/xtask/src/scorecard.rs
+++ b/xtask/src/scorecard.rs
@@ -4,7 +4,7 @@
//! - `perf/budgets.toml` + `perf/baseline.json`: the performance table (the baseline's full-run
//! numbers against the budgets). A fresh `cargo xtask perf` result in `target/` is not used:
//! publish it with `cargo xtask perf --update-baseline`.
-//! - `crates/io/tests/corpus.rs`: the corpus floors (`Source { .. }` constants).
+//! - `crates/io/tests/it/corpus.rs`: the corpus floors (`Source { .. }` constants).
//! - `scorecard/*.toml`: per-area checklists (id, target, status, issue, note).
//! - the prefs audit: `Preferences` fields that no code outside `prefs.rs` / `prefs_ui.rs` reads.
//! - counted from the tree: never-crash attribute coverage, `docs/parity-checklist.md`'s live count.
@@ -21,7 +21,7 @@ use serde_json::Value;
use crate::perf;
pub const OUT: &str = "docs/scorecard.md";
-const CORPUS_RS: &str = "crates/io/tests/corpus.rs";
+const CORPUS_RS: &str = "crates/io/tests/it/corpus.rs";
const PREFS_RS: &str = "crates/engine/src/prefs.rs";
// ---- checklists ------------------------------------------------------------------------------
@@ -522,7 +522,7 @@ pub fn render(inp: &Inputs) -> String {
let mut s = String::from(
"# PhotoCraft scorecard\n\nGenerated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.json`, \
-`crates/io/tests/corpus.rs`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and \
+`crates/io/tests/it/corpus.rs`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and \
regenerate (CI runs `cargo xtask scorecard --check`). Checklist statuses are verified against the code; numbers \
here supersede estimates elsewhere.\n\n## Summary\n\n| Area | Done | Partial | Missing | Key numbers |\n|---|---:|---:|---:|---|\n",
);
@@ -589,7 +589,7 @@ rows are targets the code doesn't meet yet: they are reported, and only their re
s.push_str(&format!("\n## {}\n\n", c.title));
match c.area.as_str() {
"file" => {
- s.push_str("Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus tests (cargo feature `corpus`, `cargo xtask test-corpus`, always run in CI) fail below them.\n\n| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |\n|---|---|---:|---:|---:|\n");
+ s.push_str("Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpus tests (cargo feature `corpus`, `cargo xtask test-corpus`, always run in CI) fail below them.\n\n| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |\n|---|---|---:|---:|---:|\n");
for k in &inp.corpora {
let n = |v: Option| v.map_or_else(|| "–".into(), |x| x.to_string());
s.push_str(&format!("| {} | `{}` | {} | {} | {} |\n", k.label, k.dir, n(k.files), n(k.pass_floor), n(k.roundtrip_floor)));