366 lines
14 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|