epiphany/crates/epiphany-editor-gui/src/goldens.rs

702 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Pixel-level golden-image comparator and bless machinery for the score raster
//! this GUI displays (`spec/CONTRACT_EDITOR_T1A_GOLDENS.md`; plan
//! `spec/PLAN_EDITOR_APP.md` §Ruling C, granted 2026-07-23 as amended).
//!
//! **Comparison contract (Ruling C, amended):** a golden is compared as
//! *decoded* RGBA pixels — dimensions first, then raw bytes — never as encoded
//! PNG file bytes. Comparing encoded bytes would also lock the PNG encoder's own
//! behavior (compression level, filter choice, …) and churn on an encoder
//! change even when every pixel is identical; `reencoding_with_different_settings_still_passes`
//! below makes that guarantee executable, not just asserted.
//!
//! This module is declared `#[cfg(test)]`-only at its `mod goldens;` site in
//! `main.rs`, so none of it ships in the built binary. It carries two kinds of
//! test: the comparator's **own** unit tests (T1a W1), which never touch
//! `goldens/` — they point [`assert_golden_at`] at private temp locations, so
//! they hold even before any baseline exists — and the **four golden-state**
//! tests (T1a W2: fixture-as-opened, after a scripted insert, after undo, after
//! casting-off), which call [`assert_golden`] against the three committed
//! baselines under `goldens/` (G3 reuses G1's file rather than owning one; see
//! its test's doc comment).
use std::fs;
use std::path::{Path, PathBuf};
use resvg::tiny_skia::Pixmap;
/// An opaque highlight color painted over every differing pixel in a `diff.png`.
const DIFF_HIGHLIGHT: [u8; 4] = [255, 0, 0, 255];
/// The committed baseline path for a named golden: `{CARGO_MANIFEST_DIR}/goldens/{name}.png`.
fn baseline_path(name: &str) -> PathBuf {
PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/goldens")).join(format!("{name}.png"))
}
/// The failure-artifact directory for a named golden, resolved repo-root-relative
/// (`{CARGO_MANIFEST_DIR}/../../target/golden-failures/{name}/`, this crate being
/// two levels under the workspace root) so the path is the same regardless of the
/// working directory `cargo test` was invoked from — matching CI's
/// `target/golden-failures/**` upload path.
fn failure_dir_path(name: &str) -> PathBuf {
PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../target/golden-failures"
))
.join(name)
}
/// Compares `pixmap` against the committed baseline `goldens/{name}.png`,
/// writing any failure artifacts under `target/golden-failures/{name}/` (see
/// [`baseline_path`] / [`failure_dir_path`]).
///
/// **Bless policy:** setting `EPIPHANY_BLESS_GOLDENS=1` writes/overwrites the
/// baseline unconditionally instead of comparing, creating `goldens/` if
/// needed. This is a *reviewed decision* — the tranche's named user deep-dive
/// point on new baselines (plan §Ruling C) — **never** a mechanism for turning a
/// red test green. Do not set it to make a failing test pass; set it only after
/// visually inspecting the new PNG and deciding it is correct.
fn assert_golden(name: &str, pixmap: &Pixmap) {
let baseline = baseline_path(name);
if std::env::var("EPIPHANY_BLESS_GOLDENS").as_deref() == Ok("1") {
write_pixmap(&baseline, pixmap);
return;
}
assert_golden_at(&baseline, pixmap, &failure_dir_path(name));
}
/// The comparator's parameterized core: compares `pixmap` against the PNG
/// decoded from `baseline_path`, panicking with the mismatch description (which
/// names every artifact written) on failure. `assert_golden` is the thin
/// default-path wrapper above; tests call this directly with temp-directory
/// paths so they never depend on a committed baseline.
fn assert_golden_at(baseline_path: &Path, pixmap: &Pixmap, failure_dir: &Path) {
if let Err(message) = compare(baseline_path, pixmap, failure_dir) {
panic!("{message}");
}
}
/// Result-returning core of the comparison, so tests can observe a mismatch
/// without needing to catch a panic. Reads and decodes the baseline PNG, then:
///
/// 1. compares dimensions — a mismatch fails here, before any pixel is looked
/// at, and writes `actual.png` + `expected.png` (no `diff.png`: a per-pixel
/// map is not meaningful across differing dimensions);
/// 2. compares decoded RGBA bytes exactly — a mismatch writes all three
/// artifacts (`actual.png`, `expected.png`, `diff.png`) and names them in the
/// returned message.
///
/// A missing or undecodable baseline file is a harness/setup error, not a
/// reviewable visual diff, so it panics directly rather than returning `Err`.
fn compare(baseline_path: &Path, actual: &Pixmap, failure_dir: &Path) -> Result<(), String> {
let baseline_bytes = fs::read(baseline_path).unwrap_or_else(|err| {
panic!(
"no golden baseline at {} ({err}); run with EPIPHANY_BLESS_GOLDENS=1, after visually \
reviewing the new image, to create it",
baseline_path.display()
)
});
let expected = Pixmap::decode_png(&baseline_bytes).unwrap_or_else(|err| {
panic!(
"golden baseline at {} is not a decodable PNG: {err}",
baseline_path.display()
)
});
if actual.width() != expected.width() || actual.height() != expected.height() {
let actual_path = failure_dir.join("actual.png");
let expected_path = failure_dir.join("expected.png");
write_pixmap(&actual_path, actual);
write_pixmap(&expected_path, &expected);
return Err(format!(
"golden mismatch: dimensions differ (actual {}x{} vs expected {}x{}); no pixel \
comparison performed. Artifacts: {}, {}",
actual.width(),
actual.height(),
expected.width(),
expected.height(),
actual_path.display(),
expected_path.display(),
));
}
// The comparison contract itself (Ruling C, amended): decoded pixels, never
// encoded file bytes. An encoder-settings change that leaves every pixel
// identical must never fail this comparison.
let pixels_equal = actual.data() == expected.data();
if pixels_equal {
return Ok(());
}
let (diff, differing) = diff_pixmap(actual, &expected);
let actual_path = failure_dir.join("actual.png");
let expected_path = failure_dir.join("expected.png");
let diff_path = failure_dir.join("diff.png");
write_pixmap(&actual_path, actual);
write_pixmap(&expected_path, &expected);
write_pixmap(&diff_path, &diff);
Err(format!(
"golden mismatch: {differing} of {} decoded pixels differ. Artifacts: {}, {}, {}",
u64::from(actual.width()) * u64::from(actual.height()),
actual_path.display(),
expected_path.display(),
diff_path.display(),
))
}
/// Builds a per-pixel highlight image: [`DIFF_HIGHLIGHT`] where `actual` and
/// `expected` disagree, `actual`'s own pixel where they agree (so the diff stays
/// legible against the surrounding score). Returns the image and the count of
/// differing pixels. Callers must have already established equal dimensions.
fn diff_pixmap(actual: &Pixmap, expected: &Pixmap) -> (Pixmap, usize) {
let mut diff = Pixmap::new(actual.width(), actual.height())
.expect("dimensions already validated equal and nonzero");
let mut differing = 0usize;
for ((out, a), b) in diff
.data_mut()
.chunks_exact_mut(4)
.zip(actual.data().chunks_exact(4))
.zip(expected.data().chunks_exact(4))
{
if a == b {
out.copy_from_slice(a);
} else {
differing += 1;
out.copy_from_slice(&DIFF_HIGHLIGHT);
}
}
(diff, differing)
}
/// Writes `pixmap` to `path` as a PNG, creating parent directories as needed.
fn write_pixmap(path: &Path, pixmap: &Pixmap) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.unwrap_or_else(|err| panic!("creating {}: {err}", parent.display()));
}
let bytes = pixmap
.encode_png()
.unwrap_or_else(|err| panic!("encoding {}: {err}", path.display()));
fs::write(path, bytes).unwrap_or_else(|err| panic!("writing {}: {err}", path.display()));
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use epiphany_core::{
CmnNominal, MusicalDuration, MusicalPosition, RationalTime, TypedObjectId,
};
use epiphany_editor_core::{EditorSession, GridResolution};
use epiphany_engrave::Engraver;
use epiphany_layout_ir::{HitShape, Point};
use epiphany_render_svg::{render, RenderOptions};
use epiphany_testkit::fixtures;
/// A private, per-call-unique scratch directory under the OS temp dir — W1
/// has no committed baselines, so every test exercises the comparator
/// against its own throwaway files rather than `goldens/`.
fn unique_temp_dir(tag: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is after the epoch")
.as_nanos();
std::env::temp_dir().join(format!("epiphany-goldens-test-{tag}-{nanos}-{n}"))
}
/// A small pixmap with varied (non-uniform) content, alpha 255 throughout so
/// premultiply/demultiply round-trips through PNG encode/decode exactly —
/// deterministic pseudo-noise, not a flat fill, so PNG row filtering and
/// compression have something to actually differ over.
fn varied_pixmap(width: u32, height: u32) -> Pixmap {
let mut pm = Pixmap::new(width, height).expect("nonzero test dimensions");
for y in 0..height {
for x in 0..width {
let idx = ((y * width + x) * 4) as usize;
let r = ((x.wrapping_mul(37)).wrapping_add(y.wrapping_mul(11)) % 256) as u8;
let g = ((x.wrapping_mul(59)).wrapping_add(y.wrapping_mul(3)) % 256) as u8;
let b = ((x.wrapping_mul(13)).wrapping_add(y.wrapping_mul(91)) % 256) as u8;
pm.data_mut()[idx..idx + 4].copy_from_slice(&[r, g, b, 255]);
}
}
pm
}
#[test]
fn identical_pixmaps_pass() {
let pixmap = varied_pixmap(5, 4);
let dir = unique_temp_dir("identical");
let baseline = dir.join("baseline.png");
write_pixmap(&baseline, &pixmap);
let failure_dir = dir.join("failures");
assert_golden_at(&baseline, &pixmap, &failure_dir);
assert!(
!failure_dir.exists(),
"a passing comparison must not write failure artifacts"
);
}
#[test]
fn altered_pixel_fails_and_writes_all_three_artifacts() {
let baseline_pixmap = varied_pixmap(5, 4);
let mut actual_pixmap = baseline_pixmap.clone();
// Alter exactly one pixel (the second one) in the actual image.
actual_pixmap.data_mut()[4..8].copy_from_slice(&[9, 8, 7, 255]);
let dir = unique_temp_dir("altered");
let baseline = dir.join("baseline.png");
write_pixmap(&baseline, &baseline_pixmap);
let failure_dir = dir.join("failures");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
assert_golden_at(&baseline, &actual_pixmap, &failure_dir);
}));
let payload = result.expect_err("a single-pixel diff must panic");
let message = payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_string()))
.expect("panic payload is a string message");
let actual_path = failure_dir.join("actual.png");
let expected_path = failure_dir.join("expected.png");
let diff_path = failure_dir.join("diff.png");
assert!(actual_path.is_file(), "actual.png was written");
assert!(expected_path.is_file(), "expected.png was written");
assert!(diff_path.is_file(), "diff.png was written");
assert!(
message.contains(&actual_path.display().to_string()),
"panic message names actual.png's path: {message}"
);
assert!(
message.contains(&expected_path.display().to_string()),
"panic message names expected.png's path: {message}"
);
assert!(
message.contains(&diff_path.display().to_string()),
"panic message names diff.png's path: {message}"
);
}
#[test]
fn reencoding_with_different_settings_still_passes() {
// Encode the *same* pixels twice, with deliberately different PNG
// encoder settings (filter type and compression level), via the `png`
// crate directly — `tiny_skia::Pixmap::encode_png` exposes no such
// knobs, so this is the only way to prove the comparator does not
// secretly depend on encoder behavior. Pinned to the exact `png` version
// already resolved via `tiny-skia` (Cargo.lock): a dev-only edge to an
// existing node, not a new dependency.
let pixmap = varied_pixmap(6, 5);
let encode_with = |filter: png::FilterType, compression: png::Compression| -> Vec<u8> {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, pixmap.width(), pixmap.height());
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_filter(filter);
encoder.set_compression(compression);
let mut writer = encoder.write_header().expect("valid PNG header");
writer
.write_image_data(pixmap.data())
.expect("valid image data");
drop(writer);
bytes
};
let bytes_a = encode_with(png::FilterType::NoFilter, png::Compression::Fast);
let bytes_b = encode_with(png::FilterType::Paeth, png::Compression::Best);
assert_ne!(
bytes_a, bytes_b,
"the two encodings must differ byte-wise, or this test is vacuous"
);
let dir = unique_temp_dir("reencode");
let failure_dir = dir.join("failures");
let baseline_a = dir.join("a.png");
fs::create_dir_all(&dir).expect("temp dir created");
fs::write(&baseline_a, &bytes_a).expect("baseline a written");
assert_golden_at(&baseline_a, &pixmap, &failure_dir);
let baseline_b = dir.join("b.png");
fs::write(&baseline_b, &bytes_b).expect("baseline b written");
assert_golden_at(&baseline_b, &pixmap, &failure_dir);
assert!(
!failure_dir.exists(),
"both differently-encoded baselines must decode to the same pixels and pass"
);
}
#[test]
fn mismatched_dimensions_fail_before_any_pixel_comparison() {
let baseline_pixmap = varied_pixmap(4, 3);
let differently_sized = varied_pixmap(5, 3);
let dir = unique_temp_dir("dims");
let baseline = dir.join("baseline.png");
write_pixmap(&baseline, &baseline_pixmap);
let failure_dir = dir.join("failures");
let err = compare(&baseline, &differently_sized, &failure_dir)
.expect_err("differing dimensions must fail the comparison");
assert!(
err.contains("dimensions"),
"failure mentions dimensions: {err}"
);
assert!(
!err.contains("decoded pixels"),
"failure must not describe a pixel diff: {err}"
);
assert!(
!failure_dir.join("diff.png").exists(),
"no pixel diff is computable across differing dimensions, so none is written"
);
}
// ---- The four golden states (T1a W2) ------------------------------------
/// Renders `session`'s resolved layout at `px_per_staff_space` (the demo's
/// default is `12.0`; `RenderOptions`'s other fields default to
/// `GlyphMode::PathOutline`, no fonts) and rasterizes it through
/// `crate::rasterize_pixmap` — the exact pixmap `main.rs`'s
/// `EditorApp::rerender` displays (`main.rs:247`). Returns the SVG string
/// alongside the pixmap so callers can run the determinism double.
fn render_pixmap(session: &EditorSession, px_per_staff_space: f32) -> (String, Pixmap) {
let options = RenderOptions {
px_per_staff_space,
..Default::default()
};
let output = render(session.resolved(), &options);
let (pixmap, _logical) =
crate::rasterize_pixmap(&output.svg).expect("a rendered score's SVG rasterizes");
(output.svg, pixmap)
}
/// The click point G2 scripts (and G3 replays before undoing) — derived from
/// `session`'s own rendered geometry, never a magic screen constant.
///
/// At this geometry, ten measures of quarter notes at `px_per_staff_space:
/// 12.0` don't fit one line, so `ten_measure_single_staff` itself casts off
/// into two systems (not only the slurred G4 fixture). That means the
/// score's *temporally* last note is not simply "the rightmost notehead
/// box": the first system happens to render wider than the second, so its
/// notes reach further right on the page even though they come first in
/// time. The last note is instead the rightmost `Pitch`-sourced notehead
/// **within the system with the lowest `bounding_box.origin.y`** — systems
/// stack top-to-bottom in this y-up world, so the lowest one is the last.
///
/// The click point sits half a staff space past that notehead's right edge
/// (clearly past it, still read as the same system) and two staff spaces
/// above its vertical center — four diatonic steps (a fifth) above the
/// existing all-C4 content under treble clef, landing on a different,
/// mid-staff pitch rather than repeating the fixture's own notes. `staff_pitch_at` /
/// `default_grid_at` / `position_at` resolve this point to exact values,
/// asserted in `g2_ten_measure_insert_matches_baseline`.
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,
)
}
/// **G1 — as opened.** `ten_measure_single_staff(0)`, exactly the demo's
/// open path (`main.rs:197`), locked against `goldens/ten_measure_open.png`.
/// This is also the file G3 (below) compares its post-undo raster against.
#[test]
fn g1_ten_measure_open_matches_baseline() {
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");
let (svg1, pixmap1) = render_pixmap(&session, 12.0);
let (svg2, pixmap2) = render_pixmap(&session, 12.0);
assert_eq!(svg1, svg2, "G1 determinism double: SVG bytes must match");
assert_eq!(
pixmap1.data(),
pixmap2.data(),
"G1 determinism double: rasterized pixels must match"
);
assert_golden("ten_measure_open", &pixmap1);
}
/// **G2 — after a scripted pencil insert.** See [`scripted_insert_target`]
/// for the click-point derivation. `staff_pitch_at` / `default_grid_at` /
/// `position_at` are asserted to their exact values *before* the insert
/// runs, then the insert is applied and the result locked against
/// `goldens/ten_measure_insert.png`.
#[test]
fn g2_ten_measure_insert_matches_baseline() {
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 pitch = session
.staff_pitch_at(target)
.expect("the target point sits over the staff");
assert_eq!(
(pitch.nominal, pitch.octave),
(CmnNominal::G, 4),
"the target resolves to G4 — a fifth above the fixture's all-C4 content"
);
let grid = session
.default_grid_at(target)
.expect("the target point sits over a metric region");
assert_eq!(
grid,
GridResolution {
step: MusicalDuration(RationalTime::new(1, 4).expect("1/4 is valid"))
},
"the 4/4 meter's default grid is a quarter-note step"
);
let placed = session
.position_at(target, &grid)
.expect("the target snaps to a musical position");
assert_eq!(
placed.position,
MusicalPosition(RationalTime::new(10, 1).expect("10/1 is valid")),
"the insert lands exactly at whole-note 10 — immediately after the fixture's \
last note ends (measure 10, beat 4 of 4), with no gap and nothing to overwrite"
);
let outcome = session
.insert_note_at(target, &grid)
.expect("the target is a clean, unoccupied insert slot");
assert!(
outcome.graph_changed,
"the insert must change the score graph"
);
let (svg1, pixmap1) = render_pixmap(&session, 12.0);
let (svg2, pixmap2) = render_pixmap(&session, 12.0);
assert_eq!(svg1, svg2, "G2 determinism double: SVG bytes must match");
assert_eq!(
pixmap1.data(),
pixmap2.data(),
"G2 determinism double: rasterized pixels must match"
);
assert_golden("ten_measure_insert", &pixmap1);
}
/// **G3 — after undo of G2's insert. No third baseline:** this compares the
/// post-undo raster against **G1's own baseline file**, reached by calling
/// [`assert_golden_at`] directly rather than [`assert_golden`] — deliberately
/// bypassing the bless path. Going through `assert_golden` would let
/// `EPIPHANY_BLESS_GOLDENS=1` overwrite `ten_measure_open.png`: an initial
/// bless run performed before undo is known to be correct would silently
/// bless a broken undo's post-undo pixels as the new "as opened" baseline,
/// after which every future run would compare undo against its own bug
/// instead of against G1. Bypassing `assert_golden` makes that impossible —
/// this comparison always compares, never blesses. A fresh session (rather
/// than continuing G2's) keeps the two tests independent of each other's
/// mutations.
#[test]
fn g3_undo_matches_g1_baseline() {
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");
session
.insert_note_at(target, &grid)
.expect("the target is a clean, unoccupied insert slot");
let outcome = session.undo().expect("there is one edit to undo");
assert!(outcome.graph_changed, "undo must revert the inserted note");
let (svg1, pixmap1) = render_pixmap(&session, 12.0);
let (svg2, pixmap2) = render_pixmap(&session, 12.0);
assert_eq!(svg1, svg2, "G3 determinism double: SVG bytes must match");
assert_eq!(
pixmap1.data(),
pixmap2.data(),
"G3 determinism double: rasterized pixels must match"
);
assert_golden_at(
&baseline_path("ten_measure_open"),
&pixmap1,
&failure_dir_path("ten_measure_undo"),
);
}
/// **G4 — casting-off.** `ten_measure_with_slurs(0)` (`fixtures.rs:777`):
/// the same ten-measure content as G1G3 plus three slurs, which at this
/// geometry casts off into multiple systems **and** forces the second slur
/// (events 5..8) across the system break — the cross-system slur-split path
/// (`casting.rs:2262`). The system count is asserted first, as a named
/// value: if casting-off ever stops triggering here, this fails as a
/// reported system count, not a silent pixel diff easily misread as an
/// unrelated rendering regression.
#[test]
fn g4_ten_measure_slurs_castoff_matches_baseline() {
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");
let system_count: usize = session
.resolved()
.pages
.iter()
.map(|page| page.systems.len())
.sum();
assert!(
system_count > 1,
"casting-off must produce more than one system, got {system_count}"
);
let (svg1, pixmap1) = render_pixmap(&session, 12.0);
let (svg2, pixmap2) = render_pixmap(&session, 12.0);
assert_eq!(svg1, svg2, "G4 determinism double: SVG bytes must match");
assert_eq!(
pixmap1.data(),
pixmap2.data(),
"G4 determinism double: rasterized pixels must match"
);
assert_golden("ten_measure_slurs_castoff", &pixmap1);
}
/// **G5 — the note-entry caret loop** (`spec/CONTRACT_EDITOR_T3_CARET.md`
/// §W2). `ten_measure_single_staff(0)`, the same fixture and the same
/// [`scripted_insert_target`] click point G2/G3 use — "past the last
/// note" is exactly the extrapolation territory the contract asks G5 to
/// reuse — but driven through [`EditorSession::set_caret_at`] +
/// [`EditorSession::enter_nominal`] instead of the pencil's
/// `insert_note_at`. Locks the entry loop's visible result the same way
/// G2 locks the pencil's.
///
/// **Value assertions before pixels** (contract's own ordering): the
/// grid/caret's exact position and entry duration are asserted as exact
/// rationals, then each of the four scripted entries' resulting caret
/// position, *before* any raster is taken — so a broken entry loop fails
/// as a named rational mismatch, not a mystery pixel diff.
#[test]
fn g5_ten_measure_caret_entry_matches_baseline() {
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);
// Same target G2 proves lands exactly at whole-note 10 with a quarter-note
// grid (the fixture's 4/4 meter) — re-derived here independently, not just
// assumed from that other test.
let grid = session
.default_grid_at(target)
.expect("the target point sits over a metric region");
assert_eq!(
grid,
GridResolution {
step: MusicalDuration(RationalTime::new(1, 4).expect("1/4 is valid"))
},
"the 4/4 meter's default grid is a quarter-note step"
);
let caret = session
.set_caret_at(target)
.expect("the target sits over the staff");
assert_eq!(
caret.entry_duration, grid.step,
"the caret's entry duration is initialized from the grid step"
);
assert_eq!(
caret.position,
MusicalPosition(RationalTime::new(10, 1).expect("10/1 is valid")),
"the caret lands exactly at whole-note 10 — immediately after the \
fixture's last note ends, with nothing to overwrite"
);
// C, D, E, F at the caret's quarter entry duration: each entry must both
// change the graph and advance the caret by exactly 1/4.
let expected_positions = [
(10, 1, 41, 4), // 10 -> 10 + 1/4 = 41/4
(41, 4, 21, 2), // 41/4 -> 42/4 = 21/2
(21, 2, 43, 4), // 21/2 -> 43/4
(43, 4, 11, 1), // 43/4 -> 44/4 = 11
];
for (nominal, (before_n, before_d, after_n, after_d)) in
[CmnNominal::C, CmnNominal::D, CmnNominal::E, CmnNominal::F]
.into_iter()
.zip(expected_positions)
{
let before = MusicalPosition(RationalTime::new(before_n, before_d).expect("valid"));
assert_eq!(
session.caret().map(|c| c.position),
Some(before),
"{nominal:?}: the caret sits at the expected pre-entry position"
);
let outcome = session
.enter_nominal(nominal)
.unwrap_or_else(|err| panic!("entering {nominal:?} at the caret: {err}"));
assert!(
outcome.graph_changed,
"{nominal:?}: entering into empty space changes the score"
);
let after = MusicalPosition(RationalTime::new(after_n, after_d).expect("valid"));
assert_eq!(
session.caret().map(|c| c.position),
Some(after),
"{nominal:?}: the caret advances by exactly the entry duration (1/4)"
);
}
let (svg1, pixmap1) = render_pixmap(&session, 12.0);
let (svg2, pixmap2) = render_pixmap(&session, 12.0);
assert_eq!(svg1, svg2, "G5 determinism double: SVG bytes must match");
assert_eq!(
pixmap1.data(),
pixmap2.data(),
"G5 determinism double: rasterized pixels must match"
);
assert_golden("ten_measure_caret_entry", &pixmap1);
}
}