710 lines
30 KiB
Rust
710 lines
30 KiB
Rust
//! Chapter 4 accidental, glyph, and engraving vocabulary (`core_spec.tex`
|
|
//! §"Accidental Registries", `sec:tuning:accidentals`, `:3054`-`:3234`; §"Glyph
|
|
//! References and SMuFL", `sec:tuning:smufl`, `:3235`-`:3277`).
|
|
//!
|
|
//! **Scope**, per `spec/CONTRACT_PUSH4B_ACCIDENTALS.md` (Push 4b tranche 3a):
|
|
//! the type surface, in memory, with two real consumers —
|
|
//! [`resolve_accidental`] (accidental resolution, honoring the precedence
|
|
//! "an override wins over an addition wins over the base registry") and
|
|
//! [`accidental_modification_compatible_with_space`]
|
|
//! (`req:tuning:accidental-modification-compatibility`, wired into
|
|
//! [`crate::invariants::check_invariants`]). Glyph and engraving metadata
|
|
//! ([`GlyphReference`], [`AccidentalEngraving`], [`AccidentalCombination`]) are
|
|
//! *carried* by both consumers — resolution returns them as part of the
|
|
//! resolved [`AccidentalDefinition`], and the compatibility check reads past
|
|
//! them to `modification` — but neither consumer *reads* them for their own
|
|
//! sake. Their deep consumer is the engraver, out of `epiphany-core`, a later
|
|
//! tranche; this module does not fabricate one to manufacture coverage.
|
|
//!
|
|
//! **No `Codec` impl exists, or may be added, for anything in this module**
|
|
//! (same discipline as `pitch_space.rs` and `tuning.rs`, Ruling C,
|
|
//! `spec/PLAN_PUSH4B_TUNING.md`): these types stay in memory this tranche.
|
|
//! [`crate::graph::ScoreTuningContext::accidental_extensions`] and `::smufl`
|
|
//! reference them without putting them on the wire (schema major 3, Push 4b
|
|
//! tranche 3b).
|
|
//!
|
|
//! ## Three ratified corrections (P13-S10/S11/S12, ratified 2026-07-23)
|
|
//!
|
|
//! * **S10** — [`PitchSpaceModification::Cents`] carries a
|
|
//! [`CanonicalF64`], not a raw `f64`: a raw `f64` is unencodable in
|
|
//! canonical state (`serialize.rs:110` decodes floats only through
|
|
//! `CanonicalF64::from_le_bytes`; there is no `Codec for f64`).
|
|
//! * **S11** — [`AnchorPoint`] is referenced by the specification (`:3166`)
|
|
//! but defined nowhere in it, and this crate cannot depend on
|
|
//! `epiphany-layout-ir`. It is core-native here, over [`SpaceUnit`], with a
|
|
//! pinned frame — see its doc comment.
|
|
//! * **S12** — [`SmuflVersion`] stores its minor fraction-normalized to
|
|
//! hundredths (`minor_centi`), not literally, so derived `Ord` agrees with
|
|
//! SMuFL's real release order. `epiphany_layout_ir` defined its own,
|
|
//! differently-shaped, literal-minor `SmuflVersion` until Push 4b tranche
|
|
//! 3b-ii unified the two onto this type — see this type's doc comment.
|
|
|
|
use core::num::NonZeroU32;
|
|
|
|
use epiphany_determinism::CanonicalF64;
|
|
|
|
use crate::graph::SpaceUnit;
|
|
use crate::pitch::{
|
|
AccidentalGroupId, AccidentalId, AccidentalRegistryId, CustomGlyphId, ModificationRegistryId,
|
|
PitchSpaceId,
|
|
};
|
|
use crate::pitch_space::{built_in_position_structure, PositionStructure};
|
|
|
|
// ===========================================================================
|
|
// Glyph references (Chapter 4 §"Glyph References and SMuFL", `:3244`).
|
|
// ===========================================================================
|
|
|
|
/// A glyph reference (Chapter 4 §"Glyph References and SMuFL", `:3244`).
|
|
/// Recursive, and **this chapter's own** — do not confuse with
|
|
/// `epiphany_layout_ir::GlyphReference` (`glyph.rs:50`), a glyph *name*
|
|
/// (`Cow<'static, str>`), a rendering/layout concern. Same name, unrelated
|
|
/// types (Push 4b Ruling D's correction: "they are homonyms, not shared
|
|
/// types"); `epiphany-core` cannot depend on `epiphany-layout-ir` in any
|
|
/// case, so within this crate there is no ambiguity.
|
|
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
|
pub enum GlyphReference {
|
|
/// SMuFL codepoint, resolved against the active SMuFL font.
|
|
Smufl(u32),
|
|
/// Custom glyph defined by the score or a plugin.
|
|
Custom(CustomGlyphId),
|
|
/// Composite glyph: multiple glyph references rendered as a single
|
|
/// accidental. Used for compound HEJI symbols and similar.
|
|
Composite(Vec<GlyphReference>),
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Pitch space modifications (Chapter 4 §"Pitch Space Modifications", `:3097`).
|
|
// ===========================================================================
|
|
|
|
/// A position modification an accidental applies (Chapter 4 §"Pitch Space
|
|
/// Modifications", `:3097`).
|
|
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
|
pub enum PitchSpaceModification {
|
|
/// CMN-style integer offset in chromatic steps of the enclosing pitch
|
|
/// space (`req:pitch:alteration-unit`).
|
|
CmnChromatic(i8),
|
|
/// Integer step offset in an EDO position space.
|
|
EdoSteps(i16),
|
|
/// Modification expressed as an exact rational ratio. Used for JI
|
|
/// accidentals such as the HEJI syntonic-comma symbols (81/80) and
|
|
/// septimal-comma symbols (64/63).
|
|
JiRatio {
|
|
numerator: i32,
|
|
denominator: NonZeroU32,
|
|
},
|
|
/// Modification expressed in cents. Used for Sagittal-style precise
|
|
/// microtonal accidentals.
|
|
///
|
|
/// **S10 correction**: `CanonicalF64`, not a raw `f64`, per
|
|
/// `req:determinism:canonical-floating-point` ("finite IEEE 754
|
|
/// binary64") — a raw `f64` cannot be canonical state at all, since there
|
|
/// is no `Codec for f64` and the byte layer decodes floats only through
|
|
/// `CanonicalF64::from_le_bytes`.
|
|
Cents(CanonicalF64),
|
|
/// Modification defined by a grammar plugin.
|
|
Registered(ModificationRegistryId),
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Accidental engraving metadata (Chapter 4 §"Accidental Engraving Metadata",
|
|
// `:3150`, `:3159`).
|
|
// ===========================================================================
|
|
|
|
/// A bounding box over canonical space units, used for engraving metadata
|
|
/// that lives in canonical score state (Chapter 4 §"Accidental Engraving
|
|
/// Metadata", `:3150`). Distinct from Chapter 7's `BoundingBox` (built on
|
|
/// `StaffSpace`, single precision, for the non-canonical resolved-layout
|
|
/// cache, `epiphany_layout_ir::spatial::BoundingBox`): this type's edges are
|
|
/// [`SpaceUnit`] (`CanonicalF64`), per
|
|
/// `req:determinism:canonical-floating-point`.
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub struct EngravingBoundingBox {
|
|
pub left: SpaceUnit,
|
|
pub right: SpaceUnit,
|
|
pub top: SpaceUnit,
|
|
pub bottom: SpaceUnit,
|
|
}
|
|
|
|
/// Where a glyph attaches to the note: typically the geometric center or a
|
|
/// custom anchor for compound glyphs (Chapter 4 §"Accidental Engraving
|
|
/// Metadata", `:3166`).
|
|
///
|
|
/// **S11 correction**: the specification *references* this type (`:3166`,
|
|
/// `AccidentalEngraving::anchor`) but never defines it, and `epiphany-core`
|
|
/// cannot depend on `epiphany-layout-ir`. Defined core-native here, over
|
|
/// [`SpaceUnit`].
|
|
///
|
|
/// **Frame** (ratified alongside S11 — `EngravingBoundingBox` is "relative to
|
|
/// the glyph's anchor point" (`:3160`), so the anchor itself needs an
|
|
/// unambiguous origin): `x`/`y` are in **canonical space units**, **y-up**,
|
|
/// relative to **the glyph's coordinate origin**.
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub struct AnchorPoint {
|
|
pub x: SpaceUnit,
|
|
pub y: SpaceUnit,
|
|
}
|
|
|
|
/// Engraving metadata for an accidental (Chapter 4 §"Accidental Engraving
|
|
/// Metadata", `:3159`). Reachable from canonical score state — via
|
|
/// [`AccidentalDefinition`] inside [`ScoreAccidentalExtensions`], which hangs
|
|
/// off [`crate::graph::ScoreTuningContext`] — so every field is
|
|
/// canonical-safe: [`EngravingBoundingBox`], [`AnchorPoint`], and
|
|
/// `advance_width` are all [`SpaceUnit`] (`CanonicalF64`)-based, never
|
|
/// Chapter 7's single-precision `StaffSpace`.
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub struct AccidentalEngraving {
|
|
/// Bounding box in canonical space units, relative to the glyph's anchor
|
|
/// point.
|
|
pub bounding_box: EngravingBoundingBox,
|
|
/// Where the glyph attaches to the note.
|
|
pub anchor: AnchorPoint,
|
|
/// Advance width for horizontal spacing computations.
|
|
pub advance_width: SpaceUnit,
|
|
/// Stacking order when multiple accidentals attach to one note. Lower
|
|
/// values are placed closer to the notehead.
|
|
pub stacking_order: i32,
|
|
/// Whether this glyph should be drawn with parentheses by default (e.g.,
|
|
/// editorial or cautionary accidentals).
|
|
pub default_parenthesized: bool,
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Combination behavior (Chapter 4 §"Combination Behavior", `:3210`).
|
|
// ===========================================================================
|
|
|
|
/// Combination behavior with other accidentals on the same note (Chapter 4
|
|
/// §"Combination Behavior", `:3210`). Most accidentals do not combine; some
|
|
/// systems (HEJI, certain microtonal notations) permit stacking multiple
|
|
/// glyphs to express compound modifications.
|
|
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
|
|
pub enum AccidentalCombination {
|
|
/// Stands alone; replaces any prior accidental on the same note.
|
|
Solitary,
|
|
/// May stack with members of the listed compatibility groups. Stacking
|
|
/// order is determined by the engraving metadata.
|
|
Stacking {
|
|
compatible_groups: Vec<AccidentalGroupId>,
|
|
},
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Accidental definitions and score-local extensions (Chapter 4
|
|
// §"Accidental Registries" / "Score-Local Extensions", `:3073`, `:3228`).
|
|
// ===========================================================================
|
|
|
|
/// An accidental: a glyph, a position modification, and engraving metadata
|
|
/// (Chapter 4 §"Accidental Registries", `:3073`).
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct AccidentalDefinition {
|
|
pub id: AccidentalId,
|
|
/// Canonical name. Used for serialization, accessibility, and
|
|
/// foreign-format export.
|
|
pub name: String,
|
|
/// Glyph reference, typically SMuFL.
|
|
pub glyph: GlyphReference,
|
|
/// The position modification this accidental applies.
|
|
pub modification: PitchSpaceModification,
|
|
/// Engraving metadata.
|
|
pub engraving: AccidentalEngraving,
|
|
/// Combination behavior with other accidentals.
|
|
pub combination: AccidentalCombination,
|
|
}
|
|
|
|
/// A score-local extension of a referenced accidental registry (Chapter 4
|
|
/// §"Score-Local Extensions", `:3228`). A score MAY extend a referenced
|
|
/// accidental registry with additional accidental definitions, provided the
|
|
/// registry's `extensible` flag is true (out of this tranche's scope: the
|
|
/// registry *body*, `extensible` flag included, is not built here — see
|
|
/// [`resolve_accidental`]'s doc comment). "Extensions are stored on the score
|
|
/// and override or augment the base registry during resolution" (`:3224`).
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct ScoreAccidentalExtensions {
|
|
pub base: AccidentalRegistryId,
|
|
pub additions: Vec<AccidentalDefinition>,
|
|
pub overrides: Vec<AccidentalDefinition>,
|
|
}
|
|
|
|
// ===========================================================================
|
|
// SMuFL versioning (Chapter 4 §"SMuFL Versioning", `:3260`-`:3277`).
|
|
// ===========================================================================
|
|
|
|
/// A SMuFL version, stored so derived `Ord` agrees with SMuFL's real release
|
|
/// history (Chapter 4 §"SMuFL Versioning").
|
|
///
|
|
/// **S12 correction.** SMuFL versions are decimal fractions — 1.12, 1.18,
|
|
/// 1.20, 1.3, 1.4, released in that order — so storing the minor *literally*
|
|
/// and deriving `Ord` over `(major, minor)` sorts wrong: 1.3 as `(1, 3)` would
|
|
/// sort *before* 1.12 as `(1, 12)`, even though 1.12 shipped first.
|
|
/// `minor_centi` instead stores the fractional part normalized to hundredths
|
|
/// — 1.12 -> `12`, 1.18 -> `18`, 1.20 -> `20`, 1.3 -> `30`, 1.4 -> `40` — so
|
|
/// derived `Ord` on `(major, minor_centi)` is correct.
|
|
///
|
|
/// Construct through [`SmuflVersion::from_decimal`] rather than building the
|
|
/// literal fields directly, so a caller cannot accidentally pass a literal
|
|
/// minor digit where a normalized one is required (`from_decimal(1, "3")` and
|
|
/// a mistaken direct `SmuflVersion { major: 1, minor_centi: 3 }` would
|
|
/// otherwise look interchangeable and are not: the former is 1.3, the latter
|
|
/// is nonsensical).
|
|
///
|
|
/// This is also `epiphany_layout_ir::SmuflVersion`: Push 4b tranche 3b-ii
|
|
/// (P13-S12) unified layout-ir's former literal-minor homonym onto this type
|
|
/// (`glyph.rs` re-exports it), so the glyph catalog's version and the tuning
|
|
/// context's version are the same type.
|
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
|
pub struct SmuflVersion {
|
|
pub major: u16,
|
|
/// The fractional part, normalized to hundredths. **Do not construct
|
|
/// this field directly with a literal minor digit** — use
|
|
/// [`SmuflVersion::from_decimal`].
|
|
pub minor_centi: u16,
|
|
}
|
|
|
|
impl SmuflVersion {
|
|
/// Builds a `SmuflVersion` from its conventional decimal notation,
|
|
/// normalizing the minor digits to hundredths: one digit is scaled by 10
|
|
/// (`from_decimal(1, "4")` -> 1.4 -> `minor_centi: 40`), two digits are
|
|
/// taken as-is (`from_decimal(1, "12")` -> 1.12 -> `minor_centi: 12`).
|
|
///
|
|
/// `minor_digits` is the literal digit string that would follow the
|
|
/// decimal point (so `"4"` for 1.4, `"12"` for 1.12, `"3"` for 1.3) —
|
|
/// *not* a numeric value to be stored as-is; `from_decimal(1, "3")` and
|
|
/// `from_decimal(1, "30")` both denote 1.3 and produce the same
|
|
/// `minor_centi: 30`. Returns `None` if `minor_digits` is empty, longer
|
|
/// than two characters, or contains anything but ASCII digits — SMuFL
|
|
/// versions in the wild (1.12, 1.18, 1.20, 1.3, 1.4, ...) never need a
|
|
/// third fractional digit.
|
|
pub fn from_decimal(major: u16, minor_digits: &str) -> Option<Self> {
|
|
if !(1..=2).contains(&minor_digits.len())
|
|
|| !minor_digits.bytes().all(|b| b.is_ascii_digit())
|
|
{
|
|
return None;
|
|
}
|
|
let value: u16 = minor_digits.parse().ok()?;
|
|
let minor_centi = if minor_digits.len() == 1 {
|
|
value * 10
|
|
} else {
|
|
value
|
|
};
|
|
Some(SmuflVersion { major, minor_centi })
|
|
}
|
|
}
|
|
|
|
/// A score's declared SMuFL version requirements (Chapter 4 §"SMuFL
|
|
/// Versioning", `:3269`).
|
|
///
|
|
/// `req:tuning:smufl-version-fallback`: "Every score MUST declare the SMuFL
|
|
/// version it targets" — this struct is that declaration. The requirement's
|
|
/// other clause (resolving an absent codepoint MUST produce a deterministic
|
|
/// fallback, never a silent failure) is a rendering-time behavior with no
|
|
/// consumer in `epiphany-core`; implementing it is the engraver's job, out of
|
|
/// this crate, a later tranche — this struct only carries the declaration
|
|
/// the engraver will need to consult.
|
|
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
|
|
pub struct SmuflVersionRequirement {
|
|
/// Minimum SMuFL version required by this score.
|
|
pub minimum: SmuflVersion,
|
|
/// SMuFL version this score was authored against. Used for detecting
|
|
/// whether newer glyphs are in use.
|
|
pub authored_against: SmuflVersion,
|
|
}
|
|
|
|
impl Default for SmuflVersionRequirement {
|
|
/// `1.4` for both fields — the SMuFL version this repository already
|
|
/// targets (`epiphany_layout_ir::glyph::GlyphCatalogIdentity`'s default,
|
|
/// `glyph.rs:120`), so this default aligns with what Push 4b tranche 3b
|
|
/// will unify against. Used as
|
|
/// [`crate::graph::ScoreTuningContext`]'s `smufl` default when decoding a
|
|
/// wire stream that predates this field (schema major <3).
|
|
fn default() -> Self {
|
|
let v1_4 = SmuflVersion::from_decimal(1, "4").expect("1.4 is a valid SMuFL version");
|
|
SmuflVersionRequirement {
|
|
minimum: v1_4,
|
|
authored_against: v1_4,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Consumer (a): accidental resolution (Chapter 4 §"Score-Local Extensions",
|
|
// `:3224`).
|
|
// ===========================================================================
|
|
|
|
/// Resolves an [`AccidentalId`] against a score's accidental extensions,
|
|
/// honoring the precedence Chapter 4 states outright (`:3224`): "Extensions
|
|
/// are stored on the score and override or augment the base registry during
|
|
/// resolution" — an `overrides` entry wins over an `additions` entry wins
|
|
/// over the base registry.
|
|
///
|
|
/// `base_registry` is the resolved contents of `extensions.base`'s catalog
|
|
/// body. `epiphany-core` has no built-in catalog of accidental-registry
|
|
/// bodies this tranche (the same deferred-data-catalog discipline as
|
|
/// `crate::pitch_space`'s six underdetermined pitch spaces and
|
|
/// `crate::tuning`'s partial tuning catalog: inventing registry contents the
|
|
/// specification does not pin would be exactly the `NOTEHEAD_ANCHORS`
|
|
/// failure this project exists to avoid), so callers supply the base
|
|
/// registry's contents directly rather than this function reaching into a
|
|
/// catalog that does not exist.
|
|
///
|
|
/// Returns `None` when `id` is not found in `overrides`, `additions`, or
|
|
/// `base_registry`.
|
|
pub fn resolve_accidental<'a>(
|
|
base_registry: &'a [AccidentalDefinition],
|
|
extensions: &'a ScoreAccidentalExtensions,
|
|
id: &AccidentalId,
|
|
) -> Option<&'a AccidentalDefinition> {
|
|
extensions
|
|
.overrides
|
|
.iter()
|
|
.find(|d| &d.id == id)
|
|
.or_else(|| extensions.additions.iter().find(|d| &d.id == id))
|
|
.or_else(|| base_registry.iter().find(|d| &d.id == id))
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Consumer (b): the modification-compatibility invariant
|
|
// (`req:tuning:accidental-modification-compatibility`, `core_spec.tex:3120`).
|
|
// ===========================================================================
|
|
|
|
/// Whether `modification` is expressible in the interval algebra of `space`
|
|
/// (`req:tuning:accidental-modification-compatibility`, `core_spec.tex:3120`:
|
|
/// "An accidental's modification MUST be expressible in the interval algebra
|
|
/// of every pitch space that references its registry ... Implementations
|
|
/// MUST reject scores referencing an accidental in a space whose algebra does
|
|
/// not admit the modification").
|
|
///
|
|
/// `space` is resolved structurally against the built-in catalog
|
|
/// ([`built_in_position_structure`], Push 4b tranche 1) — the same lookup
|
|
/// `Pitch::transposed` uses, so this consumer and that one agree on what
|
|
/// "the algebra of a pitch space" means.
|
|
///
|
|
/// The requirement states two rules by name — `CmnChromatic` only in spaces
|
|
/// "with `DiatonicChromatic` or compatible algebra" (matched here against
|
|
/// [`PositionStructure::DiatonicOverChromatic`], the position-structure
|
|
/// family that algebra describes); `EdoSteps` only in `Chromatic` or
|
|
/// `Registered` spaces — and this tranche's contract directs extending "the
|
|
/// same shape" to `JiRatio` <-> [`PositionStructure::JiLattice`] (also
|
|
/// admitting `Registered`, matching `EdoSteps`'s explicit inclusion of it).
|
|
/// `Cents` and `Registered` modifications have no requirement-stated
|
|
/// constraint of their own — inventing one *would be* the `NOTEHEAD_ANCHORS`
|
|
/// failure this project has already paid for twice — so they are accepted
|
|
/// whenever `space` itself resolves.
|
|
///
|
|
/// An unresolvable `space` (an identifier outside the built-in catalog, or
|
|
/// one of the six catalog-named-but-underdetermined spaces
|
|
/// `built_in_position_structure` deliberately returns `None` for) fails
|
|
/// closed for *every* modification kind, `Cents` and `Registered` included:
|
|
/// nothing can be shown expressible in an algebra that cannot itself be
|
|
/// established (`req:pitch:space-capability-refusal`'s discipline, applied
|
|
/// here as tranche 1 applied it to `Pitch::transposed`).
|
|
pub fn accidental_modification_compatible_with_space(
|
|
modification: &PitchSpaceModification,
|
|
space: &PitchSpaceId,
|
|
) -> bool {
|
|
let Some(structure) = built_in_position_structure(space) else {
|
|
return false;
|
|
};
|
|
match modification {
|
|
PitchSpaceModification::CmnChromatic(_) => {
|
|
matches!(structure, PositionStructure::DiatonicOverChromatic { .. })
|
|
}
|
|
PitchSpaceModification::EdoSteps(_) => matches!(
|
|
structure,
|
|
PositionStructure::Chromatic { .. } | PositionStructure::Registered(_)
|
|
),
|
|
PitchSpaceModification::JiRatio { .. } => matches!(
|
|
structure,
|
|
PositionStructure::JiLattice { .. } | PositionStructure::Registered(_)
|
|
),
|
|
PitchSpaceModification::Cents(_) | PitchSpaceModification::Registered(_) => true,
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Test-only fixtures, shared with `codec.rs`, `textvalue_graph.rs`, and
|
|
// `invariants.rs`'s test modules so each does not hand-roll its own minimal
|
|
// `AccidentalDefinition`.
|
|
// ===========================================================================
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn fixture_engraving() -> AccidentalEngraving {
|
|
let su = |x: f64| SpaceUnit(CanonicalF64::new(x).expect("test fixture value is finite"));
|
|
AccidentalEngraving {
|
|
bounding_box: EngravingBoundingBox {
|
|
left: su(-0.5),
|
|
right: su(0.5),
|
|
top: su(1.0),
|
|
bottom: su(-1.0),
|
|
},
|
|
anchor: AnchorPoint {
|
|
x: su(0.0),
|
|
y: su(0.0),
|
|
},
|
|
advance_width: su(1.0),
|
|
stacking_order: 0,
|
|
default_parenthesized: false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn fixture_definition(
|
|
id: &str,
|
|
modification: PitchSpaceModification,
|
|
) -> AccidentalDefinition {
|
|
AccidentalDefinition {
|
|
id: AccidentalId::new(id),
|
|
name: id.to_string(),
|
|
glyph: GlyphReference::Smufl(0xE262),
|
|
modification,
|
|
engraving: fixture_engraving(),
|
|
combination: AccidentalCombination::Solitary,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn fixture_extensions(
|
|
base: &str,
|
|
modification: PitchSpaceModification,
|
|
) -> ScoreAccidentalExtensions {
|
|
ScoreAccidentalExtensions {
|
|
base: AccidentalRegistryId::new(base),
|
|
additions: vec![fixture_definition("test-accidental", modification)],
|
|
overrides: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// --- S12: SmuflVersion ordering. -----------------------------------
|
|
|
|
#[test]
|
|
fn smufl_version_from_decimal_normalizes_one_and_two_digit_minors() {
|
|
assert_eq!(
|
|
SmuflVersion::from_decimal(1, "4"),
|
|
Some(SmuflVersion {
|
|
major: 1,
|
|
minor_centi: 40
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SmuflVersion::from_decimal(1, "3"),
|
|
Some(SmuflVersion {
|
|
major: 1,
|
|
minor_centi: 30
|
|
})
|
|
);
|
|
assert_eq!(
|
|
SmuflVersion::from_decimal(1, "12"),
|
|
Some(SmuflVersion {
|
|
major: 1,
|
|
minor_centi: 12
|
|
})
|
|
);
|
|
// "3" and "30" both denote 1.3.
|
|
assert_eq!(
|
|
SmuflVersion::from_decimal(1, "3"),
|
|
SmuflVersion::from_decimal(1, "30")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn smufl_version_from_decimal_rejects_malformed_input() {
|
|
assert_eq!(SmuflVersion::from_decimal(1, ""), None);
|
|
assert_eq!(SmuflVersion::from_decimal(1, "123"), None);
|
|
assert_eq!(SmuflVersion::from_decimal(1, "x"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn smufl_version_orders_the_real_release_sequence() {
|
|
// The anchor: this is SMuFL's actual release order. A test that
|
|
// would PASS under literal-minor storage (where 1.3 and 1.4 sort
|
|
// before 1.12) would not lock S12 at all — see the mutation in
|
|
// DECISIONS.md, which reproduces exactly that failure mode and
|
|
// confirms this test dies under it.
|
|
let v = |minor: &str| SmuflVersion::from_decimal(1, minor).unwrap();
|
|
let sequence = [v("12"), v("18"), v("20"), v("3"), v("4")];
|
|
for pair in sequence.windows(2) {
|
|
assert!(
|
|
pair[0] < pair[1],
|
|
"{:?} did not sort before {:?} in SMuFL's real release order",
|
|
pair[0],
|
|
pair[1]
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- S10: PitchSpaceModification::Cents. ---------------------------
|
|
|
|
#[test]
|
|
fn cents_round_trips_a_finite_value_and_guards_non_finite() {
|
|
let cents = CanonicalF64::new(23.46)
|
|
.map(PitchSpaceModification::Cents)
|
|
.expect("23.46 is finite");
|
|
match cents {
|
|
PitchSpaceModification::Cents(c) => assert_eq!(c.get(), 23.46),
|
|
other => panic!("expected Cents, got {other:?}"),
|
|
}
|
|
// The S10 guard: a non-finite value can never become a `Cents`
|
|
// payload, because `CanonicalF64::new` is the only way to produce
|
|
// one and it rejects NaN/infinity outright.
|
|
assert!(CanonicalF64::new(f64::NAN)
|
|
.map(PitchSpaceModification::Cents)
|
|
.is_none());
|
|
assert!(CanonicalF64::new(f64::INFINITY)
|
|
.map(PitchSpaceModification::Cents)
|
|
.is_none());
|
|
assert!(CanonicalF64::new(f64::NEG_INFINITY)
|
|
.map(PitchSpaceModification::Cents)
|
|
.is_none());
|
|
}
|
|
|
|
// --- Consumer (a): resolution precedence. ---------------------------
|
|
|
|
#[test]
|
|
fn resolution_precedence_overrides_beats_additions_beats_base() {
|
|
let base = vec![fixture_definition(
|
|
"flat",
|
|
PitchSpaceModification::CmnChromatic(-1),
|
|
)];
|
|
let mut extensions =
|
|
fixture_extensions("cmn-accidentals", PitchSpaceModification::CmnChromatic(1));
|
|
extensions.additions = vec![fixture_definition(
|
|
"flat",
|
|
PitchSpaceModification::CmnChromatic(-2),
|
|
)];
|
|
extensions.overrides = vec![fixture_definition(
|
|
"flat",
|
|
PitchSpaceModification::CmnChromatic(-3),
|
|
)];
|
|
|
|
let id = AccidentalId::new("flat");
|
|
// Overrides wins over additions wins over base.
|
|
let resolved = resolve_accidental(&base, &extensions, &id).expect("resolves");
|
|
assert_eq!(
|
|
resolved.modification,
|
|
PitchSpaceModification::CmnChromatic(-3),
|
|
"an overrides entry must shadow an additions entry for the same id"
|
|
);
|
|
|
|
// Drop the overrides entry: additions should now win.
|
|
extensions.overrides.clear();
|
|
let resolved = resolve_accidental(&base, &extensions, &id).expect("resolves");
|
|
assert_eq!(
|
|
resolved.modification,
|
|
PitchSpaceModification::CmnChromatic(-2),
|
|
"an additions entry must shadow the base registry for the same id"
|
|
);
|
|
|
|
// Drop the additions entry too: an id with no extension resolves to
|
|
// the base registry.
|
|
extensions.additions.clear();
|
|
let resolved = resolve_accidental(&base, &extensions, &id).expect("resolves");
|
|
assert_eq!(
|
|
resolved.modification,
|
|
PitchSpaceModification::CmnChromatic(-1),
|
|
"a base-registry id with no extension must resolve to the base"
|
|
);
|
|
|
|
// An id present nowhere resolves to nothing.
|
|
assert!(
|
|
resolve_accidental(&base, &extensions, &AccidentalId::new("nonexistent")).is_none()
|
|
);
|
|
}
|
|
|
|
// --- Consumer (b): the modification-compatibility predicate. -------
|
|
|
|
#[test]
|
|
fn cmn_chromatic_is_compatible_only_with_diatonic_over_chromatic() {
|
|
let cmn_chromatic = PitchSpaceModification::CmnChromatic(1);
|
|
assert!(accidental_modification_compatible_with_space(
|
|
&cmn_chromatic,
|
|
&PitchSpaceId::new("cmn-12")
|
|
));
|
|
// A test that only checked the accept case would miss the reject.
|
|
assert!(!accidental_modification_compatible_with_space(
|
|
&cmn_chromatic,
|
|
&PitchSpaceId::new("edo-31")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn edo_steps_is_compatible_with_chromatic_and_registered_not_diatonic() {
|
|
let edo_steps = PitchSpaceModification::EdoSteps(1);
|
|
assert!(accidental_modification_compatible_with_space(
|
|
&edo_steps,
|
|
&PitchSpaceId::new("edo-31")
|
|
));
|
|
assert!(!accidental_modification_compatible_with_space(
|
|
&edo_steps,
|
|
&PitchSpaceId::new("cmn-12")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ji_ratio_is_compatible_only_with_ji_lattice() {
|
|
// None of the built-in `ji-*limit` spaces structurally resolve this
|
|
// tranche (`built_in_position_structure` returns `None` for all
|
|
// three, tranche 1's honest gap), so both sides of this rule are
|
|
// exercised against an unresolvable space and a resolvable
|
|
// non-`JiLattice` one.
|
|
let ji_ratio = PitchSpaceModification::JiRatio {
|
|
numerator: 81,
|
|
denominator: NonZeroU32::new(80).unwrap(),
|
|
};
|
|
assert!(!accidental_modification_compatible_with_space(
|
|
&ji_ratio,
|
|
&PitchSpaceId::new("cmn-12")
|
|
));
|
|
assert!(!accidental_modification_compatible_with_space(
|
|
&ji_ratio,
|
|
&PitchSpaceId::new("ji-5limit")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn cents_and_registered_are_unconstrained_on_a_resolved_space() {
|
|
let cents = PitchSpaceModification::Cents(CanonicalF64::new(23.46).unwrap());
|
|
let registered =
|
|
PitchSpaceModification::Registered(ModificationRegistryId::new("sagittal"));
|
|
for space in ["cmn-12", "edo-31"] {
|
|
assert!(accidental_modification_compatible_with_space(
|
|
¢s,
|
|
&PitchSpaceId::new(space)
|
|
));
|
|
assert!(accidental_modification_compatible_with_space(
|
|
®istered,
|
|
&PitchSpaceId::new(space)
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_modification_kind_fails_closed_on_an_unresolvable_space() {
|
|
let unknown = PitchSpaceId::new("not-a-built-in-space");
|
|
assert!(!accidental_modification_compatible_with_space(
|
|
&PitchSpaceModification::CmnChromatic(1),
|
|
&unknown
|
|
));
|
|
assert!(!accidental_modification_compatible_with_space(
|
|
&PitchSpaceModification::EdoSteps(1),
|
|
&unknown
|
|
));
|
|
assert!(!accidental_modification_compatible_with_space(
|
|
&PitchSpaceModification::JiRatio {
|
|
numerator: 3,
|
|
denominator: NonZeroU32::new(2).unwrap()
|
|
},
|
|
&unknown
|
|
));
|
|
assert!(!accidental_modification_compatible_with_space(
|
|
&PitchSpaceModification::Cents(CanonicalF64::new(1.0).unwrap()),
|
|
&unknown
|
|
));
|
|
assert!(!accidental_modification_compatible_with_space(
|
|
&PitchSpaceModification::Registered(ModificationRegistryId::new("x")),
|
|
&unknown
|
|
));
|
|
}
|
|
}
|