epiphany/crates/epiphany-core/src/textvalue_pitch.rs

725 lines
29 KiB
Rust

//! [`TextValue`] for the hand-written `Codec` composites of Chapter 2's pitch and
//! spelling subsystem (`pitch.rs`).
//!
//! The macro-generated types of `pitch.rs` — the `struct_codec!` structs
//! ([`ScalePosition`](crate::pitch::ScalePosition), [`Pitch`](crate::pitch::Pitch),
//! [`PitchSpelling`](crate::pitch::PitchSpelling), …), the `cstyle_enum_codec!`
//! [`CmnNominal`](crate::pitch::CmnNominal) /
//! [`SpellingSourceKind`](crate::pitch::SpellingSourceKind), and the
//! `catalog_id_codec!` ids — get their [`TextValue`] from the same macro that
//! writes their bytes, so their projection cannot drift from the binary form.
//! This module supplies the rest: the tagged unions and the two structs whose
//! `Codec` is written out by hand. Each projection mirrors the field / variant
//! order of the corresponding `impl Codec` in `codec.rs` exactly.
//!
//! Two of these parse through a **validating** constructor, so they obey
//! `req:textproj:strict-parse` by re-projecting and comparing rather than
//! returning a laundered value:
//!
//! * [`ReferencePitch`](crate::pitch::ReferencePitch) — the frequency field is
//! private and reachable only through `ReferencePitch::new`, which rejects a
//! non-positive frequency.
//! * [`SpellingPrecedence`](crate::pitch::SpellingPrecedence) — the order is
//! private and reachable only through `SpellingPrecedence::new`, which rejects
//! any order that is not a total ranking (a kind missing or duplicated). A
//! `Vec` parse preserves order and so cannot see a duplicate itself; `new` is
//! what refuses it.
//!
//! Tagged-union parsing is strict about *shape* too: a fieldless variant projects
//! as a bare symbol, so its one-element list spelling (`(inherit)`) is rejected,
//! not accepted.
use epiphany_determinism::CanonicalF64;
use crate::pitch::{
AcousticRealization, PitchSpacePosition, ReferencePitch, SpellingDirective, SpellingNominal,
SpellingPrecedence, SpellingScope, SpellingSource, SpellingSourceKind, TuningReference,
VoiceSelector,
};
use crate::textvalue::{kebab, Sexp, TextError, TextValue};
use crate::textvalue_impls::class_of;
// ===========================================================================
// Tagged-union helpers.
// ===========================================================================
/// Projects a tagged-union variant (`req:textproj:value-projection` clause 3): a
/// fieldless variant is its bare kebab name; a variant with fields is a list of
/// that name followed by the fields' projections.
fn variant(name: &str, fields: Vec<Sexp>) -> Sexp {
if fields.is_empty() {
Sexp::Symbol(kebab(name))
} else {
let mut items = Vec::with_capacity(fields.len() + 1);
items.push(Sexp::Symbol(kebab(name)));
items.extend(fields);
Sexp::List(items)
}
}
/// The constructor name of a tagged-union projection, and its field list when the
/// projection is *applied* (a list). A bare symbol yields `None` for the fields —
/// it is a fieldless variant — which is what lets a caller reject a fieldless
/// variant miswritten as a one-element list, a spelling `project` never emits
/// (`req:textproj:strict-parse`).
fn constructor(s: &Sexp) -> Result<(&str, Option<&[Sexp]>), TextError> {
match s {
Sexp::Symbol(name) => Ok((name.as_str(), None)),
Sexp::List(items) => {
let (head, rest) = items.split_first().ok_or(TextError::Syntax(
"a tagged-union variant is a symbol or a non-empty list",
))?;
let name = head
.as_symbol()
.ok_or(TextError::Syntax("a variant constructor is a symbol"))?;
Ok((name, Some(rest)))
}
_ => Err(TextError::Expected {
expected: "tagged-union variant",
found: class_of(s),
}),
}
}
/// The field count of a (possibly bare) variant, for arity diagnostics.
fn field_count(fields: Option<&[Sexp]>) -> usize {
fields.map_or(0, <[Sexp]>::len)
}
fn arity(type_name: &'static str, expected: usize, found: usize) -> TextError {
TextError::Arity {
type_name,
expected,
found,
}
}
/// Rejects a fieldless variant spelled as a list. A fieldless variant projects as
/// a bare symbol, so any field list — even the empty `(inherit)` — is not its
/// canonical text and must be refused rather than absorbed
/// (`req:textproj:strict-parse`).
fn expect_fieldless(fields: Option<&[Sexp]>) -> Result<(), TextError> {
match fields {
None => Ok(()),
Some(_) => Err(TextError::Syntax(
"a fieldless variant projects as a bare symbol, not a list",
)),
}
}
// ===========================================================================
// Tagged unions.
// ===========================================================================
/// A position within a pitch space (`req:textproj:value-projection` clause 3),
/// mirroring `PitchSpacePosition`'s `Codec::enc` variant order: `Cmn`, `Integer`,
/// `JiVector`, `Registered`. The `Cmn` fields project positionally in their
/// declared order — nominal, alteration, octave — never by name.
impl TextValue for PitchSpacePosition {
fn project(&self) -> Sexp {
match self {
PitchSpacePosition::Cmn {
nominal,
alteration,
octave,
} => variant(
"Cmn",
vec![nominal.project(), alteration.project(), octave.project()],
),
PitchSpacePosition::Integer { space_size, index } => {
variant("Integer", vec![space_size.project(), index.project()])
}
PitchSpacePosition::JiVector { components } => {
variant("JiVector", vec![components.project()])
}
PitchSpacePosition::Registered(id) => variant("Registered", vec![id.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Cmn") {
let Some([nominal, alteration, octave]) = fields else {
return Err(arity("PitchSpacePosition", 3, field_count(fields)));
};
return Ok(PitchSpacePosition::Cmn {
nominal: TextValue::parse(nominal)?,
alteration: TextValue::parse(alteration)?,
octave: TextValue::parse(octave)?,
});
}
if head == kebab("Integer") {
let Some([space_size, index]) = fields else {
return Err(arity("PitchSpacePosition", 2, field_count(fields)));
};
return Ok(PitchSpacePosition::Integer {
space_size: TextValue::parse(space_size)?,
index: TextValue::parse(index)?,
});
}
if head == kebab("JiVector") {
let Some([components]) = fields else {
return Err(arity("PitchSpacePosition", 1, field_count(fields)));
};
return Ok(PitchSpacePosition::JiVector {
components: TextValue::parse(components)?,
});
}
if head == kebab("Registered") {
let Some([id]) = fields else {
return Err(arity("PitchSpacePosition", 1, field_count(fields)));
};
return Ok(PitchSpacePosition::Registered(TextValue::parse(id)?));
}
Err(TextError::UnknownConstructor {
type_name: "PitchSpacePosition",
found: head.to_owned(),
})
}
}
/// The tuning reference governing a pitch: `inherit`, or `(explicit <id>)`.
/// Mirrors `TuningReference`'s `Codec::enc`.
impl TextValue for TuningReference {
fn project(&self) -> Sexp {
match self {
TuningReference::Inherit => variant("Inherit", vec![]),
TuningReference::Explicit(id) => variant("Explicit", vec![id.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Inherit") {
expect_fieldless(fields)?;
return Ok(TuningReference::Inherit);
}
if head == kebab("Explicit") {
let Some([id]) = fields else {
return Err(arity("TuningReference", 1, field_count(fields)));
};
return Ok(TuningReference::Explicit(TextValue::parse(id)?));
}
Err(TextError::UnknownConstructor {
type_name: "TuningReference",
found: head.to_owned(),
})
}
}
/// How the tuning system resolves to a frequency: `implicit`, `(cents-offset
/// <f64>)`, or `(absolute-hz <f64>)`. Mirrors `AcousticRealization`'s
/// `Codec::enc`. Each payload is a [`CanonicalF64`], so it projects as its eight
/// canonical little-endian bytes — never a decimal, which would not be uniquely
/// spellable (Appendix D §"Floating-Point Values").
impl TextValue for AcousticRealization {
fn project(&self) -> Sexp {
match self {
AcousticRealization::Implicit => variant("Implicit", vec![]),
AcousticRealization::CentsOffset(c) => variant("CentsOffset", vec![c.project()]),
AcousticRealization::AbsoluteHz(c) => variant("AbsoluteHz", vec![c.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Implicit") {
expect_fieldless(fields)?;
return Ok(AcousticRealization::Implicit);
}
if head == kebab("CentsOffset") {
let Some([c]) = fields else {
return Err(arity("AcousticRealization", 1, field_count(fields)));
};
return Ok(AcousticRealization::CentsOffset(TextValue::parse(c)?));
}
if head == kebab("AbsoluteHz") {
let Some([c]) = fields else {
return Err(arity("AcousticRealization", 1, field_count(fields)));
};
return Ok(AcousticRealization::AbsoluteHz(TextValue::parse(c)?));
}
Err(TextError::UnknownConstructor {
type_name: "AcousticRealization",
found: head.to_owned(),
})
}
}
/// The staff position a spelling draws on: `(cmn <nominal>)`, `(integer <i>)`, or
/// `(registered <id>)`. Mirrors `SpellingNominal`'s `Codec::enc`.
impl TextValue for SpellingNominal {
fn project(&self) -> Sexp {
match self {
SpellingNominal::Cmn(n) => variant("Cmn", vec![n.project()]),
SpellingNominal::Integer(i) => variant("Integer", vec![i.project()]),
SpellingNominal::Registered(id) => variant("Registered", vec![id.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Cmn") {
let Some([n]) = fields else {
return Err(arity("SpellingNominal", 1, field_count(fields)));
};
return Ok(SpellingNominal::Cmn(TextValue::parse(n)?));
}
if head == kebab("Integer") {
let Some([i]) = fields else {
return Err(arity("SpellingNominal", 1, field_count(fields)));
};
return Ok(SpellingNominal::Integer(TextValue::parse(i)?));
}
if head == kebab("Registered") {
let Some([id]) = fields else {
return Err(arity("SpellingNominal", 1, field_count(fields)));
};
return Ok(SpellingNominal::Registered(TextValue::parse(id)?));
}
Err(TextError::UnknownConstructor {
type_name: "SpellingNominal",
found: head.to_owned(),
})
}
}
/// The provenance of a spelling attachment. Mirrors `SpellingSource`'s
/// `Codec::enc` variant order: `UserChosen`, `Inferred`, `Imported`,
/// `Propagated`, `Analytical`. (That order — with `Inferred` before `Imported` —
/// is the type's declaration order and differs from
/// [`SpellingSourceKind`](crate::pitch::SpellingSourceKind)'s discriminant order;
/// the text carries variant *names*, not tags, so only the names matter here.)
/// The single-field variants carry their named field positionally.
impl TextValue for SpellingSource {
fn project(&self) -> Sexp {
match self {
SpellingSource::UserChosen => variant("UserChosen", vec![]),
SpellingSource::Inferred => variant("Inferred", vec![]),
SpellingSource::Imported { format } => variant("Imported", vec![format.project()]),
SpellingSource::Propagated { from } => variant("Propagated", vec![from.project()]),
SpellingSource::Analytical => variant("Analytical", vec![]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("UserChosen") {
expect_fieldless(fields)?;
return Ok(SpellingSource::UserChosen);
}
if head == kebab("Inferred") {
expect_fieldless(fields)?;
return Ok(SpellingSource::Inferred);
}
if head == kebab("Imported") {
let Some([format]) = fields else {
return Err(arity("SpellingSource", 1, field_count(fields)));
};
return Ok(SpellingSource::Imported {
format: TextValue::parse(format)?,
});
}
if head == kebab("Propagated") {
let Some([from]) = fields else {
return Err(arity("SpellingSource", 1, field_count(fields)));
};
return Ok(SpellingSource::Propagated {
from: TextValue::parse(from)?,
});
}
if head == kebab("Analytical") {
expect_fieldless(fields)?;
return Ok(SpellingSource::Analytical);
}
Err(TextError::UnknownConstructor {
type_name: "SpellingSource",
found: head.to_owned(),
})
}
}
/// A voice selector: `all`, or `(voices <voice-id>…)`. Mirrors `VoiceSelector`'s
/// `Codec::enc`. Implemented here — though it is not one of the "spelling" types —
/// because it is a `pitch.rs` composite with a hand-written `Codec` (the macros
/// generate no [`TextValue`] for it) and [`SpellingScope::Range`] embeds it.
impl TextValue for VoiceSelector {
fn project(&self) -> Sexp {
match self {
VoiceSelector::All => variant("All", vec![]),
VoiceSelector::Voices(voices) => variant("Voices", vec![voices.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("All") {
expect_fieldless(fields)?;
return Ok(VoiceSelector::All);
}
if head == kebab("Voices") {
let Some([voices]) = fields else {
return Err(arity("VoiceSelector", 1, field_count(fields)));
};
return Ok(VoiceSelector::Voices(TextValue::parse(voices)?));
}
Err(TextError::UnknownConstructor {
type_name: "VoiceSelector",
found: head.to_owned(),
})
}
}
/// What a spelling attachment applies to: `(pitch <id>)`, or `(range <start>
/// <end> <voices>)`. Mirrors `SpellingScope`'s `Codec::enc`, whose `Range` fields
/// are start, end, voices in that order.
impl TextValue for SpellingScope {
fn project(&self) -> Sexp {
match self {
SpellingScope::Pitch(id) => variant("Pitch", vec![id.project()]),
SpellingScope::Range { start, end, voices } => variant(
"Range",
vec![start.project(), end.project(), voices.project()],
),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Pitch") {
let Some([id]) = fields else {
return Err(arity("SpellingScope", 1, field_count(fields)));
};
return Ok(SpellingScope::Pitch(TextValue::parse(id)?));
}
if head == kebab("Range") {
let Some([start, end, voices]) = fields else {
return Err(arity("SpellingScope", 3, field_count(fields)));
};
return Ok(SpellingScope::Range {
start: TextValue::parse(start)?,
end: TextValue::parse(end)?,
voices: TextValue::parse(voices)?,
});
}
Err(TextError::UnknownConstructor {
type_name: "SpellingScope",
found: head.to_owned(),
})
}
}
/// A spelling directive: `(explicit <pitch-spelling>)`, or `(rule <spelling-rule>)`.
/// Mirrors `SpellingDirective`'s `Codec::enc`. Both payloads are `struct_codec!`
/// composites whose own [`TextValue`] is macro-generated.
impl TextValue for SpellingDirective {
fn project(&self) -> Sexp {
match self {
SpellingDirective::Explicit(spelling) => variant("Explicit", vec![spelling.project()]),
SpellingDirective::Rule(rule) => variant("Rule", vec![rule.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Explicit") {
let Some([spelling]) = fields else {
return Err(arity("SpellingDirective", 1, field_count(fields)));
};
return Ok(SpellingDirective::Explicit(TextValue::parse(spelling)?));
}
if head == kebab("Rule") {
let Some([rule]) = fields else {
return Err(arity("SpellingDirective", 1, field_count(fields)));
};
return Ok(SpellingDirective::Rule(TextValue::parse(rule)?));
}
Err(TextError::UnknownConstructor {
type_name: "SpellingDirective",
found: head.to_owned(),
})
}
}
// ===========================================================================
// Structs with a private, validated field.
// ===========================================================================
/// `(reference-pitch <position> <frequency>)`, the frequency a [`CanonicalF64`]'s
/// eight bytes, mirroring `ReferencePitch`'s `Codec::enc` (position then
/// frequency).
///
/// The frequency field is private and reachable only through
/// [`ReferencePitch::new`], which *validates*: it rejects a non-positive or
/// non-finite frequency (Chapter 4: "positive and finite"). Parsing routes
/// through it — there is no other constructor — so a byte-legal frequency that is
/// negative or zero is refused rather than stored. `new` does not normalize, so a
/// value it accepts already projects back verbatim and a whole-value guard could
/// never fire — the `None` is the whole of the strictness.
impl TextValue for ReferencePitch {
fn project(&self) -> Sexp {
// `new` guaranteed a finite frequency, so re-wrapping cannot fail — the
// same invariant `ReferencePitch`'s `Codec::enc` asserts.
let hz = CanonicalF64::new(self.frequency_hz())
.expect("a constructed ReferencePitch has a finite frequency");
Sexp::List(vec![
Sexp::Symbol(kebab("ReferencePitch")),
self.position.project(),
hz.project(),
])
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let fields = s.expect_struct(&kebab("ReferencePitch"), 2)?;
let [position, frequency] = fields else {
return Err(arity("ReferencePitch", 2, fields.len()));
};
let position = PitchSpacePosition::parse(position)?;
let frequency: CanonicalF64 = TextValue::parse(frequency)?;
// `new` *validates* — it refuses a non-positive or non-finite frequency —
// and never adjusts one, so an accepted value re-projects to exactly its
// input. A whole-value guard here could not fire; the `None` is the whole
// of the strictness.
ReferencePitch::new(position, frequency.get()).ok_or(TextError::NotCanonical(
"a reference pitch frequency must be positive and finite",
))
}
}
/// `(spelling-precedence <order>)`, `order` the total ranking of source kinds,
/// highest precedence first. Mirrors `SpellingPrecedence`'s `Codec::enc`, which
/// writes the single `order` vector.
///
/// The order is private and reachable only through [`SpellingPrecedence::new`],
/// which *validates*: it rejects any order that is not a total ranking — the
/// wrong length, or a source kind missing or duplicated. A `Vec` parse preserves
/// order and cannot see a duplicate itself, so `new` is what refuses it, and
/// parsing routes through `new` rather than laundering a malformed order into a
/// value (`req:textproj:strict-parse`). Any *permutation* of the five kinds is a
/// distinct, legitimate value, so `new` never reorders — it only accepts or
/// rejects, and there is nothing left for a whole-value guard to catch.
impl TextValue for SpellingPrecedence {
fn project(&self) -> Sexp {
let order = Sexp::List(self.order_ref().iter().map(TextValue::project).collect());
Sexp::List(vec![Sexp::Symbol(kebab("SpellingPrecedence")), order])
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let fields = s.expect_struct(&kebab("SpellingPrecedence"), 1)?;
let [order] = fields else {
return Err(arity("SpellingPrecedence", 1, fields.len()));
};
let order: Vec<SpellingSourceKind> = TextValue::parse(order)?;
// `new` accepts only a permutation of the five source kinds and stores it
// unchanged: every permutation is a distinct legitimate value, so it never
// reorders. Validation, not normalization — the `None` rejects a duplicate
// or a short list, and nothing downstream could catch what it misses.
SpellingPrecedence::new(order).ok_or(TextError::NotCanonical(
"spelling precedence must rank every source kind exactly once",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::{PitchId, ReplicaId, VoiceId};
use crate::pitch::{
CmnNominal, ForeignFormatId, NominalRegistryId, PitchSpelling, PositionRegistryId,
SpellingRule, SpellingRuleSetId, TuningSystemId,
};
use crate::textvalue::read_sexp;
use crate::time::{TimeAnchor, WallClockTime};
/// Build a value, project it, render, read the text back, parse, and require
/// equality — the full `project → render → read_sexp → parse` loop.
#[track_caller]
fn round_trip<T: TextValue + PartialEq + std::fmt::Debug>(value: T) {
let text = value.project().render();
let sexp = read_sexp(&text).unwrap_or_else(|e| panic!("read_sexp rejected {text:?}: {e}"));
let back = T::parse(&sexp).unwrap_or_else(|e| panic!("parse rejected {text:?}: {e}"));
assert_eq!(value, back, "{text:?} did not round-trip");
}
#[test]
fn pitch_space_position_round_trips_every_variant() {
round_trip(PitchSpacePosition::Cmn {
nominal: CmnNominal::A,
alteration: -1,
octave: 4,
});
round_trip(PitchSpacePosition::Integer {
space_size: 31,
index: -5,
});
round_trip(PitchSpacePosition::JiVector {
components: vec![1, -2, 3],
});
round_trip(PitchSpacePosition::Registered(PositionRegistryId::new(
"my-pos",
)));
// The Cmn fields project positionally in declaration order.
assert_eq!(
PitchSpacePosition::Cmn {
nominal: CmnNominal::C,
alteration: 0,
octave: 4,
}
.project()
.render(),
"(cmn c 0 4)"
);
}
#[test]
fn tuning_reference_round_trips() {
round_trip(TuningReference::Inherit);
round_trip(TuningReference::Explicit(TuningSystemId::new("tet-12")));
assert_eq!(TuningReference::Inherit.project().render(), "inherit");
}
#[test]
fn acoustic_realization_round_trips() {
round_trip(AcousticRealization::Implicit);
round_trip(AcousticRealization::cents_offset(3.5).unwrap());
round_trip(AcousticRealization::absolute_hz(440.0).unwrap());
}
#[test]
fn reference_pitch_round_trips() {
round_trip(ReferencePitch::a440());
round_trip(
ReferencePitch::new(
PitchSpacePosition::Cmn {
nominal: CmnNominal::A,
alteration: 0,
octave: 4,
},
442.0,
)
.unwrap(),
);
}
/// A negative or zero frequency is a valid `CanonicalF64` byte string, so the
/// lexer and leaf parse accept it; only `ReferencePitch::new`'s validation
/// rejects it — which is the point, the text is the projection of no reference
/// pitch, and must not be laundered into one.
#[test]
fn a_non_positive_reference_frequency_is_rejected_not_accepted() {
let position = PitchSpacePosition::Cmn {
nominal: CmnNominal::A,
alteration: 0,
octave: 4,
};
for bad_hz in [-440.0, 0.0] {
let sexp = Sexp::List(vec![
Sexp::Symbol(kebab("ReferencePitch")),
position.project(),
CanonicalF64::new(bad_hz).unwrap().project(),
]);
let text = sexp.render();
let read = read_sexp(&text).unwrap();
assert!(
ReferencePitch::parse(&read).is_err(),
"{text} must be rejected"
);
}
}
#[test]
fn spelling_nominal_round_trips() {
round_trip(SpellingNominal::Cmn(CmnNominal::G));
round_trip(SpellingNominal::Integer(7));
round_trip(SpellingNominal::Registered(NominalRegistryId::new("nom")));
}
#[test]
fn spelling_source_round_trips() {
let r = ReplicaId::SYSTEM_DERIVED;
round_trip(SpellingSource::UserChosen);
round_trip(SpellingSource::Inferred);
round_trip(SpellingSource::Imported {
format: ForeignFormatId::new("musicxml"),
});
round_trip(SpellingSource::Propagated {
from: PitchId::new(r, 7),
});
round_trip(SpellingSource::Analytical);
}
/// A fieldless variant projects as a bare symbol, and a variant with fields as
/// a list; the wrong shape is refused rather than accepted.
#[test]
fn a_source_of_the_wrong_shape_is_rejected() {
// `user-chosen` is fieldless; `(user-chosen)` is a different text.
let listed = read_sexp("(user-chosen)").unwrap();
assert!(SpellingSource::parse(&listed).is_err());
// `imported` carries a field; the bare symbol is missing it.
let bare = read_sexp("imported").unwrap();
assert!(SpellingSource::parse(&bare).is_err());
}
#[test]
fn voice_selector_round_trips() {
let r = ReplicaId::SYSTEM_DERIVED;
round_trip(VoiceSelector::All);
round_trip(VoiceSelector::Voices(vec![
VoiceId::new(r, 1),
VoiceId::new(r, 2),
]));
}
#[test]
fn spelling_scope_round_trips() {
let r = ReplicaId::SYSTEM_DERIVED;
round_trip(SpellingScope::Pitch(PitchId::new(r, 3)));
round_trip(SpellingScope::Range {
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
end: TimeAnchor::WallClock {
time: WallClockTime(480),
},
voices: VoiceSelector::All,
});
}
#[test]
fn spelling_directive_round_trips() {
round_trip(SpellingDirective::Explicit(PitchSpelling::cmn(
CmnNominal::C,
4,
)));
round_trip(SpellingDirective::Rule(SpellingRule {
rule_set: SpellingRuleSetId::new("rs"),
}));
}
#[test]
fn spelling_precedence_round_trips() {
round_trip(SpellingPrecedence::default());
// Any permutation is a distinct, legitimate value.
round_trip(
SpellingPrecedence::new(vec![
SpellingSourceKind::Analytical,
SpellingSourceKind::Inferred,
SpellingSourceKind::Propagated,
SpellingSourceKind::Imported,
SpellingSourceKind::UserChosen,
])
.unwrap(),
);
assert_eq!(
SpellingPrecedence::default().project().render(),
"(spelling-precedence (user-chosen imported propagated inferred analytical))"
);
}
/// A `Vec` parse preserves order, so a duplicated or missing source kind is
/// invisible to it; only `SpellingPrecedence::new` catches it. Parsing must
/// reject, never silently repair, such an order.
#[test]
fn a_precedence_missing_or_duplicating_a_source_kind_is_rejected() {
let duplicated = read_sexp(
"(spelling-precedence (user-chosen user-chosen propagated inferred analytical))",
)
.unwrap();
assert!(SpellingPrecedence::parse(&duplicated).is_err());
let missing =
read_sexp("(spelling-precedence (user-chosen imported propagated inferred))").unwrap();
assert!(SpellingPrecedence::parse(&missing).is_err());
}
}