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