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    /// Set or clear the unpitched flag while retaining display placement.
612    pub fn set_unpitched(
613        &mut self,
614        addr: NoteAddr,
615        is_unpitched: bool,
616    ) -> Result<ChangeHint, Error> {
617        self.apply(Command::SetUnpitched(SetUnpitchedCmd {
618            part_index: addr.part,
619            staff_index: addr.staff,
620            measure_index: addr.measure,
621            voice: addr.voice,
622            note_index: addr.note,
623            is_unpitched,
624        }))
625    }
626
627    /// Set or clear a source instrument identifier attached to a note.
628    pub fn set_instrument_id(
629        &mut self,
630        addr: NoteAddr,
631        instrument_id: Option<String>,
632    ) -> Result<ChangeHint, Error> {
633        self.apply(Command::SetInstrumentId(SetInstrumentIdCmd {
634            part_index: addr.part,
635            staff_index: addr.staff,
636            measure_index: addr.measure,
637            voice: addr.voice,
638            note_index: addr.note,
639            instrument_id,
640        }))
641    }
642
643    /// Set or clear the tuplet on an existing note (undo-able).
644    pub fn set_tuplet(
645        &mut self,
646        addr: NoteAddr,
647        tuplet: Option<TupletInfo>,
648    ) -> Result<ChangeHint, Error> {
649        self.apply(Command::SetTuplet(SetTupletCmd {
650            part_index: addr.part,
651            staff_index: addr.staff,
652            measure_index: addr.measure,
653            voice_index: addr.voice,
654            note_index: addr.note,
655            tuplet,
656        }))
657    }
658
659    /// Respell all pitches in the score (undo-able).
660    pub fn respell_score(&mut self, prefer_flat: bool) -> Result<ChangeHint, Error> {
661        self.apply(Command::RespellScore(RespellScoreCmd { prefer_flat }))
662    }
663
664    /// Respell all pitches to match the score's key signature (undo-able).
665    pub fn respell_score_to_key(&mut self) -> Result<ChangeHint, Error> {
666        self.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
667    }
668
669    /// Respell one staff's measure range `start_measure..end_measure` (undo-able).
670    pub fn respell_staff_region(
671        &mut self,
672        part_index: usize,
673        staff_index: usize,
674        start_measure: usize,
675        end_measure: usize,
676        policy: RespellPolicy,
677    ) -> Result<ChangeHint, Error> {
678        self.apply(Command::RespellStaffRegion(RespellStaffRegionCmd {
679            part_index,
680            staff_index,
681            start_measure,
682            end_measure,
683            policy,
684        }))
685    }
686
687    /// Begin a two-step slur: record `start` and wait for [`end_slur`](Self::end_slur).
688    ///
689    /// Returns an error if `start` does not point to a valid note.
690    pub fn begin_slur(&mut self, start: NoteAddr) -> Result<(), Error> {
691        self.score
692            .parts
693            .get(start.part)
694            .ok_or(Error::PartNotFound(start.part))?
695            .staves
696            .get(start.staff)
697            .ok_or(Error::StaffNotFound(start.staff))?
698            .measures
699            .get(start.measure)
700            .ok_or(Error::MeasureNotFound(start.measure))?
701            .voices
702            .get(start.voice)
703            .ok_or(Error::VoiceOutOfRange(start.voice))?
704            .get(start.note)
705            .ok_or(Error::NoteNotFound(start.note))?;
706        self.pending_slur_start = Some(start);
707        Ok(())
708    }
709
710    /// Complete the slur started by [`begin_slur`](Self::begin_slur) (undo-able).
711    ///
712    /// Returns `Error::InvalidCommand` if `begin_slur` has not been called.
713    pub fn end_slur(&mut self, end: NoteAddr) -> Result<ChangeHint, Error> {
714        let start = self
715            .pending_slur_start
716            .take()
717            .ok_or_else(|| Error::InvalidCommand("no slur in progress".to_string()))?;
718        self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
719    }
720}
721
722impl EngineHistory {
723    /// Return whether this history was recorded from the supplied collaboration base.
724    pub fn base_matches(&self, base: &Score) -> bool {
725        match (
726            serde_json::to_vec(&self.initial_score),
727            serde_json::to_vec(base),
728        ) {
729            (Ok(expected), Ok(actual)) => expected == actual,
730            _ => false,
731        }
732    }
733
734    /// Compare two logs without applying commands or mutating either history.
735    pub fn compare(&self, other: &Self) -> HistoryRelation {
736        if !self.base_matches(&other.initial_score) {
737            return HistoryRelation::BaseMismatch;
738        }
739        let common_prefix_len = self
740            .commands
741            .iter()
742            .zip(&other.commands)
743            .take_while(|(left, right)| command_bytes(left) == command_bytes(right))
744            .count();
745        match (self.commands.len(), other.commands.len()) {
746            (left, right) if left == right && common_prefix_len == left => {
747                HistoryRelation::Equivalent
748            }
749            (left, _) if common_prefix_len == left => {
750                HistoryRelation::LeftExtends { common_prefix_len }
751            }
752            (_, right) if common_prefix_len == right => {
753                HistoryRelation::RightExtends { common_prefix_len }
754            }
755            _ => HistoryRelation::Diverged { common_prefix_len },
756        }
757    }
758
759    /// Return explainable details when the two same-base logs diverge.
760    pub fn conflict(&self, other: &Self) -> Option<HistoryConflict> {
761        let common_prefix_len = match self.compare(other) {
762            HistoryRelation::Diverged { common_prefix_len } => common_prefix_len,
763            _ => return None,
764        };
765        let left = self.commands.get(common_prefix_len)?;
766        let right = other.commands.get(common_prefix_len)?;
767        Some(HistoryConflict {
768            common_prefix_len,
769            left_command_index: common_prefix_len,
770            right_command_index: common_prefix_len,
771            left_command_key: command_key(left),
772            right_command_key: command_key(right),
773            left_remaining_commands: self.commands.len() - common_prefix_len,
774            right_remaining_commands: other.commands.len() - common_prefix_len,
775        })
776    }
777}
778
779fn command_bytes(command: &Command) -> Option<Vec<u8>> {
780    serde_json::to_vec(command).ok()
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786    use crate::model::commands::{NewScoreCmd, SetTempoCmd, SetTempoRampAtMeasureCmd};
787
788    #[test]
789    fn new_engine_has_default_score() {
790        let engine = ScoreEngine::new();
791        assert_eq!(engine.version, 0);
792        assert_eq!(engine.score.parts.len(), 1);
793    }
794
795    #[test]
796    fn apply_increments_version() {
797        let mut engine = ScoreEngine::new();
798        engine
799            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
800            .unwrap();
801        assert_eq!(engine.version, 1);
802    }
803
804    #[test]
805    fn tempo_ramp_command_undo_redo_restores_measure_target() {
806        let mut engine = ScoreEngine::new();
807        engine
808            .apply(Command::SetTempoRampAtMeasure(SetTempoRampAtMeasureCmd {
809                measure_index: 0,
810                target_bpm: Some(84),
811            }))
812            .expect("tempo ramp applies");
813        assert_eq!(
814            engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
815            Some(84)
816        );
817        engine.undo().expect("tempo ramp undoes");
818        assert_eq!(
819            engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
820            None
821        );
822        engine.redo().expect("tempo ramp redoes");
823        assert_eq!(
824            engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
825            Some(84)
826        );
827    }
828
829    #[test]
830    fn undo_redo_cycle() {
831        let mut engine = ScoreEngine::new();
832        engine
833            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
834            .unwrap();
835        let after_apply = engine.version;
836        engine.undo().unwrap();
837        assert_eq!(engine.score.settings.tempo_bpm, 120);
838        engine.redo().unwrap();
839        assert_eq!(engine.score.settings.tempo_bpm, 140);
840        assert!(engine.version > after_apply);
841    }
842
843    #[test]
844    fn replace_score_clears_history() {
845        let mut engine = ScoreEngine::new();
846        engine
847            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
848            .unwrap();
849        let new_score = Score::new("New", 90, 3, 4, 2, 8);
850        engine.replace_score(new_score);
851        assert!(engine.undo().is_err());
852        assert_eq!(engine.score.settings.tempo_bpm, 90);
853    }
854
855    #[test]
856    fn try_replace_score_rejects_invalid_input_without_mutation() {
857        let mut engine = ScoreEngine::new();
858        engine
859            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
860            .unwrap();
861        let original = engine.score.settings.tempo_bpm;
862        let original_version = engine.version;
863        let mut invalid = Score::default();
864        invalid.parts[0].staves[0].tablature = Some(crate::TablatureConfig {
865            lines: 0,
866            tuning_midi: Vec::new(),
867            capo: 0,
868        });
869
870        assert!(matches!(
871            engine.try_replace_score(invalid),
872            Err(Error::InvalidScore)
873        ));
874        assert_eq!(engine.score.settings.tempo_bpm, original);
875        assert_eq!(engine.version, original_version);
876        assert!(engine.commands.can_undo());
877    }
878
879    #[test]
880    fn copy_paste_voice_copies_notes() {
881        use crate::model::duration::Duration;
882        use crate::model::pitch::{Pitch, Step};
883        use crate::model::score::Note;
884        let mut engine = ScoreEngine::new();
885        engine.score.parts[0].staves[0].measures[0].voices[0] =
886            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
887        engine.copy_voice(0, 0, 0, 0).unwrap();
888        engine.paste_voice(0, 0, 0, 1).unwrap();
889        let pasted = &engine.score.parts[0].staves[0].measures[0].voices[1];
890        assert_eq!(pasted.len(), 1);
891        assert_eq!(pasted[0].pitches[0].step, Step::C);
892    }
893
894    #[test]
895    fn paste_voice_undo_restores_original() {
896        use crate::model::duration::Duration;
897        use crate::model::pitch::{Pitch, Step};
898        use crate::model::score::Note;
899        let mut engine = ScoreEngine::new();
900        engine.score.parts[0].staves[0].measures[0].voices[0] =
901            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
902        engine.copy_voice(0, 0, 0, 0).unwrap();
903        engine.paste_voice(0, 0, 0, 1).unwrap();
904        engine.undo().unwrap();
905        assert!(engine.score.parts[0].staves[0].measures[0].voices[1].is_empty());
906    }
907
908    #[test]
909    fn unpitched_flag_is_undoable_and_redoable() {
910        let mut engine = ScoreEngine::new();
911        let addr = NoteAddr {
912            part: 0,
913            staff: 0,
914            measure: 0,
915            voice: 0,
916            note: 0,
917        };
918        engine.set_unpitched(addr.clone(), true).unwrap();
919        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
920        engine.undo().unwrap();
921        assert!(!engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
922        engine.redo().unwrap();
923        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
924        engine
925            .set_instrument_id(addr, Some("P1-I2".to_string()))
926            .unwrap();
927        assert_eq!(
928            engine.score.parts[0].staves[0].measures[0].voices[0][0]
929                .instrument_id
930                .as_deref(),
931            Some("P1-I2")
932        );
933        for command in [
934            Command::SetUnpitched(super::super::commands::SetUnpitchedCmd {
935                part_index: 0,
936                staff_index: 0,
937                measure_index: 0,
938                voice: 0,
939                note_index: 0,
940                is_unpitched: false,
941            }),
942            Command::SetInstrumentId(super::super::commands::SetInstrumentIdCmd {
943                part_index: 0,
944                staff_index: 0,
945                measure_index: 0,
946                voice: 0,
947                note_index: 0,
948                instrument_id: Some("P1-I2".to_string()),
949            }),
950        ] {
951            let json = serde_json::to_string(&command).unwrap();
952            let restored: Command = serde_json::from_str(&json).unwrap();
953            assert_eq!(
954                super::super::commands::command_key(&restored),
955                super::super::commands::command_key(&command)
956            );
957        }
958    }
959
960    #[test]
961    fn paste_voice_without_copy_returns_error() {
962        let mut engine = ScoreEngine::new();
963        assert!(engine.paste_voice(0, 0, 0, 0).is_err());
964    }
965
966    #[test]
967    fn change_hint_set_tempo_is_global() {
968        use crate::model::change_hint::ChangeScope;
969        let mut engine = ScoreEngine::new();
970        let hint = engine
971            .apply(Command::SetTempo(SetTempoCmd { bpm: 100 }))
972            .unwrap();
973        assert_eq!(hint.scope, ChangeScope::Global);
974        assert!(!hint.layout_dirty);
975        assert!(hint.playback_dirty);
976    }
977
978    #[test]
979    fn change_hint_add_note_is_measure_scope() {
980        use crate::model::change_hint::ChangeScope;
981        use crate::model::commands::AddNoteCmd;
982        use crate::model::duration::Duration;
983        use crate::model::pitch::Pitch;
984        use crate::model::pitch::Step;
985        let mut engine = ScoreEngine::new();
986        let hint = engine
987            .apply(Command::AddNote(AddNoteCmd {
988                part_index: 0,
989                staff_index: 0,
990                measure_index: 0,
991                voice: 0,
992                position: 0,
993                pitch: Some(Pitch::new(Step::C, 4)),
994                duration: Duration::Quarter,
995                dot_count: 0,
996                is_rest: false,
997                tuplet: None,
998            }))
999            .unwrap();
1000        assert_eq!(
1001            hint.scope,
1002            ChangeScope::Measures {
1003                part: 0,
1004                staff: 0,
1005                start: 0,
1006                end: 1
1007            }
1008        );
1009        assert!(!hint.layout_dirty);
1010        assert!(hint.playback_dirty);
1011    }
1012
1013    #[test]
1014    fn change_hint_set_part_name_no_dirty() {
1015        use crate::model::change_hint::ChangeScope;
1016        use crate::model::commands::SetPartNameCmd;
1017        let mut engine = ScoreEngine::new();
1018        let hint = engine
1019            .apply(Command::SetPartName(SetPartNameCmd {
1020                part_index: 0,
1021                name: "Violin".into(),
1022                short_name: "Vln.".into(),
1023            }))
1024            .unwrap();
1025        assert_eq!(hint.scope, ChangeScope::Part(0));
1026        assert!(!hint.layout_dirty);
1027        assert!(!hint.playback_dirty);
1028    }
1029
1030    #[test]
1031    fn undo_returns_change_hint() {
1032        use crate::model::change_hint::ChangeScope;
1033        let mut engine = ScoreEngine::new();
1034        engine
1035            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1036            .unwrap();
1037        let hint = engine.undo().unwrap();
1038        assert_eq!(hint.scope, ChangeScope::Global);
1039        assert!(hint.playback_dirty);
1040    }
1041
1042    #[test]
1043    fn redo_returns_change_hint() {
1044        use crate::model::change_hint::ChangeScope;
1045        let mut engine = ScoreEngine::new();
1046        engine
1047            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1048            .unwrap();
1049        engine.undo().unwrap();
1050        let hint = engine.redo().unwrap();
1051        assert_eq!(hint.scope, ChangeScope::Global);
1052        assert!(hint.playback_dirty);
1053    }
1054
1055    #[test]
1056    fn batch_apply_two_commands_single_undo() {
1057        let mut engine = ScoreEngine::new();
1058        let original = engine.score.settings.tempo_bpm;
1059        engine
1060            .batch_apply(vec![
1061                Command::SetTempo(SetTempoCmd { bpm: 160 }),
1062                Command::SetTempo(SetTempoCmd { bpm: 180 }),
1063            ])
1064            .unwrap();
1065        assert_eq!(engine.score.settings.tempo_bpm, 180);
1066        engine.undo().unwrap();
1067        assert_eq!(engine.score.settings.tempo_bpm, original);
1068        assert!(engine.undo().is_err());
1069    }
1070
1071    #[test]
1072    fn batch_apply_empty_returns_no_dirty() {
1073        let mut engine = ScoreEngine::new();
1074        let v0 = engine.version;
1075        let hint = engine.batch_apply(vec![]).unwrap();
1076        assert!(!hint.layout_dirty);
1077        assert!(!hint.playback_dirty);
1078        assert_eq!(engine.version, v0);
1079    }
1080
1081    #[test]
1082    fn batch_apply_hint_merges_scopes() {
1083        use crate::model::change_hint::ChangeScope;
1084        use crate::model::commands::{AddNoteCmd, SetTempoCmd};
1085        use crate::model::duration::Duration;
1086        use crate::model::pitch::{Pitch, Step};
1087        let mut engine = ScoreEngine::new();
1088        let hint = engine
1089            .batch_apply(vec![
1090                Command::SetTempo(SetTempoCmd { bpm: 140 }),
1091                Command::AddNote(AddNoteCmd {
1092                    part_index: 0,
1093                    staff_index: 0,
1094                    measure_index: 0,
1095                    voice: 0,
1096                    position: 0,
1097                    pitch: Some(Pitch::new(Step::C, 4)),
1098                    duration: Duration::Quarter,
1099                    dot_count: 0,
1100                    is_rest: false,
1101                    tuplet: None,
1102                }),
1103            ])
1104            .unwrap();
1105        // SetTempo = Global; merged with Measures = Global
1106        assert_eq!(hint.scope, ChangeScope::Global);
1107        assert!(hint.playback_dirty);
1108    }
1109
1110    #[test]
1111    fn undo_label_none_when_empty() {
1112        let engine = ScoreEngine::new();
1113        assert!(engine.undo_label().is_none());
1114        assert!(engine.redo_label().is_none());
1115    }
1116
1117    #[test]
1118    fn undo_label_after_command() {
1119        let mut engine = ScoreEngine::new();
1120        engine
1121            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1122            .unwrap();
1123        assert_eq!(engine.undo_label(), Some("Set Tempo".to_string()));
1124        assert!(engine.redo_label().is_none());
1125    }
1126
1127    #[test]
1128    fn redo_label_after_undo() {
1129        let mut engine = ScoreEngine::new();
1130        engine
1131            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1132            .unwrap();
1133        engine.undo().unwrap();
1134        assert!(engine.undo_label().is_none());
1135        assert_eq!(engine.redo_label(), Some("Set Tempo".to_string()));
1136    }
1137
1138    #[test]
1139    fn score_fragment_paste_is_undoable_and_assigns_fresh_spanner_ids() {
1140        use crate::model::fragment::{ScoreFragmentSelection, extract_score_fragment};
1141        use crate::model::score::{NotationSpanner, NotationSpannerKind};
1142        use crate::{CrossStaff, Duration, Note, Pitch, Score, ScoreTemplate, Step};
1143
1144        let mut engine = ScoreEngine::new();
1145        engine.score = Score::template(ScoreTemplate::Piano);
1146        let mut source_note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
1147        source_note.cross_staff = Some(CrossStaff {
1148            target_staff: 1,
1149            target_voice: Some(0),
1150        });
1151        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![source_note];
1152        engine.score.spanners.push(NotationSpanner {
1153            id: "source-slur".into(),
1154            kind: NotationSpannerKind::Slur,
1155            start: NoteAddr {
1156                part: 0,
1157                staff: 0,
1158                measure: 0,
1159                voice: 0,
1160                note: 0,
1161            },
1162            end: NoteAddr {
1163                part: 0,
1164                staff: 0,
1165                measure: 0,
1166                voice: 0,
1167                note: 0,
1168            },
1169            number: None,
1170            line_type: None,
1171            text: None,
1172            placement: None,
1173            ottava_size: None,
1174            ottava_type: None,
1175        });
1176        let selection = ScoreFragmentSelection {
1177            start: NoteAddr {
1178                part: 0,
1179                staff: 0,
1180                measure: 0,
1181                voice: 0,
1182                note: 0,
1183            },
1184            end: NoteAddr {
1185                part: 0,
1186                staff: 0,
1187                measure: 0,
1188                voice: 0,
1189                note: 0,
1190            },
1191        };
1192        let fragment = extract_score_fragment(&engine.score, &[selection]).unwrap();
1193        let source_id = engine.score.parts[0].staves[0].measures[0].voices[0][0]
1194            .id
1195            .clone();
1196        let target = NoteAddr {
1197            part: 0,
1198            staff: 0,
1199            measure: 1,
1200            voice: 0,
1201            note: 0,
1202        };
1203        engine
1204            .paste_score_fragment(fragment.clone(), target.clone())
1205            .unwrap();
1206        let pasted_id = engine.score.parts[0].staves[0].measures[1].voices[0][0]
1207            .id
1208            .clone();
1209        assert_ne!(pasted_id, source_id);
1210        assert_eq!(
1211            engine.score.parts[0].staves[0].measures[1].voices[0][0]
1212                .cross_staff
1213                .as_ref()
1214                .map(|cross_staff| cross_staff.target_staff),
1215            Some(1)
1216        );
1217        assert!(
1218            engine
1219                .score
1220                .spanners
1221                .iter()
1222                .any(|span| span.id == "source-slur-copy")
1223        );
1224
1225        engine
1226            .paste_score_fragment(
1227                fragment,
1228                NoteAddr {
1229                    measure: 2,
1230                    ..target.clone()
1231                },
1232            )
1233            .unwrap();
1234        assert!(
1235            engine
1236                .score
1237                .spanners
1238                .iter()
1239                .any(|span| span.id == "source-slur-copy-2")
1240        );
1241
1242        engine.undo().unwrap();
1243        assert!(
1244            engine.score.parts[0].staves[0].measures[2].voices[0]
1245                .iter()
1246                .all(|note| note.is_rest)
1247        );
1248        engine.redo().unwrap();
1249        assert_eq!(
1250            engine.score.parts[0].staves[0].measures[2].voices[0][0].pitches[0].step,
1251            Step::C
1252        );
1253
1254        let before = engine.score.clone();
1255        let history_before = engine.commands.history_commands();
1256        assert!(
1257            engine
1258                .paste_score_fragment(
1259                    extract_score_fragment(
1260                        &engine.score,
1261                        &[ScoreFragmentSelection {
1262                            start: target.clone(),
1263                            end: target,
1264                        }]
1265                    )
1266                    .unwrap(),
1267                    NoteAddr {
1268                        part: 99,
1269                        staff: 0,
1270                        measure: 0,
1271                        voice: 0,
1272                        note: 0,
1273                    },
1274                )
1275                .is_err()
1276        );
1277        assert_eq!(
1278            serde_json::to_value(&engine.score).unwrap(),
1279            serde_json::to_value(before).unwrap()
1280        );
1281        assert_eq!(
1282            engine.commands.history_commands().len(),
1283            history_before.len()
1284        );
1285    }
1286
1287    #[test]
1288    fn score_fragment_merge_preserves_sounding_destination_lanes() {
1289        use crate::model::fragment::{ScoreFragmentSelection, extract_score_fragment};
1290        use crate::{Duration, Note, Pitch, ScoreFragmentPastePolicy, Step};
1291
1292        let mut engine = ScoreEngine::new();
1293        engine.score.parts[0].staves[0].measures[0].voices[0] =
1294            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1295        let source = NoteAddr {
1296            part: 0,
1297            staff: 0,
1298            measure: 0,
1299            voice: 0,
1300            note: 0,
1301        };
1302        let fragment = extract_score_fragment(
1303            &engine.score,
1304            &[ScoreFragmentSelection {
1305                start: source.clone(),
1306                end: source.clone(),
1307            }],
1308        )
1309        .unwrap();
1310        let rest_target = NoteAddr {
1311            measure: 1,
1312            ..source.clone()
1313        };
1314        engine
1315            .paste_score_fragment_with_policy(
1316                fragment.clone(),
1317                rest_target,
1318                ScoreFragmentPastePolicy::Merge,
1319            )
1320            .unwrap();
1321        assert_eq!(
1322            engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1323            Step::C
1324        );
1325
1326        engine.score.parts[0].staves[0].measures[2].voices[0] =
1327            vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
1328        let before = serde_json::to_value(&engine.score).unwrap();
1329        assert!(
1330            engine
1331                .paste_score_fragment_with_policy(
1332                    fragment,
1333                    NoteAddr {
1334                        measure: 2,
1335                        ..source
1336                    },
1337                    ScoreFragmentPastePolicy::Merge,
1338                )
1339                .is_err()
1340        );
1341        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1342    }
1343
1344    #[test]
1345    fn score_fragment_replace_preserves_measure_attributes_and_accepts_v2() {
1346        use crate::model::fragment::{
1347            ScoreFragmentMeasureAttributes, ScoreFragmentSelection, extract_score_fragment,
1348        };
1349        use crate::{Duration, KeySignature, Note, Pitch, Step};
1350
1351        let mut engine = ScoreEngine::new();
1352        let source_measure = &mut engine.score.parts[0].staves[0].measures[0];
1353        source_measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1354        source_measure.key_sig = Some(KeySignature {
1355            fifths: 2,
1356            mode: "major".into(),
1357        });
1358        source_measure.tempo = Some(96);
1359        source_measure.navigation = Some("Segno".into());
1360        source_measure.section_break = true;
1361        let source = NoteAddr {
1362            part: 0,
1363            staff: 0,
1364            measure: 0,
1365            voice: 0,
1366            note: 0,
1367        };
1368        let fragment = extract_score_fragment(
1369            &engine.score,
1370            &[ScoreFragmentSelection {
1371                start: source.clone(),
1372                end: source.clone(),
1373            }],
1374        )
1375        .unwrap();
1376        assert!(fragment.voices[0].measures[0].attributes.present);
1377        engine
1378            .paste_score_fragment(
1379                fragment.clone(),
1380                NoteAddr {
1381                    measure: 1,
1382                    ..source.clone()
1383                },
1384            )
1385            .unwrap();
1386        let pasted = &engine.score.parts[0].staves[0].measures[1];
1387        assert_eq!(pasted.key_sig.as_ref().map(|key| key.fifths), Some(2));
1388        assert_eq!(pasted.tempo, Some(96));
1389        assert_eq!(pasted.navigation.as_deref(), Some("Segno"));
1390        assert!(pasted.section_break);
1391
1392        let mut legacy = fragment;
1393        legacy.contract_version = 2;
1394        for measure in &mut legacy.voices[0].measures {
1395            measure.attributes = ScoreFragmentMeasureAttributes::default();
1396        }
1397        engine
1398            .paste_score_fragment(
1399                legacy,
1400                NoteAddr {
1401                    measure: 2,
1402                    ..source
1403                },
1404            )
1405            .unwrap();
1406        let legacy_paste = &engine.score.parts[0].staves[0].measures[2];
1407        assert_eq!(legacy_paste.key_sig, None);
1408        assert_eq!(legacy_paste.tempo, None);
1409        assert_eq!(legacy_paste.navigation, None);
1410        assert!(!legacy_paste.section_break);
1411    }
1412
1413    #[test]
1414    fn exchange_voices_preserves_source_numbers_spans_and_undo() {
1415        use crate::model::score::{NotationSpanner, NotationSpannerKind};
1416        use crate::{Duration, Note, Pitch, Step};
1417
1418        let mut engine = ScoreEngine::new();
1419        let measure = &mut engine.score.parts[0].staves[0].measures[0];
1420        measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1421        measure.voices[1] = vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
1422        measure.source_voice_numbers = [Some(1), Some(5), None, None];
1423        engine.score.spanners.push(NotationSpanner {
1424            id: "between-voices".into(),
1425            kind: NotationSpannerKind::Slur,
1426            start: NoteAddr {
1427                part: 0,
1428                staff: 0,
1429                measure: 0,
1430                voice: 0,
1431                note: 0,
1432            },
1433            end: NoteAddr {
1434                part: 0,
1435                staff: 0,
1436                measure: 0,
1437                voice: 1,
1438                note: 0,
1439            },
1440            number: None,
1441            line_type: None,
1442            text: None,
1443            placement: None,
1444            ottava_size: None,
1445            ottava_type: None,
1446        });
1447
1448        engine.exchange_voices(0, 0, 0, 0, 0, 1).unwrap();
1449        let measure = &engine.score.parts[0].staves[0].measures[0];
1450        assert_eq!(measure.voices[0][0].pitches[0].step, Step::D);
1451        assert_eq!(measure.voices[1][0].pitches[0].step, Step::C);
1452        assert_eq!(measure.source_voice_numbers, [Some(5), Some(1), None, None]);
1453        assert_eq!(engine.score.spanners[0].start.voice, 1);
1454        assert_eq!(engine.score.spanners[0].end.voice, 0);
1455
1456        engine.undo().unwrap();
1457        let measure = &engine.score.parts[0].staves[0].measures[0];
1458        assert_eq!(measure.voices[0][0].pitches[0].step, Step::C);
1459        assert_eq!(measure.source_voice_numbers, [Some(1), Some(5), None, None]);
1460    }
1461
1462    #[test]
1463    fn move_or_copy_voice_range_is_atomic_and_undoable() {
1464        use crate::MoveOrCopyVoiceRangeCmd;
1465        use crate::{Duration, Note, Pitch, Step};
1466
1467        let mut engine = ScoreEngine::new();
1468        engine.score.parts[0].staves[0].measures[0].voices[0] =
1469            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1470        let source = NoteAddr {
1471            part: 0,
1472            staff: 0,
1473            measure: 0,
1474            voice: 0,
1475            note: 0,
1476        };
1477        let target = NoteAddr {
1478            measure: 1,
1479            ..source.clone()
1480        };
1481        engine
1482            .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1483                source_start: source.clone(),
1484                source_end: source.clone(),
1485                target: target.clone(),
1486                move_source: false,
1487            }))
1488            .unwrap();
1489        assert_eq!(
1490            engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1491            Step::C
1492        );
1493        assert_eq!(
1494            engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].step,
1495            Step::C
1496        );
1497
1498        engine
1499            .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1500                source_start: source.clone(),
1501                source_end: source.clone(),
1502                target: NoteAddr {
1503                    measure: 2,
1504                    ..source.clone()
1505                },
1506                move_source: true,
1507            }))
1508            .unwrap();
1509        assert!(
1510            engine.score.parts[0].staves[0].measures[0].voices[0]
1511                .iter()
1512                .all(|note| note.is_rest)
1513        );
1514        assert_eq!(
1515            engine.score.parts[0].staves[0].measures[2].voices[0][0].pitches[0].step,
1516            Step::C
1517        );
1518        engine.undo().unwrap();
1519        assert_eq!(
1520            engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].step,
1521            Step::C
1522        );
1523
1524        let before = serde_json::to_value(&engine.score).unwrap();
1525        assert!(
1526            engine
1527                .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1528                    source_start: source.clone(),
1529                    source_end: source.clone(),
1530                    target: source,
1531                    move_source: true,
1532                }))
1533                .is_err()
1534        );
1535        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1536    }
1537
1538    #[test]
1539    fn split_measure_remaps_spanners_and_is_undoable() {
1540        use crate::model::score::{NotationSpanner, NotationSpannerKind};
1541        use crate::{Duration, JoinMeasuresCmd, Note, Pitch, SplitMeasureCmd, Step};
1542
1543        let mut engine = ScoreEngine::new();
1544        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![
1545            Note::new(Pitch::new(Step::C, 4), Duration::Half),
1546            Note::new(Pitch::new(Step::D, 4), Duration::Half),
1547        ];
1548        engine.score.spanners.push(NotationSpanner {
1549            id: "across-split".into(),
1550            kind: NotationSpannerKind::Slur,
1551            start: NoteAddr {
1552                part: 0,
1553                staff: 0,
1554                measure: 0,
1555                voice: 0,
1556                note: 0,
1557            },
1558            end: NoteAddr {
1559                part: 0,
1560                staff: 0,
1561                measure: 0,
1562                voice: 0,
1563                note: 1,
1564            },
1565            number: None,
1566            line_type: None,
1567            text: None,
1568            placement: None,
1569            ottava_size: None,
1570            ottava_type: None,
1571        });
1572        engine
1573            .apply(Command::SplitMeasure(SplitMeasureCmd {
1574                measure_index: 0,
1575                split_at_beats: 2.0,
1576            }))
1577            .unwrap();
1578        assert_eq!(
1579            engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1580            1
1581        );
1582        assert_eq!(
1583            engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1584            Step::D
1585        );
1586        assert_eq!(engine.score.spanners[0].end.measure, 1);
1587        assert_eq!(engine.score.spanners[0].end.note, 0);
1588        engine.undo().unwrap();
1589        assert_eq!(
1590            engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1591            2
1592        );
1593        engine
1594            .apply(Command::SplitMeasure(SplitMeasureCmd {
1595                measure_index: 0,
1596                split_at_beats: 2.0,
1597            }))
1598            .unwrap();
1599        engine
1600            .apply(Command::JoinMeasures(JoinMeasuresCmd { measure_index: 0 }))
1601            .unwrap();
1602        assert_eq!(
1603            engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1604            2
1605        );
1606        assert_eq!(engine.score.spanners[0].end.measure, 0);
1607        assert_eq!(engine.score.spanners[0].end.note, 1);
1608    }
1609
1610    #[test]
1611    fn split_and_join_preserve_tuplet_tie_lyric_span_and_source_voice() {
1612        use crate::model::score::{NotationSpanner, NotationSpannerKind};
1613        use crate::{
1614            Duration, JoinMeasuresCmd, Lyric, Note, Pitch, SplitMeasureCmd, Step, TupletInfo,
1615        };
1616
1617        let mut engine = ScoreEngine::new();
1618        let ratio = TupletInfo {
1619            actual_notes: 3,
1620            normal_notes: 2,
1621        };
1622        let mut third = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
1623        third.tuplet = Some(ratio.clone());
1624        third.tie_start = true;
1625        third.lyric = Some(Lyric {
1626            text: "tri".into(),
1627            syllabic: "begin".into(),
1628            extend: false,
1629        });
1630        let mut fourth = Note::new(Pitch::new(Step::F, 4), Duration::Half);
1631        fourth.tie_end = true;
1632        fourth.lyric = Some(Lyric {
1633            text: "plet".into(),
1634            syllabic: "end".into(),
1635            extend: false,
1636        });
1637        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1638        first.tuplet = Some(ratio.clone());
1639        let mut second = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1640        second.tuplet = Some(ratio.clone());
1641        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![first, second, third, fourth];
1642        engine.score.parts[0].staves[0].measures[0].source_voice_numbers[0] = Some(7);
1643        engine.score.spanners.push(NotationSpanner {
1644            id: "tuplet-split-span".into(),
1645            kind: NotationSpannerKind::Slur,
1646            start: NoteAddr {
1647                part: 0,
1648                staff: 0,
1649                measure: 0,
1650                voice: 0,
1651                note: 0,
1652            },
1653            end: NoteAddr {
1654                part: 0,
1655                staff: 0,
1656                measure: 0,
1657                voice: 0,
1658                note: 3,
1659            },
1660            number: None,
1661            line_type: None,
1662            text: None,
1663            placement: None,
1664            ottava_size: None,
1665            ottava_type: None,
1666        });
1667
1668        engine
1669            .apply(Command::SplitMeasure(SplitMeasureCmd {
1670                measure_index: 0,
1671                split_at_beats: 2.0,
1672            }))
1673            .unwrap();
1674        assert_eq!(
1675            engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1676            3
1677        );
1678        assert_eq!(
1679            engine.score.parts[0].staves[0].measures[1].voices[0].len(),
1680            1
1681        );
1682        assert_eq!(
1683            engine.score.parts[0].staves[0].measures[1].source_voice_numbers[0],
1684            Some(7)
1685        );
1686        assert_eq!(engine.score.spanners[0].end.measure, 1);
1687        assert_eq!(engine.score.spanners[0].end.note, 0);
1688
1689        engine
1690            .apply(Command::JoinMeasures(JoinMeasuresCmd { measure_index: 0 }))
1691            .unwrap();
1692        let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1693        assert_eq!(voice.len(), 4);
1694        assert_eq!(voice[2].tuplet, Some(ratio));
1695        assert!(voice[2].tie_start);
1696        assert!(voice[3].tie_end);
1697        assert_eq!(
1698            voice[2].lyric.as_ref().map(|lyric| lyric.text.as_str()),
1699            Some("tri")
1700        );
1701        assert_eq!(
1702            voice[3].lyric.as_ref().map(|lyric| lyric.text.as_str()),
1703            Some("plet")
1704        );
1705        assert_eq!(engine.score.spanners[0].end.measure, 0);
1706        assert_eq!(engine.score.spanners[0].end.note, 3);
1707    }
1708
1709    #[test]
1710    fn implode_then_explode_preserves_voices_spans_and_source_numbers() {
1711        use crate::model::score::{NotationSpanner, NotationSpannerKind, ScoreTemplate};
1712        use crate::{Duration, Note, Pitch, Step};
1713
1714        let mut engine = ScoreEngine::new();
1715        engine.score = Score::template(ScoreTemplate::Piano);
1716        let upper = &mut engine.score.parts[0].staves[0].measures[0];
1717        upper.voices[0] = vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
1718        upper.source_voice_numbers = [Some(1), None, None, None];
1719        let lower = &mut engine.score.parts[0].staves[1].measures[0];
1720        lower.voices[0] = vec![Note::new(Pitch::new(Step::E, 3), Duration::Whole)];
1721        lower.source_voice_numbers = [Some(5), None, None, None];
1722        engine.score.spanners.push(NotationSpanner {
1723            id: "implode-span".into(),
1724            kind: NotationSpannerKind::Slur,
1725            start: NoteAddr {
1726                part: 0,
1727                staff: 0,
1728                measure: 0,
1729                voice: 0,
1730                note: 0,
1731            },
1732            end: NoteAddr {
1733                part: 0,
1734                staff: 1,
1735                measure: 0,
1736                voice: 0,
1737                note: 0,
1738            },
1739            number: None,
1740            line_type: None,
1741            text: None,
1742            placement: None,
1743            ottava_size: None,
1744            ottava_type: None,
1745        });
1746        let before = serde_json::to_value(&engine.score).unwrap();
1747
1748        engine.implode_staves(0, vec![0, 1], 0, 0, 0).unwrap();
1749        let upper = &engine.score.parts[0].staves[0].measures[0];
1750        assert_eq!(upper.voices[0][0].pitches[0].step, Step::C);
1751        assert_eq!(upper.voices[1][0].pitches[0].step, Step::E);
1752        assert_eq!(upper.source_voice_numbers, [Some(1), Some(5), None, None]);
1753        assert!(
1754            engine.score.parts[0].staves[1].measures[0].voices[0]
1755                .iter()
1756                .all(|note| note.is_rest)
1757        );
1758        assert_eq!(engine.score.spanners[0].end.staff, 0);
1759        assert_eq!(engine.score.spanners[0].end.voice, 1);
1760
1761        engine.explode_voices(0, 0, vec![0, 1], 0, 0).unwrap();
1762        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1763        engine.undo().unwrap();
1764        assert_eq!(engine.score.spanners[0].end.staff, 0);
1765        assert_eq!(engine.score.spanners[0].end.voice, 1);
1766        engine.redo().unwrap();
1767        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1768    }
1769
1770    #[test]
1771    fn explode_rejects_an_occupied_destination_without_mutation() {
1772        use crate::model::score::ScoreTemplate;
1773        use crate::{Duration, ExplodeVoicesCmd, Note, Pitch, Step};
1774
1775        let mut engine = ScoreEngine::new();
1776        engine.score = Score::template(ScoreTemplate::Piano);
1777        let source = &mut engine.score.parts[0].staves[0].measures[0];
1778        source.voices[0] = vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
1779        source.voices[1] = vec![Note::new(Pitch::new(Step::E, 4), Duration::Whole)];
1780        engine.score.parts[0].staves[1].measures[0].voices[0] =
1781            vec![Note::new(Pitch::new(Step::G, 3), Duration::Whole)];
1782        let before = serde_json::to_value(&engine.score).unwrap();
1783
1784        assert!(
1785            engine
1786                .apply(Command::ExplodeVoices(ExplodeVoicesCmd {
1787                    part_index: 0,
1788                    source_staff: 0,
1789                    target_staves: vec![0, 1],
1790                    start_measure: 0,
1791                    end_measure: 0,
1792                }))
1793                .is_err()
1794        );
1795        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1796    }
1797
1798    #[test]
1799    fn explode_chord_pitches_distributes_pitches_and_is_undoable() {
1800        use crate::model::score::ScoreTemplate;
1801        use crate::{Duration, Note, Pitch, Step};
1802
1803        let mut engine = ScoreEngine::new();
1804        engine.score = Score::template(ScoreTemplate::Piano);
1805        let mut chord = Note::new(Pitch::new(Step::C, 5), Duration::Whole);
1806        chord.pitches.push(Pitch::new(Step::E, 4));
1807        let source_id = chord.id.clone();
1808        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
1809        let before = serde_json::to_value(&engine.score).unwrap();
1810
1811        engine
1812            .explode_chord_pitches(0, 0, vec![0, 1], 0, 0)
1813            .unwrap();
1814        let upper = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
1815        let lower = &engine.score.parts[0].staves[1].measures[0].voices[0][0];
1816        assert_eq!(upper.id, source_id);
1817        assert_eq!(upper.pitches, vec![Pitch::new(Step::C, 5)]);
1818        assert_eq!(lower.pitches, vec![Pitch::new(Step::E, 4)]);
1819        assert_ne!(lower.id, source_id);
1820
1821        engine.undo().unwrap();
1822        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1823        engine.redo().unwrap();
1824        assert_eq!(
1825            engine.score.parts[0].staves[1].measures[0].voices[0][0].pitches,
1826            vec![Pitch::new(Step::E, 4)]
1827        );
1828    }
1829
1830    #[test]
1831    fn explode_chord_pitches_rejects_occupied_destination_without_mutation() {
1832        use crate::model::score::ScoreTemplate;
1833        use crate::{Duration, Note, Pitch, Step};
1834
1835        let mut engine = ScoreEngine::new();
1836        engine.score = Score::template(ScoreTemplate::Piano);
1837        let mut chord = Note::new(Pitch::new(Step::C, 5), Duration::Whole);
1838        chord.pitches.push(Pitch::new(Step::E, 4));
1839        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
1840        engine.score.parts[0].staves[1].measures[0].voices[0] =
1841            vec![Note::new(Pitch::new(Step::G, 3), Duration::Whole)];
1842        let before = serde_json::to_value(&engine.score).unwrap();
1843
1844        assert!(
1845            engine
1846                .explode_chord_pitches(0, 0, vec![0, 1], 0, 0)
1847                .is_err()
1848        );
1849        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1850    }
1851
1852    #[test]
1853    fn scale_voice_range_is_undoable_and_rejects_measure_overflow() {
1854        use crate::{Duration, DurationScale, Note, Pitch, Step};
1855
1856        let mut engine = ScoreEngine::new();
1857        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![
1858            Note::new(Pitch::new(Step::C, 4), Duration::Half),
1859            Note::new(Pitch::new(Step::D, 4), Duration::Half),
1860        ];
1861        let before = serde_json::to_value(&engine.score).unwrap();
1862
1863        engine
1864            .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1865            .unwrap();
1866        let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1867        assert_eq!(voice.len(), 3);
1868        assert_eq!(voice[0].duration, Duration::Quarter);
1869        assert_eq!(voice[1].duration, Duration::Quarter);
1870        assert!(voice[2].is_rest);
1871        assert_eq!(voice[2].duration, Duration::Half);
1872        engine.undo().unwrap();
1873        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1874
1875        assert!(
1876            engine
1877                .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Double)
1878                .is_err()
1879        );
1880        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1881    }
1882
1883    #[test]
1884    fn scale_voice_range_preserves_tuplet_ratio_and_undoes() {
1885        use crate::{Duration, DurationScale, Note, Pitch, Step, TupletInfo};
1886
1887        let mut engine = ScoreEngine::new();
1888        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1889        note.tuplet = Some(TupletInfo {
1890            actual_notes: 3,
1891            normal_notes: 2,
1892        });
1893        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1894        let before = serde_json::to_value(&engine.score).unwrap();
1895        engine
1896            .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1897            .unwrap();
1898        let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1899        assert_eq!(voice[0].duration, Duration::Eighth);
1900        assert_eq!(
1901            voice[0].tuplet,
1902            Some(TupletInfo {
1903                actual_notes: 3,
1904                normal_notes: 2,
1905            })
1906        );
1907        assert!(voice.iter().skip(1).all(|note| note.is_rest));
1908        engine.undo().unwrap();
1909        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1910    }
1911
1912    #[test]
1913    fn scale_voice_range_rejects_mixed_tuplet_ratios_without_mutation() {
1914        use crate::{Duration, DurationScale, Note, Pitch, Step, TupletInfo};
1915
1916        let mut engine = ScoreEngine::new();
1917        let mut triplet = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1918        triplet.tuplet = Some(TupletInfo {
1919            actual_notes: 3,
1920            normal_notes: 2,
1921        });
1922        let mut quintuplet = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1923        quintuplet.tuplet = Some(TupletInfo {
1924            actual_notes: 5,
1925            normal_notes: 4,
1926        });
1927        engine.score.parts[0].staves[0].measures[0].voices[0] = vec![triplet, quintuplet];
1928        let before = serde_json::to_value(&engine.score).unwrap();
1929
1930        let error = engine
1931            .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1932            .expect_err("mixed tuplet ratios must be rejected");
1933        assert!(
1934            error
1935                .to_string()
1936                .contains("one shared tuplet ratio per voice")
1937        );
1938        assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1939    }
1940
1941    #[test]
1942    fn copy_range_paste_range_roundtrip() {
1943        use crate::model::duration::Duration;
1944        use crate::model::pitch::{Pitch, Step};
1945        use crate::model::score::{Note, NoteAddr};
1946        let mut engine = ScoreEngine::new();
1947        // Put a note in measure 0
1948        engine.score.parts[0].staves[0].measures[0].voices[0] =
1949            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1950        // Need measure 1: add one
1951        use crate::model::commands::AddMeasureCmd;
1952        engine
1953            .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
1954            .unwrap();
1955
1956        let start = NoteAddr {
1957            part: 0,
1958            staff: 0,
1959            measure: 0,
1960            voice: 0,
1961            note: 0,
1962        };
1963        let end = NoteAddr {
1964            part: 0,
1965            staff: 0,
1966            measure: 0,
1967            voice: 0,
1968            note: 0,
1969        };
1970        engine.copy_range(start, end).unwrap();
1971
1972        let target = NoteAddr {
1973            part: 0,
1974            staff: 0,
1975            measure: 1,
1976            voice: 0,
1977            note: 0,
1978        };
1979        engine.paste_range(target).unwrap();
1980
1981        let pasted = &engine.score.parts[0].staves[0].measures[1].voices[0];
1982        assert_eq!(pasted.len(), 1);
1983        assert_eq!(pasted[0].pitches[0].step, Step::C);
1984    }
1985
1986    #[test]
1987    fn paste_range_is_undoable() {
1988        use crate::model::commands::AddMeasureCmd;
1989        use crate::model::duration::Duration;
1990        use crate::model::pitch::{Pitch, Step};
1991        use crate::model::score::{Note, NoteAddr};
1992        let mut engine = ScoreEngine::new();
1993        engine.score.parts[0].staves[0].measures[0].voices[0] =
1994            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1995        engine
1996            .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
1997            .unwrap();
1998
1999        let start = NoteAddr {
2000            part: 0,
2001            staff: 0,
2002            measure: 0,
2003            voice: 0,
2004            note: 0,
2005        };
2006        let end = start.clone();
2007        engine.copy_range(start, end).unwrap();
2008        let target = NoteAddr {
2009            part: 0,
2010            staff: 0,
2011            measure: 1,
2012            voice: 0,
2013            note: 0,
2014        };
2015        engine.paste_range(target).unwrap();
2016
2017        // Undo should restore measure 1 to its pre-paste state
2018        engine.undo().unwrap();
2019        let restored = &engine.score.parts[0].staves[0].measures[1].voices[0];
2020        assert!(restored.iter().all(|n| n.is_rest));
2021    }
2022
2023    #[test]
2024    fn copy_range_mismatched_part_returns_error() {
2025        let engine = ScoreEngine::new();
2026        // Can't call copy_range mutably here since we need &mut, so test via a new engine
2027        let mut e = ScoreEngine::new();
2028        use crate::model::score::NoteAddr;
2029        let start = NoteAddr {
2030            part: 0,
2031            staff: 0,
2032            measure: 0,
2033            voice: 0,
2034            note: 0,
2035        };
2036        let end = NoteAddr {
2037            part: 1,
2038            staff: 0,
2039            measure: 0,
2040            voice: 0,
2041            note: 0,
2042        };
2043        assert!(e.copy_range(start, end).is_err());
2044        let _ = engine; // suppress unused warning
2045    }
2046
2047    #[test]
2048    fn export_history_roundtrip() {
2049        let mut engine = ScoreEngine::new();
2050        engine
2051            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2052            .unwrap();
2053        engine
2054            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2055            .unwrap();
2056        let history = engine.export_history();
2057        assert_eq!(history.commands.len(), 2);
2058        let restored = ScoreEngine::from_history(history).unwrap();
2059        assert_eq!(restored.score.settings.tempo_bpm, 180);
2060    }
2061
2062    #[test]
2063    fn history_base_check_rejects_stale_collaboration_snapshot() {
2064        let mut engine = ScoreEngine::new();
2065        engine
2066            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2067            .unwrap();
2068        let history = engine.export_history();
2069        let mut unrelated = history.initial_score.clone();
2070        unrelated.metadata.title = "unrelated".to_owned();
2071
2072        assert!(!history.base_matches(&unrelated));
2073        assert!(matches!(
2074            ScoreEngine::from_history_on_base(history, unrelated),
2075            Err(Error::HistoryBaseMismatch)
2076        ));
2077    }
2078
2079    #[test]
2080    fn history_base_check_allows_replay_on_matching_snapshot() {
2081        let mut engine = ScoreEngine::new();
2082        engine
2083            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2084            .unwrap();
2085        let history = engine.export_history();
2086        let base = history.initial_score.clone();
2087
2088        assert!(history.base_matches(&base));
2089        let restored = ScoreEngine::from_history_on_base(history, base).unwrap();
2090        assert_eq!(restored.score.settings.tempo_bpm, 160);
2091    }
2092
2093    #[test]
2094    fn history_compare_reports_prefix_and_divergence() {
2095        let base_score = ScoreEngine::new().score.clone();
2096        let mut base = ScoreEngine::new();
2097        base.replace_score(base_score.clone());
2098        let mut left = ScoreEngine::new();
2099        left.replace_score(base_score.clone());
2100        left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2101            .unwrap();
2102        let mut right = ScoreEngine::new();
2103        right.replace_score(base_score.clone());
2104        right
2105            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2106            .unwrap();
2107        right
2108            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2109            .unwrap();
2110
2111        assert_eq!(
2112            base.export_history().compare(&left.export_history()),
2113            HistoryRelation::LeftExtends {
2114                common_prefix_len: 0
2115            }
2116        );
2117        assert_eq!(
2118            left.export_history().compare(&right.export_history()),
2119            HistoryRelation::LeftExtends {
2120                common_prefix_len: 1
2121            }
2122        );
2123
2124        let mut diverged = ScoreEngine::new();
2125        diverged.replace_score(base_score);
2126        diverged
2127            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2128            .unwrap();
2129        assert_eq!(
2130            left.export_history().compare(&diverged.export_history()),
2131            HistoryRelation::Diverged {
2132                common_prefix_len: 0
2133            }
2134        );
2135    }
2136
2137    #[test]
2138    fn history_compare_reports_base_mismatch() {
2139        let left = ScoreEngine::new().export_history();
2140        let mut other = ScoreEngine::new();
2141        other.replace_score(Score::new("Other", 120, 4, 4, 1, 4));
2142        assert_eq!(
2143            left.compare(&other.export_history()),
2144            HistoryRelation::BaseMismatch
2145        );
2146    }
2147
2148    #[test]
2149    fn history_conflict_reports_branch_commands_and_remaining_lengths() {
2150        let base_score = ScoreEngine::new().score.clone();
2151        let mut left = ScoreEngine::new();
2152        left.replace_score(base_score.clone());
2153        left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2154            .unwrap();
2155        let mut right = ScoreEngine::new();
2156        right.replace_score(base_score);
2157        right
2158            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2159            .unwrap();
2160        right
2161            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2162            .unwrap();
2163
2164        assert_eq!(
2165            left.export_history().conflict(&right.export_history()),
2166            Some(HistoryConflict {
2167                common_prefix_len: 0,
2168                left_command_index: 0,
2169                right_command_index: 0,
2170                left_command_key: "SetTempo".to_owned(),
2171                right_command_key: "SetTempo".to_owned(),
2172                left_remaining_commands: 1,
2173                right_remaining_commands: 2,
2174            })
2175        );
2176    }
2177
2178    #[test]
2179    fn history_conflict_is_empty_for_safe_relationships() {
2180        let history = ScoreEngine::new().export_history();
2181        assert!(history.conflict(&history).is_none());
2182    }
2183
2184    #[test]
2185    fn append_history_extension_applies_only_remote_suffix() {
2186        let base_score = ScoreEngine::new().score.clone();
2187        let mut local = ScoreEngine::new();
2188        local.replace_score(base_score.clone());
2189        local
2190            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2191            .unwrap();
2192
2193        let mut remote = ScoreEngine::new();
2194        remote.replace_score(base_score);
2195        remote
2196            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2197            .unwrap();
2198        remote
2199            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2200            .unwrap();
2201
2202        let count = local
2203            .append_history_extension(&remote.export_history())
2204            .unwrap();
2205        assert_eq!(count, 1);
2206        assert_eq!(local.score.settings.tempo_bpm, 180);
2207        assert_eq!(
2208            local.export_history().compare(&remote.export_history()),
2209            HistoryRelation::Equivalent
2210        );
2211    }
2212
2213    #[test]
2214    fn append_history_extension_rejects_divergence_without_mutation() {
2215        let base_score = ScoreEngine::new().score.clone();
2216        let mut local = ScoreEngine::new();
2217        local.replace_score(base_score.clone());
2218        local
2219            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2220            .unwrap();
2221        let before = local.score.settings.tempo_bpm;
2222
2223        let mut remote = ScoreEngine::new();
2224        remote.replace_score(base_score);
2225        remote
2226            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2227            .unwrap();
2228
2229        assert!(matches!(
2230            local.append_history_extension(&remote.export_history()),
2231            Err(Error::HistoryNotAppendable)
2232        ));
2233        assert_eq!(local.score.settings.tempo_bpm, before);
2234    }
2235
2236    #[test]
2237    fn export_history_empty_gives_initial_state() {
2238        let engine = ScoreEngine::new();
2239        let history = engine.export_history();
2240        assert!(history.commands.is_empty());
2241        let restored = ScoreEngine::from_history(history).unwrap();
2242        assert_eq!(restored.score.settings.tempo_bpm, 120);
2243    }
2244
2245    #[test]
2246    fn replace_score_then_export_history() {
2247        let mut engine = ScoreEngine::new();
2248        let s = Score::new("Custom", 90, 3, 4, 2, 4);
2249        engine.replace_score(s);
2250        engine
2251            .apply(Command::SetTempo(SetTempoCmd { bpm: 60 }))
2252            .unwrap();
2253        let history = engine.export_history();
2254        assert_eq!(history.initial_score.settings.tempo_bpm, 90);
2255        assert_eq!(history.commands.len(), 1);
2256        let restored = ScoreEngine::from_history(history).unwrap();
2257        assert_eq!(restored.score.settings.tempo_bpm, 60);
2258    }
2259
2260    #[test]
2261    fn new_score_command_replaces_score() {
2262        let mut engine = ScoreEngine::new();
2263        engine
2264            .apply(Command::NewScore(NewScoreCmd {
2265                title: "Sonata".into(),
2266                composer: "Bach".into(),
2267                tempo_bpm: 80,
2268                time_numerator: 3,
2269                time_denominator: 4,
2270                key_fifths: -1,
2271                measure_count: 12,
2272                template: None,
2273            }))
2274            .unwrap();
2275        assert_eq!(engine.score.metadata.title, "Sonata");
2276        assert_eq!(engine.score.measure_count(), 12);
2277    }
2278
2279    #[test]
2280    fn respell_score_to_key_uses_key_signature() {
2281        use crate::model::commands::{AddNoteCmd, RespellScoreToKeyCmd};
2282        use crate::model::notation::KeySignature;
2283        use crate::model::pitch::Step;
2284        let mut engine = ScoreEngine::new();
2285        // Set Bb major (2 flats, fifths = -2) → prefer_flat = true
2286        engine.score.settings.key_signature = KeySignature {
2287            fifths: -2,
2288            mode: "major".to_string(),
2289        };
2290        engine
2291            .apply(Command::AddNote(AddNoteCmd {
2292                part_index: 0,
2293                staff_index: 0,
2294                measure_index: 0,
2295                voice: 0,
2296                position: 0,
2297                pitch: Some(crate::model::pitch::Pitch::with_alter(Step::C, 4, 1)), // C#4
2298                duration: crate::model::duration::Duration::Quarter,
2299                dot_count: 0,
2300                is_rest: false,
2301                tuplet: None,
2302            }))
2303            .unwrap();
2304        engine
2305            .apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
2306            .unwrap();
2307        let pitch = &engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
2308        assert_eq!(pitch.step, Step::D);
2309        assert_eq!(pitch.alter, -1); // Db4
2310    }
2311
2312    #[test]
2313    fn begin_end_slur_creates_slur() {
2314        use crate::model::commands::AddNoteCmd;
2315        use crate::model::duration::Duration;
2316        use crate::model::pitch::{Pitch, Step};
2317        let mut engine = ScoreEngine::new();
2318        engine
2319            .apply(Command::AddNote(AddNoteCmd {
2320                part_index: 0,
2321                staff_index: 0,
2322                measure_index: 0,
2323                voice: 0,
2324                position: 0,
2325                pitch: Some(Pitch::new(Step::C, 4)),
2326                duration: Duration::Quarter,
2327                dot_count: 0,
2328                is_rest: false,
2329                tuplet: None,
2330            }))
2331            .unwrap();
2332        engine
2333            .apply(Command::AddNote(AddNoteCmd {
2334                part_index: 0,
2335                staff_index: 0,
2336                measure_index: 0,
2337                voice: 0,
2338                position: 1,
2339                pitch: Some(Pitch::new(Step::D, 4)),
2340                duration: Duration::Quarter,
2341                dot_count: 0,
2342                is_rest: false,
2343                tuplet: None,
2344            }))
2345            .unwrap();
2346        let start = NoteAddr {
2347            part: 0,
2348            staff: 0,
2349            measure: 0,
2350            voice: 0,
2351            note: 0,
2352        };
2353        let end = NoteAddr {
2354            part: 0,
2355            staff: 0,
2356            measure: 0,
2357            voice: 0,
2358            note: 1,
2359        };
2360        engine.begin_slur(start).unwrap();
2361        engine.end_slur(end).unwrap();
2362        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
2363        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
2364    }
2365
2366    #[test]
2367    fn end_slur_without_begin_returns_error() {
2368        let mut engine = ScoreEngine::new();
2369        let end = NoteAddr {
2370            part: 0,
2371            staff: 0,
2372            measure: 0,
2373            voice: 0,
2374            note: 0,
2375        };
2376        let result = engine.end_slur(end);
2377        assert!(result.is_err());
2378    }
2379
2380    // ── SetStem ───────────────────────────────────────────────────────────────
2381
2382    #[test]
2383    fn set_stem_sets_and_clears() {
2384        use crate::model::commands::AddNoteCmd;
2385        use crate::model::duration::Duration;
2386        use crate::model::pitch::{Pitch, Step};
2387        let mut engine = ScoreEngine::new();
2388        engine
2389            .apply(Command::AddNote(AddNoteCmd {
2390                part_index: 0,
2391                staff_index: 0,
2392                measure_index: 0,
2393                voice: 0,
2394                position: 0,
2395                pitch: Some(Pitch::new(Step::C, 4)),
2396                duration: Duration::Quarter,
2397                dot_count: 0,
2398                is_rest: false,
2399                tuplet: None,
2400            }))
2401            .unwrap();
2402        let addr = NoteAddr {
2403            part: 0,
2404            staff: 0,
2405            measure: 0,
2406            voice: 0,
2407            note: 0,
2408        };
2409        engine.set_stem(addr.clone(), Some(true)).unwrap();
2410        assert_eq!(
2411            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2412            Some(true)
2413        );
2414        engine.set_stem(addr.clone(), Some(false)).unwrap();
2415        assert_eq!(
2416            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2417            Some(false)
2418        );
2419        engine.set_stem(addr, None).unwrap();
2420        assert_eq!(
2421            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2422            None
2423        );
2424    }
2425
2426    #[test]
2427    fn set_stem_is_undoable() {
2428        use crate::model::commands::AddNoteCmd;
2429        use crate::model::duration::Duration;
2430        use crate::model::pitch::{Pitch, Step};
2431        let mut engine = ScoreEngine::new();
2432        engine
2433            .apply(Command::AddNote(AddNoteCmd {
2434                part_index: 0,
2435                staff_index: 0,
2436                measure_index: 0,
2437                voice: 0,
2438                position: 0,
2439                pitch: Some(Pitch::new(Step::C, 4)),
2440                duration: Duration::Quarter,
2441                dot_count: 0,
2442                is_rest: false,
2443                tuplet: None,
2444            }))
2445            .unwrap();
2446        let addr = NoteAddr {
2447            part: 0,
2448            staff: 0,
2449            measure: 0,
2450            voice: 0,
2451            note: 0,
2452        };
2453        engine.set_stem(addr, Some(true)).unwrap();
2454        engine.undo().unwrap();
2455        assert_eq!(
2456            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2457            None
2458        );
2459    }
2460
2461    #[test]
2462    fn set_arpeggio_is_undoable() {
2463        use crate::model::commands::AddNoteCmd;
2464        use crate::model::duration::Duration;
2465        use crate::model::pitch::{Pitch, Step};
2466        let mut engine = ScoreEngine::new();
2467        engine
2468            .apply(Command::AddNote(AddNoteCmd {
2469                part_index: 0,
2470                staff_index: 0,
2471                measure_index: 0,
2472                voice: 0,
2473                position: 0,
2474                pitch: Some(Pitch::new(Step::C, 4)),
2475                duration: Duration::Quarter,
2476                dot_count: 0,
2477                is_rest: false,
2478                tuplet: None,
2479            }))
2480            .unwrap();
2481        let addr = NoteAddr {
2482            part: 0,
2483            staff: 0,
2484            measure: 0,
2485            voice: 0,
2486            note: 0,
2487        };
2488        engine.set_arpeggio(addr.clone(), Some(true)).unwrap();
2489        assert_eq!(
2490            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2491            Some(true)
2492        );
2493        engine.undo().unwrap();
2494        assert_eq!(
2495            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2496            None
2497        );
2498        engine.redo().unwrap();
2499        assert_eq!(
2500            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2501            Some(true)
2502        );
2503    }
2504
2505    // ── command_key ───────────────────────────────────────────────────────────
2506
2507    #[test]
2508    fn undo_key_returns_key_string() {
2509        let mut engine = ScoreEngine::new();
2510        engine
2511            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2512            .unwrap();
2513        assert_eq!(engine.undo_key(), Some("SetTempo".to_string()));
2514        assert!(engine.redo_key().is_none());
2515    }
2516
2517    #[test]
2518    fn redo_key_after_undo() {
2519        let mut engine = ScoreEngine::new();
2520        engine
2521            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2522            .unwrap();
2523        engine.undo().unwrap();
2524        assert!(engine.undo_key().is_none());
2525        assert_eq!(engine.redo_key(), Some("SetTempo".to_string()));
2526    }
2527
2528    // ── batch_apply_labeled ───────────────────────────────────────────────────
2529
2530    #[test]
2531    fn batch_apply_labeled_sets_undo_key() {
2532        let mut engine = ScoreEngine::new();
2533        engine
2534            .batch_apply_labeled(vec![Command::SetTempo(SetTempoCmd { bpm: 140 })], "ApplyAI")
2535            .unwrap();
2536        assert_eq!(engine.undo_key(), Some("ApplyAI".to_string()));
2537    }
2538
2539    #[test]
2540    fn batch_apply_labeled_empty_is_noop() {
2541        let mut engine = ScoreEngine::new();
2542        engine.batch_apply_labeled(vec![], "ApplyAI").unwrap();
2543        assert!(engine.undo_key().is_none());
2544    }
2545}