diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index 51e3a60..c88d6e0 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -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"); + } + } } diff --git a/crates/epiphany-testkit/src/editloop.rs b/crates/epiphany-testkit/src/editloop.rs new file mode 100644 index 0000000..06dd85b --- /dev/null +++ b/crates/epiphany-testkit/src/editloop.rs @@ -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(score: &Score, solver: &S) -> Option { + 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 { + 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(base: &Score, solver: &S) -> Option { + 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" + ); + } +} diff --git a/crates/epiphany-testkit/src/lib.rs b/crates/epiphany-testkit/src/lib.rs index 9769b41..d9b5966 100644 --- a/crates/epiphany-testkit/src/lib.rs +++ b/crates/epiphany-testkit/src/lib.rs @@ -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;