From 456792debd9194d4f147ca33965afaa89b8d993a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 17:20:14 -0400 Subject: [PATCH] Editor T2 W3: the goldens become conformance gate [9/9] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A golden-gate feature on epiphany-testkit: resvg + epiphany-render-svg as optional regular dependencies (dev-dependencies cannot be optional; the MSRV test job builds examples with --all-targets, so the raster stack must stay out of the featureless closure — proven by cargo tree, 0 vs 1). Gate 9 re-derives the three T1a golden states headlessly and compares decoded RGBA against the committed baselines; compare-only, failing loudly with a pointer to the editor-gui diagnostic tests. Without the feature the suite's output is byte-identical to before; with it, 9/9. The conformance CI job's invocation gains the feature; CONFORMANCE.md documents gate [9] and the split. Two mutations killed and coordinator re-verified. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 2 +- CONFORMANCE.md | 43 +++ Cargo.lock | 2 + crates/epiphany-testkit/Cargo.toml | 27 ++ crates/epiphany-testkit/DECISIONS.md | 121 ++++++++ .../examples/conformance_suite.rs | 277 +++++++++++++++++- 6 files changed, 462 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea9f92c..a50e38e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,7 +162,7 @@ jobs: # manifest-selection gates, convergence / reduction-determinism / # equivocation against the real epiphany-ops, and the layout round-trip. - name: Run conformance suite - run: cargo run --release -p epiphany-testkit --example conformance_suite 1 + run: cargo run --release -p epiphany-testkit --example conformance_suite --features golden-gate 1 # The Chapter 10 performance-budget gates (Phase 2 worklist F1), in # release with reduced sampling (EPIPHANY_BENCH_QUICK): the 1K and 10K diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 9c2e206..0e495be 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -128,3 +128,46 @@ substituting a vendor heuristic: continued-fraction with documented iteration/denominator bounds and a typed `TempoIntegration`-class tolerance) are advisory, per the floating-point declaration above. + +## The conformance suite's gate count + +`cargo run -p epiphany-testkit --example conformance_suite` runs the numbered +gates described above (and several supplementary lettered stages) end to end, +printing `[N/TOTAL] ...` as each gate starts and `[TOTAL/TOTAL] ok: full +conformance suite passed` on a clean run; CI's `conformance` job +(`.github/workflows/ci.yml:146-176`) runs it on every push and pull request. + +**Gate 9 — visual golden conformance, behind the `golden-gate` feature.** +`epiphany-testkit`'s default build (`TOTAL = 8`) covers determinism, +round-trip, crash recovery, equivocation, convergence, reduction, layout +round-trip, and the cross-implementation/Text-Projection decode vectors — no +rendering. Gate 9 (`TOTAL = 9` with the feature enabled) additionally +re-derives the T1a visual golden states +(`spec/CONTRACT_EDITOR_T1A_GOLDENS.md`) headlessly — `EditorSession::open` +over the real engraver and renderer, rasterized with `resvg` exactly as +`epiphany-editor-gui`'s demo binary does — and compares the decoded RGBA +pixels against the baselines committed at +`crates/epiphany-editor-gui/goldens/*.png`, dimensions first. It is +compare-only: no bless mechanism, no artifact writing; a mismatch's panic +names `cargo test -p epiphany-editor-gui goldens` as the crate that owns +that machinery. + +**Why the gate is feature-gated rather than always on.** `epiphany-testkit` +is a workspace member the MSRV `check` job (`.github/workflows/ci.yml`, pinned +to Rust 1.85) builds with `--all-targets`, `conformance_suite` included — and +that job deliberately excludes `epiphany-editor-gui`, because one of its own +dependencies (`image`, pulled in via `eframe`) declares a newer MSRV than the +workspace floor. `resvg` — the crate gate 9 needs to rasterize SVG the same +way the demo GUI does — carries that same raster-stack dependency weight. +Making it reachable unconditionally from `epiphany-testkit` would silently +re-impose that floor on a crate the MSRV job is meant to keep covering. So +`resvg` (and `epiphany-render-svg`, needed to produce the SVG in the first +place) are `optional = true` dependencies of `epiphany-testkit`, gated by a +`golden-gate` Cargo feature nothing enables by default; only the +`conformance` job's suite invocation passes `--features golden-gate` +(`.github/workflows/ci.yml:165`). Without the feature, `cargo tree -p +epiphany-testkit -e normal` shows no `resvg` node in the dependency graph; +with `--features golden-gate`, it shows exactly one. Full rationale, the +mutation evidence, and the cross-crate baseline-path coupling are recorded in +`crates/epiphany-testkit/DECISIONS.md` (T2 W3) and +`spec/CONTRACT_EDITOR_T2_SELECTION.md` §W3. diff --git a/Cargo.lock b/Cargo.lock index 4626ca2..38ba1ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1285,7 +1285,9 @@ dependencies = [ "epiphany-engrave", "epiphany-layout-ir", "epiphany-ops", + "epiphany-render-svg", "epiphany-textproj", + "resvg", ] [[package]] diff --git a/crates/epiphany-testkit/Cargo.toml b/crates/epiphany-testkit/Cargo.toml index 212bd02..7efe43d 100644 --- a/crates/epiphany-testkit/Cargo.toml +++ b/crates/epiphany-testkit/Cargo.toml @@ -17,6 +17,23 @@ epiphany-textproj.workspace = true # now drives the real crate instead of an in-tree stub. epiphany-layout-ir.workspace = true +# T2 W3 — the `[9/9]` golden conformance gate (`golden-gate` feature, +# `examples/conformance_suite.rs`): re-renders the T1a golden states headlessly +# and rasterizes them exactly as `epiphany-editor-gui`'s demo binary does, to +# compare against the baselines committed there +# (`epiphany-editor-gui/goldens/*.png`). Both crates are example-only — used +# solely by the `#[cfg(feature = "golden-gate")]` code — and `optional = true` +# so the default (no-feature) build pulls in neither; dev-dependencies cannot +# be optional, so both live here in [dependencies], gated by the +# `golden-gate` feature (below) rather than always compiled. This is precisely +# what keeps `resvg`'s raster stack out of the MSRV job's dependency closure: +# that job runs `cargo test --workspace --all-targets` at the pinned MSRV +# (`.github/workflows/ci.yml`, the `check` job), which builds every example in +# this crate with default features only. See `DECISIONS.md` (T2 W3) and +# `spec/CONTRACT_EDITOR_T2_SELECTION.md` §W3. +epiphany-render-svg = { workspace = true, optional = true } +resvg = { version = "0.45", optional = true } + # The Chapter 10 performance benches (worklist F1) run under criterion; the # budget-gate logic itself lives in `src/budget.rs` on plain `std`, so the # library builds without it. The editor-core + engrave pair drives the @@ -29,6 +46,16 @@ criterion.workspace = true epiphany-editor-core.workspace = true epiphany-engrave.workspace = true +# T2 W3: gate 9's session/session-editing surface (`EditorSession`, +# `Engraver::default()`) is exactly the pair the multi-system click test and +# the reference-suite harness above already pull in as unconditional +# dev-dependencies — reused as-is, not duplicated or made optional, since a +# dev-dependency cannot be optional and these two carry no `resvg`-class +# MSRV risk of their own (see the `[dependencies]` comment above for the two +# that do). +[features] +golden-gate = ["dep:epiphany-render-svg", "dep:resvg"] + # Drives the whole suite at scale outside the unit-test timeout — the analogue # of epiphany-determinism's `fuzz_roundtrip` and epiphany-bundle's `fuzz_crash`. [[example]] diff --git a/crates/epiphany-testkit/DECISIONS.md b/crates/epiphany-testkit/DECISIONS.md index 9cd041f..f86f6b4 100644 --- a/crates/epiphany-testkit/DECISIONS.md +++ b/crates/epiphany-testkit/DECISIONS.md @@ -352,3 +352,124 @@ mutation-verified two ways: restoring the 0.4.0 `value` production, and strippin the `schema-directed` citations. A grammar that claims to distinguish a struct from a sequence would be lying, so the test asserts the symbol-headed alternative is **absent**. + +## T2 W3 (2026-07-23) — Promoting the T1a visual goldens to gate `[9]`, behind `golden-gate` + +`spec/CONTRACT_EDITOR_T2_SELECTION.md` §W3. T1a +(`crates/epiphany-editor-gui/src/goldens.rs`, `spec/CONTRACT_EDITOR_T1A_GOLDENS.md`) +landed pixel-level golden tests in `epiphany-editor-gui` but left this crate's +`conformance_suite` frozen at 8/8 — the harness existed but nothing counted it. +This tranche adds gate 9, re-deriving the three T1a golden states headlessly +inside `examples/conformance_suite.rs` and comparing against the same committed +baselines, entirely behind a new `golden-gate` feature. + +**Why a feature, not an unconditional addition.** The MSRV `check` job +(`.github/workflows/ci.yml`, the `check` job) runs `cargo test --workspace +--exclude epiphany-editor-gui --all-targets` at the pinned floor (1.85) — +`--all-targets` builds every example in this crate, `conformance_suite` +included, with default features. `resvg` (and the transitive raster stack it +pulls: `usvg`, `tiny-skia`, font/image decoding crates) is only ever exercised +by `epiphany-editor-gui`, which is deliberately excluded from that job because +one of its own dependencies (`image`, via `eframe`) declares a newer MSRV than +the workspace floor. Making `resvg` reachable from `epiphany-testkit` +unconditionally would silently re-impose that same floor on a crate the MSRV +job is not supposed to skip. The fix is Cargo's standard shape for "a +dependency only some callers need": `resvg` (and `epiphany-render-svg`, not +previously a dependency of this crate at all) go into `[dependencies]` with +`optional = true` — **dev-dependencies cannot be optional**, which is why +example-only code still needs a `[dependencies]` entry — gated by a +`golden-gate` feature that nothing enables by default. `cargo tree -p +epiphany-testkit -e normal` shows zero `resvg` nodes without the feature and +exactly one with it (`--features golden-gate`). + +`epiphany-editor-core` and `epiphany-engrave` needed no such treatment: T1a's +`multisystem_click.rs` test and the Reference Suite harness (F5, above) already +made both **unconditional dev-dependencies** of this crate, carrying no +`resvg`-class MSRV risk of their own, so gate 9's `EditorSession` / +`Engraver::default()` calls simply reuse them as-is. + +**Keeping the no-feature output byte-identical was the binding constraint,** +stated in the contract as a hard requirement (the MSRV job's `--all-targets` +build must see zero difference). The suite's gate-count denominator moved from +a literal `8` in every `eprintln!` to a `TOTAL_GATES` const — `8` without +`golden-gate`, `9` with it — so every existing print line renders +byte-for-byte what it always has when the feature is off, and gate 9's own +line plus the renumbered final `[9/9] ok:` line appear only when it is on. +Verified directly: the pre-tranche example source, rebuilt and run at `SCALE=0`, +produces output diffed byte-for-byte against the post-tranche no-feature build +of the same invocation — `diff` reports no difference. + +**Gate 9's substance mirrors T1a's comparator, not its full machinery.** It +re-renders `ten_measure_single_staff(0)` as opened, the same fixture after the +scripted pencil insert (the click point re-derived by literally copying +`goldens.rs::scripted_insert_target`'s algorithm — last system by lowest +`bounding_box.origin.y`, rightmost Pitch-sourced notehead in that system's +vertical band, +0.5 staff spaces right / +2.0 up — since this crate cannot +call a `#[cfg(test)]`-private function in a different crate's binary), and +`ten_measure_with_slurs(0)` as opened, rasterizing each exactly as +`epiphany-editor-gui/src/main.rs::rasterize_pixmap` does (the algorithm +copied for the same cross-crate-privacy reason: usvg parse, dimensions +ceiling to whole pixels, opaque white background, `resvg::render`). Unlike +`goldens.rs`'s own comparator, gate 9 is **compare-only**: no +`EPIPHANY_BLESS_GOLDENS` path, no `actual.png`/`expected.png`/`diff.png` +artifact writing. A mismatch's panic message points at `cargo test -p +epiphany-editor-gui goldens` — the crate that owns the baselines, the bless +mechanism, and the reviewable artifacts — as the place to diagnose, rather +than duplicating that machinery here. G3 (undo-equals-G1) is deliberately not +re-derived as a fourth comparison: it reuses G1's own baseline file in T1a, +so covering G1 already covers what G3 would additionally lock at the pixel +level for this gate's purposes. + +**The cross-crate baseline path is a documented coupling, not an accident.** +The three committed PNGs live in `crates/epiphany-editor-gui/goldens/`, one +level up and over from this crate's own directory. Gate 9 resolves that path +from *this* crate's `CARGO_MANIFEST_DIR` (the only directory `env!` can see at +compile time) as `{CARGO_MANIFEST_DIR}/../epiphany-editor-gui/goldens/`. A +rename or relocation of either crate directory, or of +`epiphany-editor-gui/goldens/`, breaks this path — silently succeeding at +`cargo check` (it is a runtime path, not a compile-time one) but failing gate +9 loudly at run time with a clear "no golden baseline at ..." panic (mutation +m6 below), never a silent skip. This crate's own `Cargo.toml` comment on the +`epiphany-render-svg`/`resvg` dependencies and the `golden_gate` module doc in +`examples/conformance_suite.rs` carry the same note; `epiphany-editor-gui` is +outside this packet's blast radius (`spec/CONTRACT_EDITOR_T2_SELECTION.md` +§W3), so its own `DECISIONS.md` — which already documents the baseline +directory from the T1a side — is left untouched here. + +**Mutation evidence (independently reproduced against gate 9's real run, not +just described):** + +- **m5 — tamper one decoded pixel before comparison.** Rendering G1, XOR-ing + byte 0 of its pixmap with `0xFF`, then comparing that tampered pixmap + against the committed baseline made gate 9 die with (actual panic, + captured verbatim): `gate 9: ten_measure_open decoded RGBA differs from + the committed baseline at .../epiphany-editor-gui/goldens/ten_measure_open.png + (1 of 1119968 bytes differ); diagnose with \`cargo test -p + epiphany-editor-gui goldens\``. (The pixel comparison is a manual `if + actual.data() != expected.data() { panic!(...) }` reporting a differing-byte + count, not a bare `assert_eq!` — the latter was tried first and dumps both + full byte slices, ~1.1 MB each, into the panic message on a real score + raster; the differing-byte count is the useful signal, and the visual + artifacts to inspect already live in `epiphany-editor-gui`'s own comparator.) + Restored by reversing the exact same byte-flip line (not `git checkout`); + the suite returns to 9/9. +- **m6 — point the baselines path at a nonexistent directory.** Appending + `-DOES-NOT-EXIST` to `baseline_path`'s directory component made the + `fs::read` call fail, and the gate panicked immediately with (captured + verbatim): `gate 9: no golden baseline at + .../epiphany-editor-gui/goldens-DOES-NOT-EXIST/ten_measure_open.png (No + such file or directory (os error 2)) — \`epiphany-editor-gui/goldens/\` must + contain the committed T1a baselines for this gate to compare against; + diagnose with \`cargo test -p epiphany-editor-gui goldens\`` — a loud, + named failure, never a silent skip or pass. Restored by reversing the + substitution; the suite returns to 9/9. + +**Baselines pin the raster stack (inherited from T1a, restated here because +this crate now also depends on it).** Any `Cargo.lock` movement of +`resvg`/`usvg`/`tiny-skia`/`png` is a golden-review event — for both crates +now, not just `epiphany-editor-gui` — never a silent re-bless. Gate 9 has no +bless mechanism of its own by design (above), so a deliberate raster-stack +bump that changes pixels must be reviewed and re-blessed through +`epiphany-editor-gui`'s own `EPIPHANY_BLESS_GOLDENS=1` path; gate 9 will then +pass again against the newly-blessed baselines with no changes of its own +needed. diff --git a/crates/epiphany-testkit/examples/conformance_suite.rs b/crates/epiphany-testkit/examples/conformance_suite.rs index 345f200..bc87d01 100644 --- a/crates/epiphany-testkit/examples/conformance_suite.rs +++ b/crates/epiphany-testkit/examples/conformance_suite.rs @@ -16,6 +16,17 @@ use epiphany_testkit::{ negative, prepass_harness, roundtrip, textproj, Rng, }; +/// The suite's total gate count, printed in every `[N/TOTAL_GATES]` line and +/// the final summary. T2 W3 adds gate 9 (`golden-gate` feature); without the +/// feature this stays at 8, so every print in this file — the final `ok` +/// summary included — is byte-identical to before this tranche +/// (`spec/CONTRACT_EDITOR_T2_SELECTION.md` §W3: "Without the feature the +/// suite builds and prints exactly today's 8/8"). +#[cfg(feature = "golden-gate")] +const TOTAL_GATES: u32 = 9; +#[cfg(not(feature = "golden-gate"))] +const TOTAL_GATES: u32 = 8; + fn main() { let scale: u64 = std::env::args() .nth(1) @@ -30,12 +41,12 @@ fn main() { } else { 1_000_000 * scale }; - eprintln!("[0/8] determinism round-trip gate: {det_iters} iters"); + eprintln!("[0/{TOTAL_GATES}] determinism round-trip gate: {det_iters} iters"); roundtrip::run_determinism_roundtrip_gate(det_iters, 0x0A11_CE5E_EDED_2024); // 1. Canonical round-trip (criterion 4): type-level corpus. let iters = (50_000 * scale).max(2_000); - eprintln!("[1/8] canonical round-trip corpus: {iters} iters"); + eprintln!("[1/{TOTAL_GATES}] canonical round-trip corpus: {iters} iters"); roundtrip::run_roundtrip_corpus(iters, 0x00C0_FFEE_1234_5678); // 1b. Bundle manifest + reducer-bookkeeping serialization + full-Score byte @@ -56,26 +67,26 @@ fn main() { // 2. Crash safety (criterion 2): the testkit driver + Agent D's gate. let crash_iters = (10_000 * scale).max(1_000); - eprintln!("[2/8] crash recovery: {crash_iters} iters (testkit) + bundle gate"); + eprintln!("[2/{TOTAL_GATES}] crash recovery: {crash_iters} iters (testkit) + bundle gate"); bundle_harness::run_crash_recovery(crash_iters, 0xF00D_BEEF_1234_5678); bundle_harness::bundle_crash_recovery_fuzz(crash_iters, 0x0123_4567_89AB_CDEF); // 3. Equivocation (criterion 3): testkit driver + Agent C's gate. - eprintln!("[3/8] equivocation order-independence"); + eprintln!("[3/{TOTAL_GATES}] equivocation order-independence"); for seed in 0..n(500) { equivocation::run_equivocation(16, seed.wrapping_mul(0x9E37_79B9).wrapping_add(1)); } equivocation::ops_equivocation_fuzz((10_000 * scale).max(1_000), 0x1234_5678); // 4. Manifest selection. - eprintln!("[4/8] manifest selection"); + eprintln!("[4/{TOTAL_GATES}] manifest selection"); for seed in 0..n(32) { bundle_harness::run_manifest_selection(seed); } // 5. Convergence (criterion 1): real-Score convergence through reduce_onto, // plus the reducer-bookkeeping projection convergence. - eprintln!("[5/8] convergence across delivery orders (real Score + bookkeeping)"); + eprintln!("[5/{TOTAL_GATES}] convergence across delivery orders (real Score + bookkeeping)"); for seed in 0..n(64) { convergence::run_graph_convergence(6, seed.wrapping_mul(0x9E37_79B9).wrapping_add(11)); } @@ -93,7 +104,7 @@ fn main() { // 6. Reduction determinism (criterion 5): a large set reduced many ways, the // testkit's authoritative causal-order gate, + Agent C's own gate. let big = (1_000 * scale).max(1_000) as usize; - eprintln!("[6/8] reduction determinism: {big}-envelope set, 10 orders"); + eprintln!("[6/{TOTAL_GATES}] reduction determinism: {big}-envelope set, 10 orders"); { let mut rng = Rng::new(0x5EED_0006_0F0F_0F0F); let envelopes = generators::operation_envelopes(&mut rng, big, 3, 40, 40); @@ -110,7 +121,7 @@ fn main() { convergence::ops_reduction_determinism_fuzz((10_000 * scale).max(1_000), 0x00C0_FFEE); // 7. Layout round-trip (criterion 6). - eprintln!("[7/8] layout round-trip"); + eprintln!("[7/{TOTAL_GATES}] layout round-trip"); for seed in 0..n(128) { layout_stub::round_trip(&fixtures::ten_measure_single_staff(seed)); layout_stub::round_trip(&generators::graph::valid_score_rich(seed)); @@ -199,5 +210,253 @@ fn main() { } } - eprintln!("[8/8] ok: full conformance suite passed (scale {scale})"); + // 8. T2 W3 — the [9/9] golden conformance gate (`golden-gate` feature + // only; absent without it, and the line above prints "8/8" unchanged). + // Re-derives the three T1a golden states headlessly and rasterizes + // them exactly as `epiphany-editor-gui`'s demo binary does, comparing + // decoded RGBA against the baselines committed there. See + // `spec/CONTRACT_EDITOR_T2_SELECTION.md` §W3 and this crate's + // `DECISIONS.md` (T2 W3). + #[cfg(feature = "golden-gate")] + { + eprintln!("[8/{TOTAL_GATES}] golden conformance: T1a baselines rasterized headlessly"); + golden_gate::run(); + } + + eprintln!("[{TOTAL_GATES}/{TOTAL_GATES}] ok: full conformance suite passed (scale {scale})"); +} + +/// T2 W3: the `[9/9]` golden conformance gate. Promotes the T1a visual +/// goldens (`spec/CONTRACT_EDITOR_T1A_GOLDENS.md`, +/// `crates/epiphany-editor-gui/src/goldens.rs`) to a numbered gate in this +/// suite, entirely behind the `golden-gate` feature so the raster stack +/// (`resvg`, pulled in only via the optional dependencies in `Cargo.toml`) +/// never enters the MSRV build's dependency closure +/// (`spec/CONTRACT_EDITOR_T2_SELECTION.md` §W3). +/// +/// **Compare-only.** Unlike `epiphany-editor-gui`'s own comparator (which can +/// bless and writes failure artifacts), this gate only ever compares: no +/// `EPIPHANY_BLESS_GOLDENS` path, no artifact writing. A mismatch panics with +/// a message pointing at `cargo test -p epiphany-editor-gui goldens` — the +/// crate that owns the baselines, the bless mechanism, and the reviewable +/// `actual.png`/`expected.png`/`diff.png` artifacts — as the diagnostic +/// surface. +/// +/// **Cross-crate baseline coupling.** The three committed PNGs live in +/// `epiphany-editor-gui/goldens/`, not in this crate — this crate compares +/// against them without owning them, resolving the path from *this* crate's +/// `CARGO_MANIFEST_DIR` (the only directory `env!` can see at compile time) +/// up to the sibling crate directory. Documented here and in `DECISIONS.md` +/// (T2 W3) and `epiphany-editor-gui/DECISIONS.md` (T1a): a `git mv` of either +/// crate, or a rename of `epiphany-editor-gui/goldens/`, breaks this path +/// silently unless both DECISIONS files are updated together. +#[cfg(feature = "golden-gate")] +mod golden_gate { + use std::fs; + use std::path::PathBuf; + + use epiphany_core::TypedObjectId; + use epiphany_editor_core::EditorSession; + use epiphany_engrave::Engraver; + use epiphany_layout_ir::{HitShape, Point}; + use epiphany_render_svg::{render, RenderOptions}; + use epiphany_testkit::fixtures; + use resvg::tiny_skia::Pixmap; + + /// Runs the three T1a golden-state comparisons. Panics on the first + /// mismatch or missing baseline (never a silent skip — the `fs::read` + /// failure branch below panics directly, exactly like + /// `epiphany-editor-gui/src/goldens.rs::compare`'s own missing-baseline + /// path). + pub fn run() { + // G1 — as opened: `ten_measure_single_staff(0)`, exactly the demo's + // open path (`epiphany-editor-gui/src/main.rs:210`, + // `EditorApp::new`). + let score = fixtures::ten_measure_single_staff(0); + let session = EditorSession::open(score, Box::new(Engraver::default())) + .expect("the ten-measure fixture renders under the real engraver"); + assert_matches_baseline("ten_measure_open", &render_pixmap(&session)); + + // G2 — after the scripted pencil insert. The click point is + // re-derived through the session's own inverses, replicating + // `goldens.rs::scripted_insert_target` faithfully: the last system + // by lowest `bounding_box.origin.y`, the rightmost Pitch-sourced + // notehead within that system's vertical band, offset +0.5 staff + // spaces right / +2.0 staff spaces up from that notehead's + // right-center. + let score = fixtures::ten_measure_single_staff(0); + let mut session = EditorSession::open(score, Box::new(Engraver::default())) + .expect("the ten-measure fixture renders under the real engraver"); + let target = scripted_insert_target(&session); + let grid = session + .default_grid_at(target) + .expect("the target point sits over a metric region"); + let outcome = session + .insert_note_at(target, &grid) + .expect("the target is a clean, unoccupied insert slot"); + assert!( + outcome.graph_changed, + "gate 9: the scripted insert must change the score graph" + ); + assert_matches_baseline("ten_measure_insert", &render_pixmap(&session)); + + // G4 — casting-off: `ten_measure_with_slurs(0)` + // (`fixtures.rs:777`), the multi-system layout and cross-system + // slur-split path the T1a baseline locks. + let score = fixtures::ten_measure_with_slurs(0); + let session = EditorSession::open(score, Box::new(Engraver::default())) + .expect("the slurred ten-measure fixture renders under the real engraver"); + assert_matches_baseline("ten_measure_slurs_castoff", &render_pixmap(&session)); + } + + /// Renders `session`'s resolved layout at `px_per_staff_space: 12.0` (the + /// demo's default; `RenderOptions`'s other fields stay at their default, + /// `GlyphMode::PathOutline` — no fonts) and rasterizes it, matching + /// `render_pixmap` in `epiphany-editor-gui/src/goldens.rs`. + fn render_pixmap(session: &EditorSession) -> Pixmap { + let options = RenderOptions { + px_per_staff_space: 12.0, + ..Default::default() + }; + let output = render(session.resolved(), &options); + rasterize(&output.svg) + } + + /// Rasterizes a rendered SVG string to a `tiny_skia` pixmap, replicating + /// `epiphany-editor-gui/src/main.rs::rasterize_pixmap` exactly: usvg + /// parse, dimensions rounded up to whole pixels, an opaque white + /// background (so premultiplied alpha stays fully opaque), then + /// `resvg::render` over that background. This crate cannot call the + /// original directly (it is private to a different crate's binary), so + /// the algorithm is copied rather than shared — see the module doc for + /// why that coupling is acceptable and documented rather than factored + /// out. + fn rasterize(svg: &str) -> Pixmap { + let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()) + .expect("a rendered score's SVG parses"); + let size = tree.size(); + let width = size.width().ceil().max(1.0) as u32; + let height = size.height().ceil().max(1.0) as u32; + let mut pixmap = Pixmap::new(width, height).expect("nonzero rasterized dimensions"); + pixmap.fill(resvg::tiny_skia::Color::WHITE); + resvg::render( + &tree, + resvg::tiny_skia::Transform::identity(), + &mut pixmap.as_mut(), + ); + pixmap + } + + /// The click point G2 scripts — derived from `session`'s own rendered + /// geometry, never a magic screen constant. Faithfully replicates + /// `epiphany-editor-gui/src/goldens.rs::scripted_insert_target`; see that + /// function's doc comment for the full derivation rationale (why the + /// *last* system is the one with the lowest `bounding_box.origin.y`, and + /// why the target is the rightmost Pitch-sourced notehead within that + /// system's vertical band rather than the page-wide rightmost notehead). + fn scripted_insert_target(session: &EditorSession) -> Point { + let last_system = session + .resolved() + .pages + .iter() + .flat_map(|page| &page.systems) + .min_by(|a, b| { + a.bounding_box + .origin + .y + .0 + .total_cmp(&b.bounding_box.origin.y.0) + }) + .expect("casting-off produced at least one system"); + let sys_bottom = last_system.bounding_box.origin.y.0; + let sys_top = sys_bottom + last_system.bounding_box.size.height.0; + + let last_notehead = session + .hit_test() + .regions + .iter() + .filter_map(|r| match (&r.source, r.shape) { + (TypedObjectId::Pitch(_), HitShape::Box(b)) => Some(b), + _ => None, + }) + .filter(|b| { + let mid_y = (b.bottom.0 + b.top.0) / 2.0; + (sys_bottom..=sys_top).contains(&mid_y) + }) + .max_by(|a, b| a.right.0.total_cmp(&b.right.0)) + .expect("the last system renders at least one notehead"); + + Point::new( + last_notehead.right.0 + 0.5, + (last_notehead.bottom.0 + last_notehead.top.0) / 2.0 + 2.0, + ) + } + + /// Compares `actual` against the committed baseline + /// `epiphany-editor-gui/goldens/{name}.png`: decoded RGBA, dimensions + /// first (the Ruling-C contract) — never encoded PNG bytes. Compare-only: + /// no bless, no artifact writing (see the module doc). A missing or + /// undecodable baseline panics directly, the same "loud failure, never a + /// silent skip" contract as `goldens.rs::compare`. + fn assert_matches_baseline(name: &str, actual: &Pixmap) { + let path = baseline_path(name); + let bytes = fs::read(&path).unwrap_or_else(|err| { + panic!( + "gate 9: no golden baseline at {} ({err}) — `epiphany-editor-gui/goldens/` must \ + contain the committed T1a baselines for this gate to compare against; \ + diagnose with `cargo test -p epiphany-editor-gui goldens`", + path.display() + ) + }); + let expected = Pixmap::decode_png(&bytes).unwrap_or_else(|err| { + panic!( + "gate 9: baseline at {} is not a decodable PNG: {err}", + path.display() + ) + }); + assert_eq!( + (actual.width(), actual.height()), + (expected.width(), expected.height()), + "gate 9: {name} dimensions differ from the committed baseline at {} (actual {}x{} \ + vs expected {}x{}); diagnose with `cargo test -p epiphany-editor-gui goldens`", + path.display(), + actual.width(), + actual.height(), + expected.width(), + expected.height(), + ); + // A plain `assert_eq!` here would dump both full byte slices (millions + // of bytes for a real score raster) into the panic message; a + // differing-byte count is the useful signal, and `cargo test -p + // epiphany-editor-gui goldens` is where the actual/expected/diff PNG + // artifacts to inspect visually already live. + if actual.data() != expected.data() { + let differing = actual + .data() + .iter() + .zip(expected.data()) + .filter(|(a, b)| a != b) + .count(); + panic!( + "gate 9: {name} decoded RGBA differs from the committed baseline at {} \ + ({differing} of {} bytes differ); diagnose with `cargo test -p \ + epiphany-editor-gui goldens`", + path.display(), + actual.data().len(), + ); + } + } + + /// The committed baseline path for a named golden: + /// `{CARGO_MANIFEST_DIR}/../epiphany-editor-gui/goldens/{name}.png` — this + /// crate's `CARGO_MANIFEST_DIR` is `crates/epiphany-testkit`, and the + /// baselines live in the sibling crate `crates/epiphany-editor-gui`. See + /// the module doc's "Cross-crate baseline coupling" note. + fn baseline_path(name: &str) -> PathBuf { + PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../epiphany-editor-gui/goldens" + )) + .join(format!("{name}.png")) + } }