//! 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 `` 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/`, 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 { 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(" 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" ); } }