epiphany/crates/epiphany-core/tests/score_graph.rs

186 lines
6.9 KiB
Rust

//! Integration tests driving `epiphany-core` only through its public surface,
//! the way `epiphany-ops` (Agent C) and `epiphany-layout-ir` (Agent E) will
//! consume it. These complement the per-module unit tests by proving the
//! re-exported API is sufficient to build a score graph, check the Chapter 5
//! invariants, and round-trip the canonical primitives.
use epiphany_core::{
check_invariants, derive_promoted_voice_id, generators, AcousticPitch, AcousticRealization,
Canvas, CmnNominal, Event, EventArena, EventDuration, EventPosition, GraphInvariant,
IdentifiedPitch, IdentityContext, Instrument, InstrumentId, MusicalDuration, MusicalPosition,
OperationId, Pitch, PitchId, PitchSpaceId, PitchSpacePosition, PitchedEvent, RationalTime,
Region, RegionContent, RegionTimeModel, ReplicaId, ScalePosition, Score, StaffBasedContent,
StaffExtent, StaffInstance, StaffLineConfiguration, StemConfiguration, TimeAnchor, TimeExtent,
TuningReference, Voice, WallClockTime,
};
use epiphany_core::{Staff, StaffId, StaffInstanceId, VoiceId};
// epiphany-determinism's canonical-encoding traits are part of the contract
// downstream consumers use against core's identifier/time primitives.
use epiphany_determinism::{CanonicalDecode, CanonicalEncode};
/// Builds a one-measure, single-staff, single-voice metric score with four
/// quarter notes, entirely through the public API.
fn hand_built_score() -> Score {
let mut idc = IdentityContext::new(ReplicaId(0xC0FFEE));
let staff_id: StaffId = idc.mint();
let instrument: InstrumentId = idc.mint();
let instance_id: StaffInstanceId = idc.mint();
let voice_id: VoiceId = idc.mint();
let mut arena = EventArena::new();
let mut voice = Voice::user(voice_id);
for beat in 0..4i64 {
let eid = idc.mint();
let pid: PitchId = idc.mint();
let event = Event::Pitched(PitchedEvent {
id: eid,
voice: voice_id,
position: EventPosition::Musical(MusicalPosition(RationalTime::new(beat, 4).unwrap())),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
pitches: vec![IdentifiedPitch {
id: pid,
pitch: Pitch {
scale_position: ScalePosition {
space: PitchSpaceId::new("cmn-12"),
position: PitchSpacePosition::Cmn {
nominal: CmnNominal::C,
alteration: 0,
octave: 4,
},
},
acoustic: AcousticPitch {
tuning: TuningReference::Inherit,
realization: AcousticRealization::Implicit,
},
},
}],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: None,
});
arena.insert(event).unwrap();
voice.events.push(eid);
}
let mut instance = StaffInstance::new(instance_id, staff_id);
instance.voices.push(voice);
let region = Region {
id: idc.mint(),
time_model: RegionTimeModel::Metric(Default::default()),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![instance],
..Default::default()
}),
time_extent: TimeExtent {
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
end: TimeAnchor::WallClock {
time: WallClockTime(1_000),
},
},
staff_extent: StaffExtent {
staves: vec![staff_id],
},
local_tempo_map: None,
};
let mut score = Score::empty(idc.clone());
score.identity = idc;
score.instruments = vec![Instrument {
id: instrument,
name: "Flute".into(),
}];
score.staves = vec![Staff {
id: staff_id,
name: "Flute 1".into(),
abbreviation: Some("Fl. 1".into()),
instrument,
default_staff_lines: StaffLineConfiguration::default(),
group: None,
}];
score.events = arena;
score.canvas = Canvas {
regions: vec![region],
};
score
}
#[test]
fn hand_built_score_is_well_formed() {
let score = hand_built_score();
let violations = check_invariants(&score);
assert!(
violations.is_empty(),
"expected a clean graph, got {violations:?}"
);
assert_eq!(score.events.len(), 4);
assert_eq!(score.live_pitch_ids().len(), 4);
}
#[test]
fn deleting_an_event_from_its_voice_list_is_caught() {
let mut score = hand_built_score();
// Drop the first event from its voice's listing but leave it in the arena.
let first = score.events.ids_canonical()[0];
if let RegionContent::StaffBased(c) = &mut score.canvas.regions[0].content {
c.staff_instances[0].voices[0]
.events
.retain(|e| *e != first);
}
let v = check_invariants(&score);
assert!(v
.iter()
.any(|x| x.invariant == GraphInvariant::EventVoiceBacklink));
}
#[test]
fn full_invariant_sweep_via_public_api() {
// Every invariant: a clean valid graph passes, and the matching negative
// generator's graph reports that invariant. Drives the public re-exports.
for inv in GraphInvariant::all() {
let bad = generators::violating_score(inv, 0xBEEF);
assert!(
check_invariants(&bad).iter().any(|v| v.invariant == inv),
"{inv:?} not reported on its negative graph"
);
let shrunk = generators::shrink(&bad, inv);
assert!(generators::element_count(&shrunk) <= generators::element_count(&bad));
}
}
#[test]
fn canonical_primitives_round_trip_through_public_api() {
// Identifier and time primitives encode/decode byte-stably (Appendix D).
let id = epiphany_core::EventId::new(ReplicaId(7), 42);
assert_eq!(
epiphany_core::EventId::decode_canonical(&id.to_canonical_bytes()).unwrap(),
id
);
let pos = MusicalPosition(RationalTime::new(3, 8).unwrap());
assert_eq!(
MusicalPosition::decode_canonical(&pos.to_canonical_bytes()).unwrap(),
pos
);
}
#[test]
fn promoted_voice_derivation_is_replica_independent_but_input_dependent() {
// Two replicas independently derive the same promoted voice from the same
// canonical inputs (Chapter 5 §"System-Promoted Voices").
let si = StaffInstanceId::new(ReplicaId(1), 5);
let ov = VoiceId::new(ReplicaId(1), 6);
let win = OperationId::new(ReplicaId(2), 7);
let lose = OperationId::new(ReplicaId(3), 8);
let a = derive_promoted_voice_id(si, ov, win, lose);
let b = derive_promoted_voice_id(si, ov, win, lose);
assert_eq!(a, b);
assert_eq!(a.replica(), ReplicaId::SYSTEM_DERIVED);
assert_ne!(a, derive_promoted_voice_id(si, ov, lose, win));
}