epiphany/crates/epiphany-render-svg/tests/acceptance.rs

366 lines
14 KiB
Rust

//! Agent I's acceptance harness (the merge gate for `epiphany-render-svg`,
//! `spec/PHASE2_QUICKSTART.md` §"Acceptance criteria per agent" → I).
//!
//! For each named fixture, drive the full v0 pipeline through the **stub solver**
//! (this phase's deliverable is the renderer-against-stub) and assert:
//!
//! * the SVG is well-formed (the in-crate checker *and*, when available, the
//! system `xmllint` — a real external XML parser);
//! * every resolved glyph is drawn (one `<path>` per glyph; no silent drops) and
//! traces back to its score-graph source via `data-prov`;
//! * a **golden-locked machine acceptance snapshot** (object / glyph / path /
//! provenance / layer / per-class / hard-constraint counts + XML validity)
//! matches a committed file — changes require an explicit golden update;
//! * the full SVG bytes match a committed golden — the renderer is deterministic.
//!
//! Regenerate goldens deliberately with `UPDATE_GOLDEN=1 cargo test -p
//! epiphany-render-svg --test acceptance`.
use std::collections::BTreeMap;
use std::path::PathBuf;
use epiphany_core::Score;
use epiphany_layout_ir::{
to_constrained, to_logical, ConstrainedLayoutIR, ConstraintSolver, SolverConfig, StubSolver,
};
use epiphany_render_svg::{check_well_formed, render, GlyphClass, RenderOptions, RenderOutput};
/// The fixtures Agent I's acceptance names, each with a fixed seed so the score
/// — and therefore the layout and SVG — is deterministic.
fn fixtures() -> Vec<(&'static str, Score)> {
vec![
(
"ten_measure_single_staff",
epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE),
),
(
"valid_score_rich",
epiphany_core::generators::valid_score_rich(0x5EED),
),
(
"ten_measure_with_repeats",
epiphany_testkit::fixtures::ten_measure_with_repeats(0x000A_11CE),
),
(
"ten_measure_with_slurs",
epiphany_testkit::fixtures::ten_measure_with_slurs(0x000A_11CE),
),
(
"two_staff_close_content",
epiphany_testkit::fixtures::two_staff_close_content(0x000A_11CE),
),
(
"three_staff_close_content",
epiphany_testkit::fixtures::three_staff_close_content(0x000A_11CE),
),
]
}
fn pipeline(score: &Score) -> (ConstrainedLayoutIR, RenderOutput) {
let constrained = to_constrained(&to_logical(score));
let layout = StubSolver
.solve(&constrained, &SolverConfig::default())
.layout;
let out = render(&layout, &RenderOptions::default());
(constrained, out)
}
/// A deterministic, human-diffable serialization of the machine acceptance
/// snapshot.
fn snapshot_text(
fixture: &str,
solver: &str,
constrained: &ConstrainedLayoutIR,
out: &RenderOutput,
) -> String {
let mut s = String::new();
s.push_str(&format!("fixture={fixture} solver={solver}\n"));
s.push_str(&format!("glyph_count={}\n", out.stats.glyph_count));
s.push_str(&format!("path_count={}\n", out.stats.path_count));
s.push_str(&format!(
"fallback_rect_count={}\n",
out.stats.fallback_rect_count
));
s.push_str(&format!("stroke_count={}\n", out.stats.stroke_count));
s.push_str(&format!("curve_count={}\n", out.stats.curve_count));
s.push_str(&format!(
"provenance_count={}\n",
out.stats.provenance_count
));
s.push_str(&format!("layer_count={}\n", out.stats.layer_count));
s.push_str(&format!(
"hard_constraint_count={}\n",
constrained.constraints.len()
));
s.push_str(&format!(
"xml_well_formed={}\n",
check_well_formed(&out.svg).is_ok()
));
let vb = out.stats.view_box;
s.push_str(&format!(
"view_box=[{} {} {} {}]\n",
vb[0], vb[1], vb[2], vb[3]
));
let labelled: BTreeMap<&str, usize> = out
.stats
.class_counts
.iter()
.map(|(c, n)| (c.token(), *n))
.collect();
s.push_str("class_counts:\n");
for (token, n) in labelled {
s.push_str(&format!(" {token}={n}\n"));
}
s
}
fn golden_path(name: &str) -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests");
p.push("golden");
p.push(name);
p
}
/// Compares `actual` to the committed golden at `tests/golden/<name>`, or writes
/// it when `UPDATE_GOLDEN` is set.
fn assert_golden(name: &str, actual: &str) {
let path = golden_path(name);
if std::env::var_os("UPDATE_GOLDEN").is_some() {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, actual).unwrap();
return;
}
let expected = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"missing golden {} ({e}); regenerate with UPDATE_GOLDEN=1",
path.display()
)
});
assert_eq!(
actual,
expected,
"golden mismatch for {}; if intended, rerun with UPDATE_GOLDEN=1",
path.display()
);
}
#[test]
fn fixtures_render_to_golden_locked_svg_and_snapshot() {
for (fixture, score) in fixtures() {
let (constrained, out) = pipeline(&score);
// Structural invariants (would fail if the renderer stubbed/dropped work).
assert!(out.stats.glyph_count > 0, "{fixture}: nothing was laid out");
assert_eq!(
out.stats.path_count + out.stats.fallback_rect_count,
out.stats.glyph_count,
"{fixture}: every glyph must be drawn (path or fallback), none dropped"
);
assert_eq!(
out.stats.provenance_count,
out.stats.glyph_count + out.stats.stroke_count + out.stats.curve_count,
"{fixture}: every drawn glyph, stroke, and curve must carry a provenance trace"
);
let class_sum: usize = out.stats.class_counts.values().sum();
assert_eq!(
class_sum, out.stats.glyph_count,
"{fixture}: classes partition glyphs"
);
// The in-crate well-formedness gate.
check_well_formed(&out.svg)
.unwrap_or_else(|e| panic!("{fixture}: emitted SVG is not well-formed: {e}"));
// Provenance survival: every laid-out glyph's stable id appears as a
// data-prov trace in the SVG text.
for g in &out_glyph_ids(&score) {
assert!(
out.svg.contains(&format!("data-prov=\"{g:032x}\"")),
"{fixture}: provenance {g:032x} did not survive into the SVG"
);
}
// Golden locks: the machine snapshot and the full SVG bytes.
assert_golden(
&format!("{fixture}.stub.snapshot.txt"),
&snapshot_text(fixture, "stub", &constrained, &out),
);
assert_golden(&format!("{fixture}.stub.svg"), &out.svg);
}
}
/// The stable layout-object ids the pipeline lays out for a score (used to check
/// provenance survival into the SVG).
fn out_glyph_ids(score: &Score) -> Vec<u128> {
let layout = StubSolver
.solve(
&to_constrained(&to_logical(score)),
&SolverConfig::default(),
)
.layout;
layout
.glyphs
.iter()
.map(|g| g.provenance.stable_id.0)
.collect()
}
#[test]
fn the_renderer_draws_real_bravura_curves_not_placeholders() {
// Non-vacuity: the SVG must contain genuine outline data (cubic bezier 'C'
// commands from the Bravura CFF outlines), not just rectangles or moves.
let (_, out) = pipeline(&epiphany_testkit::fixtures::ten_measure_single_staff(
0x000A_11CE,
));
let curve_paths = out
.svg
.lines()
.filter(|l| l.contains("<path") && l.contains('C'))
.count();
assert!(
curve_paths > 0,
"expected genuine Bravura bezier outlines, found none"
);
}
#[test]
fn classes_are_non_trivial() {
// The stub pipeline maps several object kinds to several glyph classes; the
// snapshot should not collapse to a single class (which would suggest the
// classifier or the pipeline went vacuous).
let (_, out) = pipeline(&epiphany_core::generators::valid_score_rich(0x5EED));
let nonzero = out.stats.class_counts.values().filter(|&&n| n > 0).count();
assert!(
nonzero >= 2,
"expected several glyph classes, got {nonzero}"
);
// Sanity: the classifier knows the standard categories.
assert_eq!(GlyphClass::of("gClef"), GlyphClass::Clef);
assert_eq!(GlyphClass::of("noteheadBlack"), GlyphClass::Notehead);
}
/// Best-effort cross-check with the system `xmllint` (a real XML parser). When
/// `xmllint` is absent the in-crate checker remains the gate; when present, the
/// "XML-validates" claim rests on an external parser too.
#[test]
fn svg_validates_under_xmllint_when_available() {
use std::io::Write;
use std::process::Command;
let (_, out) = pipeline(&epiphany_testkit::fixtures::ten_measure_single_staff(
0x000A_11CE,
));
let mut tmp = std::env::temp_dir();
tmp.push("epiphany_render_svg_xmllint_check.svg");
let mut f = match std::fs::File::create(&tmp) {
Ok(f) => f,
Err(_) => return, // can't write a temp file; skip
};
f.write_all(out.svg.as_bytes()).unwrap();
drop(f);
match Command::new("xmllint").arg("--noout").arg(&tmp).status() {
Ok(status) => assert!(
status.success(),
"xmllint rejected the emitted SVG as malformed"
),
Err(_) => {
// xmllint not installed; the in-crate checker already gated this run.
eprintln!("note: xmllint not available; relied on the in-crate checker");
}
}
let _ = std::fs::remove_file(&tmp);
}
/// Drives the full pipeline through Agent I's real `Engraver` — whose
/// horizontal-spacing pass re-spaces every glyph and the strokes that track them.
fn engrave_pipeline(score: &Score) -> (ConstrainedLayoutIR, RenderOutput) {
use epiphany_engrave::Engraver;
let constrained = to_constrained(&to_logical(score));
let layout = Engraver::default()
.solve(&constrained, &SolverConfig::default())
.layout;
let out = render(&layout, &RenderOptions::default());
(constrained, out)
}
/// The renderer's contract is "consumes *any* solver's `ResolvedLayoutIR`", but
/// the `.stub.*` goldens above only exercise the stub. This drives the real
/// `Engraver` — whose horizontal-spacing pass produces geometry that *differs*
/// from the stub's verbatim columns — through the same renderer and **golden-locks
/// its SVG bytes and machine snapshot** under `.engrave.*`, so an `Engraver`
/// geometry regression is caught at the byte level (the stub goldens cannot see
/// it), while the same structural invariants (every glyph drawn, every primitive
/// traced, well-formed XML, no fallbacks) hold as for the stub.
#[test]
fn engraver_output_is_golden_locked_well_formed_with_every_glyph_drawn() {
use epiphany_engrave::Engraver;
use epiphany_layout_ir::SolveStatus;
for (fixture, score) in fixtures() {
let constrained = to_constrained(&to_logical(&score));
let report = Engraver::default().solve(&constrained, &SolverConfig::default());
assert_eq!(
report.status,
SolveStatus::Solved,
"{fixture}: engrave should solve the constraint-free stub pipeline"
);
let (_, out) = engrave_pipeline(&score);
// Structural invariants — identical to the stub's, because the Engraver
// re-spaces geometry without adding, dropping, or un-tracing a primitive.
check_well_formed(&out.svg)
.unwrap_or_else(|e| panic!("{fixture}: engraver SVG is not well-formed: {e}"));
assert!(out.stats.glyph_count > 0, "{fixture}: nothing was laid out");
assert_eq!(
out.stats.path_count + out.stats.fallback_rect_count,
out.stats.glyph_count,
"{fixture}: every engraver glyph must be drawn (path or fallback), none dropped"
);
assert_eq!(
out.stats.provenance_count,
out.stats.glyph_count + out.stats.stroke_count + out.stats.curve_count,
"{fixture}: every drawn glyph, stroke, and curve must carry a provenance trace"
);
assert!(
out.diagnostics.is_empty(),
"{fixture}: stub-pipeline glyphs are all bundled, so no fallback is expected"
);
// Provenance survival into the SVG, as for the stub goldens.
for g in &out_glyph_ids(&score) {
assert!(
out.svg.contains(&format!("data-prov=\"{g:032x}\"")),
"{fixture}: provenance {g:032x} did not survive the engrave into the SVG"
);
}
// Golden locks: the engraver's machine snapshot and full SVG bytes.
assert_golden(
&format!("{fixture}.engrave.snapshot.txt"),
&snapshot_text(fixture, "engrave", &constrained, &out),
);
assert_golden(&format!("{fixture}.engrave.svg"), &out.svg);
}
}
/// The two solvers' goldens must genuinely differ: the Engraver re-spaces, so its
/// SVG cannot be byte-identical to the stub's verbatim-column SVG. A regression
/// that made the Engraver echo the stub would pass both golden checks above (each
/// would just match its own committed file) — this catches that degeneracy.
#[test]
fn engraver_goldens_differ_from_the_stub_goldens() {
for (fixture, score) in fixtures() {
let (_, stub_out) = pipeline(&score);
let (_, engrave_out) = engrave_pipeline(&score);
assert_ne!(
stub_out.svg, engrave_out.svg,
"{fixture}: the Engraver must re-space, so its SVG must differ from the stub's"
);
}
}