//! 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" ); } }