testkit: an editing-loop vertical slice across the ops/layout/render seams

One iteration of an interactive edit, wired end to end so a missing connection
between the crates surfaces here, not in a GUI: render a score to its RenderIR,
resolve a click on a notehead to its graph pitch through the hit-test map, apply a
real operation (sharpen -- a +1-chromatic Transpose) by reducing it onto the score
graph, re-render, and confirm the selection survives the relayout.

  - run_edit_loop_with<S: ConstraintSolver>(base, solver) returns an EditLoopReport
    (selected_pitch, selection, graph_changed, selection_preserved, render_changed);
    run_edit_loop is the stub wrapper. render_with returns None for a
    diagnostic-only (non-renderable) solver report, so the loop never hit-tests or
    edits a layout the caller must not render.
  - The click resolves like a GUI's: aim at each real notehead's centre (a notehead
    glyph, not synthesized, pitch-backed) and select whatever the topmost hit there
    is, taking the first aim whose topmost hit is a pitch-backed glyph -- faithful
    to a click and robust to an occluding unison/chord notehead.
  - Selection survival rests on the MUSCLOID layout id, a function of the pitch's
    identity (PitchId), not its content: the sharpen changes the pitch's value but
    not its id, so its layout object keeps the same stable id and the cursor does
    not jump off the edited note. The prepass re-runs inside to_logical, so the
    edited pitch is re-spelled and its accidental reflects the new value (why the
    re-render reliably differs).

Tests drive the loop on valid_score_rich (one fixture and across 48 seeds, every
seed required), refuse a diagnostic-only solver layout, and -- in epiphany-engrave,
via its existing testkit dev-dep -- run it through the real Engraver across 16
seeds, proving the selection survives even as the Engraver re-spaces every glyph.
Full gate green: build, fmt, clippy, conformance scale 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-27 16:25:17 -04:00
parent cb92d890ec
commit bdab6dee1f
3 changed files with 279 additions and 0 deletions

View File

@ -977,4 +977,28 @@ mod tests {
"the Engraver must re-space, not echo the stub's verbatim columns"
);
}
/// The editing-loop vertical slice (testkit's `run_edit_loop_with`) driven
/// through the **real Engraver**: click a notehead, sharpen its pitch, re-space
/// with the Engraver, and confirm the selection survives. The selection is the
/// `MUSCLOID` layout id (content-independent), so it holds even though the
/// Engraver re-spaces every glyph — proving the slice's "real Engraver too"
/// claim, not just the stub's.
#[test]
fn the_editing_loop_holds_through_the_real_engraver() {
use epiphany_core::generators::valid_score_rich;
for seed in 0..16u64 {
let report =
epiphany_testkit::editloop::run_edit_loop_with(&valid_score_rich(seed), &Engraver)
.unwrap_or_else(|| {
panic!("seed {seed}: no clickable notehead to drive the loop")
});
assert!(report.graph_changed, "seed {seed}: graph unchanged");
assert!(
report.selection_preserved,
"seed {seed}: selection lost across the Engraver's relayout"
);
assert!(report.render_changed, "seed {seed}: edit not visible");
}
}
}

View File

