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