epiphany/crates/epiphany-ops/tests/graph_reduction.rs

1216 lines
39 KiB
Rust

//! M2 regression coverage for reducing operations onto Agent B's real score
//! graph rather than only the Chapter 6 bookkeeping projection.
use epiphany_core::{
check_invariants, derive_promoted_voice_id, AnchorOffset, EventId, MusicalDuration,
MusicalPosition, OperationId, PitchId, RationalTime, RegionEdge, RegionTimeModel, ReplicaId,
Score, SlurId, StaffInstanceId, TimeAnchor, TransactionId, TypedObjectId, VoiceId, VoiceOrigin,
WallClockTime,
};
use epiphany_ops::{
valuegen, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp,
CrossCuttingValue, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason,
OperationEffect, OperationEnvelope, OperationKind, OperationPayload, OperationSet,
OperationStamp, PositionRemapping, PreconditionFailureReason, SetUserSystemBreakOp,
TransactionCategory, TransactionDescriptor, TupletCompensation, UndoPolicy,
UndoTransactionPayload,
};
fn envelope(
replica: u64,
counter: u64,
physical: i64,
context: CausalContext,
transaction: Option<TransactionId>,
payload: OperationPayload,
) -> OperationEnvelope {
let id = OperationId::new(ReplicaId(replica), counter);
OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
causal_context: context,
transaction,
payload,
}
}
fn target(score: &Score) -> (StaffInstanceId, VoiceId) {
let instance = &score.canvas.regions[0].staff_instances()[0];
(instance.id, instance.voices[0].id)
}
fn insert(
staff_instance: StaffInstanceId,
voice: VoiceId,
event: EventId,
pitch: PitchId,
position: i32,
) -> OperationPayload {
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
staff_instance,
event: valuegen::insert_event_value(
event,
voice,
MusicalPosition(RationalTime::from_int(position)),
MusicalDuration::whole(),
&[pitch],
),
}))
}
fn voice(score: &Score, id: VoiceId) -> Option<&epiphany_core::Voice> {
score
.voices()
.find_map(|(_, _, voice)| (voice.id == id).then_some(voice))
}
#[test]
fn insert_materializes_in_the_real_arena_and_voice() {
let base = epiphany_core::generators::valid_score(100);
let (staff_instance, target_voice) = target(&base);
let event = EventId::new(ReplicaId(50), 0);
let pitch = PitchId::new(ReplicaId(50), 1);
let op = envelope(
50,
0,
10,
CausalContext::new(),
None,
insert(staff_instance, target_voice, event, pitch, 100),
);
let mut set = OperationSet::new();
set.accept(op);
let result = set.reduce_onto(&base);
assert!(result.score.events.contains(event));
assert!(voice(&result.score, target_voice)
.expect("target voice remains present")
.events
.contains(&event));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn graph_reduction_rejects_an_unknown_voice_without_creating_it() {
let base = epiphany_core::generators::valid_score(101);
let (staff_instance, _) = target(&base);
let missing_voice = VoiceId::new(ReplicaId(51), 99);
let event = EventId::new(ReplicaId(51), 0);
let op = envelope(
51,
0,
10,
CausalContext::new(),
None,
insert(
staff_instance,
missing_voice,
event,
PitchId::new(ReplicaId(51), 1),
100,
),
);
let mut set = OperationSet::new();
set.accept(op.clone());
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(event));
assert!(voice(&result.score, missing_voice).is_none());
assert_eq!(
result.state.effects,
vec![(
op.id,
OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::VoiceMissing,
},
},
)]
);
}
#[test]
fn concurrent_overlap_materializes_an_invariant_clean_promoted_voice() {
let base = epiphany_core::generators::valid_score(102);
let (staff_instance, target_voice) = target(&base);
let winner = envelope(
52,
0,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(52), 10),
PitchId::new(ReplicaId(52), 11),
100,
),
);
let loser = envelope(
53,
0,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(53), 10),
PitchId::new(ReplicaId(53), 11),
100,
),
);
let promoted = derive_promoted_voice_id(staff_instance, target_voice, winner.id, loser.id);
let mut set = OperationSet::new();
set.accept_all(vec![loser.clone(), winner.clone()]);
let mut reversed = OperationSet::new();
reversed.accept_all(vec![winner.clone(), loser.clone()]);
let result = set.reduce_onto(&base);
assert_eq!(result, reversed.reduce_onto(&base));
let promoted_voice = voice(&result.score, promoted).expect("promoted voice was materialized");
assert!(promoted_voice
.events
.contains(&EventId::new(ReplicaId(53), 10)));
assert_eq!(
promoted_voice.origin,
VoiceOrigin::SystemPromoted {
winning_operation: winner.id,
losing_operation: loser.id,
original_voice: target_voice,
}
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn delete_removes_the_event_and_records_graph_tombstones() {
let base = epiphany_core::generators::valid_score(103);
let (staff_instance, target_voice) = target(&base);
let event = EventId::new(ReplicaId(54), 10);
let pitch = PitchId::new(ReplicaId(54), 11);
let insertion = envelope(
54,
0,
10,
CausalContext::new(),
None,
insert(staff_instance, target_voice, event, pitch, 100),
);
let deletion = envelope(
54,
1,
11,
CausalContext::new().with_seen(ReplicaId(54), 0),
None,
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event,
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
);
let mut set = OperationSet::new();
set.accept_all(vec![deletion, insertion]);
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(event));
assert!(!voice(&result.score, target_voice)
.expect("target voice remains present")
.events
.contains(&event));
assert!(result.score.tombstoned_events.contains(&event));
assert!(result.score.tombstoned_pitches.contains(&pitch));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn failed_transaction_rolls_back_real_graph_mutations() {
let base = epiphany_core::generators::valid_score(104);
let (staff_instance, target_voice) = target(&base);
let tx = TransactionId::from_raw(77);
let descriptor = envelope(
55,
0,
10,
CausalContext::new(),
Some(tx),
OperationPayload::Primitive(OperationKind::DeclareTransaction(TransactionDescriptor {
id: tx,
label: String::from("graph rollback"),
category: Some(TransactionCategory::NoteEntry),
})),
);
let tx_context = CausalContext::new().with_seen(ReplicaId(55), 0);
let inserted_event = EventId::new(ReplicaId(55), 10);
let insertion = envelope(
55,
1,
11,
tx_context.clone(),
Some(tx),
insert(
staff_instance,
target_voice,
inserted_event,
PitchId::new(ReplicaId(55), 11),
100,
),
);
let failing = envelope(
55,
2,
12,
tx_context,
Some(tx),
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: EventId::new(ReplicaId(55), 999),
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
);
let mut set = OperationSet::new();
set.accept_all(vec![failing, insertion, descriptor]);
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(inserted_event));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn forward_undo_removes_transaction_mints_from_the_graph() {
let base = epiphany_core::generators::valid_score(105);
let (staff_instance, target_voice) = target(&base);
let tx = TransactionId::from_raw(78);
let descriptor = envelope(
56,
0,
10,
CausalContext::new(),
Some(tx),
OperationPayload::Primitive(OperationKind::DeclareTransaction(TransactionDescriptor {
id: tx,
label: String::from("graph undo"),
category: None,
})),
);
let inserted_event = EventId::new(ReplicaId(56), 10);
let insertion = envelope(
56,
1,
11,
CausalContext::new().with_seen(ReplicaId(56), 0),
Some(tx),
insert(
staff_instance,
target_voice,
inserted_event,
PitchId::new(ReplicaId(56), 11),
100,
),
);
let undo = envelope(
56,
2,
12,
CausalContext::new().with_seen(ReplicaId(56), 1),
None,
OperationPayload::UndoTransaction(UndoTransactionPayload {
target: tx,
policy: UndoPolicy::StrictInverse,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![undo, insertion, descriptor]);
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(inserted_event));
assert!(result.score.tombstoned_events.contains(&inserted_event));
assert!(matches!(
result
.state
.objects
.get(&TypedObjectId::Event(inserted_event)),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn system_break_lww_state_is_materialized_in_the_region() {
let base = epiphany_core::generators::valid_score(106);
let region = base.canvas.regions[0].id;
let position = MusicalPosition(RationalTime::from_int(8));
let operation = envelope(
57,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
region,
anchor: valuegen::region_start_anchor(region, position.clone()),
present: true,
})),
);
let mut set = OperationSet::new();
set.accept(operation);
let result = set.reduce_onto(&base);
let breaks = &result.score.canvas.regions[0]
.content
.staff_based()
.expect("fixture is staff based")
.user_system_breaks;
assert_eq!(
breaks,
&[TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration(position.0)),
}]
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn migration_computes_incompatible_events_from_the_graph() {
let base = epiphany_core::generators::valid_score(107);
let region = base.canvas.regions[0].id;
let operation = envelope(
58,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp {
region,
new_time_model: valuegen::proportional_model(),
declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime,
},
)),
);
let mut set = OperationSet::new();
set.accept(operation);
let result = set.reduce_onto(&base);
assert_eq!(result.score, base);
assert!(result
.state
.conflicts
.records()
.iter()
.any(|record| matches!(record.kind, ConflictKind::TimeModelMigrationFailure { .. })));
}
#[test]
fn create_cross_cutting_materializes_supported_graph_structures() {
let base = epiphany_core::generators::valid_score(108);
let endpoints = base.canvas.regions[0].staff_instances()[0].voices[0].events[..2].to_vec();
let slur = SlurId::new(ReplicaId(59), 10);
let operation = envelope(
59,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingValue::Slur(valuegen::slur(slur, endpoints[0], endpoints[1])),
})),
);
let mut set = OperationSet::new();
set.accept(operation);
let result = set.reduce_onto(&base);
assert!(result
.score
.cross_cutting
.slurs
.iter()
.any(|value| value.id == slur));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn causally_ordered_time_migrations_do_not_conflict() {
let base = epiphany_core::generators::valid_score(109);
let region = base.canvas.regions[0].id;
let first = envelope(
60,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp {
region,
new_time_model: valuegen::aleatoric_model(),
declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime,
},
)),
);
let second = envelope(
60,
1,
11,
CausalContext::new().with_seen(ReplicaId(60), 0),
None,
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp {
region,
new_time_model: valuegen::metric_model(),
declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime,
},
)),
);
let mut set = OperationSet::new();
set.accept_all(vec![second, first]);
let result = set.reduce_onto(&base);
assert!(result.state.conflicts.is_empty());
assert!(matches!(
result.score.canvas.regions[0].time_model,
RegionTimeModel::Metric(_)
));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn graph_materialization_is_deterministic_across_base_corpus_and_delivery_order() {
for seed in 0..64_u64 {
let base = epiphany_core::generators::valid_score(1_000 + seed);
let (staff_instance, target_voice) = target(&base);
let winner = envelope(
0xC001,
seed,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(0xC001), seed),
PitchId::new(ReplicaId(0xC001), seed),
100,
),
);
let loser = envelope(
0xC002,
seed,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(0xC002), seed),
PitchId::new(ReplicaId(0xC002), seed),
100,
),
);
let mut forward = OperationSet::new();
forward.accept_all(vec![winner.clone(), loser.clone()]);
let mut backward = OperationSet::new();
backward.accept_all(vec![loser, winner]);
let expected = forward.reduce_onto(&base);
let actual = backward.reduce_onto(&base);
assert_eq!(actual, expected, "base seed {seed}");
assert!(
check_invariants(&actual.score).is_empty(),
"base seed {seed}"
);
}
}
#[test]
fn delete_last_identified_pitch_degrades_the_note_to_a_rest() {
// A single-pitch note whose only pitch is deleted must NOT materialize as an
// empty (invalid) pitched event; Chapter 5 forbids that, so it degrades to a
// rest of the same placement (and `check_invariants` would reject otherwise).
let base = epiphany_core::generators::valid_score(100);
let (staff_instance, target_voice) = target(&base);
let event = EventId::new(ReplicaId(60), 0);
let pitch = PitchId::new(ReplicaId(60), 1);
let insert_note = envelope(
60,
0,
10,
CausalContext::new(),
None,
insert(staff_instance, target_voice, event, pitch, 100),
);
let delete_pitch = envelope(
60,
1,
20,
CausalContext::new().with_seen(ReplicaId(60), 0),
None,
OperationPayload::Primitive(OperationKind::DeleteIdentifiedPitch(
epiphany_ops::DeleteIdentifiedPitchOp { pitch },
)),
);
let mut set = OperationSet::new();
set.accept_all(vec![insert_note, delete_pitch]);
let result = set.reduce_onto(&base);
assert!(
matches!(
result.score.events.get(event),
Some(epiphany_core::Event::Rest(_))
),
"a note whose last pitch is deleted must become a rest, not an empty chord"
);
assert!(
matches!(
result.state.objects.get(&TypedObjectId::Pitch(pitch)),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
),
"the deleted pitch is tombstoned in the bookkeeping projection"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn insert_identified_pitch_into_a_rest_promotes_it_to_a_note() {
// Adding a pitch to a rest turns the rest into a note — the dual of the
// last-pitch delete — so the graph holds the pitch the bookkeeping minted
// (otherwise the live pitch object would have no graph counterpart).
let base = epiphany_core::generators::valid_score(100);
let (staff_instance, target_voice) = target(&base);
let rest = EventId::new(ReplicaId(61), 0);
let insert_rest = envelope(
61,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
staff_instance,
event: valuegen::insert_event_value(
rest,
target_voice,
MusicalPosition(RationalTime::from_int(100)),
MusicalDuration::whole(),
&[],
),
})),
);
let pitch = PitchId::new(ReplicaId(61), 1);
let add_pitch = envelope(
61,
1,
20,
CausalContext::new().with_seen(ReplicaId(61), 0),
None,
OperationPayload::Primitive(OperationKind::InsertIdentifiedPitch(
epiphany_ops::InsertIdentifiedPitchOp {
event: rest,
pitch: valuegen::identified_pitch(pitch),
},
)),
);
let mut set = OperationSet::new();
set.accept_all(vec![insert_rest, add_pitch]);
let result = set.reduce_onto(&base);
match result.score.events.get(rest) {
Some(epiphany_core::Event::Pitched(pe)) => assert!(
pe.pitches.iter().any(|ip| ip.id == pitch),
"the inserted pitch is present on the promoted note"
),
other => panic!("expected the rest to become a note, got {other:?}"),
}
assert!(check_invariants(&result.score).is_empty());
}
/// Three non-overlapping events in the target voice plus a cross-cutting
/// structure over them (built by `structure`) — a self-contained corpus authored
/// on one replica so each op causally follows (and therefore sees) the events it
/// depends on. Returns the events, the create envelope, and the three inserts;
/// tests append their own op at counter 4 (causally after the create).
fn cross_cutting_fixture(
base: &Score,
structure: impl FnOnce(EventId, EventId, EventId) -> CrossCuttingValue,
) -> (
EventId,
EventId,
EventId,
OperationEnvelope,
Vec<OperationEnvelope>,
) {
let (staff_instance, target_voice) = target(base);
let r = 70;
let (e1, e2, e3) = (
EventId::new(ReplicaId(r), 0),
EventId::new(ReplicaId(r), 1),
EventId::new(ReplicaId(r), 2),
);
let ins = |counter: u64, ev: EventId, pos: i32| {
let ctx = if counter == 0 {
CausalContext::new()
} else {
CausalContext::new().with_seen(ReplicaId(r), counter - 1)
};
envelope(
r,
counter,
10 + counter as i64,
ctx,
None,
insert(
staff_instance,
target_voice,
ev,
PitchId::new(ReplicaId(r), 100 + counter),
pos,
),
)
};
let inserts = vec![ins(0, e1, 100), ins(1, e2, 101), ins(2, e3, 102)];
let create = envelope(
r,
3,
14,
CausalContext::new().with_seen(ReplicaId(r), 2),
None,
OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: structure(e1, e2, e3),
})),
);
(e1, e2, e3, create, inserts)
}
/// An op authored after the fixture's create (counter 4, sees counters 0..=3).
fn after_create(payload: OperationPayload) -> OperationEnvelope {
envelope(
70,
4,
15,
CausalContext::new().with_seen(ReplicaId(70), 3),
None,
payload,
)
}
/// An event-anchored spanner over `a`..`b` (the reduction reads its endpoints
/// from the two [`TimeAnchor::Event`] anchors).
fn spanner_over(id: epiphany_core::SpannerId, a: EventId, b: EventId) -> epiphany_core::Spanner {
epiphany_core::Spanner {
id,
start: TimeAnchor::Event {
id: a,
offset: AnchorOffset::Zero,
},
end: TimeAnchor::Event {
id: b,
offset: AnchorOffset::Zero,
},
staves: Vec::new(),
}
}
/// Create a structure, then delete it; assert it leaves the graph and the
/// structure id is tombstoned (delete-wins), graph invariants intact.
fn assert_delete_removes_and_tombstones(
structure: impl FnOnce(EventId, EventId, EventId) -> CrossCuttingValue,
sid: TypedObjectId,
still_present: impl Fn(&Score) -> bool,
) {
let base = epiphany_core::generators::valid_score(100);
let (_e1, _e2, _e3, create, inserts) = cross_cutting_fixture(&base, structure);
let delete = after_create(OperationPayload::Primitive(
OperationKind::DeleteCrossCutting(epiphany_ops::DeleteCrossCuttingOp { structure: sid }),
));
let mut set = OperationSet::new();
set.accept_all(inserts.into_iter().chain([create, delete]));
let result = set.reduce_onto(&base);
assert!(
!still_present(&result.score),
"DeleteCrossCutting removes {sid:?} from the graph"
);
assert!(
matches!(
result.state.objects.get(&sid),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
),
"DeleteCrossCutting tombstones {sid:?} (delete-wins)"
);
assert!(check_invariants(&result.score).is_empty());
}
/// Create a structure, then overwrite it with `modified`; run `verify` on the
/// resulting graph (which receives the fixture's three events), invariants intact.
fn assert_modify_updates(
initial: impl FnOnce(EventId, EventId, EventId) -> CrossCuttingValue,
modified: impl FnOnce(EventId, EventId, EventId) -> CrossCuttingValue,
verify: impl FnOnce(&Score, EventId, EventId, EventId),
) {
let base = epiphany_core::generators::valid_score(100);
let (e1, e2, e3, create, inserts) = cross_cutting_fixture(&base, initial);
let modify = after_create(OperationPayload::Primitive(
OperationKind::ModifyCrossCutting(epiphany_ops::ModifyCrossCuttingOp {
structure: modified(e1, e2, e3),
}),
));
let mut set = OperationSet::new();
set.accept_all(inserts.into_iter().chain([create, modify]));
let result = set.reduce_onto(&base);
verify(&result.score, e1, e2, e3);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn delete_cross_cutting_removes_the_structure_from_the_graph() {
let slur = SlurId::new(ReplicaId(70), 9);
// First confirm the create actually materializes (the delete assertions below
// would pass vacuously if it didn't).
let base = epiphany_core::generators::valid_score(100);
let (_e1, _e2, _e3, create, inserts) = cross_cutting_fixture(&base, |e1, e2, _| {
CrossCuttingValue::Slur(valuegen::slur(slur, e1, e2))
});
let mut created = OperationSet::new();
created.accept_all(inserts.into_iter().chain([create]));
assert!(
created
.reduce_onto(&base)
.score
.cross_cutting
.slurs
.iter()
.any(|s| s.id == slur),
"CreateCrossCutting materializes the slur in the graph"
);
assert_delete_removes_and_tombstones(
|e1, e2, _| CrossCuttingValue::Slur(valuegen::slur(slur, e1, e2)),
TypedObjectId::Slur(slur),
move |score| score.cross_cutting.slurs.iter().any(|s| s.id == slur),
);
}
#[test]
fn delete_cross_cutting_handles_tie_beam_and_spanner() {
let tie = epiphany_core::TieId::new(ReplicaId(70), 1);
assert_delete_removes_and_tombstones(
|e1, e2, _| CrossCuttingValue::Tie(valuegen::tie(tie, e1, e2)),
TypedObjectId::Tie(tie),
move |score| score.cross_cutting.ties.iter().any(|t| t.id == tie),
);
let beam = epiphany_core::BeamId::new(ReplicaId(70), 1);
assert_delete_removes_and_tombstones(
|e1, e2, e3| CrossCuttingValue::Beam(valuegen::beam(beam, vec![e1, e2, e3])),
TypedObjectId::Beam(beam),
move |score| score.cross_cutting.beams.iter().any(|b| b.id == beam),
);
let spanner = epiphany_core::SpannerId::new(ReplicaId(70), 1);
assert_delete_removes_and_tombstones(
|e1, e2, _| CrossCuttingValue::Spanner(spanner_over(spanner, e1, e2)),
TypedObjectId::Spanner(spanner),
move |score| score.cross_cutting.spanners.iter().any(|s| s.id == spanner),
);
}
#[test]
fn modify_cross_cutting_updates_the_structure_in_the_graph() {
let slur = SlurId::new(ReplicaId(70), 9);
// Re-point the slur's end from e2 to e3 (a different live endpoint).
assert_modify_updates(
|e1, e2, _| CrossCuttingValue::Slur(valuegen::slur(slur, e1, e2)),
|e1, _, e3| CrossCuttingValue::Slur(valuegen::slur(slur, e1, e3)),
move |score, _e1, _e2, e3| {
let s = score
.cross_cutting
.slurs
.iter()
.find(|s| s.id == slur)
.expect("the slur is still present after a modify");
assert_eq!(s.end_event, e3, "modify updates the slur's endpoint");
},
);
}
#[test]
fn modify_cross_cutting_updates_tie_beam_and_spanner() {
let tie = epiphany_core::TieId::new(ReplicaId(70), 1);
assert_modify_updates(
|e1, e2, _| CrossCuttingValue::Tie(valuegen::tie(tie, e1, e2)),
|e1, _, e3| CrossCuttingValue::Tie(valuegen::tie(tie, e1, e3)),
move |score, _e1, _e2, e3| {
let t = score
.cross_cutting
.ties
.iter()
.find(|t| t.id == tie)
.expect("the tie is still present after a modify");
assert_eq!(t.end_event, e3, "modify updates the tie's endpoint");
},
);
let beam = epiphany_core::BeamId::new(ReplicaId(70), 1);
assert_modify_updates(
|e1, e2, _| CrossCuttingValue::Beam(valuegen::beam(beam, vec![e1, e2])),
|e1, e2, e3| CrossCuttingValue::Beam(valuegen::beam(beam, vec![e1, e2, e3])),
move |score, _e1, _e2, _e3| {
let b = score
.cross_cutting
.beams
.iter()
.find(|b| b.id == beam)
.expect("the beam is still present after a modify");
assert_eq!(b.events.len(), 3, "modify grows the beam to three events");
},
);
let spanner = epiphany_core::SpannerId::new(ReplicaId(70), 1);
assert_modify_updates(
|e1, e2, _| CrossCuttingValue::Spanner(spanner_over(spanner, e1, e2)),
|e1, _, e3| CrossCuttingValue::Spanner(spanner_over(spanner, e1, e3)),
move |score, _e1, _e2, e3| {
let s = score
.cross_cutting
.spanners
.iter()
.find(|s| s.id == spanner)
.expect("the spanner is still present after a modify");
assert!(
matches!(s.end, TimeAnchor::Event { id, .. } if id == e3),
"modify re-points the spanner's end anchor"
);
},
);
}
#[test]
fn modify_cross_cutting_rejects_an_undersized_beam() {
// Dropping a beam below two events is a precondition NoOp (mirrors
// CreateCrossCutting); the graph keeps the original beam, hitting the
// `beam.events.len() < 2` branch of modify_cross_cutting.
let base = epiphany_core::generators::valid_score(100);
let beam = epiphany_core::BeamId::new(ReplicaId(70), 1);
let (e1, e2, _e3, create, inserts) = cross_cutting_fixture(&base, |e1, e2, _| {
CrossCuttingValue::Beam(valuegen::beam(beam, vec![e1, e2]))
});
let shrink = after_create(OperationPayload::Primitive(
OperationKind::ModifyCrossCutting(epiphany_ops::ModifyCrossCuttingOp {
structure: CrossCuttingValue::Beam(valuegen::beam(beam, vec![e1])),
}),
));
let shrink_id = shrink.id;
let mut set = OperationSet::new();
set.accept_all(inserts.into_iter().chain([create, shrink]));
let result = set.reduce_onto(&base);
assert!(
matches!(
result
.state
.effects
.iter()
.find(|(id, _)| *id == shrink_id)
.map(|(_, e)| e),
Some(OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction { .. }
})
),
"a beam-modify dropping below two events is a precondition NoOp"
);
let materialized = result
.score
.cross_cutting
.beams
.iter()
.find(|b| b.id == beam)
.expect("the original beam survives the rejected modify");
assert_eq!(
materialized.events,
vec![e1, e2],
"the rejected modify left the original two-event beam"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn deleting_a_slur_endpoint_reanchors_in_both_graph_and_ledger() {
// Deleting one endpoint of a slur re-anchors it onto the survivor: the slur
// stays Live in the ledger AND present in the graph (collapsed onto the
// surviving endpoint), so the two never disagree on its existence.
let base = epiphany_core::generators::valid_score(100);
let slur = SlurId::new(ReplicaId(70), 9);
let (e1, e2, _e3, create, inserts) = cross_cutting_fixture(&base, |a, b, _| {
CrossCuttingValue::Slur(valuegen::slur(slur, a, b))
});
let delete_e1 = after_create(OperationPayload::Primitive(OperationKind::DeleteEvent(
DeleteEventOp {
event: e1,
tuplet_compensation: TupletCompensation::NotInTuplet,
},
)));
let mut set = OperationSet::new();
set.accept_all(inserts.into_iter().chain([create, delete_e1]));
let result = set.reduce_onto(&base);
assert_eq!(
result.state.objects.get(&TypedObjectId::Slur(slur)),
Some(&epiphany_ops::ObjectState::Live),
"a re-anchored slur stays live in the ledger"
);
let materialized = result
.score
.cross_cutting
.slurs
.iter()
.find(|s| s.id == slur)
.expect("the re-anchored slur is still present in the graph");
assert_eq!(
(materialized.start_event, materialized.end_event),
(e2, e2),
"the slur collapses onto the surviving endpoint"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn deleting_both_slur_endpoints_cascades_in_both_graph_and_ledger() {
// With no endpoint surviving, the slur cascade-deletes: tombstoned in the
// ledger AND removed from the graph (the other side of the same coin).
let base = epiphany_core::generators::valid_score(100);
let slur = SlurId::new(ReplicaId(70), 9);
let (e1, e2, _e3, create, inserts) = cross_cutting_fixture(&base, |a, b, _| {
CrossCuttingValue::Slur(valuegen::slur(slur, a, b))
});
let delete_e1 = after_create(OperationPayload::Primitive(OperationKind::DeleteEvent(
DeleteEventOp {
event: e1,
tuplet_compensation: TupletCompensation::NotInTuplet,
},
)));
let delete_e2 = envelope(
70,
5,
16,
CausalContext::new().with_seen(ReplicaId(70), 4),
None,
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: e2,
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
);
let mut set = OperationSet::new();
set.accept_all(inserts.into_iter().chain([create, delete_e1, delete_e2]));
let result = set.reduce_onto(&base);
assert!(
matches!(
result.state.objects.get(&TypedObjectId::Slur(slur)),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
),
"a slur with no surviving endpoint is tombstoned in the ledger"
);
assert!(
!result
.score
.cross_cutting
.slurs
.iter()
.any(|s| s.id == slur),
"the cascaded slur is removed from the graph"
);
assert!(check_invariants(&result.score).is_empty());
}
/// Helper: the effect recorded for `id` in a reduction.
fn effect_of(result: &epiphany_ops::GraphMaterialization, id: OperationId) -> OperationEffect {
result
.state
.effects
.iter()
.find(|(e, _)| *e == id)
.map(|(_, eff)| eff.clone())
.expect("the operation has an effect")
}
#[test]
fn structural_containers_create_and_empty_only_delete_in_the_graph() {
use epiphany_core::{RegionId, StaffInstanceId};
let base = epiphany_core::generators::valid_score(100);
let staff = base.staves[0].id;
let region = RegionId::new(ReplicaId(72), 0);
let instance = StaffInstanceId::new(ReplicaId(72), 1);
let v = VoiceId::new(ReplicaId(72), 2);
let r = 72;
let create_region = envelope(
r,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::CreateRegion(epiphany_ops::CreateRegionOp {
region: valuegen::region(region),
})),
);
let create_instance = envelope(
r,
1,
11,
CausalContext::new().with_seen(ReplicaId(r), 0),
None,
OperationPayload::Primitive(OperationKind::CreateStaffInstance(
epiphany_ops::CreateStaffInstanceOp {
region,
instance: valuegen::staff_instance(instance, staff),
},
)),
);
let create_voice = envelope(
r,
2,
12,
CausalContext::new().with_seen(ReplicaId(r), 1),
None,
OperationPayload::Primitive(OperationKind::CreateVoice(epiphany_ops::CreateVoiceOp {
staff_instance: instance,
voice: valuegen::voice(v),
})),
);
let creates = [
create_region.clone(),
create_instance.clone(),
create_voice.clone(),
];
// Creates materialize the container subtree, invariant-clean.
let mut after_create = OperationSet::new();
after_create.accept_all(creates.clone());
let created = after_create.reduce_onto(&base);
let materialized_region = created
.score
.canvas
.regions
.iter()
.find(|rg| rg.id == region)
.expect("the region is materialized");
assert!(
materialized_region
.staff_instances()
.iter()
.any(|i| i.id == instance),
"the staff instance is materialized in its region"
);
assert!(
materialized_region
.staff_instances()
.iter()
.flat_map(|i| &i.voices)
.any(|vo| vo.id == v),
"the voice is materialized in its staff instance"
);
assert!(check_invariants(&created.score).is_empty());
// Empty-only: deleting the non-empty region (and instance) is refused.
let del_region_early = envelope(
r,
3,
13,
CausalContext::new().with_seen(ReplicaId(r), 2),
None,
OperationPayload::Primitive(OperationKind::DeleteRegion(epiphany_ops::DeleteRegionOp {
region,
})),
);
let mut early = OperationSet::new();
early.accept_all(creates.iter().cloned().chain([del_region_early.clone()]));
let early_res = early.reduce_onto(&base);
assert!(
early_res
.score
.canvas
.regions
.iter()
.any(|rg| rg.id == region),
"a non-empty region delete is refused and leaves the region in the graph"
);
assert_eq!(
effect_of(&early_res, del_region_early.id),
OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::ContainerNotEmpty,
},
},
"the refused delete reports ContainerNotEmpty"
);
// Ordered teardown (voice, instance, region) clears the subtree from graph
// and ledger, invariant-clean.
let del_voice = envelope(
r,
3,
13,
CausalContext::new().with_seen(ReplicaId(r), 2),
None,
OperationPayload::Primitive(OperationKind::DeleteVoice(epiphany_ops::DeleteVoiceOp {
voice: v,
})),
);
let del_instance = envelope(
r,
4,
14,
CausalContext::new().with_seen(ReplicaId(r), 3),
None,
OperationPayload::Primitive(OperationKind::DeleteStaffInstance(
epiphany_ops::DeleteStaffInstanceOp {
staff_instance: instance,
},
)),
);
let del_region = envelope(
r,
5,
15,
CausalContext::new().with_seen(ReplicaId(r), 4),
None,
OperationPayload::Primitive(OperationKind::DeleteRegion(epiphany_ops::DeleteRegionOp {
region,
})),
);
let mut teardown = OperationSet::new();
teardown.accept_all(
creates
.into_iter()
.chain([del_voice, del_instance, del_region]),
);
let result = teardown.reduce_onto(&base);
assert!(
!result.score.canvas.regions.iter().any(|rg| rg.id == region),
"the region is removed from the graph after the ordered teardown"
);
for obj in [
TypedObjectId::Region(region),
TypedObjectId::StaffInstance(instance),
TypedObjectId::Voice(v),
] {
assert!(
matches!(
result.state.objects.get(&obj),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
),
"{obj:?} is tombstoned in the ledger after teardown"
);
}
assert!(check_invariants(&result.score).is_empty());
}