Skip to main content

acorde_core/model/
engine.rs

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