@ -0,0 +1,251 @@
//! The editing-loop vertical slice — the seam between ops, layout, and render.
//!
//! One iteration of an interactive edit, end to end: render a score to its
//! `RenderIR`, **click** a notehead and resolve it to its graph pitch through the
//! hit-test map, **apply** a real operation (sharpen — a `+1`-chromatic
//! `Transpose`), **reduce** it onto the score graph, **re-render**, and confirm the
//! **selection survives** the relayout. This walks the exact path an editor walks —
//! hit-test → score object → operation → reduction → re-layout → re-render →
//! re-resolve selection — across the crate seams, so a missing connection surfaces
//! here rather than in a GUI.
//!
//! Selection survival rests on the `MUSCLOID` layout-object id, which is a function
//! of the score object's *identity*, not its content: the sharpen changes the
//! pitch's value but not its `PitchId`, so its layout object keeps the same stable
//! id and the editor re-finds the selection after the relayout.
use epiphany_core::{OperationId, PitchId, ReplicaId, Score, TypedObjectId, WallClockTime};
use epiphany_layout_ir::{
to_constrained, to_logical, to_render, ConstraintSolver, HitShape, LayoutObjectId, Point,
PrimitiveRef, RenderIR, SolverConfig, StubSolver,
};
use epiphany_ops::{
AuthorId, CausalContext, HybridLogicalClock, OperationEnvelope, OperationKind,
OperationPayload, OperationSet, OperationStamp, TransposeOp,
};
/// What one editing-loop iteration did, for inspection by tests.
#[derive(Clone, Debug)]
pub struct EditLoopReport {
/// The pitch the click resolved to — the score object that gets selected.
pub selected_pitch: PitchId,
/// The layout object the selection is anchored on (its stable id), what an
/// editor holds across edits.
pub selection: LayoutObjectId,
/// The operation changed the score graph (the edit reduced onto it).
pub graph_changed: bool,
/// After the relayout, the same layout-object id still resolves — to the same
/// pitch — so the selection survived (a cursor would not jump off the note).
pub selection_preserved: bool,
/// The re-rendered output differs from the original (the edit is visible).
pub render_changed: bool,
}
/// Renders a score through the v0 pipeline with `solver` to its `RenderIR`, or
/// `None` if the solver's report is **diagnostic-only** (not renderable). A generic
/// [`ConstraintSolver`] may return glyphs in an `Unsatisfiable`/`InternalError`
/// report; that layout must never be rendered, hit-tested, or edited against, so
/// the loop refuses it.
fn render_with<S: ConstraintSolver>(score: &Score, solver: &S) -> Option<RenderIR> {
let report = solver.solve(
&to_constrained(&to_logical(score)),
&SolverConfig::default(),
);
report
.status
.is_renderable()
.then(|| to_render(&report.layout))
}
/// Builds a "sharpen" edit — a `+1`-chromatic [`TransposeOp`] on `pitch` — and
/// reduces it onto `base`, returning the edited score (the same `PitchId`s, the
/// target's CMN alteration shifted by one).
fn sharpen(base: &Score, pitch: PitchId) -> Score {
let id = OperationId::new(ReplicaId(1), 1);
let envelope = OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(1), 0), id),
causal_context: CausalContext::new(),
transaction: None,
payload: OperationPayload::Primitive(OperationKind::Transpose(TransposeOp {
targets: vec![pitch],
chromatic_steps: 1,
})),
};
let mut set = OperationSet::new();
set.accept(envelope);
set.reduce_onto(base).score
}
/// Runs one editing-loop iteration through the stub solver. See
/// [`run_edit_loop_with`]; this is the stub wrapper.
pub fn run_edit_loop(base: &Score) -> Option<EditLoopReport> {
run_edit_loop_with(base, &StubSolver)
}
/// Runs one editing-loop iteration on `base` with `solver`: render, click a real
/// notehead, sharpen its pitch, re-render, and check the selection survived.
/// Parameterized over the solver so the same slice exercises both the stub and the
/// real `Engraver` (the latter from a crate that depends on `epiphany-engrave`).
/// Returns `None` if either solver pass reports a diagnostic-only layout, or if
/// the renderable layout has no pitch-backed notehead to click.
pub fn run_edit_loop_with<S: ConstraintSolver>(base: &Score, solver: &S) -> Option<EditLoopReport> {
let render0 = render_with(base, solver)?;
let map0 = render0.hit_test_map();
// 1. Resolve a "click": aim at each real notehead's centre in turn (an actual
// notehead glyph, not a synthesized/cautionary mark, backed by a pitch), and
// select whatever a GUI would there — the *topmost* hit. Take the first aim
// whose topmost hit is a pitch-backed glyph, and derive the selection from
// *that* hit: faithful to a real click and robust to an occluding unison or
// chord notehead (which a "the aimed glyph must be topmost" check would
// false-fail on).
let (pitch, selection) = render0
.primitives
.iter()
.enumerate()
.filter(|(_, p)| {
p.glyph.as_str().starts_with("notehead")
&& p.provenance.synthesis.is_none()
&& matches!(p.provenance.source, TypedObjectId::Pitch(_))
})
.find_map(|(i, _)| {
let region = map0
.regions
.iter()
.find(|r| r.primitive == PrimitiveRef::Glyph(i))?;
let HitShape::Box(b) = region.shape else {
return None; // a glyph region is always a box
};
let click = Point::new((b.left.0 + b.right.0) / 2.0, (b.bottom.0 + b.top.0) / 2.0);
let top = map0.hit(click).into_iter().next()?;
match (top.primitive.is_glyph(), top.source) {
(true, TypedObjectId::Pitch(pid)) => Some((pid, top.layout_object)),
_ => None,
}
})?;
// 2. Apply the edit by reducing the operation onto the score graph.
let edited = sharpen(base, pitch);
let graph_changed = &edited != base;
// 3. Re-render and re-resolve (refusing a diagnostic-only edited layout).
let render1 = render_with(&edited, solver)?;
let map1 = render1.hit_test_map();
// 4. The selection survives: the same stable id still resolves, to the same
// pitch. (Its id is content-independent, so the relayout does not move the
// cursor off the edited note even though the note's glyphs changed.)
let selection_preserved = map1
.regions
.iter()
.any(|r| r.layout_object == selection && r.source == TypedObjectId::Pitch(pitch));
let render_changed = render0 != render1;
Some(EditLoopReport {
selected_pitch: pitch,
selection,
graph_changed,
selection_preserved,
render_changed,
})
}
#[cfg(test)]
mod tests {
use super::*;
use epiphany_core::generators::valid_score_rich;
#[test]
fn one_editing_loop_iteration_holds_every_seam() {
// A returned report means the click resolved to a pitch-backed notehead (the
// hit-test → score-object seam); the fields cover the rest.
let report = run_edit_loop(&valid_score_rich(0x5EED))
.expect("the rich fixture renders a clickable notehead");
// operation → reduction.
assert!(report.graph_changed, "the sharpen changed the score graph");
// re-layout → re-render → re-resolve selection.
assert!(
report.selection_preserved,
"the selection survived the relayout (stable layout id)"
);
// the edit is visible.
assert!(
report.render_changed,
"the sharpen changed the re-rendered output"
);
}
#[test]
fn the_seams_hold_across_many_scores() {
// The rich fixture always renders a clickable notehead, so *every* seed must
// drive the loop — a regression that left only some seeds clickable must not
// pass by being silently skipped.
for seed in 0..48u64 {
let report = run_edit_loop(&valid_score_rich(seed))
.unwrap_or_else(|| panic!("seed {seed}: no clickable notehead to drive the loop"));
assert!(report.graph_changed, "seed {seed}: graph unchanged");
assert!(
report.selection_preserved,
"seed {seed}: selection lost across relayout"
);
assert!(report.render_changed, "seed {seed}: edit not visible");
}
}
/// A solver whose report is diagnostic-only (`Unsatisfiable`) but still carries a
/// non-empty layout — exactly the case a hit-test/edit must not run against.
struct UnsatisfiableSolver;
impl ConstraintSolver for UnsatisfiableSolver {
fn tier(&self) -> epiphany_layout_ir::SolverTier {
epiphany_layout_ir::SolverTier::Minimal
}
fn version(&self) -> epiphany_layout_ir::SolverVersion {
epiphany_layout_ir::SolverVersion(99)
}
fn solve(
&self,
input: &epiphany_layout_ir::ConstrainedLayoutIR,
config: &SolverConfig,
) -> epiphany_layout_ir::SolveReport {
// Borrow the stub's (non-empty) geometry, then mark the report
// diagnostic-only — a conformant Unsatisfiable layout the caller must
// not render.
let mut report = StubSolver.solve(input, config);
report.status = epiphany_layout_ir::SolveStatus::Unsatisfiable;
report.satisfied_hard_constraints = false;
report
}
fn solve_incremental(
&self,
input: &epiphany_layout_ir::ConstrainedLayoutIR,
_prior: &epiphany_layout_ir::SolverState,
_invalidations: &epiphany_layout_ir::InvalidationSet,
config: &SolverConfig,
) -> epiphany_layout_ir::SolveReport {
self.solve(input, config)
}
}
#[test]
fn a_diagnostic_only_solver_layout_is_refused() {
// The unsatisfiable solver returns a non-empty layout, but it is not
// renderable, so the loop refuses to hit-test or edit against it.
let report = UnsatisfiableSolver.solve(
&to_constrained(&to_logical(&valid_score_rich(0x5EED))),
&SolverConfig::default(),
);
assert!(
!report.layout.glyphs.is_empty(),
"the fake layout is non-empty"
);
assert!(!report.status.is_renderable());
assert!(
run_edit_loop_with(&valid_score_rich(0x5EED), &UnsatisfiableSolver).is_none(),
"a diagnostic-only layout must not drive the editing loop"
);
}
}

View File

@ -112,4 +112,8 @@ pub mod bundle_harness;
pub mod layout_stub;
// The editing-loop vertical slice: hit-test → score object → operation → reduce →
// re-layout → re-render → re-resolve selection, across the ops/layout/render seams.
pub mod editloop;
pub use rng::Rng;