Editor T2 W4a: the clipboard fragment projection and copy/paste
Ruling E made concrete: a versioned s-expression fragment — values only, reusing epiphany-core's own TextValue productions, no ids, no envelopes — with named untrusted-input caps (1 MiB / 4096 events / depth 64) checked before parsing and clean unknown-major rejection. copy_selection reports closure drops (boundary slurs/ties) on CopyOutcome; a partially-selected tuplet refuses; incidental rubber-band geometry (staff lines, stems) is skipped, mirroring alter_selection's precedent. paste_at and paste_over_selection mint fresh operations — InsertEvent + carried RespellPitch — per-voice-ordinal lanes, make-room overwrite, one atomic transaction; a second lane's refusal rolls the whole paste back byte-identically. Contained slur/tie capture lands in the format; replay on paste is W4b. Six worker mutations killed; coordinator review added a seventh test pinning the emitted stream (one DeclareTransaction descriptor plus members — descriptor-precedence at peers is canonical surface) after its absence let a per-op-commit mutation survive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f50f0ee52d
commit
1dfa8b4f3a
|
|
@ -130,3 +130,165 @@ one untransposable pitch (`AcousticRealization::AbsoluteHz`, pinned) leaves
|
|||
*every* selected pitch — including the otherwise-transposable one — unmoved
|
||||
(`graph_changed == false`), proving the reducer's own atomicity, not this
|
||||
method's plumbing, is what's carrying the guarantee.
|
||||
|
||||
## The clipboard fragment projection (2026-07-23, T2-W4a)
|
||||
|
||||
Dispatched under `spec/PLAN_EDITOR_APP.md` §Ruling E (granted 2026-07-23) and
|
||||
`spec/CONTRACT_EDITOR_T2_SELECTION.md` §W4. New module `src/fragment.rs` (the
|
||||
versioned s-expression format, private/`pub(crate)` types) plus three new
|
||||
`EditorSession` intents in `lib.rs`: `copy_selection`, `paste_at`,
|
||||
`paste_over_selection`. Blast radius: `epiphany-editor-core` only — the GUI
|
||||
clipboard wiring is packet W4b.
|
||||
|
||||
**Grammar: reuse the Text Projection's leaf productions directly, not a
|
||||
re-derived lookalike.** `Pitch`, `PitchSpelling`, `MusicalDuration`,
|
||||
`SlurKind`, `TieClass`, `SpanStyle`, `CurvatureOverride`,
|
||||
`ArticulationMark`/`DynamicMark`/`OrnamentMark`/`StemConfiguration`/`GraceKind`/`StaffPosition`
|
||||
already implement `epiphany_core::textvalue::TextValue` — the same
|
||||
`Sexp`/`TextValue`/`read_sexp` machinery `epiphany-textproj`/`epiphany-ops`'s
|
||||
`textproj_leaf` build on, generated by `epiphany-core`'s `struct_codec!` /
|
||||
`cstyle_enum_codec!` macros alongside their binary codec. The fragment format
|
||||
calls `.project()`/`::parse()` on these directly rather than reinventing
|
||||
their textual shape, and needs **no new dependency on `epiphany-textproj`**:
|
||||
`epiphany_core::textvalue` is already public, and `epiphany-editor-core`
|
||||
already depends on `epiphany-core`. What is bespoke is only the
|
||||
document-shaped container Ruling E says the fragment must NOT borrow from
|
||||
TP's document grammar: `(epiphany-fragment (0 1 0) VOICES SLURS TIES)`, with
|
||||
`EventRef { voice: u32, event: u32 }` (a position in the fragment's own
|
||||
per-voice lanes) as the sole way anything inside a fragment refers to
|
||||
anything else — never a source `EventId`/`PitchId`/`SlurId`/`TieId`. Worked
|
||||
example, captured live from `copy_paste_round_trip_preserves_values_with_fresh_ids`
|
||||
(a two-note voice; the first note carries an authored spelling override, the
|
||||
second does not — no cross-cutting structures span this range):
|
||||
```
|
||||
(epiphany-fragment (0 1 0) ((voice ((event (ratio 0 1) (ratio 1 4) (pitched ((pitch-entry (pitch (scale-position "cmn-12" (cmn g 0 4)) (acoustic-pitch inherit implicit)) (some (pitch-spelling (cmn d) () 4 (spelling-render-hints false false false false))))) () () () stem-configuration ())) (event (ratio 1 4) (ratio 1 4) (pitched ((pitch-entry (pitch (scale-position "cmn-12" (cmn g 0 4)) (acoustic-pitch inherit implicit)) ())) () () () stem-configuration ()))))) () ())
|
||||
```
|
||||
|
||||
**API, as landed (diverges from the packet's indicative shape in two
|
||||
places, both flagged there and re-flagged here):**
|
||||
* `copy_selection(&self) -> Result<CopyOutcome, EditorError>` — `&self`, not
|
||||
`&mut self` (it never mutates); `CopyOutcome { fragment: String, dropped:
|
||||
Vec<DroppedItem> }`. The indicative shape had `copy_selection() ->
|
||||
Result<String, EditorError>` with `dropped` living on `PasteOutcome`; the
|
||||
required boundary-slur/tie test needs the dropped report at the point the
|
||||
closure *decision* is made, which is copy time, against the source
|
||||
selection — a fragment carries no memory of what it isn't, so paste could
|
||||
never reconstruct this list. `PasteOutcome { outcome: EditOutcome,
|
||||
events_inserted: usize }` therefore carries no `dropped`.
|
||||
* `paste_at(&mut self, point: Point, grid: &GridResolution, fragment: &str)
|
||||
-> Result<PasteOutcome, EditorError>`, `paste_over_selection(&mut self,
|
||||
fragment: &str) -> Result<PasteOutcome, EditorError>` — match the
|
||||
indicative shape.
|
||||
* `fragment::{FragmentError, MAX_FRAGMENT_BYTES, MAX_FRAGMENT_EVENTS,
|
||||
MAX_FRAGMENT_NESTING_DEPTH}` are re-exported at the crate root;
|
||||
`EditorError::InvalidFragment(fragment::FragmentError)` folds decode
|
||||
failures in via `From`. New `EditorError` variants:
|
||||
`PartialTupletSelection { tuplet }` (copy-side closure refusal),
|
||||
`InsufficientVoicesForFragment { needed, available }` (paste-side lane
|
||||
policy, below), `EmptyFragment` (a decoded fragment naming no events).
|
||||
|
||||
**Closure v1, as implemented.** Tuplet refusal and the slur/tie
|
||||
copy-or-drop-and-report decision are both evaluated in `copy_selection`
|
||||
against the live score (`fragment.rs` never sees a tuplet, a `Score`, or a
|
||||
selection — it only knows its own already-built `FragmentDocument`). A slur
|
||||
whose `start_event`/`end_event` both resolve to fragment-local `EventRef`s
|
||||
is carried into the fragment (`kind`/`curvature_override`/`style` copied
|
||||
verbatim, since none are identities); exactly one resolving is a boundary
|
||||
cut, reported via `DroppedItem::Slur`/`Tie`; neither resolving means the
|
||||
slur/tie has nothing to do with this copy and is silently absent (not a
|
||||
"drop" — it was never in scope). A partially-covered tuplet's members
|
||||
short-circuit the whole copy before any fragment content is built.
|
||||
|
||||
**Points Ruling E left underdetermined, decided here (flagged per the
|
||||
brief):**
|
||||
1. **Event-kind scope.** Ruling E names no event-kind boundary for
|
||||
"copies". `copy_selection` scopes to what `make_room` already treats as
|
||||
copyable — a live, metric `Pitched`/`Rest` event — mirroring the crate's
|
||||
one other place this exact line gets drawn, rather than inventing a
|
||||
second boundary. Non-metric events and every other `Event` variant
|
||||
(`Unpitched`/`Indeterminate`/`Trajectory`/`Graphic`/`Cue`) are out of v1.
|
||||
2. **A selection member that is not a copyable pitch/event is *skipped*, not
|
||||
refused — a deliberate departure from `delete_selection`'s hard-refusal
|
||||
precedent, discovered while writing the required `select_within`-driven
|
||||
tests.** A rubber-band `select_within` over a real staff routinely also
|
||||
selects incidental geometry it visually crosses — staff lines, a note's
|
||||
own stem — verified directly (a tight rect around two on-staff notes
|
||||
still selected the staff line their noteheads sit on, plus both notes'
|
||||
stems, five members total for two intended notes). A hard refusal
|
||||
(`delete_selection`'s own rule: anything that is not a pitch/event errors
|
||||
the whole call) would make geometric copy nearly unusable in practice.
|
||||
`copy_selection` instead mirrors `alter_selection`'s established
|
||||
"silently ignored" precedent (this file, above): a member that is not a
|
||||
pitch/event, or a pitch/event that does not resolve to a live copyable
|
||||
note/rest, is skipped; the whole copy still refuses
|
||||
(`WrongSelection`) only if *nothing* usable remains. A note's own stem
|
||||
(an `Event`-sourced region distinct from its `Pitch`-sourced notehead)
|
||||
being incidentally selected alongside its notehead is harmless either
|
||||
way — both resolve to the same `EventId` and collapse in the
|
||||
`BTreeSet`.
|
||||
3. **Multi-lane paste placement is resolved positionally, refusing rather
|
||||
than guessing when short.** Ruling E specifies the destination staff/voice
|
||||
for both placement forms but not a policy for a fragment naming more than
|
||||
one voice lane. This session maps fragment lane `i` onto the destination
|
||||
staff instance's `i`-th voice (`StaffInstance::voices[i]`) for both
|
||||
`paste_at` (rooted at the clicked staff instance) and
|
||||
`paste_over_selection` (rooted at the anchor's own staff instance);
|
||||
`InsufficientVoicesForFragment` refuses cleanly when the destination has
|
||||
fewer voices than the fragment has lanes, rather than collapsing lanes
|
||||
onto one voice or dropping the extras silently.
|
||||
4. **Slur/tie closure captures into the fragment *format* fully; paste does
|
||||
not yet replay a captured one.** A fully-contained slur/tie is carried in
|
||||
the fragment text (satisfying "copies" as a wire-format guarantee — the
|
||||
data is not lost, and a future paste enhancement needs no format bump to
|
||||
consume it), but Ruling E's own placement bullet names exactly
|
||||
`InsertEvent` + `RespellPitch` as what paste mints, and no required test
|
||||
exercises re-minting a pasted slur/tie (`CreateCrossCutting`, which would
|
||||
need its own `SlurId`/`TieId` high-water-mark minter alongside
|
||||
`mint_event_id`/`mint_pitch_id`). Filed here as a named follow-up, not a
|
||||
silent gap: a fragment carrying a captured slur/tie today decodes and
|
||||
pastes its notes correctly; the slur/tie itself is inert cargo until a
|
||||
later packet wires the mint.
|
||||
5. **A tie's explicit `pitch_pairing` is not carried; captured ties fall
|
||||
back to `None`** (default enharmonic pairing on any future replay).
|
||||
`pitch_pairing: Option<Vec<(PitchId, PitchId)>>` keys on source
|
||||
`PitchId`s, which the fragment deliberately never carries (Ruling E: no
|
||||
object ids); remapping specific pairings through per-pitch ordinals was
|
||||
judged not worth the added grammar for a rare feature with no test
|
||||
coverage requirement. `TieClass`/`SpanStyle` are still carried in full.
|
||||
|
||||
**Untrusted-input caps**, each a named public constant with its own
|
||||
value-asserting rejection test in `fragment.rs`: `MAX_FRAGMENT_BYTES = 1 <<
|
||||
20` (byte length, checked first — cheapest rejection); `MAX_FRAGMENT_EVENTS
|
||||
= 4096` (checked after structural parse, since counting needs typed
|
||||
voices); `MAX_FRAGMENT_NESTING_DEPTH = 64` (`(`-nesting, checked by one
|
||||
linear, non-recursive scan over the raw text *before* `read_sexp`'s
|
||||
recursive-descent reader ever sees adversarial input — the scan is
|
||||
string-literal-aware, so a legitimate catalog-id string's own characters
|
||||
can never spuriously trip it). An unrecognized major is rejected
|
||||
immediately after the version triple is read and *before* `Vec<FragmentVoice>::parse`
|
||||
or its siblings ever attempt to interpret the body — "never partially
|
||||
parsed" verified directly (a major-1 fragment whose body would not even
|
||||
parse under today's grammar still reports `UnsupportedVersion`, not a parse
|
||||
error, proving the gate runs first).
|
||||
|
||||
**Paste atomicity.** `paste_document` builds the *entire* op list — every
|
||||
lane's make-room clears plus every fragment event's `InsertEvent` (+
|
||||
`RespellPitch` for a carried spelling override, insert-before-its-own-respell,
|
||||
the same discipline `make_room_ops`'s split-tail loop already uses) — before
|
||||
calling `apply`/`apply_transaction` even once; a refusal discovered while
|
||||
building it (`make_room` overlapping a nested tuplet) therefore can never
|
||||
leave a partial mutation, regardless of how the final application is shaped.
|
||||
Because of that, the packet's own suggested atomicity scenario ("make-room
|
||||
hits a refusal") cannot distinguish a transaction-committing implementation
|
||||
from one that applies ops individually — both return `Err` before anything
|
||||
would be applied either way (verified by construction, not asserted away:
|
||||
`paste_refuses_cleanly_on_a_nested_tuplet_and_changes_nothing` keeps that
|
||||
scenario as a real, useful test of a different property). The mutation that
|
||||
*does* separate the two designs needs a refusal that a **second lane's**
|
||||
make-room hits *after* a first lane's ops have already been built —
|
||||
`paste_atomicity_rolls_back_mid_transaction_on_a_second_lanes_refusal` pastes
|
||||
a two-lane fragment onto a destination whose voice 0 is clear and voice 1
|
||||
carries a nested tuplet; mutating the commit to run per-lane inside the
|
||||
loop (instead of once, over every lane's accumulated ops) lands voice 0's
|
||||
insert before voice 1's refusal is discovered, changing `canonical_bytes`
|
||||
even though the call still returns `Err` — killed, confirmed live.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,788 @@
|
|||
//! The clipboard fragment projection (Ruling E, `spec/PLAN_EDITOR_APP.md`
|
||||
//! §Ruling E, GRANTED 2026-07-23): a versioned, values-only s-expression
|
||||
//! format for [`crate::EditorSession::copy_selection`] /
|
||||
//! [`crate::EditorSession::paste_at`] / [`crate::EditorSession::paste_over_selection`].
|
||||
//!
|
||||
//! # Grammar
|
||||
//!
|
||||
//! ```text
|
||||
//! fragment ::= "(epiphany-fragment" version voices slurs ties ")"
|
||||
//! version ::= "(" u32 " " u32 " " u32 ")" ; (major minor patch)
|
||||
//! voices ::= "(" voice* ")"
|
||||
//! voice ::= "(voice (" event* "))"
|
||||
//! event ::= "(event" onset duration content ")"
|
||||
//! onset ::= <MusicalDuration> ; relative to the fragment origin
|
||||
//! duration ::= <MusicalDuration>
|
||||
//! content ::= "rest" fields | "pitched" fields
|
||||
//! pitches ::= "(" pitch-entry* ")"
|
||||
//! pitch-entry ::= "(pitch-entry" <Pitch> spelling-override ")"
|
||||
//! spelling-override ::= "()" | "(some" <PitchSpelling> ")"
|
||||
//! slurs ::= "(" slur* ")"
|
||||
//! slur ::= "(slur" event-ref event-ref <SlurKind> curvature-override <SpanStyle> ")"
|
||||
//! ties ::= "(" tie* ")"
|
||||
//! tie ::= "(tie" event-ref event-ref <TieClass> <SpanStyle> ")"
|
||||
//! event-ref ::= "(event-ref" u32{voice} u32{event} ")"
|
||||
//! ```
|
||||
//!
|
||||
//! `<Pitch>`, `<PitchSpelling>`, `<SlurKind>`, `<TieClass>`, `<SpanStyle>`,
|
||||
//! `<CurvatureOverride>`, `<MusicalDuration>` are the Text Projection's own
|
||||
//! ratified leaf/value productions — [`epiphany_core::textvalue::TextValue`]
|
||||
//! impls this module reuses verbatim (`struct_codec!`/`cstyle_enum_codec!` in
|
||||
//! `epiphany-core`), never re-derived. What is bespoke here is everything
|
||||
//! *document*-shaped the Text Projection does not have an opinion on: no
|
||||
//! document id, no envelopes, no causal contexts, and — the load-bearing
|
||||
//! difference from every id in the rest of the codebase — **no object ids**.
|
||||
//! An [`EventRef`] names an event by its position in the fragment's own
|
||||
//! per-voice lanes (`voice` ordinal, `event` ordinal within that lane), never
|
||||
//! a source `EventId`/`PitchId`/`SlurId`/`TieId`. Paste mints fresh ids from
|
||||
//! the session's own minters; nothing in this module ever sees a source id.
|
||||
//!
|
||||
//! # Closure v1 (Ruling E, fail closed)
|
||||
//!
|
||||
//! This module only *represents* closure's outcome — a fragment either
|
||||
//! carries a slur/tie (both its endpoints resolved to in-fragment
|
||||
//! [`EventRef`]s) or it does not exist in the fragment at all. The decision
|
||||
//! of *which* — and the "report dropped" bookkeeping for what closure
|
||||
//! discarded — is [`crate::EditorSession::copy_selection`]'s job, against the
|
||||
//! live selection and score; this module has no access to either. A
|
||||
//! partially-selected tuplet's refusal is likewise a copy-time decision (this
|
||||
//! module never sees a tuplet).
|
||||
//!
|
||||
//! # Untrusted input
|
||||
//!
|
||||
//! Fragments arrive from the OS clipboard. [`decode`] enforces three named
|
||||
//! caps — [`MAX_FRAGMENT_BYTES`], [`MAX_FRAGMENT_EVENTS`],
|
||||
//! [`MAX_FRAGMENT_NESTING_DEPTH`] — each checked, in that order, *before* the
|
||||
//! more expensive check after it (byte length is a slice op; nesting depth is
|
||||
//! one linear scan that never recurses, so it bounds stack depth *before*
|
||||
//! [`read_sexp`]'s recursive-descent reader ever runs on attacker input; the
|
||||
//! event count is checked only after a full structural parse, since it needs
|
||||
//! typed voices to count). An unrecognized major version is rejected
|
||||
//! immediately after the version triple is read and *before* any attempt to
|
||||
//! interpret the body as today's voices/slurs/ties grammar — Ruling E: "never
|
||||
//! partially parsed".
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use epiphany_core::textvalue::{read_sexp, Sexp, TextError, TextValue};
|
||||
use epiphany_core::{
|
||||
ArticulationMark, CurvatureOverride, DynamicMark, GraceKind, MusicalDuration, OrnamentMark,
|
||||
Pitch, PitchSpelling, SlurKind, SpanStyle, StaffPosition, StemConfiguration, TieClass,
|
||||
};
|
||||
|
||||
/// The one fragment major version this build encodes and accepts. Ruling E:
|
||||
/// starting `(0 1 0)`.
|
||||
pub const FRAGMENT_VERSION: (u32, u32, u32) = (0, 1, 0);
|
||||
|
||||
/// Hard cap on a fragment's encoded byte length. Checked first, on the raw
|
||||
/// text, before any parsing — the cheapest possible rejection of an
|
||||
/// unreasonably large clipboard payload.
|
||||
pub const MAX_FRAGMENT_BYTES: usize = 1 << 20; // 1 MiB
|
||||
|
||||
/// Hard cap on the total number of events a fragment may carry, summed over
|
||||
/// every voice lane. Checked after structural parsing (counting typed events
|
||||
/// needs the typed voices).
|
||||
pub const MAX_FRAGMENT_EVENTS: usize = 4096;
|
||||
|
||||
/// Hard cap on `(`-nesting depth, checked by one linear, non-recursive scan
|
||||
/// over the raw text *before* [`read_sexp`] — whose reader recurses one stack
|
||||
/// frame per open paren — ever sees the input. Comfortably above this
|
||||
/// grammar's legitimate worst case (a fully-populated pitched event with a
|
||||
/// spelling override nests on the order of a dozen levels); its job is
|
||||
/// bounding stack depth against adversarial input, not modeling this
|
||||
/// grammar's real shape precisely.
|
||||
pub const MAX_FRAGMENT_NESTING_DEPTH: usize = 64;
|
||||
|
||||
/// Why a clipboard fragment could not be decoded. Folded into
|
||||
/// [`crate::EditorError::InvalidFragment`].
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum FragmentError {
|
||||
/// The encoded text is over [`MAX_FRAGMENT_BYTES`].
|
||||
TooManyBytes {
|
||||
/// The byte cap.
|
||||
limit: usize,
|
||||
/// The text's actual length.
|
||||
found: usize,
|
||||
},
|
||||
/// The text's `(`-nesting exceeds [`MAX_FRAGMENT_NESTING_DEPTH`].
|
||||
NestingTooDeep {
|
||||
/// The depth cap.
|
||||
limit: usize,
|
||||
},
|
||||
/// The header named a major version this build does not read (only
|
||||
/// `FRAGMENT_VERSION`'s major is accepted). Rejected before any attempt
|
||||
/// to parse the body under today's grammar.
|
||||
UnsupportedVersion {
|
||||
/// The unrecognized major version.
|
||||
major: u32,
|
||||
},
|
||||
/// The text is not a well-formed s-expression, or not a well-formed
|
||||
/// fragment of a recognized major version.
|
||||
Malformed(TextError),
|
||||
/// The fragment's total event count (summed over every voice) is over
|
||||
/// [`MAX_FRAGMENT_EVENTS`].
|
||||
TooManyEvents {
|
||||
/// The event-count cap.
|
||||
limit: usize,
|
||||
/// The fragment's actual total event count.
|
||||
found: usize,
|
||||
},
|
||||
/// A slur or tie names an `EventRef` outside the fragment's own voices —
|
||||
/// corrupt or adversarial input; a well-formed fragment produced by
|
||||
/// `encode` never emits one.
|
||||
DanglingEventRef,
|
||||
}
|
||||
|
||||
impl fmt::Display for FragmentError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
FragmentError::TooManyBytes { limit, found } => {
|
||||
write!(f, "fragment is {found} bytes, over the {limit}-byte cap")
|
||||
}
|
||||
FragmentError::NestingTooDeep { limit } => {
|
||||
write!(f, "fragment nesting exceeds the {limit}-level cap")
|
||||
}
|
||||
FragmentError::UnsupportedVersion { major } => write!(
|
||||
f,
|
||||
"fragment major version {major} is not supported (this build reads major {})",
|
||||
FRAGMENT_VERSION.0
|
||||
),
|
||||
FragmentError::Malformed(err) => write!(f, "malformed fragment: {err}"),
|
||||
FragmentError::TooManyEvents { limit, found } => {
|
||||
write!(f, "fragment has {found} events, over the {limit}-event cap")
|
||||
}
|
||||
FragmentError::DanglingEventRef => {
|
||||
write!(f, "a slur or tie references an event outside the fragment")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FragmentError {}
|
||||
|
||||
impl From<TextError> for FragmentError {
|
||||
fn from(err: TextError) -> Self {
|
||||
FragmentError::Malformed(err)
|
||||
}
|
||||
}
|
||||
|
||||
/// The lexical class of `s`, for error messages — mirrors `Sexp`'s own
|
||||
/// private classifier (duplicated here the same way `epiphany-ops`'
|
||||
/// `textproj_leaf`/`textvalue_event` each keep their own copy; `Sexp::class`
|
||||
/// is private to its defining module).
|
||||
fn class_of(s: &Sexp) -> &'static str {
|
||||
match s {
|
||||
Sexp::List(_) => "list",
|
||||
Sexp::Symbol(_) => "symbol",
|
||||
Sexp::Int(_) => "integer",
|
||||
Sexp::Bytes(_) => "byte string",
|
||||
Sexp::Str(_) => "string",
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to one event **within this fragment**, by its position in a
|
||||
/// voice lane — never a source `EventId`/`PitchId` (Ruling E: "no object
|
||||
/// ids"). `voice`/`event` are 0-based indices into [`FragmentDocument::voices`]
|
||||
/// and that voice's `events`. [`decode`] validates every reference actually
|
||||
/// resolves before returning a [`FragmentDocument`] — nothing downstream
|
||||
/// needs to re-check.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub(crate) struct EventRef {
|
||||
pub(crate) voice: u32,
|
||||
pub(crate) event: u32,
|
||||
}
|
||||
|
||||
impl TextValue for EventRef {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::List(vec![
|
||||
Sexp::sym("event-ref"),
|
||||
self.voice.project(),
|
||||
self.event.project(),
|
||||
])
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let fields = s.expect_struct("event-ref", 2)?;
|
||||
Ok(EventRef {
|
||||
voice: u32::parse(&fields[0])?,
|
||||
event: u32::parse(&fields[1])?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One note in a chord: its pitch **value** plus an authored spelling
|
||||
/// override, if the source pitch carried one (an *inferred* spelling never
|
||||
/// copies — Ruling E: derived state re-derives).
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub(crate) struct FragmentPitch {
|
||||
pub(crate) pitch: Pitch,
|
||||
pub(crate) spelling_override: Option<PitchSpelling>,
|
||||
}
|
||||
|
||||
impl TextValue for FragmentPitch {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::List(vec![
|
||||
Sexp::sym("pitch-entry"),
|
||||
self.pitch.project(),
|
||||
self.spelling_override.project(),
|
||||
])
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let fields = s.expect_struct("pitch-entry", 2)?;
|
||||
Ok(FragmentPitch {
|
||||
pitch: Pitch::parse(&fields[0])?,
|
||||
spelling_override: Option::<PitchSpelling>::parse(&fields[1])?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An event's content: a rest, or a chord of one or more pitches with its
|
||||
/// per-event attachments (Ruling E: "notes/rests with their per-event
|
||||
/// attachments copy"). Scoped to what [`crate::EditorSession::make_room`]
|
||||
/// itself already treats as copyable — a note or a rest — mirroring the
|
||||
/// crate's established make-room boundary rather than inventing a new one;
|
||||
/// Ruling E names no event-kind scope explicitly (flagged in the W4a
|
||||
/// report).
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub(crate) enum FragmentEventContent {
|
||||
Rest {
|
||||
vertical_position: Option<StaffPosition>,
|
||||
visible: bool,
|
||||
},
|
||||
Pitched {
|
||||
pitches: Vec<FragmentPitch>,
|
||||
articulations: Vec<ArticulationMark>,
|
||||
dynamic: Option<DynamicMark>,
|
||||
ornaments: Vec<OrnamentMark>,
|
||||
stem: StemConfiguration,
|
||||
grace: Option<GraceKind>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TextValue for FragmentEventContent {
|
||||
fn project(&self) -> Sexp {
|
||||
match self {
|
||||
FragmentEventContent::Rest {
|
||||
vertical_position,
|
||||
visible,
|
||||
} => Sexp::List(vec![
|
||||
Sexp::sym("rest"),
|
||||
vertical_position.project(),
|
||||
visible.project(),
|
||||
]),
|
||||
FragmentEventContent::Pitched {
|
||||
pitches,
|
||||
articulations,
|
||||
dynamic,
|
||||
ornaments,
|
||||
stem,
|
||||
grace,
|
||||
} => Sexp::List(vec![
|
||||
Sexp::sym("pitched"),
|
||||
pitches.project(),
|
||||
articulations.project(),
|
||||
dynamic.project(),
|
||||
ornaments.project(),
|
||||
stem.project(),
|
||||
grace.project(),
|
||||
]),
|
||||
}
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let items = s.as_list().ok_or(TextError::Expected {
|
||||
expected: "FragmentEventContent",
|
||||
found: class_of(s),
|
||||
})?;
|
||||
let head = items
|
||||
.first()
|
||||
.and_then(Sexp::as_symbol)
|
||||
.ok_or(TextError::Syntax(
|
||||
"a fragment event content is a list headed by its kind",
|
||||
))?;
|
||||
match head {
|
||||
"rest" => {
|
||||
let fields = s.expect_struct("rest", 2)?;
|
||||
Ok(FragmentEventContent::Rest {
|
||||
vertical_position: Option::<StaffPosition>::parse(&fields[0])?,
|
||||
visible: bool::parse(&fields[1])?,
|
||||
})
|
||||
}
|
||||
"pitched" => {
|
||||
let fields = s.expect_struct("pitched", 6)?;
|
||||
let pitches = Vec::<FragmentPitch>::parse(&fields[0])?;
|
||||
if pitches.is_empty() {
|
||||
return Err(TextError::NotCanonical(
|
||||
"a pitched fragment event must have at least one pitch",
|
||||
));
|
||||
}
|
||||
Ok(FragmentEventContent::Pitched {
|
||||
pitches,
|
||||
articulations: Vec::<ArticulationMark>::parse(&fields[1])?,
|
||||
dynamic: Option::<DynamicMark>::parse(&fields[2])?,
|
||||
ornaments: Vec::<OrnamentMark>::parse(&fields[3])?,
|
||||
stem: StemConfiguration::parse(&fields[4])?,
|
||||
grace: Option::<GraceKind>::parse(&fields[5])?,
|
||||
})
|
||||
}
|
||||
found => Err(TextError::UnknownConstructor {
|
||||
type_name: "FragmentEventContent",
|
||||
found: found.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One event: a rational onset **relative to the fragment origin**, its
|
||||
/// written duration, and its content (Ruling E).
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub(crate) struct FragmentEvent {
|
||||
pub(crate) onset: MusicalDuration,
|
||||
pub(crate) duration: MusicalDuration,
|
||||
pub(crate) content: FragmentEventContent,
|
||||
}
|
||||
|
||||
impl TextValue for FragmentEvent {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::List(vec![
|
||||
Sexp::sym("event"),
|
||||
self.onset.project(),
|
||||
self.duration.project(),
|
||||
self.content.project(),
|
||||
])
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let fields = s.expect_struct("event", 3)?;
|
||||
let onset = MusicalDuration::parse(&fields[0])?;
|
||||
if onset.0.is_negative() {
|
||||
return Err(TextError::NotCanonical(
|
||||
"a fragment event's onset must be non-negative",
|
||||
));
|
||||
}
|
||||
let duration = MusicalDuration::parse(&fields[1])?;
|
||||
if !duration.is_positive() {
|
||||
return Err(TextError::NotCanonical(
|
||||
"a fragment event's duration must be positive",
|
||||
));
|
||||
}
|
||||
let content = FragmentEventContent::parse(&fields[2])?;
|
||||
Ok(FragmentEvent {
|
||||
onset,
|
||||
duration,
|
||||
content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One **ordinal-keyed** voice lane (Ruling E: "in per-voice lanes keyed by
|
||||
/// ordinal (not `VoiceId`)") — its ordinal is this voice's index in
|
||||
/// [`FragmentDocument::voices`], not a field of this type.
|
||||
#[derive(Clone, PartialEq, Debug, Default)]
|
||||
pub(crate) struct FragmentVoice {
|
||||
pub(crate) events: Vec<FragmentEvent>,
|
||||
}
|
||||
|
||||
impl TextValue for FragmentVoice {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::List(vec![Sexp::sym("voice"), self.events.project()])
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let fields = s.expect_struct("voice", 1)?;
|
||||
Ok(FragmentVoice {
|
||||
events: Vec::<FragmentEvent>::parse(&fields[0])?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A slur fully inside the copied range — closure v1 carried it (both
|
||||
/// endpoints resolved to in-fragment events); a boundary-cut slur never
|
||||
/// reaches this type (it is dropped and reported at copy time instead, see
|
||||
/// the module doc).
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub(crate) struct FragmentSlur {
|
||||
pub(crate) start: EventRef,
|
||||
pub(crate) end: EventRef,
|
||||
pub(crate) kind: SlurKind,
|
||||
pub(crate) curvature_override: Option<CurvatureOverride>,
|
||||
pub(crate) style: SpanStyle,
|
||||
}
|
||||
|
||||
impl TextValue for FragmentSlur {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::List(vec![
|
||||
Sexp::sym("slur"),
|
||||
self.start.project(),
|
||||
self.end.project(),
|
||||
self.kind.project(),
|
||||
self.curvature_override.project(),
|
||||
self.style.project(),
|
||||
])
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let fields = s.expect_struct("slur", 5)?;
|
||||
Ok(FragmentSlur {
|
||||
start: EventRef::parse(&fields[0])?,
|
||||
end: EventRef::parse(&fields[1])?,
|
||||
kind: SlurKind::parse(&fields[2])?,
|
||||
curvature_override: Option::<CurvatureOverride>::parse(&fields[3])?,
|
||||
style: SpanStyle::parse(&fields[4])?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A tie fully inside the copied range (see [`FragmentSlur`]'s doc — the
|
||||
/// same closure rule). The source tie's explicit pitch pairing, if any, is
|
||||
/// **not** carried (a decision this packet made explicitly, see
|
||||
/// `DECISIONS.md`): pairing keys on source `PitchId`s, which the fragment
|
||||
/// deliberately never carries, and remapping it through per-pitch ordinals
|
||||
/// was judged not worth the added grammar for a v1 whose paste does not yet
|
||||
/// re-mint cross-cutting structures at all (below). A pasted tie, when
|
||||
/// pasting is extended to replay one, falls back to `None` — the default
|
||||
/// enharmonic pairing.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub(crate) struct FragmentTie {
|
||||
pub(crate) start: EventRef,
|
||||
pub(crate) end: EventRef,
|
||||
pub(crate) class: TieClass,
|
||||
pub(crate) style: SpanStyle,
|
||||
}
|
||||
|
||||
impl TextValue for FragmentTie {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::List(vec![
|
||||
Sexp::sym("tie"),
|
||||
self.start.project(),
|
||||
self.end.project(),
|
||||
self.class.project(),
|
||||
self.style.project(),
|
||||
])
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let fields = s.expect_struct("tie", 4)?;
|
||||
Ok(FragmentTie {
|
||||
start: EventRef::parse(&fields[0])?,
|
||||
end: EventRef::parse(&fields[1])?,
|
||||
class: TieClass::parse(&fields[2])?,
|
||||
style: SpanStyle::parse(&fields[3])?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed, validated fragment body (everything after the version header).
|
||||
/// **Values, never identities** (Ruling E): no document id, no envelopes, no
|
||||
/// causal contexts, no object ids anywhere in this type or its fields.
|
||||
#[derive(Clone, PartialEq, Debug, Default)]
|
||||
pub(crate) struct FragmentDocument {
|
||||
pub(crate) voices: Vec<FragmentVoice>,
|
||||
pub(crate) slurs: Vec<FragmentSlur>,
|
||||
pub(crate) ties: Vec<FragmentTie>,
|
||||
}
|
||||
|
||||
fn project_version((major, minor, patch): (u32, u32, u32)) -> Sexp {
|
||||
Sexp::List(vec![Sexp::int(major), Sexp::int(minor), Sexp::int(patch)])
|
||||
}
|
||||
|
||||
fn parse_version(s: &Sexp) -> Result<(u32, u32, u32), FragmentError> {
|
||||
let items = s.as_list().ok_or(TextError::Expected {
|
||||
expected: "version triple",
|
||||
found: class_of(s),
|
||||
})?;
|
||||
let [major, minor, patch] = items else {
|
||||
return Err(TextError::Arity {
|
||||
type_name: "version",
|
||||
expected: 3,
|
||||
found: items.len(),
|
||||
}
|
||||
.into());
|
||||
};
|
||||
Ok((u32::parse(major)?, u32::parse(minor)?, u32::parse(patch)?))
|
||||
}
|
||||
|
||||
/// Renders `document` as fragment text: `(epiphany-fragment (0 1 0) …)`.
|
||||
/// Never fails — it is built from a live, already-valid session selection,
|
||||
/// not untrusted input (the caps in [`decode`] are a **read**-side
|
||||
/// discipline; [`crate::EditorSession::copy_selection`] does not need them on
|
||||
/// the way out).
|
||||
pub(crate) fn encode(document: &FragmentDocument) -> String {
|
||||
Sexp::List(vec![
|
||||
Sexp::sym("epiphany-fragment"),
|
||||
project_version(FRAGMENT_VERSION),
|
||||
document.voices.project(),
|
||||
document.slurs.project(),
|
||||
document.ties.project(),
|
||||
])
|
||||
.render()
|
||||
}
|
||||
|
||||
/// Decodes and validates fragment text — untrusted input (Ruling E). See the
|
||||
/// module doc for the cap ordering and the "never partially parsed" version
|
||||
/// discipline.
|
||||
pub(crate) fn decode(text: &str) -> Result<FragmentDocument, FragmentError> {
|
||||
if text.len() > MAX_FRAGMENT_BYTES {
|
||||
return Err(FragmentError::TooManyBytes {
|
||||
limit: MAX_FRAGMENT_BYTES,
|
||||
found: text.len(),
|
||||
});
|
||||
}
|
||||
check_nesting_depth(text)?;
|
||||
|
||||
let sexp = read_sexp(text)?;
|
||||
let items = sexp.as_list().ok_or(TextError::Expected {
|
||||
expected: "epiphany-fragment",
|
||||
found: class_of(&sexp),
|
||||
})?;
|
||||
let Some((head, rest)) = items.split_first() else {
|
||||
return Err(TextError::Syntax("an empty fragment").into());
|
||||
};
|
||||
if head.as_symbol() != Some("epiphany-fragment") {
|
||||
return Err(TextError::Syntax("a fragment begins `(epiphany-fragment …)`").into());
|
||||
}
|
||||
let [version, voices, slurs, ties] = rest else {
|
||||
return Err(TextError::Arity {
|
||||
type_name: "epiphany-fragment",
|
||||
expected: 4,
|
||||
found: rest.len(),
|
||||
}
|
||||
.into());
|
||||
};
|
||||
|
||||
// The version gate: checked, and dispositioned, before any attempt to
|
||||
// interpret the body under today's grammar — an unrecognized major is
|
||||
// rejected cleanly, never partially parsed (Ruling E).
|
||||
let (major, _minor, _patch) = parse_version(version)?;
|
||||
if major != FRAGMENT_VERSION.0 {
|
||||
return Err(FragmentError::UnsupportedVersion { major });
|
||||
}
|
||||
|
||||
let voices = Vec::<FragmentVoice>::parse(voices)?;
|
||||
let slurs = Vec::<FragmentSlur>::parse(slurs)?;
|
||||
let ties = Vec::<FragmentTie>::parse(ties)?;
|
||||
|
||||
let total_events: usize = voices.iter().map(|v| v.events.len()).sum();
|
||||
if total_events > MAX_FRAGMENT_EVENTS {
|
||||
return Err(FragmentError::TooManyEvents {
|
||||
limit: MAX_FRAGMENT_EVENTS,
|
||||
found: total_events,
|
||||
});
|
||||
}
|
||||
|
||||
let ref_resolves = |r: &EventRef| {
|
||||
voices
|
||||
.get(r.voice as usize)
|
||||
.is_some_and(|v| (r.event as usize) < v.events.len())
|
||||
};
|
||||
let dangling = slurs
|
||||
.iter()
|
||||
.any(|s| !ref_resolves(&s.start) || !ref_resolves(&s.end))
|
||||
|| ties
|
||||
.iter()
|
||||
.any(|t| !ref_resolves(&t.start) || !ref_resolves(&t.end));
|
||||
if dangling {
|
||||
return Err(FragmentError::DanglingEventRef);
|
||||
}
|
||||
|
||||
Ok(FragmentDocument {
|
||||
voices,
|
||||
slurs,
|
||||
ties,
|
||||
})
|
||||
}
|
||||
|
||||
/// A linear, non-recursive scan bounding `(`-nesting depth *before*
|
||||
/// [`read_sexp`]'s recursive-descent reader runs — see [`MAX_FRAGMENT_NESTING_DEPTH`].
|
||||
/// Parens inside a quoted string are not counted (a legitimate catalog-id
|
||||
/// string, the only string-shaped leaf this grammar's leaf productions ever
|
||||
/// emit, must not spuriously trip the cap); byte strings (`#x…`) never
|
||||
/// contain parens by grammar, so they need no special handling.
|
||||
fn check_nesting_depth(text: &str) -> Result<(), FragmentError> {
|
||||
let mut depth: usize = 0;
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
for &b in text.as_bytes() {
|
||||
if in_string {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if b == b'\\' {
|
||||
escaped = true;
|
||||
} else if b == b'"' {
|
||||
in_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match b {
|
||||
b'"' => in_string = true,
|
||||
b'(' => {
|
||||
depth += 1;
|
||||
if depth > MAX_FRAGMENT_NESTING_DEPTH {
|
||||
return Err(FragmentError::NestingTooDeep {
|
||||
limit: MAX_FRAGMENT_NESTING_DEPTH,
|
||||
});
|
||||
}
|
||||
}
|
||||
b')' => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use epiphany_core::{AcousticPitch, AcousticRealization, TuningReference};
|
||||
use epiphany_core::{
|
||||
CmnNominal, PitchSpaceId, PitchSpacePosition, RationalTime, ScalePosition,
|
||||
};
|
||||
|
||||
fn a_pitch() -> 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn a_duration(n: i64, d: i64) -> MusicalDuration {
|
||||
MusicalDuration(RationalTime::new(n, d).unwrap())
|
||||
}
|
||||
|
||||
fn rest_event(onset: i64) -> FragmentEvent {
|
||||
FragmentEvent {
|
||||
onset: a_duration(onset, 4),
|
||||
duration: a_duration(1, 4),
|
||||
content: FragmentEventContent::Rest {
|
||||
vertical_position: None,
|
||||
visible: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn pitched_event(onset: i64) -> FragmentEvent {
|
||||
FragmentEvent {
|
||||
onset: a_duration(onset, 4),
|
||||
duration: a_duration(1, 4),
|
||||
content: FragmentEventContent::Pitched {
|
||||
pitches: vec![FragmentPitch {
|
||||
pitch: a_pitch(),
|
||||
spelling_override: Some(PitchSpelling::cmn(CmnNominal::D, 4)),
|
||||
}],
|
||||
articulations: vec![],
|
||||
dynamic: None,
|
||||
ornaments: vec![],
|
||||
stem: StemConfiguration,
|
||||
grace: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_document_round_trips_through_text() {
|
||||
let document = FragmentDocument {
|
||||
voices: vec![FragmentVoice {
|
||||
events: vec![pitched_event(0), rest_event(1)],
|
||||
}],
|
||||
slurs: vec![FragmentSlur {
|
||||
start: EventRef { voice: 0, event: 0 },
|
||||
end: EventRef { voice: 0, event: 1 },
|
||||
kind: SlurKind::Legato,
|
||||
curvature_override: None,
|
||||
style: SpanStyle::default(),
|
||||
}],
|
||||
ties: vec![FragmentTie {
|
||||
start: EventRef { voice: 0, event: 0 },
|
||||
end: EventRef { voice: 0, event: 1 },
|
||||
class: TieClass::Standard,
|
||||
style: SpanStyle::default(),
|
||||
}],
|
||||
};
|
||||
let text = encode(&document);
|
||||
assert!(text.starts_with("(epiphany-fragment (0 1 0) "));
|
||||
let decoded = decode(&text).expect("a well-formed fragment decodes");
|
||||
assert_eq!(decoded, document);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_a_fragment_over_the_byte_cap() {
|
||||
let text = "a".repeat(MAX_FRAGMENT_BYTES + 1);
|
||||
assert_eq!(
|
||||
decode(&text),
|
||||
Err(FragmentError::TooManyBytes {
|
||||
limit: MAX_FRAGMENT_BYTES,
|
||||
found: text.len(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_a_fragment_over_the_event_cap() {
|
||||
let document = FragmentDocument {
|
||||
voices: vec![FragmentVoice {
|
||||
events: (0..=MAX_FRAGMENT_EVENTS as i64).map(rest_event).collect(),
|
||||
}],
|
||||
slurs: vec![],
|
||||
ties: vec![],
|
||||
};
|
||||
let text = encode(&document);
|
||||
let found = MAX_FRAGMENT_EVENTS + 1;
|
||||
assert_eq!(
|
||||
decode(&text),
|
||||
Err(FragmentError::TooManyEvents {
|
||||
limit: MAX_FRAGMENT_EVENTS,
|
||||
found,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_nesting_over_the_depth_cap_before_parsing() {
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
"(".repeat(MAX_FRAGMENT_NESTING_DEPTH + 1),
|
||||
")".repeat(MAX_FRAGMENT_NESTING_DEPTH + 1)
|
||||
);
|
||||
assert_eq!(
|
||||
decode(&text),
|
||||
Err(FragmentError::NestingTooDeep {
|
||||
limit: MAX_FRAGMENT_NESTING_DEPTH,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_an_unrecognized_major_cleanly() {
|
||||
// A major-1 header over a body that would not even parse under
|
||||
// today's grammar (three atoms, not three lists) — proving the
|
||||
// version gate rejects before any attempt to interpret the body.
|
||||
let text = "(epiphany-fragment (1 0 0) x y z)";
|
||||
assert_eq!(
|
||||
decode(text),
|
||||
Err(FragmentError::UnsupportedVersion { major: 1 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_a_dangling_event_ref() {
|
||||
let document = FragmentDocument {
|
||||
voices: vec![FragmentVoice {
|
||||
events: vec![rest_event(0)],
|
||||
}],
|
||||
slurs: vec![FragmentSlur {
|
||||
start: EventRef { voice: 0, event: 0 },
|
||||
end: EventRef { voice: 0, event: 5 }, // out of range
|
||||
kind: SlurKind::Legato,
|
||||
curvature_override: None,
|
||||
style: SpanStyle::default(),
|
||||
}],
|
||||
ties: vec![],
|
||||
};
|
||||
let text = encode(&document);
|
||||
assert_eq!(decode(&text), Err(FragmentError::DanglingEventRef));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue