Stop the conformance suite from authoring the frozen operation
req:opcat:transpose-frozen says a conforming implementation MUST NOT emit Transpose from new authoring. testkit's editloop::sharpen -- the [7c] UI-seam gate's editing action -- was emitting exactly that, so the conformance suite itself would have violated the requirement the same push introduced. It now authors TransposeInterval with (0, +1): the same alteration shift, the same staff line, the same rendered result. The random-kind corpora in generators.rs keep producing the frozen Transpose, and should: it must reduce correctly forever, and a generator is now the only thing that will ever produce one. The distinction is authoring versus replay. This is not inert. TransposeInterval also writes the Propagated spelling attachment the frozen operation never did, so the edited score genuinely changes shape. the_sharpen_records_the_spelling_it_propagated pins that, and was mutation-verified by removing graph_propagate_spelling. Gate: fmt clean, clippy 0, 30 targets / 983 passed / 0 failed, docs 0 under -D warnings, conformance 8/8, zero golden churn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2740a6c53c
commit
91a99b2093
|
|
@ -14,14 +14,16 @@
|
|||
//! 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_core::{
|
||||
OperationId, PitchId, ReplicaId, Score, TranspositionInterval, 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,
|
||||
OperationPayload, OperationSet, OperationStamp, TransposeIntervalOp,
|
||||
};
|
||||
|
||||
/// What one editing-loop iteration did, for inspection by tests.
|
||||
|
|
@ -57,9 +59,15 @@ fn render_with<S: ConstraintSolver>(score: &Score, solver: &S) -> Option<RenderI
|
|||
.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).
|
||||
/// Builds a "sharpen" edit — a `+1`-chromatic [`TransposeIntervalOp`] on
|
||||
/// `pitch` — and reduces it onto `base`, returning the edited score (the same
|
||||
/// `PitchId`s, the target's CMN alteration shifted by one, its staff line
|
||||
/// unchanged).
|
||||
///
|
||||
/// This models an *editing action*, so it authors the faithful operation. The
|
||||
/// frozen `Transpose` MUST NOT be emitted by new authoring
|
||||
/// (`req:opcat:transpose-frozen`); it survives only in the random-kind corpora
|
||||
/// of `generators.rs`, which exist to prove it still replays.
|
||||
fn sharpen(base: &Score, pitch: PitchId) -> Score {
|
||||
let id = OperationId::new(ReplicaId(1), 1);
|
||||
let envelope = OperationEnvelope {
|
||||
|
|
@ -68,10 +76,15 @@ fn sharpen(base: &Score, pitch: PitchId) -> Score {
|
|||
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(1), 0), id),
|
||||
causal_context: CausalContext::new(),
|
||||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::Transpose(TransposeOp {
|
||||
targets: vec![pitch],
|
||||
payload: OperationPayload::Primitive(OperationKind::TransposeInterval(
|
||||
TransposeIntervalOp {
|
||||
targets: [pitch].into_iter().collect(),
|
||||
interval: TranspositionInterval {
|
||||
diatonic_steps: 0,
|
||||
chromatic_steps: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
)),
|
||||
};
|
||||
let mut set = OperationSet::new();
|
||||
set.accept(envelope);
|
||||
|
|
@ -158,6 +171,40 @@ mod tests {
|
|||
use super::*;
|
||||
use epiphany_core::generators::valid_score_rich;
|
||||
|
||||
#[test]
|
||||
fn the_sharpen_records_the_spelling_it_propagated() {
|
||||
// The frozen `Transpose` recorded none, so an authored spelling stayed
|
||||
// pinned to the pre-edit notehead. `TransposeInterval` must attach the
|
||||
// spelling its interval determined (`req:opcat:transpose-interval-spelling`).
|
||||
use epiphany_core::{SpellingScope, SpellingSource};
|
||||
let base = valid_score_rich(0x5EED);
|
||||
let pitch = base
|
||||
.events
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
let mut ips = Vec::new();
|
||||
e.collect_identified_pitches(&mut ips);
|
||||
ips.first().map(|ip| ip.id)
|
||||
})
|
||||
.expect("the rich fixture has a pitch");
|
||||
assert!(
|
||||
!base
|
||||
.spelling_attachments
|
||||
.iter()
|
||||
.any(|a| matches!(&a.source, SpellingSource::Propagated { .. })),
|
||||
"the fixture starts with no propagated attachment"
|
||||
);
|
||||
|
||||
let edited = sharpen(&base, pitch);
|
||||
assert!(
|
||||
edited.spelling_attachments.iter().any(|a| {
|
||||
matches!(a.source, SpellingSource::Propagated { from } if from == pitch)
|
||||
&& matches!(&a.scope, SpellingScope::Pitch(p) if *p == pitch)
|
||||
}),
|
||||
"the sharpen propagated its spelling"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_editing_loop_iteration_holds_every_seam() {
|
||||
// A returned report means the click resolved to a pitch-backed notehead (the
|
||||
|
|
|
|||
Loading…
Reference in New Issue