Skip to main content

acorde_core/model/
engine.rs

1use super::change_hint::{ChangeHint, ChangeScope};
2use super::commands::{
3    AddStaffCmd, Command, CommandStack, DeleteStaffCmd, PasteRangeCmd, PasteVoiceCmd,
4    RespellScoreCmd, RespellScoreToKeyCmd, SetArpeggioCmd, SetCueCmd, SetDurationCmd,
5    SetInstrumentIdCmd, SetNoteHeadCmd, SetPartGroupCmd, SetStemCmd, SetTupletCmd, SetUnpitchedCmd,
6    ToggleSlurCmd, ToggleTrillLineCmd, command_hint, command_key,
7};
8use super::duration::Duration;
9use super::notation::{Clef, NoteHead, TupletInfo};
10use super::score::PartGroup;
11use super::score::{Note, NoteAddr, Score};
12use crate::Error;
13use serde::{Deserialize, Serialize};
14
15/// Serialisable snapshot of a [`ScoreEngine`]'s command history for crash recovery or replay.
16///
17/// `initial_score` is the state of the score before any commands were applied (i.e. the base
18/// loaded via [`ScoreEngine::replace_score`] or the built-in default).
19/// `commands` are the commands applied after that, in execution order.
20///
21/// Round-trip: `ScoreEngine::from_history(engine.export_history())` produces an engine whose
22/// score and version match the original.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct EngineHistory {
25    pub initial_score: Score,
26    pub commands: Vec<Command>,
27}
28
29/// Deterministic relationship between two command logs sharing a collaboration base.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub enum HistoryRelation {
32    Equivalent,
33    LeftExtends { common_prefix_len: usize },
34    RightExtends { common_prefix_len: usize },
35    Diverged { common_prefix_len: usize },
36    BaseMismatch,
37}
38
39/// Explainable details for a divergent pair of command logs.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct HistoryConflict {
42    pub common_prefix_len: usize,
43    pub left_command_index: usize,
44    pub right_command_index: usize,
45    pub left_command_key: String,
46    pub right_command_key: String,
47    pub left_remaining_commands: usize,
48    pub right_remaining_commands: usize,
49}
50
51#[derive(Debug, Clone)]
52struct RangeClipboard {
53    voice: usize,
54    measures: Vec<Vec<Note>>,
55}
56
57pub struct ScoreEngine {
58    pub score: Score,
59    pub commands: CommandStack,
60    pub version: u64,
61    pub clipboard: Option<Vec<Note>>,
62    range_clipboard: Option<RangeClipboard>,
63    initial_score: Score,
64    pending_slur_start: Option<NoteAddr>,
65}
66
67impl Default for ScoreEngine {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl ScoreEngine {
74    pub fn new() -> Self {
75        let mut score = Score::default();
76        for part in &mut score.parts {
77            for staff in &mut part.staves {
78                for (i, m) in staff.measures.iter_mut().enumerate() {
79                    m.number = i as u32 + 1;
80                }
81            }
82        }
83        let initial_score = score.clone();
84        Self {
85            score,
86            commands: CommandStack::new(200),
87            version: 0,
88            clipboard: None,
89            range_clipboard: None,
90            initial_score,
91            pending_slur_start: None,
92        }
93    }
94
95    pub fn apply(&mut self, cmd: Command) -> Result<ChangeHint, Error> {
96        let hint = command_hint(&cmd);
97        self.commands.execute(cmd, &mut self.score)?;
98        self.version += 1;
99        Ok(hint)
100    }
101
102    pub fn undo(&mut self) -> Result<ChangeHint, Error> {
103        let hint = self.commands.undo(&mut self.score)?;
104        self.version += 1;
105        Ok(hint)
106    }
107
108    pub fn redo(&mut self) -> Result<ChangeHint, Error> {
109        let hint = self.commands.redo(&mut self.score)?;
110        self.version += 1;
111        Ok(hint)
112    }
113
114    /// Apply multiple commands as a single undo entry.
115    pub fn batch_apply(&mut self, cmds: Vec<Command>) -> Result<ChangeHint, Error> {
116        if cmds.is_empty() {
117            return Ok(ChangeHint {
118                scope: ChangeScope::Global,
119                layout_dirty: false,
120                playback_dirty: false,
121            });
122        }
123        let mut hint = command_hint(&cmds[0]);
124        for cmd in cmds.iter().skip(1) {
125            hint = hint.merge(command_hint(cmd));
126        }
127        self.commands.batch_execute(cmds, &mut self.score)?;
128        self.version += 1;
129        Ok(hint)
130    }
131
132    /// Apply a batch of commands as a single undo entry with an explicit label.
133    ///
134    /// The `label` appears as the [`command_key`] in undo/redo UI (e.g. `"ApplyAI"`).
135    pub fn batch_apply_labeled(
136        &mut self,
137        cmds: Vec<Command>,
138        label: &str,
139    ) -> Result<ChangeHint, Error> {
140        if cmds.is_empty() {
141            return Ok(ChangeHint {
142                scope: ChangeScope::Global,
143                layout_dirty: false,
144                playback_dirty: false,
145            });
146        }
147        let mut hint = command_hint(&cmds[0]);
148        for cmd in cmds.iter().skip(1) {
149            hint = hint.merge(command_hint(cmd));
150        }
151        self.commands
152            .batch_execute_labeled(cmds, label.to_string(), &mut self.score)?;
153        self.version += 1;
154        Ok(hint)
155    }
156
157    /// Label of the next undoable command (for "Undo: Add Note" menu items).
158    pub fn undo_label(&self) -> Option<String> {
159        self.commands.undo_label()
160    }
161
162    /// Label of the next redoable command (for "Redo: Add Note" menu items).
163    pub fn redo_label(&self) -> Option<String> {
164        self.commands.redo_label()
165    }
166
167    /// i18n key of the next undoable command (e.g. `"SetTempo"`).
168    pub fn undo_key(&self) -> Option<String> {
169        self.commands.undo_key()
170    }
171
172    /// i18n key of the next redoable command.
173    pub fn redo_key(&self) -> Option<String> {
174        self.commands.redo_key()
175    }
176
177    pub fn replace_score(&mut self, score: Score) {
178        self.initial_score = score.clone();
179        self.score = score;
180        self.version += 1;
181        self.commands = CommandStack::new(200);
182    }
183
184    /// Export the command history for serialization (crash recovery, AI replay).
185    ///
186    /// The returned [`EngineHistory`] can be stored as JSON and later restored with
187    /// [`ScoreEngine::from_history`].
188    pub fn export_history(&self) -> EngineHistory {
189        EngineHistory {
190            initial_score: self.initial_score.clone(),
191            commands: self.commands.history_commands(),
192        }
193    }
194
195    /// Reconstruct an engine from a previously exported [`EngineHistory`].
196    ///
197    /// Replays all commands against `history.initial_score` in order.
198    /// Returns an error if any command fails (e.g. index out of bounds due to stale data).
199    pub fn from_history(history: EngineHistory) -> Result<Self, Error> {
200        let mut engine = ScoreEngine::new();
201        engine.replace_score(history.initial_score);
202        for cmd in history.commands {
203            engine.apply(cmd)?;
204        }
205        Ok(engine)
206    }
207
208    /// Reconstruct history only when its declared base matches the supplied collaboration base.
209    ///
210    /// This prevents a stale command log from being replayed onto an unrelated score. Callers
211    /// can use [`EngineHistory::base_matches`] for a non-mutating preflight check first.
212    pub fn from_history_on_base(history: EngineHistory, base: Score) -> Result<Self, Error> {
213        if !history.base_matches(&base) {
214            return Err(Error::HistoryBaseMismatch);
215        }
216        Self::from_history(history)
217    }
218
219    /// Append a remote history only when it strictly extends this engine's command log.
220    ///
221    /// The complete incoming history is replayed into a candidate engine before replacement.
222    /// Diverged or base-mismatched histories, or replay failures, leave this engine unchanged.
223    pub fn append_history_extension(&mut self, incoming: &EngineHistory) -> Result<usize, Error> {
224        let local = self.export_history();
225        let common_prefix_len = match local.compare(incoming) {
226            HistoryRelation::LeftExtends { common_prefix_len } => common_prefix_len,
227            _ => return Err(Error::HistoryNotAppendable),
228        };
229        let count = incoming.commands.len() - common_prefix_len;
230        if count == 0 {
231            return Ok(0);
232        }
233        let candidate = Self::from_history(incoming.clone())?;
234        *self = candidate;
235        Ok(count)
236    }
237
238    pub fn copy_voice(
239        &mut self,
240        part_index: usize,
241        staff_index: usize,
242        measure_index: usize,
243        voice_index: usize,
244    ) -> Result<(), Error> {
245        let voice = self
246            .score
247            .parts
248            .get(part_index)
249            .ok_or(Error::PartNotFound(part_index))?
250            .staves
251            .get(staff_index)
252            .ok_or(Error::StaffNotFound(staff_index))?
253            .measures
254            .get(measure_index)
255            .ok_or(Error::MeasureNotFound(measure_index))?
256            .voices
257            .get(voice_index)
258            .ok_or(Error::VoiceOutOfRange(voice_index))?;
259        self.clipboard = Some(voice.clone());
260        Ok(())
261    }
262
263    pub fn paste_voice(
264        &mut self,
265        part_index: usize,
266        staff_index: usize,
267        measure_index: usize,
268        voice_index: usize,
269    ) -> Result<ChangeHint, Error> {
270        let notes = self.clipboard.clone().ok_or(Error::ClipboardEmpty)?;
271        self.apply(Command::PasteVoice(PasteVoiceCmd {
272            part_index,
273            staff_index,
274            measure_index,
275            voice_index,
276            notes,
277        }))
278    }
279
280    /// Copy a range of measures from a single voice into the range clipboard.
281    ///
282    /// The range is inclusive: all measures from `start.measure` to `end.measure`.
283    /// `start` and `end` must share the same `part`, `staff`, and `voice`.
284    pub fn copy_range(&mut self, start: NoteAddr, end: NoteAddr) -> Result<(), Error> {
285        if start.part != end.part || start.staff != end.staff || start.voice != end.voice {
286            return Err(Error::InvalidCommand(
287                "copy_range: start and end must share the same part, staff, and voice".into(),
288            ));
289        }
290        let from = start.measure.min(end.measure);
291        let to = start.measure.max(end.measure);
292        let staff = self
293            .score
294            .parts
295            .get(start.part)
296            .ok_or(Error::PartNotFound(start.part))?
297            .staves
298            .get(start.staff)
299            .ok_or(Error::StaffNotFound(start.staff))?;
300        if start.voice >= 4 {
301            return Err(Error::VoiceOutOfRange(start.voice));
302        }
303        let mut measures = Vec::new();
304        for mi in from..=to {
305            let m = staff.measures.get(mi).ok_or(Error::MeasureNotFound(mi))?;
306            measures.push(m.voices[start.voice].clone());
307        }
308        self.range_clipboard = Some(RangeClipboard {
309            voice: start.voice,
310            measures,
311        });
312        Ok(())
313    }
314
315    /// Paste the range clipboard starting at `target`, creating an undo-able command.
316    ///
317    /// The voice index from the original `copy_range` call is used; `target.voice` is ignored.
318    pub fn paste_range(&mut self, target: NoteAddr) -> Result<ChangeHint, Error> {
319        let rc = self.range_clipboard.clone().ok_or(Error::ClipboardEmpty)?;
320        self.apply(Command::PasteRange(PasteRangeCmd {
321            part_index: target.part,
322            staff_index: target.staff,
323            voice_index: rc.voice,
324            target_measure: target.measure,
325            measures: rc.measures,
326        }))
327    }
328
329    /// Toggle the slur between two notes (undo-able).
330    pub fn toggle_slur(&mut self, start: NoteAddr, end: NoteAddr) -> Result<ChangeHint, Error> {
331        self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
332    }
333
334    /// Add a staff to a part (undo-able).
335    pub fn add_staff(&mut self, part_index: usize, clef: Clef) -> Result<ChangeHint, Error> {
336        self.apply(Command::AddStaff(AddStaffCmd { part_index, clef }))
337    }
338
339    /// Remove a staff from a part (undo-able). Fails if it is the last remaining staff.
340    pub fn delete_staff(
341        &mut self,
342        part_index: usize,
343        staff_index: usize,
344    ) -> Result<ChangeHint, Error> {
345        self.apply(Command::DeleteStaff(DeleteStaffCmd {
346            part_index,
347            staff_index,
348        }))
349    }
350
351    /// Set or clear the stem direction on an existing note (undo-able).
352    ///
353    /// `stem_up`: `None` = auto, `Some(true)` = up, `Some(false)` = down.
354    pub fn set_stem(&mut self, addr: NoteAddr, stem_up: Option<bool>) -> Result<ChangeHint, Error> {
355        self.apply(Command::SetStem(SetStemCmd {
356            part_index: addr.part,
357            staff_index: addr.staff,
358            measure_index: addr.measure,
359            voice_index: addr.voice,
360            note_index: addr.note,
361            stem_up,
362        }))
363    }
364
365    /// Set the duration and dot count on an existing note (undo-able).
366    pub fn set_duration(
367        &mut self,
368        addr: NoteAddr,
369        duration: Duration,
370        dot_count: u8,
371    ) -> Result<ChangeHint, Error> {
372        self.apply(Command::SetDuration(SetDurationCmd {
373            part_index: addr.part,
374            staff_index: addr.staff,
375            measure_index: addr.measure,
376            voice: addr.voice,
377            note_index: addr.note,
378            duration,
379            dot_count,
380        }))
381    }
382
383    /// Set or clear the arpeggio direction on an existing note (undo-able).
384    pub fn set_arpeggio(
385        &mut self,
386        addr: NoteAddr,
387        direction: Option<bool>,
388    ) -> Result<ChangeHint, Error> {
389        self.apply(Command::SetArpeggio(SetArpeggioCmd {
390            part_index: addr.part,
391            staff_index: addr.staff,
392            measure_index: addr.measure,
393            voice_index: addr.voice,
394            note_index: addr.note,
395            direction,
396        }))
397    }
398
399    /// Set the note head shape on an existing note (undo-able).
400    pub fn set_note_head(
401        &mut self,
402        addr: NoteAddr,
403        note_head: NoteHead,
404    ) -> Result<ChangeHint, Error> {
405        self.apply(Command::SetNoteHead(SetNoteHeadCmd {
406            part_index: addr.part,
407            staff_index: addr.staff,
408            measure_index: addr.measure,
409            voice: addr.voice,
410            note_index: addr.note,
411            note_head,
412        }))
413    }
414
415    /// Add or replace a part group (undo-able). Pass `None` to clear all groups.
416    pub fn set_part_group(&mut self, group: Option<PartGroup>) -> Result<ChangeHint, Error> {
417        self.apply(Command::SetPartGroup(SetPartGroupCmd { group }))
418    }
419
420    /// Toggle a trill line span between two notes (undo-able).
421    pub fn toggle_trill_line(
422        &mut self,
423        start: NoteAddr,
424        end: NoteAddr,
425    ) -> Result<ChangeHint, Error> {
426        self.apply(Command::ToggleTrillLine(ToggleTrillLineCmd { start, end }))
427    }
428
429    /// Set or clear the cue flag on a note (undo-able). Cue notes have zero beats.
430    pub fn set_cue(&mut self, addr: NoteAddr, is_cue: bool) -> Result<ChangeHint, Error> {
431        self.apply(Command::SetCue(SetCueCmd {
432            part_index: addr.part,
433            staff_index: addr.staff,
434            measure_index: addr.measure,
435            voice: addr.voice,
436            note_index: addr.note,
437            is_cue,
438        }))
439    }
440
441    /// Set or clear the unpitched flag while retaining display placement.
442    pub fn set_unpitched(
443        &mut self,
444        addr: NoteAddr,
445        is_unpitched: bool,
446    ) -> Result<ChangeHint, Error> {
447        self.apply(Command::SetUnpitched(SetUnpitchedCmd {
448            part_index: addr.part,
449            staff_index: addr.staff,
450            measure_index: addr.measure,
451            voice: addr.voice,
452            note_index: addr.note,
453            is_unpitched,
454        }))
455    }
456
457    /// Set or clear a source instrument identifier attached to a note.
458    pub fn set_instrument_id(
459        &mut self,
460        addr: NoteAddr,
461        instrument_id: Option<String>,
462    ) -> Result<ChangeHint, Error> {
463        self.apply(Command::SetInstrumentId(SetInstrumentIdCmd {
464            part_index: addr.part,
465            staff_index: addr.staff,
466            measure_index: addr.measure,
467            voice: addr.voice,
468            note_index: addr.note,
469            instrument_id,
470        }))
471    }
472
473    /// Set or clear the tuplet on an existing note (undo-able).
474    pub fn set_tuplet(
475        &mut self,
476        addr: NoteAddr,
477        tuplet: Option<TupletInfo>,
478    ) -> Result<ChangeHint, Error> {
479        self.apply(Command::SetTuplet(SetTupletCmd {
480            part_index: addr.part,
481            staff_index: addr.staff,
482            measure_index: addr.measure,
483            voice_index: addr.voice,
484            note_index: addr.note,
485            tuplet,
486        }))
487    }
488
489    /// Respell all pitches in the score (undo-able).
490    pub fn respell_score(&mut self, prefer_flat: bool) -> Result<ChangeHint, Error> {
491        self.apply(Command::RespellScore(RespellScoreCmd { prefer_flat }))
492    }
493
494    /// Respell all pitches to match the score's key signature (undo-able).
495    pub fn respell_score_to_key(&mut self) -> Result<ChangeHint, Error> {
496        self.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
497    }
498
499    /// Begin a two-step slur: record `start` and wait for [`end_slur`](Self::end_slur).
500    ///
501    /// Returns an error if `start` does not point to a valid note.
502    pub fn begin_slur(&mut self, start: NoteAddr) -> Result<(), Error> {
503        self.score
504            .parts
505            .get(start.part)
506            .ok_or(Error::PartNotFound(start.part))?
507            .staves
508            .get(start.staff)
509            .ok_or(Error::StaffNotFound(start.staff))?
510            .measures
511            .get(start.measure)
512            .ok_or(Error::MeasureNotFound(start.measure))?
513            .voices
514            .get(start.voice)
515            .ok_or(Error::VoiceOutOfRange(start.voice))?
516            .get(start.note)
517            .ok_or(Error::NoteNotFound(start.note))?;
518        self.pending_slur_start = Some(start);
519        Ok(())
520    }
521
522    /// Complete the slur started by [`begin_slur`](Self::begin_slur) (undo-able).
523    ///
524    /// Returns `Error::InvalidCommand` if `begin_slur` has not been called.
525    pub fn end_slur(&mut self, end: NoteAddr) -> Result<ChangeHint, Error> {
526        let start = self
527            .pending_slur_start
528            .take()
529            .ok_or_else(|| Error::InvalidCommand("no slur in progress".to_string()))?;
530        self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
531    }
532}
533
534impl EngineHistory {
535    /// Return whether this history was recorded from the supplied collaboration base.
536    pub fn base_matches(&self, base: &Score) -> bool {
537        match (
538            serde_json::to_vec(&self.initial_score),
539            serde_json::to_vec(base),
540        ) {
541            (Ok(expected), Ok(actual)) => expected == actual,
542            _ => false,
543        }
544    }
545
546    /// Compare two logs without applying commands or mutating either history.
547    pub fn compare(&self, other: &Self) -> HistoryRelation {
548        if !self.base_matches(&other.initial_score) {
549            return HistoryRelation::BaseMismatch;
550        }
551        let common_prefix_len = self
552            .commands
553            .iter()
554            .zip(&other.commands)
555            .take_while(|(left, right)| command_bytes(left) == command_bytes(right))
556            .count();
557        match (self.commands.len(), other.commands.len()) {
558            (left, right) if left == right && common_prefix_len == left => {
559                HistoryRelation::Equivalent
560            }
561            (left, _) if common_prefix_len == left => {
562                HistoryRelation::LeftExtends { common_prefix_len }
563            }
564            (_, right) if common_prefix_len == right => {
565                HistoryRelation::RightExtends { common_prefix_len }
566            }
567            _ => HistoryRelation::Diverged { common_prefix_len },
568        }
569    }
570
571    /// Return explainable details when the two same-base logs diverge.
572    pub fn conflict(&self, other: &Self) -> Option<HistoryConflict> {
573        let common_prefix_len = match self.compare(other) {
574            HistoryRelation::Diverged { common_prefix_len } => common_prefix_len,
575            _ => return None,
576        };
577        let left = self.commands.get(common_prefix_len)?;
578        let right = other.commands.get(common_prefix_len)?;
579        Some(HistoryConflict {
580            common_prefix_len,
581            left_command_index: common_prefix_len,
582            right_command_index: common_prefix_len,
583            left_command_key: command_key(left),
584            right_command_key: command_key(right),
585            left_remaining_commands: self.commands.len() - common_prefix_len,
586            right_remaining_commands: other.commands.len() - common_prefix_len,
587        })
588    }
589}
590
591fn command_bytes(command: &Command) -> Option<Vec<u8>> {
592    serde_json::to_vec(command).ok()
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use crate::model::commands::{NewScoreCmd, SetTempoCmd};
599
600    #[test]
601    fn new_engine_has_default_score() {
602        let engine = ScoreEngine::new();
603        assert_eq!(engine.version, 0);
604        assert_eq!(engine.score.parts.len(), 1);
605    }
606
607    #[test]
608    fn apply_increments_version() {
609        let mut engine = ScoreEngine::new();
610        engine
611            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
612            .unwrap();
613        assert_eq!(engine.version, 1);
614    }
615
616    #[test]
617    fn undo_redo_cycle() {
618        let mut engine = ScoreEngine::new();
619        engine
620            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
621            .unwrap();
622        let after_apply = engine.version;
623        engine.undo().unwrap();
624        assert_eq!(engine.score.settings.tempo_bpm, 120);
625        engine.redo().unwrap();
626        assert_eq!(engine.score.settings.tempo_bpm, 140);
627        assert!(engine.version > after_apply);
628    }
629
630    #[test]
631    fn replace_score_clears_history() {
632        let mut engine = ScoreEngine::new();
633        engine
634            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
635            .unwrap();
636        let new_score = Score::new("New", 90, 3, 4, 2, 8);
637        engine.replace_score(new_score);
638        assert!(engine.undo().is_err());
639        assert_eq!(engine.score.settings.tempo_bpm, 90);
640    }
641
642    #[test]
643    fn copy_paste_voice_copies_notes() {
644        use crate::model::duration::Duration;
645        use crate::model::pitch::{Pitch, Step};
646        use crate::model::score::Note;
647        let mut engine = ScoreEngine::new();
648        engine.score.parts[0].staves[0].measures[0].voices[0] =
649            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
650        engine.copy_voice(0, 0, 0, 0).unwrap();
651        engine.paste_voice(0, 0, 0, 1).unwrap();
652        let pasted = &engine.score.parts[0].staves[0].measures[0].voices[1];
653        assert_eq!(pasted.len(), 1);
654        assert_eq!(pasted[0].pitches[0].step, Step::C);
655    }
656
657    #[test]
658    fn paste_voice_undo_restores_original() {
659        use crate::model::duration::Duration;
660        use crate::model::pitch::{Pitch, Step};
661        use crate::model::score::Note;
662        let mut engine = ScoreEngine::new();
663        engine.score.parts[0].staves[0].measures[0].voices[0] =
664            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
665        engine.copy_voice(0, 0, 0, 0).unwrap();
666        engine.paste_voice(0, 0, 0, 1).unwrap();
667        engine.undo().unwrap();
668        assert!(engine.score.parts[0].staves[0].measures[0].voices[1].is_empty());
669    }
670
671    #[test]
672    fn unpitched_flag_is_undoable_and_redoable() {
673        let mut engine = ScoreEngine::new();
674        let addr = NoteAddr {
675            part: 0,
676            staff: 0,
677            measure: 0,
678            voice: 0,
679            note: 0,
680        };
681        engine.set_unpitched(addr.clone(), true).unwrap();
682        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
683        engine.undo().unwrap();
684        assert!(!engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
685        engine.redo().unwrap();
686        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
687        engine
688            .set_instrument_id(addr, Some("P1-I2".to_string()))
689            .unwrap();
690        assert_eq!(
691            engine.score.parts[0].staves[0].measures[0].voices[0][0]
692                .instrument_id
693                .as_deref(),
694            Some("P1-I2")
695        );
696        for command in [
697            Command::SetUnpitched(super::super::commands::SetUnpitchedCmd {
698                part_index: 0,
699                staff_index: 0,
700                measure_index: 0,
701                voice: 0,
702                note_index: 0,
703                is_unpitched: false,
704            }),
705            Command::SetInstrumentId(super::super::commands::SetInstrumentIdCmd {
706                part_index: 0,
707                staff_index: 0,
708                measure_index: 0,
709                voice: 0,
710                note_index: 0,
711                instrument_id: Some("P1-I2".to_string()),
712            }),
713        ] {
714            let json = serde_json::to_string(&command).unwrap();
715            let restored: Command = serde_json::from_str(&json).unwrap();
716            assert_eq!(
717                super::super::commands::command_key(&restored),
718                super::super::commands::command_key(&command)
719            );
720        }
721    }
722
723    #[test]
724    fn paste_voice_without_copy_returns_error() {
725        let mut engine = ScoreEngine::new();
726        assert!(engine.paste_voice(0, 0, 0, 0).is_err());
727    }
728
729    #[test]
730    fn change_hint_set_tempo_is_global() {
731        use crate::model::change_hint::ChangeScope;
732        let mut engine = ScoreEngine::new();
733        let hint = engine
734            .apply(Command::SetTempo(SetTempoCmd { bpm: 100 }))
735            .unwrap();
736        assert_eq!(hint.scope, ChangeScope::Global);
737        assert!(!hint.layout_dirty);
738        assert!(hint.playback_dirty);
739    }
740
741    #[test]
742    fn change_hint_add_note_is_measure_scope() {
743        use crate::model::change_hint::ChangeScope;
744        use crate::model::commands::AddNoteCmd;
745        use crate::model::duration::Duration;
746        use crate::model::pitch::Pitch;
747        use crate::model::pitch::Step;
748        let mut engine = ScoreEngine::new();
749        let hint = engine
750            .apply(Command::AddNote(AddNoteCmd {
751                part_index: 0,
752                staff_index: 0,
753                measure_index: 0,
754                voice: 0,
755                position: 0,
756                pitch: Some(Pitch::new(Step::C, 4)),
757                duration: Duration::Quarter,
758                dot_count: 0,
759                is_rest: false,
760                tuplet: None,
761            }))
762            .unwrap();
763        assert_eq!(
764            hint.scope,
765            ChangeScope::Measures {
766                part: 0,
767                staff: 0,
768                start: 0,
769                end: 1
770            }
771        );
772        assert!(!hint.layout_dirty);
773        assert!(hint.playback_dirty);
774    }
775
776    #[test]
777    fn change_hint_set_part_name_no_dirty() {
778        use crate::model::change_hint::ChangeScope;
779        use crate::model::commands::SetPartNameCmd;
780        let mut engine = ScoreEngine::new();
781        let hint = engine
782            .apply(Command::SetPartName(SetPartNameCmd {
783                part_index: 0,
784                name: "Violin".into(),
785                short_name: "Vln.".into(),
786            }))
787            .unwrap();
788        assert_eq!(hint.scope, ChangeScope::Part(0));
789        assert!(!hint.layout_dirty);
790        assert!(!hint.playback_dirty);
791    }
792
793    #[test]
794    fn undo_returns_change_hint() {
795        use crate::model::change_hint::ChangeScope;
796        let mut engine = ScoreEngine::new();
797        engine
798            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
799            .unwrap();
800        let hint = engine.undo().unwrap();
801        assert_eq!(hint.scope, ChangeScope::Global);
802        assert!(hint.playback_dirty);
803    }
804
805    #[test]
806    fn redo_returns_change_hint() {
807        use crate::model::change_hint::ChangeScope;
808        let mut engine = ScoreEngine::new();
809        engine
810            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
811            .unwrap();
812        engine.undo().unwrap();
813        let hint = engine.redo().unwrap();
814        assert_eq!(hint.scope, ChangeScope::Global);
815        assert!(hint.playback_dirty);
816    }
817
818    #[test]
819    fn batch_apply_two_commands_single_undo() {
820        let mut engine = ScoreEngine::new();
821        let original = engine.score.settings.tempo_bpm;
822        engine
823            .batch_apply(vec![
824                Command::SetTempo(SetTempoCmd { bpm: 160 }),
825                Command::SetTempo(SetTempoCmd { bpm: 180 }),
826            ])
827            .unwrap();
828        assert_eq!(engine.score.settings.tempo_bpm, 180);
829        engine.undo().unwrap();
830        assert_eq!(engine.score.settings.tempo_bpm, original);
831        assert!(engine.undo().is_err());
832    }
833
834    #[test]
835    fn batch_apply_empty_returns_no_dirty() {
836        let mut engine = ScoreEngine::new();
837        let v0 = engine.version;
838        let hint = engine.batch_apply(vec![]).unwrap();
839        assert!(!hint.layout_dirty);
840        assert!(!hint.playback_dirty);
841        assert_eq!(engine.version, v0);
842    }
843
844    #[test]
845    fn batch_apply_hint_merges_scopes() {
846        use crate::model::change_hint::ChangeScope;
847        use crate::model::commands::{AddNoteCmd, SetTempoCmd};
848        use crate::model::duration::Duration;
849        use crate::model::pitch::{Pitch, Step};
850        let mut engine = ScoreEngine::new();
851        let hint = engine
852            .batch_apply(vec![
853                Command::SetTempo(SetTempoCmd { bpm: 140 }),
854                Command::AddNote(AddNoteCmd {
855                    part_index: 0,
856                    staff_index: 0,
857                    measure_index: 0,
858                    voice: 0,
859                    position: 0,
860                    pitch: Some(Pitch::new(Step::C, 4)),
861                    duration: Duration::Quarter,
862                    dot_count: 0,
863                    is_rest: false,
864                    tuplet: None,
865                }),
866            ])
867            .unwrap();
868        // SetTempo = Global; merged with Measures = Global
869        assert_eq!(hint.scope, ChangeScope::Global);
870        assert!(hint.playback_dirty);
871    }
872
873    #[test]
874    fn undo_label_none_when_empty() {
875        let engine = ScoreEngine::new();
876        assert!(engine.undo_label().is_none());
877        assert!(engine.redo_label().is_none());
878    }
879
880    #[test]
881    fn undo_label_after_command() {
882        let mut engine = ScoreEngine::new();
883        engine
884            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
885            .unwrap();
886        assert_eq!(engine.undo_label(), Some("Set Tempo".to_string()));
887        assert!(engine.redo_label().is_none());
888    }
889
890    #[test]
891    fn redo_label_after_undo() {
892        let mut engine = ScoreEngine::new();
893        engine
894            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
895            .unwrap();
896        engine.undo().unwrap();
897        assert!(engine.undo_label().is_none());
898        assert_eq!(engine.redo_label(), Some("Set Tempo".to_string()));
899    }
900
901    #[test]
902    fn copy_range_paste_range_roundtrip() {
903        use crate::model::duration::Duration;
904        use crate::model::pitch::{Pitch, Step};
905        use crate::model::score::{Note, NoteAddr};
906        let mut engine = ScoreEngine::new();
907        // Put a note in measure 0
908        engine.score.parts[0].staves[0].measures[0].voices[0] =
909            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
910        // Need measure 1: add one
911        use crate::model::commands::AddMeasureCmd;
912        engine
913            .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
914            .unwrap();
915
916        let start = NoteAddr {
917            part: 0,
918            staff: 0,
919            measure: 0,
920            voice: 0,
921            note: 0,
922        };
923        let end = NoteAddr {
924            part: 0,
925            staff: 0,
926            measure: 0,
927            voice: 0,
928            note: 0,
929        };
930        engine.copy_range(start, end).unwrap();
931
932        let target = NoteAddr {
933            part: 0,
934            staff: 0,
935            measure: 1,
936            voice: 0,
937            note: 0,
938        };
939        engine.paste_range(target).unwrap();
940
941        let pasted = &engine.score.parts[0].staves[0].measures[1].voices[0];
942        assert_eq!(pasted.len(), 1);
943        assert_eq!(pasted[0].pitches[0].step, Step::C);
944    }
945
946    #[test]
947    fn paste_range_is_undoable() {
948        use crate::model::commands::AddMeasureCmd;
949        use crate::model::duration::Duration;
950        use crate::model::pitch::{Pitch, Step};
951        use crate::model::score::{Note, NoteAddr};
952        let mut engine = ScoreEngine::new();
953        engine.score.parts[0].staves[0].measures[0].voices[0] =
954            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
955        engine
956            .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
957            .unwrap();
958
959        let start = NoteAddr {
960            part: 0,
961            staff: 0,
962            measure: 0,
963            voice: 0,
964            note: 0,
965        };
966        let end = start.clone();
967        engine.copy_range(start, end).unwrap();
968        let target = NoteAddr {
969            part: 0,
970            staff: 0,
971            measure: 1,
972            voice: 0,
973            note: 0,
974        };
975        engine.paste_range(target).unwrap();
976
977        // Undo should restore measure 1 to its pre-paste state
978        engine.undo().unwrap();
979        let restored = &engine.score.parts[0].staves[0].measures[1].voices[0];
980        assert!(restored.iter().all(|n| n.is_rest));
981    }
982
983    #[test]
984    fn copy_range_mismatched_part_returns_error() {
985        let engine = ScoreEngine::new();
986        // Can't call copy_range mutably here since we need &mut, so test via a new engine
987        let mut e = ScoreEngine::new();
988        use crate::model::score::NoteAddr;
989        let start = NoteAddr {
990            part: 0,
991            staff: 0,
992            measure: 0,
993            voice: 0,
994            note: 0,
995        };
996        let end = NoteAddr {
997            part: 1,
998            staff: 0,
999            measure: 0,
1000            voice: 0,
1001            note: 0,
1002        };
1003        assert!(e.copy_range(start, end).is_err());
1004        let _ = engine; // suppress unused warning
1005    }
1006
1007    #[test]
1008    fn export_history_roundtrip() {
1009        let mut engine = ScoreEngine::new();
1010        engine
1011            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1012            .unwrap();
1013        engine
1014            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
1015            .unwrap();
1016        let history = engine.export_history();
1017        assert_eq!(history.commands.len(), 2);
1018        let restored = ScoreEngine::from_history(history).unwrap();
1019        assert_eq!(restored.score.settings.tempo_bpm, 180);
1020    }
1021
1022    #[test]
1023    fn history_base_check_rejects_stale_collaboration_snapshot() {
1024        let mut engine = ScoreEngine::new();
1025        engine
1026            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1027            .unwrap();
1028        let history = engine.export_history();
1029        let mut unrelated = history.initial_score.clone();
1030        unrelated.metadata.title = "unrelated".to_owned();
1031
1032        assert!(!history.base_matches(&unrelated));
1033        assert!(matches!(
1034            ScoreEngine::from_history_on_base(history, unrelated),
1035            Err(Error::HistoryBaseMismatch)
1036        ));
1037    }
1038
1039    #[test]
1040    fn history_base_check_allows_replay_on_matching_snapshot() {
1041        let mut engine = ScoreEngine::new();
1042        engine
1043            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1044            .unwrap();
1045        let history = engine.export_history();
1046        let base = history.initial_score.clone();
1047
1048        assert!(history.base_matches(&base));
1049        let restored = ScoreEngine::from_history_on_base(history, base).unwrap();
1050        assert_eq!(restored.score.settings.tempo_bpm, 160);
1051    }
1052
1053    #[test]
1054    fn history_compare_reports_prefix_and_divergence() {
1055        let base_score = ScoreEngine::new().score.clone();
1056        let mut base = ScoreEngine::new();
1057        base.replace_score(base_score.clone());
1058        let mut left = ScoreEngine::new();
1059        left.replace_score(base_score.clone());
1060        left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1061            .unwrap();
1062        let mut right = ScoreEngine::new();
1063        right.replace_score(base_score.clone());
1064        right
1065            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1066            .unwrap();
1067        right
1068            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
1069            .unwrap();
1070
1071        assert_eq!(
1072            base.export_history().compare(&left.export_history()),
1073            HistoryRelation::LeftExtends {
1074                common_prefix_len: 0
1075            }
1076        );
1077        assert_eq!(
1078            left.export_history().compare(&right.export_history()),
1079            HistoryRelation::LeftExtends {
1080                common_prefix_len: 1
1081            }
1082        );
1083
1084        let mut diverged = ScoreEngine::new();
1085        diverged.replace_score(base_score);
1086        diverged
1087            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1088            .unwrap();
1089        assert_eq!(
1090            left.export_history().compare(&diverged.export_history()),
1091            HistoryRelation::Diverged {
1092                common_prefix_len: 0
1093            }
1094        );
1095    }
1096
1097    #[test]
1098    fn history_compare_reports_base_mismatch() {
1099        let left = ScoreEngine::new().export_history();
1100        let mut other = ScoreEngine::new();
1101        other.replace_score(Score::new("Other", 120, 4, 4, 1, 4));
1102        assert_eq!(
1103            left.compare(&other.export_history()),
1104            HistoryRelation::BaseMismatch
1105        );
1106    }
1107
1108    #[test]
1109    fn history_conflict_reports_branch_commands_and_remaining_lengths() {
1110        let base_score = ScoreEngine::new().score.clone();
1111        let mut left = ScoreEngine::new();
1112        left.replace_score(base_score.clone());
1113        left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1114            .unwrap();
1115        let mut right = ScoreEngine::new();
1116        right.replace_score(base_score);
1117        right
1118            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1119            .unwrap();
1120        right
1121            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
1122            .unwrap();
1123
1124        assert_eq!(
1125            left.export_history().conflict(&right.export_history()),
1126            Some(HistoryConflict {
1127                common_prefix_len: 0,
1128                left_command_index: 0,
1129                right_command_index: 0,
1130                left_command_key: "SetTempo".to_owned(),
1131                right_command_key: "SetTempo".to_owned(),
1132                left_remaining_commands: 1,
1133                right_remaining_commands: 2,
1134            })
1135        );
1136    }
1137
1138    #[test]
1139    fn history_conflict_is_empty_for_safe_relationships() {
1140        let history = ScoreEngine::new().export_history();
1141        assert!(history.conflict(&history).is_none());
1142    }
1143
1144    #[test]
1145    fn append_history_extension_applies_only_remote_suffix() {
1146        let base_score = ScoreEngine::new().score.clone();
1147        let mut local = ScoreEngine::new();
1148        local.replace_score(base_score.clone());
1149        local
1150            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1151            .unwrap();
1152
1153        let mut remote = ScoreEngine::new();
1154        remote.replace_score(base_score);
1155        remote
1156            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1157            .unwrap();
1158        remote
1159            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
1160            .unwrap();
1161
1162        let count = local
1163            .append_history_extension(&remote.export_history())
1164            .unwrap();
1165        assert_eq!(count, 1);
1166        assert_eq!(local.score.settings.tempo_bpm, 180);
1167        assert_eq!(
1168            local.export_history().compare(&remote.export_history()),
1169            HistoryRelation::Equivalent
1170        );
1171    }
1172
1173    #[test]
1174    fn append_history_extension_rejects_divergence_without_mutation() {
1175        let base_score = ScoreEngine::new().score.clone();
1176        let mut local = ScoreEngine::new();
1177        local.replace_score(base_score.clone());
1178        local
1179            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1180            .unwrap();
1181        let before = local.score.settings.tempo_bpm;
1182
1183        let mut remote = ScoreEngine::new();
1184        remote.replace_score(base_score);
1185        remote
1186            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1187            .unwrap();
1188
1189        assert!(matches!(
1190            local.append_history_extension(&remote.export_history()),
1191            Err(Error::HistoryNotAppendable)
1192        ));
1193        assert_eq!(local.score.settings.tempo_bpm, before);
1194    }
1195
1196    #[test]
1197    fn export_history_empty_gives_initial_state() {
1198        let engine = ScoreEngine::new();
1199        let history = engine.export_history();
1200        assert!(history.commands.is_empty());
1201        let restored = ScoreEngine::from_history(history).unwrap();
1202        assert_eq!(restored.score.settings.tempo_bpm, 120);
1203    }
1204
1205    #[test]
1206    fn replace_score_then_export_history() {
1207        let mut engine = ScoreEngine::new();
1208        let s = Score::new("Custom", 90, 3, 4, 2, 4);
1209        engine.replace_score(s);
1210        engine
1211            .apply(Command::SetTempo(SetTempoCmd { bpm: 60 }))
1212            .unwrap();
1213        let history = engine.export_history();
1214        assert_eq!(history.initial_score.settings.tempo_bpm, 90);
1215        assert_eq!(history.commands.len(), 1);
1216        let restored = ScoreEngine::from_history(history).unwrap();
1217        assert_eq!(restored.score.settings.tempo_bpm, 60);
1218    }
1219
1220    #[test]
1221    fn new_score_command_replaces_score() {
1222        let mut engine = ScoreEngine::new();
1223        engine
1224            .apply(Command::NewScore(NewScoreCmd {
1225                title: "Sonata".into(),
1226                composer: "Bach".into(),
1227                tempo_bpm: 80,
1228                time_numerator: 3,
1229                time_denominator: 4,
1230                key_fifths: -1,
1231                measure_count: 12,
1232                template: None,
1233            }))
1234            .unwrap();
1235        assert_eq!(engine.score.metadata.title, "Sonata");
1236        assert_eq!(engine.score.measure_count(), 12);
1237    }
1238
1239    #[test]
1240    fn respell_score_to_key_uses_key_signature() {
1241        use crate::model::commands::{AddNoteCmd, RespellScoreToKeyCmd};
1242        use crate::model::notation::KeySignature;
1243        use crate::model::pitch::Step;
1244        let mut engine = ScoreEngine::new();
1245        // Set Bb major (2 flats, fifths = -2) → prefer_flat = true
1246        engine.score.settings.key_signature = KeySignature {
1247            fifths: -2,
1248            mode: "major".to_string(),
1249        };
1250        engine
1251            .apply(Command::AddNote(AddNoteCmd {
1252                part_index: 0,
1253                staff_index: 0,
1254                measure_index: 0,
1255                voice: 0,
1256                position: 0,
1257                pitch: Some(crate::model::pitch::Pitch::with_alter(Step::C, 4, 1)), // C#4
1258                duration: crate::model::duration::Duration::Quarter,
1259                dot_count: 0,
1260                is_rest: false,
1261                tuplet: None,
1262            }))
1263            .unwrap();
1264        engine
1265            .apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
1266            .unwrap();
1267        let pitch = &engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
1268        assert_eq!(pitch.step, Step::D);
1269        assert_eq!(pitch.alter, -1); // Db4
1270    }
1271
1272    #[test]
1273    fn begin_end_slur_creates_slur() {
1274        use crate::model::commands::AddNoteCmd;
1275        use crate::model::duration::Duration;
1276        use crate::model::pitch::{Pitch, Step};
1277        let mut engine = ScoreEngine::new();
1278        engine
1279            .apply(Command::AddNote(AddNoteCmd {
1280                part_index: 0,
1281                staff_index: 0,
1282                measure_index: 0,
1283                voice: 0,
1284                position: 0,
1285                pitch: Some(Pitch::new(Step::C, 4)),
1286                duration: Duration::Quarter,
1287                dot_count: 0,
1288                is_rest: false,
1289                tuplet: None,
1290            }))
1291            .unwrap();
1292        engine
1293            .apply(Command::AddNote(AddNoteCmd {
1294                part_index: 0,
1295                staff_index: 0,
1296                measure_index: 0,
1297                voice: 0,
1298                position: 1,
1299                pitch: Some(Pitch::new(Step::D, 4)),
1300                duration: Duration::Quarter,
1301                dot_count: 0,
1302                is_rest: false,
1303                tuplet: None,
1304            }))
1305            .unwrap();
1306        let start = NoteAddr {
1307            part: 0,
1308            staff: 0,
1309            measure: 0,
1310            voice: 0,
1311            note: 0,
1312        };
1313        let end = NoteAddr {
1314            part: 0,
1315            staff: 0,
1316            measure: 0,
1317            voice: 0,
1318            note: 1,
1319        };
1320        engine.begin_slur(start).unwrap();
1321        engine.end_slur(end).unwrap();
1322        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
1323        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
1324    }
1325
1326    #[test]
1327    fn end_slur_without_begin_returns_error() {
1328        let mut engine = ScoreEngine::new();
1329        let end = NoteAddr {
1330            part: 0,
1331            staff: 0,
1332            measure: 0,
1333            voice: 0,
1334            note: 0,
1335        };
1336        let result = engine.end_slur(end);
1337        assert!(result.is_err());
1338    }
1339
1340    // ── SetStem ───────────────────────────────────────────────────────────────
1341
1342    #[test]
1343    fn set_stem_sets_and_clears() {
1344        use crate::model::commands::AddNoteCmd;
1345        use crate::model::duration::Duration;
1346        use crate::model::pitch::{Pitch, Step};
1347        let mut engine = ScoreEngine::new();
1348        engine
1349            .apply(Command::AddNote(AddNoteCmd {
1350                part_index: 0,
1351                staff_index: 0,
1352                measure_index: 0,
1353                voice: 0,
1354                position: 0,
1355                pitch: Some(Pitch::new(Step::C, 4)),
1356                duration: Duration::Quarter,
1357                dot_count: 0,
1358                is_rest: false,
1359                tuplet: None,
1360            }))
1361            .unwrap();
1362        let addr = NoteAddr {
1363            part: 0,
1364            staff: 0,
1365            measure: 0,
1366            voice: 0,
1367            note: 0,
1368        };
1369        engine.set_stem(addr.clone(), Some(true)).unwrap();
1370        assert_eq!(
1371            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1372            Some(true)
1373        );
1374        engine.set_stem(addr.clone(), Some(false)).unwrap();
1375        assert_eq!(
1376            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1377            Some(false)
1378        );
1379        engine.set_stem(addr, None).unwrap();
1380        assert_eq!(
1381            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1382            None
1383        );
1384    }
1385
1386    #[test]
1387    fn set_stem_is_undoable() {
1388        use crate::model::commands::AddNoteCmd;
1389        use crate::model::duration::Duration;
1390        use crate::model::pitch::{Pitch, Step};
1391        let mut engine = ScoreEngine::new();
1392        engine
1393            .apply(Command::AddNote(AddNoteCmd {
1394                part_index: 0,
1395                staff_index: 0,
1396                measure_index: 0,
1397                voice: 0,
1398                position: 0,
1399                pitch: Some(Pitch::new(Step::C, 4)),
1400                duration: Duration::Quarter,
1401                dot_count: 0,
1402                is_rest: false,
1403                tuplet: None,
1404            }))
1405            .unwrap();
1406        let addr = NoteAddr {
1407            part: 0,
1408            staff: 0,
1409            measure: 0,
1410            voice: 0,
1411            note: 0,
1412        };
1413        engine.set_stem(addr, Some(true)).unwrap();
1414        engine.undo().unwrap();
1415        assert_eq!(
1416            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1417            None
1418        );
1419    }
1420
1421    #[test]
1422    fn set_arpeggio_is_undoable() {
1423        use crate::model::commands::AddNoteCmd;
1424        use crate::model::duration::Duration;
1425        use crate::model::pitch::{Pitch, Step};
1426        let mut engine = ScoreEngine::new();
1427        engine
1428            .apply(Command::AddNote(AddNoteCmd {
1429                part_index: 0,
1430                staff_index: 0,
1431                measure_index: 0,
1432                voice: 0,
1433                position: 0,
1434                pitch: Some(Pitch::new(Step::C, 4)),
1435                duration: Duration::Quarter,
1436                dot_count: 0,
1437                is_rest: false,
1438                tuplet: None,
1439            }))
1440            .unwrap();
1441        let addr = NoteAddr {
1442            part: 0,
1443            staff: 0,
1444            measure: 0,
1445            voice: 0,
1446            note: 0,
1447        };
1448        engine.set_arpeggio(addr.clone(), Some(true)).unwrap();
1449        assert_eq!(
1450            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1451            Some(true)
1452        );
1453        engine.undo().unwrap();
1454        assert_eq!(
1455            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1456            None
1457        );
1458        engine.redo().unwrap();
1459        assert_eq!(
1460            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1461            Some(true)
1462        );
1463    }
1464
1465    // ── command_key ───────────────────────────────────────────────────────────
1466
1467    #[test]
1468    fn undo_key_returns_key_string() {
1469        let mut engine = ScoreEngine::new();
1470        engine
1471            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1472            .unwrap();
1473        assert_eq!(engine.undo_key(), Some("SetTempo".to_string()));
1474        assert!(engine.redo_key().is_none());
1475    }
1476
1477    #[test]
1478    fn redo_key_after_undo() {
1479        let mut engine = ScoreEngine::new();
1480        engine
1481            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1482            .unwrap();
1483        engine.undo().unwrap();
1484        assert!(engine.undo_key().is_none());
1485        assert_eq!(engine.redo_key(), Some("SetTempo".to_string()));
1486    }
1487
1488    // ── batch_apply_labeled ───────────────────────────────────────────────────
1489
1490    #[test]
1491    fn batch_apply_labeled_sets_undo_key() {
1492        let mut engine = ScoreEngine::new();
1493        engine
1494            .batch_apply_labeled(vec![Command::SetTempo(SetTempoCmd { bpm: 140 })], "ApplyAI")
1495            .unwrap();
1496        assert_eq!(engine.undo_key(), Some("ApplyAI".to_string()));
1497    }
1498
1499    #[test]
1500    fn batch_apply_labeled_empty_is_noop() {
1501        let mut engine = ScoreEngine::new();
1502        engine.batch_apply_labeled(vec![], "ApplyAI").unwrap();
1503        assert!(engine.undo_key().is_none());
1504    }
1505}