Skip to main content

acorde_core/model/
engine.rs

1use super::change_hint::{ChangeHint, ChangeScope};
2use super::commands::{
3    AddStaffCmd, Command, CommandStack, DeleteStaffCmd, DurationScale, ExchangeVoicesCmd,
4    ExplodeChordPitchesCmd, ExplodeVoicesCmd, ImplodeStavesCmd, PasteRangeCmd,
5    PasteScoreFragmentCmd, PasteVoiceCmd, RespellScoreCmd, RespellScoreToKeyCmd,
6    RespellStaffRegionCmd, ScaleVoiceRangeCmd, ScoreFragmentPastePolicy, SetArpeggioCmd, SetCueCmd,
7    SetDurationCmd, SetInstrumentIdCmd, SetNoteHeadCmd, SetNotePlacementCmd, SetPartGroupCmd,
8    SetStemCmd, SetTupletCmd, SetUnpitchedCmd, ToggleSlurCmd, ToggleTrillLineCmd, command_hint,
9    command_key,
10};
11use super::duration::Duration;
12use super::fragment::ScoreFragment;
13use super::notation::{Clef, NoteHead, TupletInfo};
14use super::score::PartGroup;
15use super::score::{Note, NoteAddr, RespellPolicy, Score};
16use crate::Error;
17use serde::{Deserialize, Serialize};
18
19/// Serialisable snapshot of a [`ScoreEngine`]'s command history for crash recovery or replay.
20///
21/// `initial_score` is the state of the score before any commands were applied (i.e. the base
22/// loaded via [`ScoreEngine::replace_score`] or the built-in default).
23/// `commands` are the commands applied after that, in execution order.
24///
25/// Round-trip: `ScoreEngine::from_history(engine.export_history())` produces an engine whose
26/// score and version match the original.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct EngineHistory {
29    pub initial_score: Score,
30    pub commands: Vec<Command>,
31}
32
33/// Deterministic relationship between two command logs sharing a collaboration base.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub enum HistoryRelation {
36    Equivalent,
37    LeftExtends { common_prefix_len: usize },
38    RightExtends { common_prefix_len: usize },
39    Diverged { common_prefix_len: usize },
40    BaseMismatch,
41}
42
43/// Explainable details for a divergent pair of command logs.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct HistoryConflict {
46    pub common_prefix_len: usize,
47    pub left_command_index: usize,
48    pub right_command_index: usize,
49    pub left_command_key: String,
50    pub right_command_key: String,
51    pub left_remaining_commands: usize,
52    pub right_remaining_commands: usize,
53}
54
55#[derive(Debug, Clone)]
56struct RangeClipboard {
57    voice: usize,
58    measures: Vec<Vec<Note>>,
59}
60
61pub struct ScoreEngine {
62    pub score: Score,
63    pub commands: CommandStack,
64    pub version: u64,
65    pub clipboard: Option<Vec<Note>>,
66    range_clipboard: Option<RangeClipboard>,
67    initial_score: Score,
68    pending_slur_start: Option<NoteAddr>,
69}
70
71impl Default for ScoreEngine {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl ScoreEngine {
78    pub fn new() -> Self {
79        let mut score = Score::default();
80        for part in &mut score.parts {
81            for staff in &mut part.staves {
82                for (i, m) in staff.measures.iter_mut().enumerate() {
83                    m.number = i as u32 + 1;
84                }
85            }
86        }
87        let initial_score = score.clone();
88        Self {
89            score,
90            commands: CommandStack::new(200),
91            version: 0,
92            clipboard: None,
93            range_clipboard: None,
94            initial_score,
95            pending_slur_start: None,
96        }
97    }
98
99    pub fn apply(&mut self, cmd: Command) -> Result<ChangeHint, Error> {
100        let hint = command_hint(&cmd);
101        self.commands.execute(cmd, &mut self.score)?;
102        self.version += 1;
103        Ok(hint)
104    }
105
106    pub fn undo(&mut self) -> Result<ChangeHint, Error> {
107        let hint = self.commands.undo(&mut self.score)?;
108        self.version += 1;
109        Ok(hint)
110    }
111
112    pub fn redo(&mut self) -> Result<ChangeHint, Error> {
113        let hint = self.commands.redo(&mut self.score)?;
114        self.version += 1;
115        Ok(hint)
116    }
117
118    /// Apply multiple commands as a single undo entry.
119    pub fn batch_apply(&mut self, cmds: Vec<Command>) -> Result<ChangeHint, Error> {
120        if cmds.is_empty() {
121            return Ok(ChangeHint {
122                scope: ChangeScope::Global,
123                layout_dirty: false,
124                playback_dirty: false,
125            });
126        }
127        let mut hint = command_hint(&cmds[0]);
128        for cmd in cmds.iter().skip(1) {
129            hint = hint.merge(command_hint(cmd));
130        }
131        self.commands.batch_execute(cmds, &mut self.score)?;
132        self.version += 1;
133        Ok(hint)
134    }
135
136    /// Apply a batch of commands as a single undo entry with an explicit label.
137    ///
138    /// The `label` appears as the [`command_key`] in undo/redo UI (e.g. `"ApplyAI"`).
139    pub fn batch_apply_labeled(
140        &mut self,
141        cmds: Vec<Command>,
142        label: &str,
143    ) -> Result<ChangeHint, Error> {
144        if cmds.is_empty() {
145            return Ok(ChangeHint {
146                scope: ChangeScope::Global,
147                layout_dirty: false,
148                playback_dirty: false,
149            });
150        }
151        let mut hint = command_hint(&cmds[0]);
152        for cmd in cmds.iter().skip(1) {
153            hint = hint.merge(command_hint(cmd));
154        }
155        self.commands
156            .batch_execute_labeled(cmds, label.to_string(), &mut self.score)?;
157        self.version += 1;
158        Ok(hint)
159    }
160
161    /// Label of the next undoable command (for "Undo: Add Note" menu items).
162    pub fn undo_label(&self) -> Option<String> {
163        self.commands.undo_label()
164    }
165
166    /// Label of the next redoable command (for "Redo: Add Note" menu items).
167    pub fn redo_label(&self) -> Option<String> {
168        self.commands.redo_label()
169    }
170
171    /// i18n key of the next undoable command (e.g. `"SetTempo"`).
172    pub fn undo_key(&self) -> Option<String> {
173        self.commands.undo_key()
174    }
175
176    /// i18n key of the next redoable command.
177    pub fn redo_key(&self) -> Option<String> {
178        self.commands.redo_key()
179    }
180
181    /// Replace a score that has already crossed a validation boundary.
182    ///
183    /// For deserialized or host-provided data, prefer [`ScoreEngine::try_replace_score`].
184    pub fn replace_score(&mut self, score: Score) {
185        self.initial_score = score.clone();
186        self.score = score;
187        self.version += 1;
188        self.commands = CommandStack::new(200);
189    }
190
191    /// Replace the score after checking its structural invariants.
192    ///
193    /// Unlike [`ScoreEngine::replace_score`], this is the safe boundary for
194    /// deserialized or host-provided scores. On failure, the engine and its
195    /// history remain unchanged.
196    pub fn try_replace_score(&mut self, score: Score) -> Result<(), Error> {
197        if !super::validate::validate(&score).is_valid() {
198            return Err(Error::InvalidScore);
199        }
200        self.replace_score(score);
201        Ok(())
202    }
203
204    /// Export the command history for serialization (crash recovery, AI replay).
205    ///
206    /// The returned [`EngineHistory`] can be stored as JSON and later restored with
207    /// [`ScoreEngine::from_history`].
208    pub fn export_history(&self) -> EngineHistory {
209        EngineHistory {
210            initial_score: self.initial_score.clone(),
211            commands: self.commands.history_commands(),
212        }
213    }
214
215    /// Reconstruct an engine from a previously exported [`EngineHistory`].
216    ///
217    /// Replays all commands against `history.initial_score` in order.
218    /// Returns an error if any command fails (e.g. index out of bounds due to stale data).
219    pub fn from_history(history: EngineHistory) -> Result<Self, Error> {
220        let mut engine = ScoreEngine::new();
221        engine.try_replace_score(history.initial_score)?;
222        for cmd in history.commands {
223            engine.apply(cmd)?;
224        }
225        Ok(engine)
226    }
227
228    /// Reconstruct history only when its declared base matches the supplied collaboration base.
229    ///
230    /// This prevents a stale command log from being replayed onto an unrelated score. Callers
231    /// can use [`EngineHistory::base_matches`] for a non-mutating preflight check first.
232    pub fn from_history_on_base(history: EngineHistory, base: Score) -> Result<Self, Error> {
233        if !history.base_matches(&base) {
234            return Err(Error::HistoryBaseMismatch);
235        }
236        Self::from_history(history)
237    }
238
239    /// Append a remote history only when it strictly extends this engine's command log.
240    ///
241    /// The complete incoming history is replayed into a candidate engine before replacement.
242    /// Diverged or base-mismatched histories, or replay failures, leave this engine unchanged.
243    pub fn append_history_extension(&mut self, incoming: &EngineHistory) -> Result<usize, Error> {
244        let local = self.export_history();
245        let common_prefix_len = match local.compare(incoming) {
246            HistoryRelation::LeftExtends { common_prefix_len } => common_prefix_len,
247            _ => return Err(Error::HistoryNotAppendable),
248        };
249        let count = incoming.commands.len() - common_prefix_len;
250        if count == 0 {
251            return Ok(0);
252        }
253        let candidate = Self::from_history(incoming.clone())?;
254        *self = candidate;
255        Ok(count)
256    }
257
258    pub fn copy_voice(
259        &mut self,
260        part_index: usize,
261        staff_index: usize,
262        measure_index: usize,
263        voice_index: usize,
264    ) -> Result<(), Error> {
265        let voice = self
266            .score
267            .parts
268            .get(part_index)
269            .ok_or(Error::PartNotFound(part_index))?
270            .staves
271            .get(staff_index)
272            .ok_or(Error::StaffNotFound(staff_index))?
273            .measures
274            .get(measure_index)
275            .ok_or(Error::MeasureNotFound(measure_index))?
276            .voices
277            .get(voice_index)
278            .ok_or(Error::VoiceOutOfRange(voice_index))?;
279        self.clipboard = Some(voice.clone());
280        Ok(())
281    }
282
283    pub fn paste_voice(
284        &mut self,
285        part_index: usize,
286        staff_index: usize,
287        measure_index: usize,
288        voice_index: usize,
289    ) -> Result<ChangeHint, Error> {
290        let notes = self.clipboard.clone().ok_or(Error::ClipboardEmpty)?;
291        self.apply(Command::PasteVoice(PasteVoiceCmd {
292            part_index,
293            staff_index,
294            measure_index,
295            voice_index,
296            notes,
297        }))
298    }
299
300    /// Copy a range of measures from a single voice into the range clipboard.
301    ///
302    /// The range is inclusive: all measures from `start.measure` to `end.measure`.
303    /// `start` and `end` must share the same `part`, `staff`, and `voice`.
304    pub fn copy_range(&mut self, start: NoteAddr, end: NoteAddr) -> Result<(), Error> {
305        if start.part != end.part || start.staff != end.staff || start.voice != end.voice {
306            return Err(Error::InvalidCommand(
307                "copy_range: start and end must share the same part, staff, and voice".into(),
308            ));
309        }
310        let from = start.measure.min(end.measure);
311        let to = start.measure.max(end.measure);
312        let staff = self
313            .score
314            .parts
315            .get(start.part)
316            .ok_or(Error::PartNotFound(start.part))?
317            .staves
318            .get(start.staff)
319            .ok_or(Error::StaffNotFound(start.staff))?;
320        if start.voice >= 4 {
321            return Err(Error::VoiceOutOfRange(start.voice));
322        }
323        let mut measures = Vec::new();
324        for mi in from..=to {
325            let m = staff.measures.get(mi).ok_or(Error::MeasureNotFound(mi))?;
326            measures.push(m.voices[start.voice].clone());
327        }
328        self.range_clipboard = Some(RangeClipboard {
329            voice: start.voice,
330            measures,
331        });
332        Ok(())
333    }
334
335    /// Paste the range clipboard starting at `target`, creating an undo-able command.
336    ///
337    /// The voice index from the original `copy_range` call is used; `target.voice` is ignored.
338    pub fn paste_range(&mut self, target: NoteAddr) -> Result<ChangeHint, Error> {
339        let rc = self.range_clipboard.clone().ok_or(Error::ClipboardEmpty)?;
340        self.apply(Command::PasteRange(PasteRangeCmd {
341            part_index: target.part,
342            staff_index: target.staff,
343            voice_index: rc.voice,
344            target_measure: target.measure,
345            measures: rc.measures,
346        }))
347    }
348
349    /// Paste a versioned multi-lane score fragment at `target` as one undoable
350    /// operation. Relative fragment addresses are resolved from `target`.
351    pub fn paste_score_fragment(
352        &mut self,
353        fragment: ScoreFragment,
354        target: NoteAddr,
355    ) -> Result<ChangeHint, Error> {
356        self.apply(Command::PasteScoreFragment(PasteScoreFragmentCmd {
357            fragment,
358            target,
359            policy: ScoreFragmentPastePolicy::Replace,
360        }))
361    }
362
363    /// Paste a versioned multi-lane fragment with an explicit collision policy.
364    pub fn paste_score_fragment_with_policy(
365        &mut self,
366        fragment: ScoreFragment,
367        target: NoteAddr,
368        policy: ScoreFragmentPastePolicy,
369    ) -> Result<ChangeHint, Error> {
370        self.apply(Command::PasteScoreFragment(PasteScoreFragmentCmd {
371            fragment,
372            target,
373            policy,
374        }))
375    }
376
377    /// Exchange two voices over an inclusive measure range as one undoable
378    /// operation, retaining source MusicXML voice numbers and typed spans.
379    pub fn exchange_voices(
380        &mut self,
381        part_index: usize,
382        staff_index: usize,
383        start_measure: usize,
384        end_measure: usize,
385        first_voice: usize,
386        second_voice: usize,
387    ) -> Result<ChangeHint, Error> {
388        self.apply(Command::ExchangeVoices(ExchangeVoicesCmd {
389            part_index,
390            staff_index,
391            start_measure,
392            end_measure,
393            first_voice,
394            second_voice,
395        }))
396    }
397
398    /// Move compatible primary staff voices into the voices of `target_staff`
399    /// as one undoable, lossless structural transformation.
400    pub fn implode_staves(
401        &mut self,
402        part_index: usize,
403        source_staves: Vec<usize>,
404        target_staff: usize,
405        start_measure: usize,
406        end_measure: usize,
407    ) -> Result<ChangeHint, Error> {
408        self.apply(Command::ImplodeStaves(ImplodeStavesCmd {
409            part_index,
410            source_staves,
411            target_staff,
412            start_measure,
413            end_measure,
414        }))
415    }
416
417    /// Move voices from `source_staff` to the primary voices of
418    /// `target_staves` as one undoable structural transformation.
419    pub fn explode_voices(
420        &mut self,
421        part_index: usize,
422        source_staff: usize,
423        target_staves: Vec<usize>,
424        start_measure: usize,
425        end_measure: usize,
426    ) -> Result<ChangeHint, Error> {
427        self.apply(Command::ExplodeVoices(ExplodeVoicesCmd {
428            part_index,
429            source_staff,
430            target_staves,
431            start_measure,
432            end_measure,
433        }))
434    }
435
436    /// Split pitches in primary-voice chords across compatible staves while
437    /// retaining all chord-attached notation on the first source pitch.
438    pub fn explode_chord_pitches(
439        &mut self,
440        part_index: usize,
441        source_staff: usize,
442        target_staves: Vec<usize>,
443        start_measure: usize,
444        end_measure: usize,
445    ) -> Result<ChangeHint, Error> {
446        self.apply(Command::ExplodeChordPitches(ExplodeChordPitchesCmd {
447            part_index,
448            source_staff,
449            target_staves,
450            start_measure,
451            end_measure,
452        }))
453    }
454
455    /// Scale every note in a voice range by one half or double while keeping
456    /// each affected measure rhythmically complete.
457    pub fn scale_voice_range(
458        &mut self,
459        part_index: usize,
460        staff_index: usize,
461        voice: usize,
462        start_measure: usize,
463        end_measure: usize,
464        scale: DurationScale,
465    ) -> Result<ChangeHint, Error> {
466        self.apply(Command::ScaleVoiceRange(ScaleVoiceRangeCmd {
467            part_index,
468            staff_index,
469            voice,
470            start_measure,
471            end_measure,
472            scale,
473            tuplet_policy: super::commands::TupletScalePolicy::PreserveRatio,
474        }))
475    }
476
477    /// Toggle the slur between two notes (undo-able).
478    pub fn toggle_slur(&mut self, start: NoteAddr, end: NoteAddr) -> Result<ChangeHint, Error> {
479        self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
480    }
481
482    /// Add a staff to a part (undo-able).
483    pub fn add_staff(&mut self, part_index: usize, clef: Clef) -> Result<ChangeHint, Error> {
484        self.apply(Command::AddStaff(AddStaffCmd { part_index, clef }))
485    }
486
487    /// Remove a staff from a part (undo-able). Fails if it is the last remaining staff.
488    pub fn delete_staff(
489        &mut self,
490        part_index: usize,
491        staff_index: usize,
492    ) -> Result<ChangeHint, Error> {
493        self.apply(Command::DeleteStaff(DeleteStaffCmd {
494            part_index,
495            staff_index,
496        }))
497    }
498
499    /// Set or clear the stem direction on an existing note (undo-able).
500    ///
501    /// `stem_up`: `None` = auto, `Some(true)` = up, `Some(false)` = down.
502    pub fn set_stem(&mut self, addr: NoteAddr, stem_up: Option<bool>) -> Result<ChangeHint, Error> {
503        self.apply(Command::SetStem(SetStemCmd {
504            part_index: addr.part,
505            staff_index: addr.staff,
506            measure_index: addr.measure,
507            voice_index: addr.voice,
508            note_index: addr.note,
509            stem_up,
510        }))
511    }
512
513    /// Set or clear MusicXML-compatible note placement offsets in tenths (undo-able).
514    pub fn set_note_placement(
515        &mut self,
516        addr: NoteAddr,
517        offset_x: Option<f64>,
518        offset_y: Option<f64>,
519        relative_x: Option<f64>,
520        relative_y: Option<f64>,
521    ) -> Result<ChangeHint, Error> {
522        self.apply(Command::SetNotePlacement(SetNotePlacementCmd {
523            part_index: addr.part,
524            staff_index: addr.staff,
525            measure_index: addr.measure,
526            voice: addr.voice,
527            note_index: addr.note,
528            offset_x,
529            offset_y,
530            relative_x,
531            relative_y,
532        }))
533    }
534
535    /// Set the duration and dot count on an existing note (undo-able).
536    pub fn set_duration(
537        &mut self,
538        addr: NoteAddr,
539        duration: Duration,
540        dot_count: u8,
541    ) -> Result<ChangeHint, Error> {
542        self.apply(Command::SetDuration(SetDurationCmd {
543            part_index: addr.part,
544            staff_index: addr.staff,
545            measure_index: addr.measure,
546            voice: addr.voice,
547            note_index: addr.note,
548            duration,
549            dot_count,
550        }))
551    }
552
553    /// Set or clear the arpeggio direction on an existing note (undo-able).
554    pub fn set_arpeggio(
555        &mut self,
556        addr: NoteAddr,
557        direction: Option<bool>,
558    ) -> Result<ChangeHint, Error> {
559        self.apply(Command::SetArpeggio(SetArpeggioCmd {
560            part_index: addr.part,
561            staff_index: addr.staff,
562            measure_index: addr.measure,
563            voice_index: addr.voice,
564            note_index: addr.note,
565            direction,
566        }))
567    }
568
569    /// Set the note head shape on an existing note (undo-able).
570    pub fn set_note_head(
571        &mut self,
572        addr: NoteAddr,
573        note_head: NoteHead,
574    ) -> Result<ChangeHint, Error> {
575        self.apply(Command::SetNoteHead(SetNoteHeadCmd {
576            part_index: addr.part,
577            staff_index: addr.staff,
578            measure_index: addr.measure,
579            voice: addr.voice,
580            note_index: addr.note,
581            note_head,
582        }))
583    }
584
585    /// Add or replace a part group (undo-able). Pass `None` to clear all groups.
586    pub fn set_part_group(&mut self, group: Option<PartGroup>) -> Result<ChangeHint, Error> {
587        self.apply(Command::SetPartGroup(SetPartGroupCmd { group }))
588    }
589
590    /// Toggle a trill line span between two notes (undo-able).
591    pub fn toggle_trill_line(
592        &mut self,
593        start: NoteAddr,
594        end: NoteAddr,
595    ) -> Result<ChangeHint, Error> {
596        self.apply(Command::ToggleTrillLine(ToggleTrillLineCmd { start, end }))
597    }
598
599    /// Set or clear the cue flag on a note (undo-able). Cue notes have zero beats.
600    pub fn set_cue(&mut self, addr: NoteAddr, is_cue: bool) -> Result<ChangeHint, Error> {
601        self.apply(Command::SetCue(SetCueCmd {
602            part_index: addr.part,
603            staff_index: addr.staff,
604            measure_index: addr.measure,
605            voice: addr.voice,
606            note_index: addr.note,
607            is_cue,
608        }))
609    }
610
611    /// Hide or show a note or rest (undo-able); it keeps its time and sound.
612    pub fn set_hidden(&mut self, addr: NoteAddr, hidden: bool) -> Result<ChangeHint, Error> {
613        self.apply(Command::SetHidden(super::commands::SetHiddenCmd {
614            part_index: addr.part,
615            staff_index: addr.staff,
616            measure_index: addr.measure,
617            voice: addr.voice,
618            note_index: addr.note,
619            hidden,
620        }))
621    }
622
623    /// Set or clear the unpitched flag while retaining display placement.
624    pub fn set_unpitched(
625        &mut self,
626        addr: NoteAddr,
627        is_unpitched: bool,
628    ) -> Result<ChangeHint, Error> {
629        self.apply(Command::SetUnpitched(SetUnpitchedCmd {
630            part_index: addr.part,
631            staff_index: addr.staff,
632            measure_index: addr.measure,
633            voice: addr.voice,
634            note_index: addr.note,
635            is_unpitched,
636        }))
637    }
638
639    /// Set or clear a source instrument identifier attached to a note.
640    pub fn set_instrument_id(
641        &mut self,
642        addr: NoteAddr,
643        instrument_id: Option<String>,
644    ) -> Result<ChangeHint, Error> {
645        self.apply(Command::SetInstrumentId(SetInstrumentIdCmd {
646            part_index: addr.part,
647            staff_index: addr.staff,
648            measure_index: addr.measure,
649            voice: addr.voice,
650            note_index: addr.note,
651            instrument_id,
652        }))
653    }
654
655    /// Set or clear the tuplet on an existing note (undo-able).
656    pub fn set_tuplet(
657        &mut self,
658        addr: NoteAddr,
659        tuplet: Option<TupletInfo>,
660    ) -> Result<ChangeHint, Error> {
661        self.apply(Command::SetTuplet(SetTupletCmd {
662            part_index: addr.part,
663            staff_index: addr.staff,
664            measure_index: addr.measure,
665            voice_index: addr.voice,
666            note_index: addr.note,
667            tuplet,
668        }))
669    }
670
671    /// Respell all pitches in the score (undo-able).
672    pub fn respell_score(&mut self, prefer_flat: bool) -> Result<ChangeHint, Error> {
673        self.apply(Command::RespellScore(RespellScoreCmd { prefer_flat }))
674    }
675
676    /// Respell all pitches to match the score's key signature (undo-able).
677    pub fn respell_score_to_key(&mut self) -> Result<ChangeHint, Error> {
678        self.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
679    }
680
681    /// Respell one staff's measure range `start_measure..end_measure` (undo-able).
682    pub fn respell_staff_region(
683        &mut self,
684        part_index: usize,
685        staff_index: usize,
686        start_measure: usize,
687        end_measure: usize,
688        policy: RespellPolicy,
689    ) -> Result<ChangeHint, Error> {
690        self.apply(Command::RespellStaffRegion(RespellStaffRegionCmd {
691            part_index,
692            staff_index,
693            start_measure,
694            end_measure,
695            policy,
696        }))
697    }
698
699    /// Begin a two-step slur: record `start` and wait for [`end_slur`](Self::end_slur).
700    ///
701    /// Returns an error if `start` does not point to a valid note.
702    pub fn begin_slur(&mut self, start: NoteAddr) -> Result<(), Error> {
703        self.score
704            .parts
705            .get(start.part)
706            .ok_or(Error::PartNotFound(start.part))?
707            .staves
708            .get(start.staff)
709            .ok_or(Error::StaffNotFound(start.staff))?
710            .measures
711            .get(start.measure)
712            .ok_or(Error::MeasureNotFound(start.measure))?
713            .voices
714            .get(start.voice)
715            .ok_or(Error::VoiceOutOfRange(start.voice))?
716            .get(start.note)
717            .ok_or(Error::NoteNotFound(start.note))?;
718        self.pending_slur_start = Some(start);
719        Ok(())
720    }
721
722    /// Complete the slur started by [`begin_slur`](Self::begin_slur) (undo-able).
723    ///
724    /// Returns `Error::InvalidCommand` if `begin_slur` has not been called.
725    pub fn end_slur(&mut self, end: NoteAddr) -> Result<ChangeHint, Error> {
726        let start = self
727            .pending_slur_start
728            .take()
729            .ok_or_else(|| Error::InvalidCommand("no slur in progress".to_string()))?;
730        self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
731    }
732}
733
734impl EngineHistory {
735    /// Return whether this history was recorded from the supplied collaboration base.
736    pub fn base_matches(&self, base: &Score) -> bool {
737        match (
738            serde_json::to_vec(&self.initial_score),
739            serde_json::to_vec(base),
740        ) {
741            (Ok(expected), Ok(actual)) => expected == actual,
742            _ => false,
743        }
744    }
745
746    /// Compare two logs without applying commands or mutating either history.
747    pub fn compare(&self, other: &Self) -> HistoryRelation {
748        if !self.base_matches(&other.initial_score) {
749            return HistoryRelation::BaseMismatch;
750        }
751        let common_prefix_len = self
752            .commands
753            .iter()
754            .zip(&other.commands)
755            .take_while(|(left, right)| command_bytes(left) == command_bytes(right))
756            .count();
757        match (self.commands.len(), other.commands.len()) {
758            (left, right) if left == right && common_prefix_len == left => {
759                HistoryRelation::Equivalent
760            }
761            (left, _) if common_prefix_len == left => {
762                HistoryRelation::LeftExtends { common_prefix_len }
763            }
764            (_, right) if common_prefix_len == right => {
765                HistoryRelation::RightExtends { common_prefix_len }
766            }
767            _ => HistoryRelation::Diverged { common_prefix_len },
768        }
769    }
770
771    /// Return explainable details when the two same-base logs diverge.
772    pub fn conflict(&self, other: &Self) -> Option<HistoryConflict> {
773        let common_prefix_len = match self.compare(other) {
774            HistoryRelation::Diverged { common_prefix_len } => common_prefix_len,
775            _ => return None,
776        };
777        let left = self.commands.get(common_prefix_len)?;
778        let right = other.commands.get(common_prefix_len)?;
779        Some(HistoryConflict {
780            common_prefix_len,
781            left_command_index: common_prefix_len,
782            right_command_index: common_prefix_len,
783            left_command_key: command_key(left),
784            right_command_key: command_key(right),
785            left_remaining_commands: self.commands.len() - common_prefix_len,
786            right_remaining_commands: other.commands.len() - common_prefix_len,
787        })
788    }
789}
790
791fn command_bytes(command: &Command) -> Option<Vec<u8>> {
792    serde_json::to_vec(command).ok()
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798    use crate::model::commands::{NewScoreCmd, SetTempoCmd, SetTempoRampAtMeasureCmd};
799
800    #[test]
801    fn new_engine_has_default_score() {
802        let engine = ScoreEngine::new();
803        assert_eq!(engine.version, 0);
804        assert_eq!(engine.score.parts.len(), 1);
805    }
806
807    #[test]
808    fn apply_increments_version() {
809        let mut engine = ScoreEngine::new();
810        engine
811            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
812            .unwrap();
813        assert_eq!(engine.version, 1);
814    }
815
816    #[test]
817    fn tempo_ramp_command_undo_redo_restores_measure_target() {
818        let mut engine = ScoreEngine::new();
819        engine
820            .apply(Command::SetTempoRampAtMeasure(SetTempoRampAtMeasureCmd {
821                measure_index: 0,
822                target_bpm: Some(84),
823            }))
824            .expect("tempo ramp applies");
825        assert_eq!(
826            engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
827            Some(84)
828        );
829        engine.undo().expect("tempo ramp undoes");
830        assert_eq!(
831            engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
832            None
833        );
834        engine.redo().expect("tempo ramp redoes");
835        assert_eq!(
836            engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
837            Some(84)
838        );
839    }
840
841    #[test]
842    fn set_hidden_hides_a_rest_and_undoes() {
843        let mut engine = ScoreEngine::new();
844        let voice = &mut engine.score.parts[0].staves[0].measures[0].voices[0];
845        if voice.is_empty() {
846            voice.push(crate::Note::rest(Duration::Whole));
847        }
848        let addr = NoteAddr {
849            part: 0,
850            staff: 0,
851            measure: 0,
852            voice: 0,
853            note: 0,
854        };
855        engine.set_hidden(addr, true).expect("hides");
856        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].hidden);
857        assert_eq!(engine.undo_label().as_deref(), Some("Hide Note"));
858        engine.undo().expect("undoes");
859        assert!(!engine.score.parts[0].staves[0].measures[0].voices[0][0].hidden);
860    }
861
862    #[test]
863    fn undo_redo_cycle() {
864        let mut engine = ScoreEngine::new();
865        engine
866            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
867            .unwrap();
868        let after_apply = engine.version;
869        engine.undo().unwrap();
870        assert_eq!(engine.score.settings.tempo_bpm, 120);
871        engine.redo().unwrap();
872        assert_eq!(engine.score.settings.tempo_bpm, 140);
873        assert!(engine.version > after_apply);
874    }
875
876    #[test]
877    fn replace_score_clears_history() {
878        let mut engine = ScoreEngine::new();
879        engine
880            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
881            .unwrap();
882        let new_score = Score::new("New", 90, 3, 4, 2, 8);
883        engine.replace_score(new_score);
884        assert!(engine.undo().is_err());
885        assert_eq!(engine.score.settings.tempo_bpm, 90);
886    }
887
888    #[test]
889    fn try_replace_score_rejects_invalid_input_without_mutation() {
890        let mut engine = ScoreEngine::new();
891        engine
892            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
893            .unwrap();
894        let original = engine.score.settings.tempo_bpm;
895        let original_version = engine.version;
896        let mut invalid = Score::default();
897        invalid.parts[0].staves[0].tablature = Some(crate::TablatureConfig {
898            lines: 0,
899            tuning_midi: Vec::new(),
900            capo: 0,
901        });
902
903        assert!(matches!(
904            engine.try_replace_score(invalid),
905            Err(Error::InvalidScore)
906        ));
907        assert_eq!(engine.score.settings.tempo_bpm, original);
908        assert_eq!(engine.version, original_version);
909        assert!(engine.commands.can_undo());
910    }
911
912    #[test]
913    fn copy_paste_voice_copies_notes() {
914        use crate::model::duration::Duration;
915        use crate::model::pitch::{Pitch, Step};
916        use crate::model::score::Note;
917        let mut engine = ScoreEngine::new();
918        engine.score.parts[0].staves[0].measures[0].voices[0] =
919            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
920        engine.copy_voice(0, 0, 0, 0).unwrap();
921        engine.paste_voice(0, 0, 0, 1).unwrap();
922        let pasted = &engine.score.parts[0].staves[0].measures[0].voices[1];
923        assert_eq!(pasted.len(), 1);
924        assert_eq!(pasted[0].pitches[0].step, Step::C);
925    }
926
927    #[test]
928    fn paste_voice_undo_restores_original() {
929        use crate::model::duration::Duration;
930        use crate::model::pitch::{Pitch, Step};
931        use crate::model::score::Note;
932        let mut engine = ScoreEngine::new();
933        engine.score.parts[0].staves[0].measures[0].voices[0] =
934            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
935        engine.copy_voice(0, 0, 0, 0).unwrap();
936        engine.paste_voice(0, 0, 0, 1).unwrap();
937        engine.undo().unwrap();
938        assert!(engine.score.parts[0].staves[0].measures[0].voices[1].is_empty());
939    }
940
941    #[test]
942    fn unpitched_flag_is_undoable_and_redoable() {
943        let mut engine = ScoreEngine::new();
944        let addr = NoteAddr {
945            part: 0,
946            staff: 0,
947            measure: 0,
948            voice: 0,
949            note: 0,
950        };
951        engine.set_unpitched(addr.clone(), true).unwrap();
952        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
953        engine.undo().unwrap();
954        assert!(!engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
955        engine.redo().unwrap();
956        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
957        engine
958            .set_instrument_id(addr, Some("P1-I2".to_string()))
959            .unwrap();
960        assert_eq!(
961            engine.score.parts[0].staves[0].measures[0].voices[0][0]
962                .instrument_id
963                .as_deref(),
964            Some("P1-I2")
965        );
966        for command in [
967            Command::SetUnpitched(super::super::commands::SetUnpitchedCmd {
968                part_index: 0,
969                staff_index: 0,
970                measure_index: 0,
971                voice: 0,
972                note_index: 0,
973                is_unpitched: false,
974            }),
975            Command::SetInstrumentId(super::super::commands::SetInstrumentIdCmd {
976                part_index: 0,
977                staff_index: 0,
978                measure_index: 0,
979                voice: 0,
980                note_index: 0,
981                instrument_id: Some("P1-I2".to_string()),
982            }),
983        ] {
984            let json = serde_json::to_string(&command).unwrap();
985            let restored: Command = serde_json::from_str(&json).unwrap();
986            assert_eq!(
987                super::super::commands::command_key(&restored),
988                super::super::commands::command_key(&command)
989            );
990        }
991    }
992
993    #[test]
994    fn paste_voice_without_copy_returns_error() {
995        let mut engine = ScoreEngine::new();
996        assert!(engine.paste_voice(0, 0, 0, 0).is_err());
997    }
998
999    #[test]
1000    fn change_hint_set_tempo_is_global() {
1001        use crate::model::change_hint::ChangeScope;
1002        let mut engine = ScoreEngine::new();
1003        let hint = engine
1004            .apply(Command::SetTempo(SetTempoCmd { bpm: 100 }))
1005            .unwrap();
1006        assert_eq!(hint.scope, ChangeScope::Global);
1007        assert!(!hint.layout_dirty);
1008        assert!(hint.playback_dirty);
1009    }
1010
1011    #[test]
1012    fn change_hint_add_note_is_measure_scope() {
1013        use crate::model::change_hint::ChangeScope;
1014        use crate::model::commands::AddNoteCmd;
1015        use crate::model::duration::Duration;
1016        use crate::model::pitch::Pitch;
1017        use crate::model::pitch::Step;
1018        let mut engine = ScoreEngine::new();
1019        let hint = engine
1020            .apply(Command::AddNote(AddNoteCmd {
1021                part_index: 0,
1022                staff_index: 0,
1023                measure_index: 0,
1024                voice: 0,
1025                position: 0,
1026                pitch: Some(Pitch::new(Step::C, 4)),
1027                duration: Duration::Quarter,
1028                dot_count: 0,
1029                is_rest: false,
1030                tuplet: None,
1031            }))
1032            .unwrap();
1033        assert_eq!(
1034            hint.scope,
1035            ChangeScope::Measures {
1036                part: 0,
1037                staff: 0,
1038                start: 0,
1039                end: 1
1040            }
1041        );
1042        assert!(!hint.layout_dirty);
1043        assert!(hint.playback_dirty);
1044    }
1045
1046    #[test]
1047    fn change_hint_set_part_name_no_dirty() {
1048        use crate::model::change_hint::ChangeScope;
1049        use crate::model::commands::SetPartNameCmd;
1050        let mut engine = ScoreEngine::new();
1051        let hint = engine
1052            .apply(Command::SetPartName(SetPartNameCmd {
1053                part_index: 0,
1054                name: "Violin".into(),
1055                short_name: "Vln.".into(),
1056            }))
1057            .unwrap();
1058        assert_eq!(hint.scope, ChangeScope::Part(0));
1059        assert!(!hint.layout_dirty);
1060        assert!(!hint.playback_dirty);
1061    }
1062
1063    #[test]
1064    fn undo_returns_change_hint() {
1065        use crate::model::change_hint::ChangeScope;
1066        let mut engine = ScoreEngine::new();
1067        engine
1068            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1069            .unwrap();
1070        let hint = engine.undo().unwrap();
1071        assert_eq!(hint.scope, ChangeScope::Global);
1072        assert!(hint.playback_dirty);
1073    }
1074
1075    #[test]
1076    fn redo_returns_change_hint() {
1077        use crate::model::change_hint::ChangeScope;
1078        let mut engine = ScoreEngine::new();
1079        engine
1080            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1081            .unwrap();
1082        engine.undo().unwrap();
1083        let hint = engine.redo().unwrap();
1084        assert_eq!(hint.scope, ChangeScope::Global);
1085        assert!(hint.playback_dirty);
1086    }
1087
1088    #[test]
1089    fn batch_apply_two_commands_single_undo() {
1090        let mut engine = ScoreEngine::new();
1091        let original = engine.score.settings.tempo_bpm;
1092        engine
1093            .batch_apply(vec![
1094                Command::SetTempo(SetTempoCmd { bpm: 160 }),
1095                Command::SetTempo(SetTempoCmd { bpm: 180 }),
1096            ])
1097            .unwrap();
1098        assert_eq!(engine.score.settings.tempo_bpm, 180);
1099        engine.undo().unwrap();
1100        assert_eq!(engine.score.settings.tempo_bpm, original);
1101        assert!(engine.undo().is_err());
1102    }
1103
1104    #[test]
1105    fn batch_apply_empty_returns_no_dirty() {
1106        let mut engine = ScoreEngine::new();
1107        let v0 = engine.version;
1108        let hint = engine.batch_apply(vec![]).unwrap();
1109        assert!(!hint.layout_dirty);
1110        assert!(!hint.playback_dirty);
1111        assert_eq!(engine.version, v0);
1112    }
1113
1114    #[test]
1115    fn batch_apply_hint_merges_scopes() {
1116        use crate::model::change_hint::ChangeScope;
1117        use crate::model::commands::{AddNoteCmd, SetTempoCmd};
1118        use crate::model::duration::Duration;
1119        use crate::model::pitch::{Pitch, Step};
1120        let mut engine = ScoreEngine::new();
1121        let hint = engine
1122            .batch_apply(vec![
1123                Command::SetTempo(SetTempoCmd { bpm: 140 }),
1124                Command::AddNote(AddNoteCmd {
1125                    part_index: 0,
1126                    staff_index: 0,
1127                    measure_index: 0,
1128                    voice: 0,
1129                    position: 0,
1130                    pitch: Some(Pitch::new(Step::C, 4)),
1131                    duration: Duration::Quarter,
1132                    dot_count: 0,
1133                    is_rest: false,
1134                    tuplet: None,
1135                }),
1136            ])
1137            .unwrap();
1138        // SetTempo = Global; merged with Measures = Global
1139        assert_eq!(hint.scope, ChangeScope::Global);
1140        assert!(hint.playback_dirty);
1141    }
1142
1143    #[test]
1144    fn undo_label_none_when_empty() {
1145        let engine = ScoreEngine::new();
1146        assert!(engine.undo_label().is_none());
1147        assert!(engine.redo_label().is_none());
1148    }
1149
1150    #[test]
1151    fn undo_label_after_command() {
1152        let mut engine = ScoreEngine::new();
1153        engine
1154            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1155            .unwrap();
1156        assert_eq!(engine.undo_label(), Some("Set Tempo".to_string()));
1157        assert!(engine.redo_label().is_none());
1158    }
1159
1160    #[test]
1161    fn redo_label_after_undo() {
1162        let mut engine = ScoreEngine::new();
1163        engine
1164            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1165            .unwrap();
1166        engine.undo().unwrap();
1167        assert!(engine.undo_label().is_none());
1168        assert_eq!(engine.redo_label(), Some("Set Tempo".to_string()));
1169    }
1170
1171    #[test]
1172    fn score_fragment_paste_is_undoable_and_assigns_fresh_spanner_ids() {
1173        use crate::model::fragment::{ScoreFragmentSelection, extract_score_fragment};
1174        use crate::model::score::{NotationSpanner, NotationSpannerKind};
1175        use crate::{CrossStaff, Duration, Note, Pitch, Score, ScoreTemplate, Step};
1176
1177        let mut engine = ScoreEngine::new();
1178        engine.score = Score::template(ScoreTemplate::Piano);
1179        let mut source_note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
1180        source_note.cross_staff = Some(CrossStaff {
1181            target_staff: 1,
1182            target_voice: Some(0),
1183        });
1184        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![source_note];
1185        engine.score.spanners.push(NotationSpanner {
1186            id: "source-slur".into(),
1187            kind: NotationSpannerKind::Slur,
1188            start: NoteAddr {
1189                part: 0,
1190                staff: 0,
1191                measure: 0,
1192                voice: 0,
1193                note: 0,
1194            },
1195            end: NoteAddr {
1196                part: 0,
1197                staff: 0,
1198                measure: 0,
1199                voice: 0,
1200                note: 0,
1201            },
1202            number: None,
1203            line_type: None,
1204            text: None,
1205            placement: None,
1206            ottava_size: None,
1207            ottava_type: None,
1208        });
1209        let selection = ScoreFragmentSelection {
1210            start: NoteAddr {
1211                part: 0,
1212                staff: 0,
1213                measure: 0,
1214                voice: 0,
1215                note: 0,
1216            },
1217            end: NoteAddr {
1218                part: 0,
1219                staff: 0,
1220                measure: 0,
1221                voice: 0,
1222                note: 0,
1223            },
1224        };
1225        let fragment = extract_score_fragment(&engine.score, &[selection]).unwrap();
1226        let source_id = engine.score.parts[0].staves[0].measures[0].voices[0][0]
1227            .id
1228            .clone();
1229        let target = NoteAddr {
1230            part: 0,
1231            staff: 0,
1232            measure: 1,
1233            voice: 0,
1234            note: 0,
1235        };
1236        engine
1237            .paste_score_fragment(fragment.clone(), target.clone())
1238            .unwrap();
1239        let pasted_id = engine.score.parts[0].staves[0].measures[1].voices[0][0]
1240            .id
1241            .clone();
1242        assert_ne!(pasted_id, source_id);
1243        assert_eq!(
1244            engine.score.parts[0].staves[0].measures[1].voices[0][0]
1245                .cross_staff
1246                .as_ref()
1247                .map(|cross_staff| cross_staff.target_staff),
1248            Some(1)
1249        );
1250        assert!(
1251            engine
1252                .score
1253                .spanners
1254                .iter()
1255                .any(|span| span.id == "source-slur-copy")
1256        );
1257
1258        engine
1259            .paste_score_fragment(
1260                fragment,
1261                NoteAddr {
1262                    measure: 2,
1263                    ..target.clone()
1264                },
1265            )
1266            .unwrap();
1267        assert!(
1268            engine
1269                .score
1270                .spanners
1271                .iter()
1272                .any(|span| span.id == "source-slur-copy-2")
1273        );
1274
1275        engine.undo().unwrap();
1276        assert!(
1277            engine.score.parts[0].staves[0].measures[2].voices[0]
1278                .iter()
1279                .all(|note| note.is_rest)
1280        );
1281        engine.redo().unwrap();
1282        assert_eq!(
1283            engine.score.parts[0].staves[0].measures[2].voices[0][0].pitches[0].step,
1284            Step::C
1285        );
1286
1287        let before = engine.score.clone();
1288        let history_before = engine.commands.history_commands();
1289        assert!(
1290            engine
1291                .paste_score_fragment(
1292                    extract_score_fragment(
1293                        &engine.score,
1294                        &[ScoreFragmentSelection {
1295                            start: target.clone(),
1296                            end: target,
1297                        }]
1298                    )
1299                    .unwrap(),
1300                    NoteAddr {
1301                        part: 99,
1302                        staff: 0,
1303                        measure: 0,
1304                        voice: 0,
1305                        note: 0,
1306                    },
1307                )
1308                .is_err()
1309        );
1310        assert_eq!(
1311            serde_json::to_value(&engine.score).unwrap(),
1312            serde_json::to_value(before).unwrap()
1313        );
1314        assert_eq!(
1315            engine.commands.history_commands().len(),
1316            history_before.len()
1317        );
1318    }
1319
1320    #[test]
1321    fn score_fragment_merge_preserves_sounding_destination_lanes() {
1322        use crate::model::fragment::{ScoreFragmentSelection, extract_score_fragment};
1323        use crate::{Duration, Note, Pitch, ScoreFragmentPastePolicy, Step};
1324
1325        let mut engine = ScoreEngine::new();
1326        engine.score.parts[0].staves[0].measures[0].voices[0] =
1327            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1328        let source = NoteAddr {
1329            part: 0,
1330            staff: 0,
1331            measure: 0,
1332            voice: 0,
1333            note: 0,
1334        };
1335        let fragment = extract_score_fragment(
1336            &engine.score,
1337            &[ScoreFragmentSelection {
1338                start: source.clone(),
1339                end: source.clone(),
1340            }],
1341        )
1342        .unwrap();
1343        let rest_target = NoteAddr {
1344            measure: 1,
1345            ..source.clone()
1346        };
1347        engine
1348            .paste_score_fragment_with_policy(
1349                fragment.clone(),
1350                rest_target,
1351                ScoreFragmentPastePolicy::Merge,
1352            )
1353            .unwrap();
1354        assert_eq!(
1355            engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1356            Step::C
1357        );
1358
1359        engine.score.parts[0].staves[0].measures[2].voices[0] =
1360            vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
1361        let before = serde_json::to_value(&engine.score).unwrap();
1362        assert!(
1363            engine
1364                .paste_score_fragment_with_policy(
1365                    fragment,
1366                    NoteAddr {
1367                        measure: 2,
1368                        ..source
1369                    },
1370                    ScoreFragmentPastePolicy::Merge,
1371                )
1372                .is_err()
1373        );
1374        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1375    }
1376
1377    #[test]
1378    fn score_fragment_replace_preserves_measure_attributes_and_accepts_v2() {
1379        use crate::model::fragment::{
1380            ScoreFragmentMeasureAttributes, ScoreFragmentSelection, extract_score_fragment,
1381        };
1382        use crate::{Duration, KeySignature, Note, Pitch, Step};
1383
1384        let mut engine = ScoreEngine::new();
1385        let source_measure = &mut engine.score.parts[0].staves[0].measures[0];
1386        source_measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1387        source_measure.key_sig = Some(KeySignature {
1388            fifths: 2,
1389            mode: "major".into(),
1390        });
1391        source_measure.tempo = Some(96);
1392        source_measure.navigation = Some("Segno".into());
1393        source_measure.section_break = true;
1394        let source = NoteAddr {
1395            part: 0,
1396            staff: 0,
1397            measure: 0,
1398            voice: 0,
1399            note: 0,
1400        };
1401        let fragment = extract_score_fragment(
1402            &engine.score,
1403            &[ScoreFragmentSelection {
1404                start: source.clone(),
1405                end: source.clone(),
1406            }],
1407        )
1408        .unwrap();
1409        assert!(fragment.voices[0].measures[0].attributes.present);
1410        engine
1411            .paste_score_fragment(
1412                fragment.clone(),
1413                NoteAddr {
1414                    measure: 1,
1415                    ..source.clone()
1416                },
1417            )
1418            .unwrap();
1419        let pasted = &engine.score.parts[0].staves[0].measures[1];
1420        assert_eq!(pasted.key_sig.as_ref().map(|key| key.fifths), Some(2));
1421        assert_eq!(pasted.tempo, Some(96));
1422        assert_eq!(pasted.navigation.as_deref(), Some("Segno"));
1423        assert!(pasted.section_break);
1424
1425        let mut legacy = fragment;
1426        legacy.contract_version = 2;
1427        for measure in &mut legacy.voices[0].measures {
1428            measure.attributes = ScoreFragmentMeasureAttributes::default();
1429        }
1430        engine
1431            .paste_score_fragment(
1432                legacy,
1433                NoteAddr {
1434                    measure: 2,
1435                    ..source
1436                },
1437            )
1438            .unwrap();
1439        let legacy_paste = &engine.score.parts[0].staves[0].measures[2];
1440        assert_eq!(legacy_paste.key_sig, None);
1441        assert_eq!(legacy_paste.tempo, None);
1442        assert_eq!(legacy_paste.navigation, None);
1443        assert!(!legacy_paste.section_break);
1444    }
1445
1446    #[test]
1447    fn exchange_voices_preserves_source_numbers_spans_and_undo() {
1448        use crate::model::score::{NotationSpanner, NotationSpannerKind};
1449        use crate::{Duration, Note, Pitch, Step};
1450
1451        let mut engine = ScoreEngine::new();
1452        let measure = &mut engine.score.parts[0].staves[0].measures[0];
1453        measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1454        measure.voices[1] = vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
1455        measure.source_voice_numbers = [Some(1), Some(5), None, None];
1456        engine.score.spanners.push(NotationSpanner {
1457            id: "between-voices".into(),
1458            kind: NotationSpannerKind::Slur,
1459            start: NoteAddr {
1460                part: 0,
1461                staff: 0,
1462                measure: 0,
1463                voice: 0,
1464                note: 0,
1465            },
1466            end: NoteAddr {
1467                part: 0,
1468                staff: 0,
1469                measure: 0,
1470                voice: 1,
1471                note: 0,
1472            },
1473            number: None,
1474            line_type: None,
1475            text: None,
1476            placement: None,
1477            ottava_size: None,
1478            ottava_type: None,
1479        });
1480
1481        engine.exchange_voices(0, 0, 0, 0, 0, 1).unwrap();
1482        let measure = &engine.score.parts[0].staves[0].measures[0];
1483        assert_eq!(measure.voices[0][0].pitches[0].step, Step::D);
1484        assert_eq!(measure.voices[1][0].pitches[0].step, Step::C);
1485        assert_eq!(measure.source_voice_numbers, [Some(5), Some(1), None, None]);
1486        assert_eq!(engine.score.spanners[0].start.voice, 1);
1487        assert_eq!(engine.score.spanners[0].end.voice, 0);
1488
1489        engine.undo().unwrap();
1490        let measure = &engine.score.parts[0].staves[0].measures[0];
1491        assert_eq!(measure.voices[0][0].pitches[0].step, Step::C);
1492        assert_eq!(measure.source_voice_numbers, [Some(1), Some(5), None, None]);
1493    }
1494
1495    #[test]
1496    fn move_or_copy_voice_range_is_atomic_and_undoable() {
1497        use crate::MoveOrCopyVoiceRangeCmd;
1498        use crate::{Duration, Note, Pitch, Step};
1499
1500        let mut engine = ScoreEngine::new();
1501        engine.score.parts[0].staves[0].measures[0].voices[0] =
1502            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1503        let source = NoteAddr {
1504            part: 0,
1505            staff: 0,
1506            measure: 0,
1507            voice: 0,
1508            note: 0,
1509        };
1510        let target = NoteAddr {
1511            measure: 1,
1512            ..source.clone()
1513        };
1514        engine
1515            .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1516                source_start: source.clone(),
1517                source_end: source.clone(),
1518                target: target.clone(),
1519                move_source: false,
1520            }))
1521            .unwrap();
1522        assert_eq!(
1523            engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1524            Step::C
1525        );
1526        assert_eq!(
1527            engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].step,
1528            Step::C
1529        );
1530
1531        engine
1532            .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1533                source_start: source.clone(),
1534                source_end: source.clone(),
1535                target: NoteAddr {
1536                    measure: 2,
1537                    ..source.clone()
1538                },
1539                move_source: true,
1540            }))
1541            .unwrap();
1542        assert!(
1543            engine.score.parts[0].staves[0].measures[0].voices[0]
1544                .iter()
1545                .all(|note| note.is_rest)
1546        );
1547        assert_eq!(
1548            engine.score.parts[0].staves[0].measures[2].voices[0][0].pitches[0].step,
1549            Step::C
1550        );
1551        engine.undo().unwrap();
1552        assert_eq!(
1553            engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].step,
1554            Step::C
1555        );
1556
1557        let before = serde_json::to_value(&engine.score).unwrap();
1558        assert!(
1559            engine
1560                .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1561                    source_start: source.clone(),
1562                    source_end: source.clone(),
1563                    target: source,
1564                    move_source: true,
1565                }))
1566                .is_err()
1567        );
1568        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1569    }
1570
1571    #[test]
1572    fn split_measure_remaps_spanners_and_is_undoable() {
1573        use crate::model::score::{NotationSpanner, NotationSpannerKind};
1574        use crate::{Duration, JoinMeasuresCmd, Note, Pitch, SplitMeasureCmd, Step};
1575
1576        let mut engine = ScoreEngine::new();
1577        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![
1578            Note::new(Pitch::new(Step::C, 4), Duration::Half),
1579            Note::new(Pitch::new(Step::D, 4), Duration::Half),
1580        ];
1581        engine.score.spanners.push(NotationSpanner {
1582            id: "across-split".into(),
1583            kind: NotationSpannerKind::Slur,
1584            start: NoteAddr {
1585                part: 0,
1586                staff: 0,
1587                measure: 0,
1588                voice: 0,
1589                note: 0,
1590            },
1591            end: NoteAddr {
1592                part: 0,
1593                staff: 0,
1594                measure: 0,
1595                voice: 0,
1596                note: 1,
1597            },
1598            number: None,
1599            line_type: None,
1600            text: None,
1601            placement: None,
1602            ottava_size: None,
1603            ottava_type: None,
1604        });
1605        engine
1606            .apply(Command::SplitMeasure(SplitMeasureCmd {
1607                measure_index: 0,
1608                split_at_beats: 2.0,
1609            }))
1610            .unwrap();
1611        assert_eq!(
1612            engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1613            1
1614        );
1615        assert_eq!(
1616            engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1617            Step::D
1618        );
1619        assert_eq!(engine.score.spanners[0].end.measure, 1);
1620        assert_eq!(engine.score.spanners[0].end.note, 0);
1621        engine.undo().unwrap();
1622        assert_eq!(
1623            engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1624            2
1625        );
1626        engine
1627            .apply(Command::SplitMeasure(SplitMeasureCmd {
1628                measure_index: 0,
1629                split_at_beats: 2.0,
1630            }))
1631            .unwrap();
1632        engine
1633            .apply(Command::JoinMeasures(JoinMeasuresCmd { measure_index: 0 }))
1634            .unwrap();
1635        assert_eq!(
1636            engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1637            2
1638        );
1639        assert_eq!(engine.score.spanners[0].end.measure, 0);
1640        assert_eq!(engine.score.spanners[0].end.note, 1);
1641    }
1642
1643    #[test]
1644    fn split_and_join_preserve_tuplet_tie_lyric_span_and_source_voice() {
1645        use crate::model::score::{NotationSpanner, NotationSpannerKind};
1646        use crate::{
1647            Duration, JoinMeasuresCmd, Lyric, Note, Pitch, SplitMeasureCmd, Step, TupletInfo,
1648        };
1649
1650        let mut engine = ScoreEngine::new();
1651        let ratio = TupletInfo {
1652            actual_notes: 3,
1653            normal_notes: 2,
1654        };
1655        let mut third = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
1656        third.tuplet = Some(ratio.clone());
1657        third.tie_start = true;
1658        third.lyric = Some(Lyric {
1659            text: "tri".into(),
1660            syllabic: "begin".into(),
1661            extend: false,
1662        });
1663        let mut fourth = Note::new(Pitch::new(Step::F, 4), Duration::Half);
1664        fourth.tie_end = true;
1665        fourth.lyric = Some(Lyric {
1666            text: "plet".into(),
1667            syllabic: "end".into(),
1668            extend: false,
1669        });
1670        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1671        first.tuplet = Some(ratio.clone());
1672        let mut second = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1673        second.tuplet = Some(ratio.clone());
1674        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![first, second, third, fourth];
1675        engine.score.parts[0].staves[0].measures[0].source_voice_numbers[0] = Some(7);
1676        engine.score.spanners.push(NotationSpanner {
1677            id: "tuplet-split-span".into(),
1678            kind: NotationSpannerKind::Slur,
1679            start: NoteAddr {
1680                part: 0,
1681                staff: 0,
1682                measure: 0,
1683                voice: 0,
1684                note: 0,
1685            },
1686            end: NoteAddr {
1687                part: 0,
1688                staff: 0,
1689                measure: 0,
1690                voice: 0,
1691                note: 3,
1692            },
1693            number: None,
1694            line_type: None,
1695            text: None,
1696            placement: None,
1697            ottava_size: None,
1698            ottava_type: None,
1699        });
1700
1701        engine
1702            .apply(Command::SplitMeasure(SplitMeasureCmd {
1703                measure_index: 0,
1704                split_at_beats: 2.0,
1705            }))
1706            .unwrap();
1707        assert_eq!(
1708            engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1709            3
1710        );
1711        assert_eq!(
1712            engine.score.parts[0].staves[0].measures[1].voices[0].len(),
1713            1
1714        );
1715        assert_eq!(
1716            engine.score.parts[0].staves[0].measures[1].source_voice_numbers[0],
1717            Some(7)
1718        );
1719        assert_eq!(engine.score.spanners[0].end.measure, 1);
1720        assert_eq!(engine.score.spanners[0].end.note, 0);
1721
1722        engine
1723            .apply(Command::JoinMeasures(JoinMeasuresCmd { measure_index: 0 }))
1724            .unwrap();
1725        let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1726        assert_eq!(voice.len(), 4);
1727        assert_eq!(voice[2].tuplet, Some(ratio));
1728        assert!(voice[2].tie_start);
1729        assert!(voice[3].tie_end);
1730        assert_eq!(
1731            voice[2].lyric.as_ref().map(|lyric| lyric.text.as_str()),
1732            Some("tri")
1733        );
1734        assert_eq!(
1735            voice[3].lyric.as_ref().map(|lyric| lyric.text.as_str()),
1736            Some("plet")
1737        );
1738        assert_eq!(engine.score.spanners[0].end.measure, 0);
1739        assert_eq!(engine.score.spanners[0].end.note, 3);
1740    }
1741
1742    #[test]
1743    fn implode_then_explode_preserves_voices_spans_and_source_numbers() {
1744        use crate::model::score::{NotationSpanner, NotationSpannerKind, ScoreTemplate};
1745        use crate::{Duration, Note, Pitch, Step};
1746
1747        let mut engine = ScoreEngine::new();
1748        engine.score = Score::template(ScoreTemplate::Piano);
1749        let upper = &mut engine.score.parts[0].staves[0].measures[0];
1750        upper.voices[0] = vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
1751        upper.source_voice_numbers = [Some(1), None, None, None];
1752        let lower = &mut engine.score.parts[0].staves[1].measures[0];
1753        lower.voices[0] = vec![Note::new(Pitch::new(Step::E, 3), Duration::Whole)];
1754        lower.source_voice_numbers = [Some(5), None, None, None];
1755        engine.score.spanners.push(NotationSpanner {
1756            id: "implode-span".into(),
1757            kind: NotationSpannerKind::Slur,
1758            start: NoteAddr {
1759                part: 0,
1760                staff: 0,
1761                measure: 0,
1762                voice: 0,
1763                note: 0,
1764            },
1765            end: NoteAddr {
1766                part: 0,
1767                staff: 1,
1768                measure: 0,
1769                voice: 0,
1770                note: 0,
1771            },
1772            number: None,
1773            line_type: None,
1774            text: None,
1775            placement: None,
1776            ottava_size: None,
1777            ottava_type: None,
1778        });
1779        let before = serde_json::to_value(&engine.score).unwrap();
1780
1781        engine.implode_staves(0, vec![0, 1], 0, 0, 0).unwrap();
1782        let upper = &engine.score.parts[0].staves[0].measures[0];
1783        assert_eq!(upper.voices[0][0].pitches[0].step, Step::C);
1784        assert_eq!(upper.voices[1][0].pitches[0].step, Step::E);
1785        assert_eq!(upper.source_voice_numbers, [Some(1), Some(5), None, None]);
1786        assert!(
1787            engine.score.parts[0].staves[1].measures[0].voices[0]
1788                .iter()
1789                .all(|note| note.is_rest)
1790        );
1791        assert_eq!(engine.score.spanners[0].end.staff, 0);
1792        assert_eq!(engine.score.spanners[0].end.voice, 1);
1793
1794        engine.explode_voices(0, 0, vec![0, 1], 0, 0).unwrap();
1795        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1796        engine.undo().unwrap();
1797        assert_eq!(engine.score.spanners[0].end.staff, 0);
1798        assert_eq!(engine.score.spanners[0].end.voice, 1);
1799        engine.redo().unwrap();
1800        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1801    }
1802
1803    #[test]
1804    fn explode_rejects_an_occupied_destination_without_mutation() {
1805        use crate::model::score::ScoreTemplate;
1806        use crate::{Duration, ExplodeVoicesCmd, Note, Pitch, Step};
1807
1808        let mut engine = ScoreEngine::new();
1809        engine.score = Score::template(ScoreTemplate::Piano);
1810        let source = &mut engine.score.parts[0].staves[0].measures[0];
1811        source.voices[0] = vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
1812        source.voices[1] = vec![Note::new(Pitch::new(Step::E, 4), Duration::Whole)];
1813        engine.score.parts[0].staves[1].measures[0].voices[0] =
1814            vec![Note::new(Pitch::new(Step::G, 3), Duration::Whole)];
1815        let before = serde_json::to_value(&engine.score).unwrap();
1816
1817        assert!(
1818            engine
1819                .apply(Command::ExplodeVoices(ExplodeVoicesCmd {
1820                    part_index: 0,
1821                    source_staff: 0,
1822                    target_staves: vec![0, 1],
1823                    start_measure: 0,
1824                    end_measure: 0,
1825                }))
1826                .is_err()
1827        );
1828        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1829    }
1830
1831    #[test]
1832    fn explode_chord_pitches_distributes_pitches_and_is_undoable() {
1833        use crate::model::score::ScoreTemplate;
1834        use crate::{Duration, Note, Pitch, Step};
1835
1836        let mut engine = ScoreEngine::new();
1837        engine.score = Score::template(ScoreTemplate::Piano);
1838        let mut chord = Note::new(Pitch::new(Step::C, 5), Duration::Whole);
1839        chord.pitches.push(Pitch::new(Step::E, 4));
1840        let source_id = chord.id.clone();
1841        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
1842        let before = serde_json::to_value(&engine.score).unwrap();
1843
1844        engine
1845            .explode_chord_pitches(0, 0, vec![0, 1], 0, 0)
1846            .unwrap();
1847        let upper = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
1848        let lower = &engine.score.parts[0].staves[1].measures[0].voices[0][0];
1849        assert_eq!(upper.id, source_id);
1850        assert_eq!(upper.pitches, vec![Pitch::new(Step::C, 5)]);
1851        assert_eq!(lower.pitches, vec![Pitch::new(Step::E, 4)]);
1852        assert_ne!(lower.id, source_id);
1853
1854        engine.undo().unwrap();
1855        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1856        engine.redo().unwrap();
1857        assert_eq!(
1858            engine.score.parts[0].staves[1].measures[0].voices[0][0].pitches,
1859            vec![Pitch::new(Step::E, 4)]
1860        );
1861    }
1862
1863    #[test]
1864    fn explode_chord_pitches_rejects_occupied_destination_without_mutation() {
1865        use crate::model::score::ScoreTemplate;
1866        use crate::{Duration, Note, Pitch, Step};
1867
1868        let mut engine = ScoreEngine::new();
1869        engine.score = Score::template(ScoreTemplate::Piano);
1870        let mut chord = Note::new(Pitch::new(Step::C, 5), Duration::Whole);
1871        chord.pitches.push(Pitch::new(Step::E, 4));
1872        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
1873        engine.score.parts[0].staves[1].measures[0].voices[0] =
1874            vec![Note::new(Pitch::new(Step::G, 3), Duration::Whole)];
1875        let before = serde_json::to_value(&engine.score).unwrap();
1876
1877        assert!(
1878            engine
1879                .explode_chord_pitches(0, 0, vec![0, 1], 0, 0)
1880                .is_err()
1881        );
1882        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1883    }
1884
1885    #[test]
1886    fn scale_voice_range_is_undoable_and_rejects_measure_overflow() {
1887        use crate::{Duration, DurationScale, Note, Pitch, Step};
1888
1889        let mut engine = ScoreEngine::new();
1890        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![
1891            Note::new(Pitch::new(Step::C, 4), Duration::Half),
1892            Note::new(Pitch::new(Step::D, 4), Duration::Half),
1893        ];
1894        let before = serde_json::to_value(&engine.score).unwrap();
1895
1896        engine
1897            .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1898            .unwrap();
1899        let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1900        assert_eq!(voice.len(), 3);
1901        assert_eq!(voice[0].duration, Duration::Quarter);
1902        assert_eq!(voice[1].duration, Duration::Quarter);
1903        assert!(voice[2].is_rest);
1904        assert_eq!(voice[2].duration, Duration::Half);
1905        engine.undo().unwrap();
1906        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1907
1908        assert!(
1909            engine
1910                .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Double)
1911                .is_err()
1912        );
1913        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1914    }
1915
1916    #[test]
1917    fn scale_voice_range_preserves_tuplet_ratio_and_undoes() {
1918        use crate::{Duration, DurationScale, Note, Pitch, Step, TupletInfo};
1919
1920        let mut engine = ScoreEngine::new();
1921        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1922        note.tuplet = Some(TupletInfo {
1923            actual_notes: 3,
1924            normal_notes: 2,
1925        });
1926        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1927        let before = serde_json::to_value(&engine.score).unwrap();
1928        engine
1929            .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1930            .unwrap();
1931        let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1932        assert_eq!(voice[0].duration, Duration::Eighth);
1933        assert_eq!(
1934            voice[0].tuplet,
1935            Some(TupletInfo {
1936                actual_notes: 3,
1937                normal_notes: 2,
1938            })
1939        );
1940        assert!(voice.iter().skip(1).all(|note| note.is_rest));
1941        engine.undo().unwrap();
1942        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1943    }
1944
1945    #[test]
1946    fn scale_voice_range_rejects_mixed_tuplet_ratios_without_mutation() {
1947        use crate::{Duration, DurationScale, Note, Pitch, Step, TupletInfo};
1948
1949        let mut engine = ScoreEngine::new();
1950        let mut triplet = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1951        triplet.tuplet = Some(TupletInfo {
1952            actual_notes: 3,
1953            normal_notes: 2,
1954        });
1955        let mut quintuplet = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1956        quintuplet.tuplet = Some(TupletInfo {
1957            actual_notes: 5,
1958            normal_notes: 4,
1959        });
1960        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![triplet, quintuplet];
1961        let before = serde_json::to_value(&engine.score).unwrap();
1962
1963        let error = engine
1964            .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1965            .expect_err("mixed tuplet ratios must be rejected");
1966        assert!(
1967            error
1968                .to_string()
1969                .contains("one shared tuplet ratio per voice")
1970        );
1971        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1972    }
1973
1974    #[test]
1975    fn copy_range_paste_range_roundtrip() {
1976        use crate::model::duration::Duration;
1977        use crate::model::pitch::{Pitch, Step};
1978        use crate::model::score::{Note, NoteAddr};
1979        let mut engine = ScoreEngine::new();
1980        // Put a note in measure 0
1981        engine.score.parts[0].staves[0].measures[0].voices[0] =
1982            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1983        // Need measure 1: add one
1984        use crate::model::commands::AddMeasureCmd;
1985        engine
1986            .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
1987            .unwrap();
1988
1989        let start = NoteAddr {
1990            part: 0,
1991            staff: 0,
1992            measure: 0,
1993            voice: 0,
1994            note: 0,
1995        };
1996        let end = NoteAddr {
1997            part: 0,
1998            staff: 0,
1999            measure: 0,
2000            voice: 0,
2001            note: 0,
2002        };
2003        engine.copy_range(start, end).unwrap();
2004
2005        let target = NoteAddr {
2006            part: 0,
2007            staff: 0,
2008            measure: 1,
2009            voice: 0,
2010            note: 0,
2011        };
2012        engine.paste_range(target).unwrap();
2013
2014        let pasted = &engine.score.parts[0].staves[0].measures[1].voices[0];
2015        assert_eq!(pasted.len(), 1);
2016        assert_eq!(pasted[0].pitches[0].step, Step::C);
2017    }
2018
2019    #[test]
2020    fn paste_range_is_undoable() {
2021        use crate::model::commands::AddMeasureCmd;
2022        use crate::model::duration::Duration;
2023        use crate::model::pitch::{Pitch, Step};
2024        use crate::model::score::{Note, NoteAddr};
2025        let mut engine = ScoreEngine::new();
2026        engine.score.parts[0].staves[0].measures[0].voices[0] =
2027            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
2028        engine
2029            .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
2030            .unwrap();
2031
2032        let start = NoteAddr {
2033            part: 0,
2034            staff: 0,
2035            measure: 0,
2036            voice: 0,
2037            note: 0,
2038        };
2039        let end = start.clone();
2040        engine.copy_range(start, end).unwrap();
2041        let target = NoteAddr {
2042            part: 0,
2043            staff: 0,
2044            measure: 1,
2045            voice: 0,
2046            note: 0,
2047        };
2048        engine.paste_range(target).unwrap();
2049
2050        // Undo should restore measure 1 to its pre-paste state
2051        engine.undo().unwrap();
2052        let restored = &engine.score.parts[0].staves[0].measures[1].voices[0];
2053        assert!(restored.iter().all(|n| n.is_rest));
2054    }
2055
2056    #[test]
2057    fn copy_range_mismatched_part_returns_error() {
2058        let engine = ScoreEngine::new();
2059        // Can't call copy_range mutably here since we need &mut, so test via a new engine
2060        let mut e = ScoreEngine::new();
2061        use crate::model::score::NoteAddr;
2062        let start = NoteAddr {
2063            part: 0,
2064            staff: 0,
2065            measure: 0,
2066            voice: 0,
2067            note: 0,
2068        };
2069        let end = NoteAddr {
2070            part: 1,
2071            staff: 0,
2072            measure: 0,
2073            voice: 0,
2074            note: 0,
2075        };
2076        assert!(e.copy_range(start, end).is_err());
2077        let _ = engine; // suppress unused warning
2078    }
2079
2080    #[test]
2081    fn export_history_roundtrip() {
2082        let mut engine = ScoreEngine::new();
2083        engine
2084            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2085            .unwrap();
2086        engine
2087            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2088            .unwrap();
2089        let history = engine.export_history();
2090        assert_eq!(history.commands.len(), 2);
2091        let restored = ScoreEngine::from_history(history).unwrap();
2092        assert_eq!(restored.score.settings.tempo_bpm, 180);
2093    }
2094
2095    #[test]
2096    fn history_base_check_rejects_stale_collaboration_snapshot() {
2097        let mut engine = ScoreEngine::new();
2098        engine
2099            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2100            .unwrap();
2101        let history = engine.export_history();
2102        let mut unrelated = history.initial_score.clone();
2103        unrelated.metadata.title = "unrelated".to_owned();
2104
2105        assert!(!history.base_matches(&unrelated));
2106        assert!(matches!(
2107            ScoreEngine::from_history_on_base(history, unrelated),
2108            Err(Error::HistoryBaseMismatch)
2109        ));
2110    }
2111
2112    #[test]
2113    fn history_base_check_allows_replay_on_matching_snapshot() {
2114        let mut engine = ScoreEngine::new();
2115        engine
2116            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2117            .unwrap();
2118        let history = engine.export_history();
2119        let base = history.initial_score.clone();
2120
2121        assert!(history.base_matches(&base));
2122        let restored = ScoreEngine::from_history_on_base(history, base).unwrap();
2123        assert_eq!(restored.score.settings.tempo_bpm, 160);
2124    }
2125
2126    #[test]
2127    fn history_compare_reports_prefix_and_divergence() {
2128        let base_score = ScoreEngine::new().score.clone();
2129        let mut base = ScoreEngine::new();
2130        base.replace_score(base_score.clone());
2131        let mut left = ScoreEngine::new();
2132        left.replace_score(base_score.clone());
2133        left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2134            .unwrap();
2135        let mut right = ScoreEngine::new();
2136        right.replace_score(base_score.clone());
2137        right
2138            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2139            .unwrap();
2140        right
2141            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2142            .unwrap();
2143
2144        assert_eq!(
2145            base.export_history().compare(&left.export_history()),
2146            HistoryRelation::LeftExtends {
2147                common_prefix_len: 0
2148            }
2149        );
2150        assert_eq!(
2151            left.export_history().compare(&right.export_history()),
2152            HistoryRelation::LeftExtends {
2153                common_prefix_len: 1
2154            }
2155        );
2156
2157        let mut diverged = ScoreEngine::new();
2158        diverged.replace_score(base_score);
2159        diverged
2160            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2161            .unwrap();
2162        assert_eq!(
2163            left.export_history().compare(&diverged.export_history()),
2164            HistoryRelation::Diverged {
2165                common_prefix_len: 0
2166            }
2167        );
2168    }
2169
2170    #[test]
2171    fn history_compare_reports_base_mismatch() {
2172        let left = ScoreEngine::new().export_history();
2173        let mut other = ScoreEngine::new();
2174        other.replace_score(Score::new("Other", 120, 4, 4, 1, 4));
2175        assert_eq!(
2176            left.compare(&other.export_history()),
2177            HistoryRelation::BaseMismatch
2178        );
2179    }
2180
2181    #[test]
2182    fn history_conflict_reports_branch_commands_and_remaining_lengths() {
2183        let base_score = ScoreEngine::new().score.clone();
2184        let mut left = ScoreEngine::new();
2185        left.replace_score(base_score.clone());
2186        left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2187            .unwrap();
2188        let mut right = ScoreEngine::new();
2189        right.replace_score(base_score);
2190        right
2191            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2192            .unwrap();
2193        right
2194            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2195            .unwrap();
2196
2197        assert_eq!(
2198            left.export_history().conflict(&right.export_history()),
2199            Some(HistoryConflict {
2200                common_prefix_len: 0,
2201                left_command_index: 0,
2202                right_command_index: 0,
2203                left_command_key: "SetTempo".to_owned(),
2204                right_command_key: "SetTempo".to_owned(),
2205                left_remaining_commands: 1,
2206                right_remaining_commands: 2,
2207            })
2208        );
2209    }
2210
2211    #[test]
2212    fn history_conflict_is_empty_for_safe_relationships() {
2213        let history = ScoreEngine::new().export_history();
2214        assert!(history.conflict(&history).is_none());
2215    }
2216
2217    #[test]
2218    fn append_history_extension_applies_only_remote_suffix() {
2219        let base_score = ScoreEngine::new().score.clone();
2220        let mut local = ScoreEngine::new();
2221        local.replace_score(base_score.clone());
2222        local
2223            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2224            .unwrap();
2225
2226        let mut remote = ScoreEngine::new();
2227        remote.replace_score(base_score);
2228        remote
2229            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2230            .unwrap();
2231        remote
2232            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2233            .unwrap();
2234
2235        let count = local
2236            .append_history_extension(&remote.export_history())
2237            .unwrap();
2238        assert_eq!(count, 1);
2239        assert_eq!(local.score.settings.tempo_bpm, 180);
2240        assert_eq!(
2241            local.export_history().compare(&remote.export_history()),
2242            HistoryRelation::Equivalent
2243        );
2244    }
2245
2246    #[test]
2247    fn append_history_extension_rejects_divergence_without_mutation() {
2248        let base_score = ScoreEngine::new().score.clone();
2249        let mut local = ScoreEngine::new();
2250        local.replace_score(base_score.clone());
2251        local
2252            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2253            .unwrap();
2254        let before = local.score.settings.tempo_bpm;
2255
2256        let mut remote = ScoreEngine::new();
2257        remote.replace_score(base_score);
2258        remote
2259            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2260            .unwrap();
2261
2262        assert!(matches!(
2263            local.append_history_extension(&remote.export_history()),
2264            Err(Error::HistoryNotAppendable)
2265        ));
2266        assert_eq!(local.score.settings.tempo_bpm, before);
2267    }
2268
2269    #[test]
2270    fn export_history_empty_gives_initial_state() {
2271        let engine = ScoreEngine::new();
2272        let history = engine.export_history();
2273        assert!(history.commands.is_empty());
2274        let restored = ScoreEngine::from_history(history).unwrap();
2275        assert_eq!(restored.score.settings.tempo_bpm, 120);
2276    }
2277
2278    #[test]
2279    fn replace_score_then_export_history() {
2280        let mut engine = ScoreEngine::new();
2281        let s = Score::new("Custom", 90, 3, 4, 2, 4);
2282        engine.replace_score(s);
2283        engine
2284            .apply(Command::SetTempo(SetTempoCmd { bpm: 60 }))
2285            .unwrap();
2286        let history = engine.export_history();
2287        assert_eq!(history.initial_score.settings.tempo_bpm, 90);
2288        assert_eq!(history.commands.len(), 1);
2289        let restored = ScoreEngine::from_history(history).unwrap();
2290        assert_eq!(restored.score.settings.tempo_bpm, 60);
2291    }
2292
2293    #[test]
2294    fn new_score_command_replaces_score() {
2295        let mut engine = ScoreEngine::new();
2296        engine
2297            .apply(Command::NewScore(NewScoreCmd {
2298                title: "Sonata".into(),
2299                composer: "Bach".into(),
2300                tempo_bpm: 80,
2301                time_numerator: 3,
2302                time_denominator: 4,
2303                key_fifths: -1,
2304                measure_count: 12,
2305                template: None,
2306            }))
2307            .unwrap();
2308        assert_eq!(engine.score.metadata.title, "Sonata");
2309        assert_eq!(engine.score.measure_count(), 12);
2310    }
2311
2312    #[test]
2313    fn respell_score_to_key_uses_key_signature() {
2314        use crate::model::commands::{AddNoteCmd, RespellScoreToKeyCmd};
2315        use crate::model::notation::KeySignature;
2316        use crate::model::pitch::Step;
2317        let mut engine = ScoreEngine::new();
2318        // Set Bb major (2 flats, fifths = -2) → prefer_flat = true
2319        engine.score.settings.key_signature = KeySignature {
2320            fifths: -2,
2321            mode: "major".to_string(),
2322        };
2323        engine
2324            .apply(Command::AddNote(AddNoteCmd {
2325                part_index: 0,
2326                staff_index: 0,
2327                measure_index: 0,
2328                voice: 0,
2329                position: 0,
2330                pitch: Some(crate::model::pitch::Pitch::with_alter(Step::C, 4, 1)), // C#4
2331                duration: crate::model::duration::Duration::Quarter,
2332                dot_count: 0,
2333                is_rest: false,
2334                tuplet: None,
2335            }))
2336            .unwrap();
2337        engine
2338            .apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
2339            .unwrap();
2340        let pitch = &engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
2341        assert_eq!(pitch.step, Step::D);
2342        assert_eq!(pitch.alter, -1); // Db4
2343    }
2344
2345    #[test]
2346    fn begin_end_slur_creates_slur() {
2347        use crate::model::commands::AddNoteCmd;
2348        use crate::model::duration::Duration;
2349        use crate::model::pitch::{Pitch, Step};
2350        let mut engine = ScoreEngine::new();
2351        engine
2352            .apply(Command::AddNote(AddNoteCmd {
2353                part_index: 0,
2354                staff_index: 0,
2355                measure_index: 0,
2356                voice: 0,
2357                position: 0,
2358                pitch: Some(Pitch::new(Step::C, 4)),
2359                duration: Duration::Quarter,
2360                dot_count: 0,
2361                is_rest: false,
2362                tuplet: None,
2363            }))
2364            .unwrap();
2365        engine
2366            .apply(Command::AddNote(AddNoteCmd {
2367                part_index: 0,
2368                staff_index: 0,
2369                measure_index: 0,
2370                voice: 0,
2371                position: 1,
2372                pitch: Some(Pitch::new(Step::D, 4)),
2373                duration: Duration::Quarter,
2374                dot_count: 0,
2375                is_rest: false,
2376                tuplet: None,
2377            }))
2378            .unwrap();
2379        let start = NoteAddr {
2380            part: 0,
2381            staff: 0,
2382            measure: 0,
2383            voice: 0,
2384            note: 0,
2385        };
2386        let end = NoteAddr {
2387            part: 0,
2388            staff: 0,
2389            measure: 0,
2390            voice: 0,
2391            note: 1,
2392        };
2393        engine.begin_slur(start).unwrap();
2394        engine.end_slur(end).unwrap();
2395        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
2396        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
2397    }
2398
2399    #[test]
2400    fn end_slur_without_begin_returns_error() {
2401        let mut engine = ScoreEngine::new();
2402        let end = NoteAddr {
2403            part: 0,
2404            staff: 0,
2405            measure: 0,
2406            voice: 0,
2407            note: 0,
2408        };
2409        let result = engine.end_slur(end);
2410        assert!(result.is_err());
2411    }
2412
2413    // ── SetStem ───────────────────────────────────────────────────────────────
2414
2415    #[test]
2416    fn set_stem_sets_and_clears() {
2417        use crate::model::commands::AddNoteCmd;
2418        use crate::model::duration::Duration;
2419        use crate::model::pitch::{Pitch, Step};
2420        let mut engine = ScoreEngine::new();
2421        engine
2422            .apply(Command::AddNote(AddNoteCmd {
2423                part_index: 0,
2424                staff_index: 0,
2425                measure_index: 0,
2426                voice: 0,
2427                position: 0,
2428                pitch: Some(Pitch::new(Step::C, 4)),
2429                duration: Duration::Quarter,
2430                dot_count: 0,
2431                is_rest: false,
2432                tuplet: None,
2433            }))
2434            .unwrap();
2435        let addr = NoteAddr {
2436            part: 0,
2437            staff: 0,
2438            measure: 0,
2439            voice: 0,
2440            note: 0,
2441        };
2442        engine.set_stem(addr.clone(), Some(true)).unwrap();
2443        assert_eq!(
2444            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2445            Some(true)
2446        );
2447        engine.set_stem(addr.clone(), Some(false)).unwrap();
2448        assert_eq!(
2449            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2450            Some(false)
2451        );
2452        engine.set_stem(addr, None).unwrap();
2453        assert_eq!(
2454            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2455            None
2456        );
2457    }
2458
2459    #[test]
2460    fn set_stem_is_undoable() {
2461        use crate::model::commands::AddNoteCmd;
2462        use crate::model::duration::Duration;
2463        use crate::model::pitch::{Pitch, Step};
2464        let mut engine = ScoreEngine::new();
2465        engine
2466            .apply(Command::AddNote(AddNoteCmd {
2467                part_index: 0,
2468                staff_index: 0,
2469                measure_index: 0,
2470                voice: 0,
2471                position: 0,
2472                pitch: Some(Pitch::new(Step::C, 4)),
2473                duration: Duration::Quarter,
2474                dot_count: 0,
2475                is_rest: false,
2476                tuplet: None,
2477            }))
2478            .unwrap();
2479        let addr = NoteAddr {
2480            part: 0,
2481            staff: 0,
2482            measure: 0,
2483            voice: 0,
2484            note: 0,
2485        };
2486        engine.set_stem(addr, Some(true)).unwrap();
2487        engine.undo().unwrap();
2488        assert_eq!(
2489            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2490            None
2491        );
2492    }
2493
2494    #[test]
2495    fn set_arpeggio_is_undoable() {
2496        use crate::model::commands::AddNoteCmd;
2497        use crate::model::duration::Duration;
2498        use crate::model::pitch::{Pitch, Step};
2499        let mut engine = ScoreEngine::new();
2500        engine
2501            .apply(Command::AddNote(AddNoteCmd {
2502                part_index: 0,
2503                staff_index: 0,
2504                measure_index: 0,
2505                voice: 0,
2506                position: 0,
2507                pitch: Some(Pitch::new(Step::C, 4)),
2508                duration: Duration::Quarter,
2509                dot_count: 0,
2510                is_rest: false,
2511                tuplet: None,
2512            }))
2513            .unwrap();
2514        let addr = NoteAddr {
2515            part: 0,
2516            staff: 0,
2517            measure: 0,
2518            voice: 0,
2519            note: 0,
2520        };
2521        engine.set_arpeggio(addr.clone(), Some(true)).unwrap();
2522        assert_eq!(
2523            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2524            Some(true)
2525        );
2526        engine.undo().unwrap();
2527        assert_eq!(
2528            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2529            None
2530        );
2531        engine.redo().unwrap();
2532        assert_eq!(
2533            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2534            Some(true)
2535        );
2536    }
2537
2538    // ── command_key ───────────────────────────────────────────────────────────
2539
2540    #[test]
2541    fn undo_key_returns_key_string() {
2542        let mut engine = ScoreEngine::new();
2543        engine
2544            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2545            .unwrap();
2546        assert_eq!(engine.undo_key(), Some("SetTempo".to_string()));
2547        assert!(engine.redo_key().is_none());
2548    }
2549
2550    #[test]
2551    fn redo_key_after_undo() {
2552        let mut engine = ScoreEngine::new();
2553        engine
2554            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2555            .unwrap();
2556        engine.undo().unwrap();
2557        assert!(engine.undo_key().is_none());
2558        assert_eq!(engine.redo_key(), Some("SetTempo".to_string()));
2559    }
2560
2561    // ── batch_apply_labeled ───────────────────────────────────────────────────
2562
2563    #[test]
2564    fn batch_apply_labeled_sets_undo_key() {
2565        let mut engine = ScoreEngine::new();
2566        engine
2567            .batch_apply_labeled(vec![Command::SetTempo(SetTempoCmd { bpm: 140 })], "ApplyAI")
2568            .unwrap();
2569        assert_eq!(engine.undo_key(), Some("ApplyAI".to_string()));
2570    }
2571
2572    #[test]
2573    fn batch_apply_labeled_empty_is_noop() {
2574        let mut engine = ScoreEngine::new();
2575        engine.batch_apply_labeled(vec![], "ApplyAI").unwrap();
2576        assert!(engine.undo_key().is_none());
2577    }
2578}