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    SetNoteHeadCmd, SetPartGroupCmd, SetStemCmd, SetTupletCmd, ToggleSlurCmd, ToggleTrillLineCmd,
6    command_hint,
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, Serialize, Deserialize)]
24pub struct EngineHistory {
25    pub initial_score: Score,
26    pub commands: Vec<Command>,
27}
28
29#[derive(Debug, Clone)]
30struct RangeClipboard {
31    voice: usize,
32    measures: Vec<Vec<Note>>,
33}
34
35pub struct ScoreEngine {
36    pub score: Score,
37    pub commands: CommandStack,
38    pub version: u64,
39    pub clipboard: Option<Vec<Note>>,
40    range_clipboard: Option<RangeClipboard>,
41    initial_score: Score,
42    pending_slur_start: Option<NoteAddr>,
43}
44
45impl Default for ScoreEngine {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl ScoreEngine {
52    pub fn new() -> Self {
53        let mut score = Score::default();
54        for part in &mut score.parts {
55            for staff in &mut part.staves {
56                for (i, m) in staff.measures.iter_mut().enumerate() {
57                    m.number = i as u32 + 1;
58                }
59            }
60        }
61        let initial_score = score.clone();
62        Self {
63            score,
64            commands: CommandStack::new(200),
65            version: 0,
66            clipboard: None,
67            range_clipboard: None,
68            initial_score,
69            pending_slur_start: None,
70        }
71    }
72
73    pub fn apply(&mut self, cmd: Command) -> Result<ChangeHint, Error> {
74        let hint = command_hint(&cmd);
75        self.commands.execute(cmd, &mut self.score)?;
76        self.version += 1;
77        Ok(hint)
78    }
79
80    pub fn undo(&mut self) -> Result<ChangeHint, Error> {
81        let hint = self.commands.undo(&mut self.score)?;
82        self.version += 1;
83        Ok(hint)
84    }
85
86    pub fn redo(&mut self) -> Result<ChangeHint, Error> {
87        let hint = self.commands.redo(&mut self.score)?;
88        self.version += 1;
89        Ok(hint)
90    }
91
92    /// Apply multiple commands as a single undo entry.
93    pub fn batch_apply(&mut self, cmds: Vec<Command>) -> Result<ChangeHint, Error> {
94        if cmds.is_empty() {
95            return Ok(ChangeHint {
96                scope: ChangeScope::Global,
97                layout_dirty: false,
98                playback_dirty: false,
99            });
100        }
101        let mut hint = command_hint(&cmds[0]);
102        for cmd in cmds.iter().skip(1) {
103            hint = hint.merge(command_hint(cmd));
104        }
105        self.commands.batch_execute(cmds, &mut self.score)?;
106        self.version += 1;
107        Ok(hint)
108    }
109
110    /// Apply a batch of commands as a single undo entry with an explicit label.
111    ///
112    /// The `label` appears as the [`command_key`] in undo/redo UI (e.g. `"ApplyAI"`).
113    pub fn batch_apply_labeled(
114        &mut self,
115        cmds: Vec<Command>,
116        label: &str,
117    ) -> Result<ChangeHint, Error> {
118        if cmds.is_empty() {
119            return Ok(ChangeHint {
120                scope: ChangeScope::Global,
121                layout_dirty: false,
122                playback_dirty: false,
123            });
124        }
125        let mut hint = command_hint(&cmds[0]);
126        for cmd in cmds.iter().skip(1) {
127            hint = hint.merge(command_hint(cmd));
128        }
129        self.commands
130            .batch_execute_labeled(cmds, label.to_string(), &mut self.score)?;
131        self.version += 1;
132        Ok(hint)
133    }
134
135    /// Label of the next undoable command (for "Undo: Add Note" menu items).
136    pub fn undo_label(&self) -> Option<String> {
137        self.commands.undo_label()
138    }
139
140    /// Label of the next redoable command (for "Redo: Add Note" menu items).
141    pub fn redo_label(&self) -> Option<String> {
142        self.commands.redo_label()
143    }
144
145    /// i18n key of the next undoable command (e.g. `"SetTempo"`).
146    pub fn undo_key(&self) -> Option<String> {
147        self.commands.undo_key()
148    }
149
150    /// i18n key of the next redoable command.
151    pub fn redo_key(&self) -> Option<String> {
152        self.commands.redo_key()
153    }
154
155    pub fn replace_score(&mut self, score: Score) {
156        self.initial_score = score.clone();
157        self.score = score;
158        self.version += 1;
159        self.commands = CommandStack::new(200);
160    }
161
162    /// Export the command history for serialization (crash recovery, AI replay).
163    ///
164    /// The returned [`EngineHistory`] can be stored as JSON and later restored with
165    /// [`ScoreEngine::from_history`].
166    pub fn export_history(&self) -> EngineHistory {
167        EngineHistory {
168            initial_score: self.initial_score.clone(),
169            commands: self.commands.history_commands(),
170        }
171    }
172
173    /// Reconstruct an engine from a previously exported [`EngineHistory`].
174    ///
175    /// Replays all commands against `history.initial_score` in order.
176    /// Returns an error if any command fails (e.g. index out of bounds due to stale data).
177    pub fn from_history(history: EngineHistory) -> Result<Self, Error> {
178        let mut engine = ScoreEngine::new();
179        engine.replace_score(history.initial_score);
180        for cmd in history.commands {
181            engine.apply(cmd)?;
182        }
183        Ok(engine)
184    }
185
186    pub fn copy_voice(
187        &mut self,
188        part_index: usize,
189        staff_index: usize,
190        measure_index: usize,
191        voice_index: usize,
192    ) -> Result<(), Error> {
193        let voice = self
194            .score
195            .parts
196            .get(part_index)
197            .ok_or(Error::PartNotFound(part_index))?
198            .staves
199            .get(staff_index)
200            .ok_or(Error::StaffNotFound(staff_index))?
201            .measures
202            .get(measure_index)
203            .ok_or(Error::MeasureNotFound(measure_index))?
204            .voices
205            .get(voice_index)
206            .ok_or(Error::VoiceOutOfRange(voice_index))?;
207        self.clipboard = Some(voice.clone());
208        Ok(())
209    }
210
211    pub fn paste_voice(
212        &mut self,
213        part_index: usize,
214        staff_index: usize,
215        measure_index: usize,
216        voice_index: usize,
217    ) -> Result<ChangeHint, Error> {
218        let notes = self.clipboard.clone().ok_or(Error::ClipboardEmpty)?;
219        self.apply(Command::PasteVoice(PasteVoiceCmd {
220            part_index,
221            staff_index,
222            measure_index,
223            voice_index,
224            notes,
225        }))
226    }
227
228    /// Copy a range of measures from a single voice into the range clipboard.
229    ///
230    /// The range is inclusive: all measures from `start.measure` to `end.measure`.
231    /// `start` and `end` must share the same `part`, `staff`, and `voice`.
232    pub fn copy_range(&mut self, start: NoteAddr, end: NoteAddr) -> Result<(), Error> {
233        if start.part != end.part || start.staff != end.staff || start.voice != end.voice {
234            return Err(Error::InvalidCommand(
235                "copy_range: start and end must share the same part, staff, and voice".into(),
236            ));
237        }
238        let from = start.measure.min(end.measure);
239        let to = start.measure.max(end.measure);
240        let staff = self
241            .score
242            .parts
243            .get(start.part)
244            .ok_or(Error::PartNotFound(start.part))?
245            .staves
246            .get(start.staff)
247            .ok_or(Error::StaffNotFound(start.staff))?;
248        if start.voice >= 4 {
249            return Err(Error::VoiceOutOfRange(start.voice));
250        }
251        let mut measures = Vec::new();
252        for mi in from..=to {
253            let m = staff.measures.get(mi).ok_or(Error::MeasureNotFound(mi))?;
254            measures.push(m.voices[start.voice].clone());
255        }
256        self.range_clipboard = Some(RangeClipboard {
257            voice: start.voice,
258            measures,
259        });
260        Ok(())
261    }
262
263    /// Paste the range clipboard starting at `target`, creating an undo-able command.
264    ///
265    /// The voice index from the original `copy_range` call is used; `target.voice` is ignored.
266    pub fn paste_range(&mut self, target: NoteAddr) -> Result<ChangeHint, Error> {
267        let rc = self.range_clipboard.clone().ok_or(Error::ClipboardEmpty)?;
268        self.apply(Command::PasteRange(PasteRangeCmd {
269            part_index: target.part,
270            staff_index: target.staff,
271            voice_index: rc.voice,
272            target_measure: target.measure,
273            measures: rc.measures,
274        }))
275    }
276
277    /// Toggle the slur between two notes (undo-able).
278    pub fn toggle_slur(&mut self, start: NoteAddr, end: NoteAddr) -> Result<ChangeHint, Error> {
279        self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
280    }
281
282    /// Add a staff to a part (undo-able).
283    pub fn add_staff(&mut self, part_index: usize, clef: Clef) -> Result<ChangeHint, Error> {
284        self.apply(Command::AddStaff(AddStaffCmd { part_index, clef }))
285    }
286
287    /// Remove a staff from a part (undo-able). Fails if it is the last remaining staff.
288    pub fn delete_staff(
289        &mut self,
290        part_index: usize,
291        staff_index: usize,
292    ) -> Result<ChangeHint, Error> {
293        self.apply(Command::DeleteStaff(DeleteStaffCmd {
294            part_index,
295            staff_index,
296        }))
297    }
298
299    /// Set or clear the stem direction on an existing note (undo-able).
300    ///
301    /// `stem_up`: `None` = auto, `Some(true)` = up, `Some(false)` = down.
302    pub fn set_stem(&mut self, addr: NoteAddr, stem_up: Option<bool>) -> Result<ChangeHint, Error> {
303        self.apply(Command::SetStem(SetStemCmd {
304            part_index: addr.part,
305            staff_index: addr.staff,
306            measure_index: addr.measure,
307            voice_index: addr.voice,
308            note_index: addr.note,
309            stem_up,
310        }))
311    }
312
313    /// Set the duration and dot count on an existing note (undo-able).
314    pub fn set_duration(
315        &mut self,
316        addr: NoteAddr,
317        duration: Duration,
318        dot_count: u8,
319    ) -> Result<ChangeHint, Error> {
320        self.apply(Command::SetDuration(SetDurationCmd {
321            part_index: addr.part,
322            staff_index: addr.staff,
323            measure_index: addr.measure,
324            voice: addr.voice,
325            note_index: addr.note,
326            duration,
327            dot_count,
328        }))
329    }
330
331    /// Set or clear the arpeggio direction on an existing note (undo-able).
332    pub fn set_arpeggio(
333        &mut self,
334        addr: NoteAddr,
335        direction: Option<bool>,
336    ) -> Result<ChangeHint, Error> {
337        self.apply(Command::SetArpeggio(SetArpeggioCmd {
338            part_index: addr.part,
339            staff_index: addr.staff,
340            measure_index: addr.measure,
341            voice_index: addr.voice,
342            note_index: addr.note,
343            direction,
344        }))
345    }
346
347    /// Set the note head shape on an existing note (undo-able).
348    pub fn set_note_head(
349        &mut self,
350        addr: NoteAddr,
351        note_head: NoteHead,
352    ) -> Result<ChangeHint, Error> {
353        self.apply(Command::SetNoteHead(SetNoteHeadCmd {
354            part_index: addr.part,
355            staff_index: addr.staff,
356            measure_index: addr.measure,
357            voice: addr.voice,
358            note_index: addr.note,
359            note_head,
360        }))
361    }
362
363    /// Add or replace a part group (undo-able). Pass `None` to clear all groups.
364    pub fn set_part_group(&mut self, group: Option<PartGroup>) -> Result<ChangeHint, Error> {
365        self.apply(Command::SetPartGroup(SetPartGroupCmd { group }))
366    }
367
368    /// Toggle a trill line span between two notes (undo-able).
369    pub fn toggle_trill_line(
370        &mut self,
371        start: NoteAddr,
372        end: NoteAddr,
373    ) -> Result<ChangeHint, Error> {
374        self.apply(Command::ToggleTrillLine(ToggleTrillLineCmd { start, end }))
375    }
376
377    /// Set or clear the cue flag on a note (undo-able). Cue notes have zero beats.
378    pub fn set_cue(&mut self, addr: NoteAddr, is_cue: bool) -> Result<ChangeHint, Error> {
379        self.apply(Command::SetCue(SetCueCmd {
380            part_index: addr.part,
381            staff_index: addr.staff,
382            measure_index: addr.measure,
383            voice: addr.voice,
384            note_index: addr.note,
385            is_cue,
386        }))
387    }
388
389    /// Set or clear the tuplet on an existing note (undo-able).
390    pub fn set_tuplet(
391        &mut self,
392        addr: NoteAddr,
393        tuplet: Option<TupletInfo>,
394    ) -> Result<ChangeHint, Error> {
395        self.apply(Command::SetTuplet(SetTupletCmd {
396            part_index: addr.part,
397            staff_index: addr.staff,
398            measure_index: addr.measure,
399            voice_index: addr.voice,
400            note_index: addr.note,
401            tuplet,
402        }))
403    }
404
405    /// Respell all pitches in the score (undo-able).
406    pub fn respell_score(&mut self, prefer_flat: bool) -> Result<ChangeHint, Error> {
407        self.apply(Command::RespellScore(RespellScoreCmd { prefer_flat }))
408    }
409
410    /// Respell all pitches to match the score's key signature (undo-able).
411    pub fn respell_score_to_key(&mut self) -> Result<ChangeHint, Error> {
412        self.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
413    }
414
415    /// Begin a two-step slur: record `start` and wait for [`end_slur`](Self::end_slur).
416    ///
417    /// Returns an error if `start` does not point to a valid note.
418    pub fn begin_slur(&mut self, start: NoteAddr) -> Result<(), Error> {
419        self.score
420            .parts
421            .get(start.part)
422            .ok_or(Error::PartNotFound(start.part))?
423            .staves
424            .get(start.staff)
425            .ok_or(Error::StaffNotFound(start.staff))?
426            .measures
427            .get(start.measure)
428            .ok_or(Error::MeasureNotFound(start.measure))?
429            .voices
430            .get(start.voice)
431            .ok_or(Error::VoiceOutOfRange(start.voice))?
432            .get(start.note)
433            .ok_or(Error::NoteNotFound(start.note))?;
434        self.pending_slur_start = Some(start);
435        Ok(())
436    }
437
438    /// Complete the slur started by [`begin_slur`](Self::begin_slur) (undo-able).
439    ///
440    /// Returns `Error::InvalidCommand` if `begin_slur` has not been called.
441    pub fn end_slur(&mut self, end: NoteAddr) -> Result<ChangeHint, Error> {
442        let start = self
443            .pending_slur_start
444            .take()
445            .ok_or_else(|| Error::InvalidCommand("no slur in progress".to_string()))?;
446        self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::model::commands::{NewScoreCmd, SetTempoCmd};
454
455    #[test]
456    fn new_engine_has_default_score() {
457        let engine = ScoreEngine::new();
458        assert_eq!(engine.version, 0);
459        assert_eq!(engine.score.parts.len(), 1);
460    }
461
462    #[test]
463    fn apply_increments_version() {
464        let mut engine = ScoreEngine::new();
465        engine
466            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
467            .unwrap();
468        assert_eq!(engine.version, 1);
469    }
470
471    #[test]
472    fn undo_redo_cycle() {
473        let mut engine = ScoreEngine::new();
474        engine
475            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
476            .unwrap();
477        let after_apply = engine.version;
478        engine.undo().unwrap();
479        assert_eq!(engine.score.settings.tempo_bpm, 120);
480        engine.redo().unwrap();
481        assert_eq!(engine.score.settings.tempo_bpm, 140);
482        assert!(engine.version > after_apply);
483    }
484
485    #[test]
486    fn replace_score_clears_history() {
487        let mut engine = ScoreEngine::new();
488        engine
489            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
490            .unwrap();
491        let new_score = Score::new("New", 90, 3, 4, 2, 8);
492        engine.replace_score(new_score);
493        assert!(engine.undo().is_err());
494        assert_eq!(engine.score.settings.tempo_bpm, 90);
495    }
496
497    #[test]
498    fn copy_paste_voice_copies_notes() {
499        use crate::model::duration::Duration;
500        use crate::model::pitch::{Pitch, Step};
501        use crate::model::score::Note;
502        let mut engine = ScoreEngine::new();
503        engine.score.parts[0].staves[0].measures[0].voices[0] =
504            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
505        engine.copy_voice(0, 0, 0, 0).unwrap();
506        engine.paste_voice(0, 0, 0, 1).unwrap();
507        let pasted = &engine.score.parts[0].staves[0].measures[0].voices[1];
508        assert_eq!(pasted.len(), 1);
509        assert_eq!(pasted[0].pitches[0].step, Step::C);
510    }
511
512    #[test]
513    fn paste_voice_undo_restores_original() {
514        use crate::model::duration::Duration;
515        use crate::model::pitch::{Pitch, Step};
516        use crate::model::score::Note;
517        let mut engine = ScoreEngine::new();
518        engine.score.parts[0].staves[0].measures[0].voices[0] =
519            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
520        engine.copy_voice(0, 0, 0, 0).unwrap();
521        engine.paste_voice(0, 0, 0, 1).unwrap();
522        engine.undo().unwrap();
523        assert!(engine.score.parts[0].staves[0].measures[0].voices[1].is_empty());
524    }
525
526    #[test]
527    fn paste_voice_without_copy_returns_error() {
528        let mut engine = ScoreEngine::new();
529        assert!(engine.paste_voice(0, 0, 0, 0).is_err());
530    }
531
532    #[test]
533    fn change_hint_set_tempo_is_global() {
534        use crate::model::change_hint::ChangeScope;
535        let mut engine = ScoreEngine::new();
536        let hint = engine
537            .apply(Command::SetTempo(SetTempoCmd { bpm: 100 }))
538            .unwrap();
539        assert_eq!(hint.scope, ChangeScope::Global);
540        assert!(!hint.layout_dirty);
541        assert!(hint.playback_dirty);
542    }
543
544    #[test]
545    fn change_hint_add_note_is_measure_scope() {
546        use crate::model::change_hint::ChangeScope;
547        use crate::model::commands::AddNoteCmd;
548        use crate::model::duration::Duration;
549        use crate::model::pitch::Pitch;
550        use crate::model::pitch::Step;
551        let mut engine = ScoreEngine::new();
552        let hint = engine
553            .apply(Command::AddNote(AddNoteCmd {
554                part_index: 0,
555                staff_index: 0,
556                measure_index: 0,
557                voice: 0,
558                position: 0,
559                pitch: Some(Pitch::new(Step::C, 4)),
560                duration: Duration::Quarter,
561                dot_count: 0,
562                is_rest: false,
563                tuplet: None,
564            }))
565            .unwrap();
566        assert_eq!(
567            hint.scope,
568            ChangeScope::Measures {
569                part: 0,
570                staff: 0,
571                start: 0,
572                end: 1
573            }
574        );
575        assert!(!hint.layout_dirty);
576        assert!(hint.playback_dirty);
577    }
578
579    #[test]
580    fn change_hint_set_part_name_no_dirty() {
581        use crate::model::change_hint::ChangeScope;
582        use crate::model::commands::SetPartNameCmd;
583        let mut engine = ScoreEngine::new();
584        let hint = engine
585            .apply(Command::SetPartName(SetPartNameCmd {
586                part_index: 0,
587                name: "Violin".into(),
588                short_name: "Vln.".into(),
589            }))
590            .unwrap();
591        assert_eq!(hint.scope, ChangeScope::Part(0));
592        assert!(!hint.layout_dirty);
593        assert!(!hint.playback_dirty);
594    }
595
596    #[test]
597    fn undo_returns_change_hint() {
598        use crate::model::change_hint::ChangeScope;
599        let mut engine = ScoreEngine::new();
600        engine
601            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
602            .unwrap();
603        let hint = engine.undo().unwrap();
604        assert_eq!(hint.scope, ChangeScope::Global);
605        assert!(hint.playback_dirty);
606    }
607
608    #[test]
609    fn redo_returns_change_hint() {
610        use crate::model::change_hint::ChangeScope;
611        let mut engine = ScoreEngine::new();
612        engine
613            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
614            .unwrap();
615        engine.undo().unwrap();
616        let hint = engine.redo().unwrap();
617        assert_eq!(hint.scope, ChangeScope::Global);
618        assert!(hint.playback_dirty);
619    }
620
621    #[test]
622    fn batch_apply_two_commands_single_undo() {
623        let mut engine = ScoreEngine::new();
624        let original = engine.score.settings.tempo_bpm;
625        engine
626            .batch_apply(vec![
627                Command::SetTempo(SetTempoCmd { bpm: 160 }),
628                Command::SetTempo(SetTempoCmd { bpm: 180 }),
629            ])
630            .unwrap();
631        assert_eq!(engine.score.settings.tempo_bpm, 180);
632        engine.undo().unwrap();
633        assert_eq!(engine.score.settings.tempo_bpm, original);
634        assert!(engine.undo().is_err());
635    }
636
637    #[test]
638    fn batch_apply_empty_returns_no_dirty() {
639        let mut engine = ScoreEngine::new();
640        let v0 = engine.version;
641        let hint = engine.batch_apply(vec![]).unwrap();
642        assert!(!hint.layout_dirty);
643        assert!(!hint.playback_dirty);
644        assert_eq!(engine.version, v0);
645    }
646
647    #[test]
648    fn batch_apply_hint_merges_scopes() {
649        use crate::model::change_hint::ChangeScope;
650        use crate::model::commands::{AddNoteCmd, SetTempoCmd};
651        use crate::model::duration::Duration;
652        use crate::model::pitch::{Pitch, Step};
653        let mut engine = ScoreEngine::new();
654        let hint = engine
655            .batch_apply(vec![
656                Command::SetTempo(SetTempoCmd { bpm: 140 }),
657                Command::AddNote(AddNoteCmd {
658                    part_index: 0,
659                    staff_index: 0,
660                    measure_index: 0,
661                    voice: 0,
662                    position: 0,
663                    pitch: Some(Pitch::new(Step::C, 4)),
664                    duration: Duration::Quarter,
665                    dot_count: 0,
666                    is_rest: false,
667                    tuplet: None,
668                }),
669            ])
670            .unwrap();
671        // SetTempo = Global; merged with Measures = Global
672        assert_eq!(hint.scope, ChangeScope::Global);
673        assert!(hint.playback_dirty);
674    }
675
676    #[test]
677    fn undo_label_none_when_empty() {
678        let engine = ScoreEngine::new();
679        assert!(engine.undo_label().is_none());
680        assert!(engine.redo_label().is_none());
681    }
682
683    #[test]
684    fn undo_label_after_command() {
685        let mut engine = ScoreEngine::new();
686        engine
687            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
688            .unwrap();
689        assert_eq!(engine.undo_label(), Some("Set Tempo".to_string()));
690        assert!(engine.redo_label().is_none());
691    }
692
693    #[test]
694    fn redo_label_after_undo() {
695        let mut engine = ScoreEngine::new();
696        engine
697            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
698            .unwrap();
699        engine.undo().unwrap();
700        assert!(engine.undo_label().is_none());
701        assert_eq!(engine.redo_label(), Some("Set Tempo".to_string()));
702    }
703
704    #[test]
705    fn copy_range_paste_range_roundtrip() {
706        use crate::model::duration::Duration;
707        use crate::model::pitch::{Pitch, Step};
708        use crate::model::score::{Note, NoteAddr};
709        let mut engine = ScoreEngine::new();
710        // Put a note in measure 0
711        engine.score.parts[0].staves[0].measures[0].voices[0] =
712            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
713        // Need measure 1: add one
714        use crate::model::commands::AddMeasureCmd;
715        engine
716            .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
717            .unwrap();
718
719        let start = NoteAddr {
720            part: 0,
721            staff: 0,
722            measure: 0,
723            voice: 0,
724            note: 0,
725        };
726        let end = NoteAddr {
727            part: 0,
728            staff: 0,
729            measure: 0,
730            voice: 0,
731            note: 0,
732        };
733        engine.copy_range(start, end).unwrap();
734
735        let target = NoteAddr {
736            part: 0,
737            staff: 0,
738            measure: 1,
739            voice: 0,
740            note: 0,
741        };
742        engine.paste_range(target).unwrap();
743
744        let pasted = &engine.score.parts[0].staves[0].measures[1].voices[0];
745        assert_eq!(pasted.len(), 1);
746        assert_eq!(pasted[0].pitches[0].step, Step::C);
747    }
748
749    #[test]
750    fn paste_range_is_undoable() {
751        use crate::model::commands::AddMeasureCmd;
752        use crate::model::duration::Duration;
753        use crate::model::pitch::{Pitch, Step};
754        use crate::model::score::{Note, NoteAddr};
755        let mut engine = ScoreEngine::new();
756        engine.score.parts[0].staves[0].measures[0].voices[0] =
757            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
758        engine
759            .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
760            .unwrap();
761
762        let start = NoteAddr {
763            part: 0,
764            staff: 0,
765            measure: 0,
766            voice: 0,
767            note: 0,
768        };
769        let end = start.clone();
770        engine.copy_range(start, end).unwrap();
771        let target = NoteAddr {
772            part: 0,
773            staff: 0,
774            measure: 1,
775            voice: 0,
776            note: 0,
777        };
778        engine.paste_range(target).unwrap();
779
780        // Undo should restore measure 1 to its pre-paste state
781        engine.undo().unwrap();
782        let restored = &engine.score.parts[0].staves[0].measures[1].voices[0];
783        assert!(restored.iter().all(|n| n.is_rest));
784    }
785
786    #[test]
787    fn copy_range_mismatched_part_returns_error() {
788        let engine = ScoreEngine::new();
789        // Can't call copy_range mutably here since we need &mut, so test via a new engine
790        let mut e = ScoreEngine::new();
791        use crate::model::score::NoteAddr;
792        let start = NoteAddr {
793            part: 0,
794            staff: 0,
795            measure: 0,
796            voice: 0,
797            note: 0,
798        };
799        let end = NoteAddr {
800            part: 1,
801            staff: 0,
802            measure: 0,
803            voice: 0,
804            note: 0,
805        };
806        assert!(e.copy_range(start, end).is_err());
807        let _ = engine; // suppress unused warning
808    }
809
810    #[test]
811    fn export_history_roundtrip() {
812        let mut engine = ScoreEngine::new();
813        engine
814            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
815            .unwrap();
816        engine
817            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
818            .unwrap();
819        let history = engine.export_history();
820        assert_eq!(history.commands.len(), 2);
821        let restored = ScoreEngine::from_history(history).unwrap();
822        assert_eq!(restored.score.settings.tempo_bpm, 180);
823    }
824
825    #[test]
826    fn export_history_empty_gives_initial_state() {
827        let engine = ScoreEngine::new();
828        let history = engine.export_history();
829        assert!(history.commands.is_empty());
830        let restored = ScoreEngine::from_history(history).unwrap();
831        assert_eq!(restored.score.settings.tempo_bpm, 120);
832    }
833
834    #[test]
835    fn replace_score_then_export_history() {
836        let mut engine = ScoreEngine::new();
837        let s = Score::new("Custom", 90, 3, 4, 2, 4);
838        engine.replace_score(s);
839        engine
840            .apply(Command::SetTempo(SetTempoCmd { bpm: 60 }))
841            .unwrap();
842        let history = engine.export_history();
843        assert_eq!(history.initial_score.settings.tempo_bpm, 90);
844        assert_eq!(history.commands.len(), 1);
845        let restored = ScoreEngine::from_history(history).unwrap();
846        assert_eq!(restored.score.settings.tempo_bpm, 60);
847    }
848
849    #[test]
850    fn new_score_command_replaces_score() {
851        let mut engine = ScoreEngine::new();
852        engine
853            .apply(Command::NewScore(NewScoreCmd {
854                title: "Sonata".into(),
855                composer: "Bach".into(),
856                tempo_bpm: 80,
857                time_numerator: 3,
858                time_denominator: 4,
859                key_fifths: -1,
860                measure_count: 12,
861                template: None,
862            }))
863            .unwrap();
864        assert_eq!(engine.score.metadata.title, "Sonata");
865        assert_eq!(engine.score.measure_count(), 12);
866    }
867
868    #[test]
869    fn respell_score_to_key_uses_key_signature() {
870        use crate::model::commands::{AddNoteCmd, RespellScoreToKeyCmd};
871        use crate::model::notation::KeySignature;
872        use crate::model::pitch::Step;
873        let mut engine = ScoreEngine::new();
874        // Set Bb major (2 flats, fifths = -2) → prefer_flat = true
875        engine.score.settings.key_signature = KeySignature {
876            fifths: -2,
877            mode: "major".to_string(),
878        };
879        engine
880            .apply(Command::AddNote(AddNoteCmd {
881                part_index: 0,
882                staff_index: 0,
883                measure_index: 0,
884                voice: 0,
885                position: 0,
886                pitch: Some(crate::model::pitch::Pitch::with_alter(Step::C, 4, 1)), // C#4
887                duration: crate::model::duration::Duration::Quarter,
888                dot_count: 0,
889                is_rest: false,
890                tuplet: None,
891            }))
892            .unwrap();
893        engine
894            .apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
895            .unwrap();
896        let pitch = &engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
897        assert_eq!(pitch.step, Step::D);
898        assert_eq!(pitch.alter, -1); // Db4
899    }
900
901    #[test]
902    fn begin_end_slur_creates_slur() {
903        use crate::model::commands::AddNoteCmd;
904        use crate::model::duration::Duration;
905        use crate::model::pitch::{Pitch, Step};
906        let mut engine = ScoreEngine::new();
907        engine
908            .apply(Command::AddNote(AddNoteCmd {
909                part_index: 0,
910                staff_index: 0,
911                measure_index: 0,
912                voice: 0,
913                position: 0,
914                pitch: Some(Pitch::new(Step::C, 4)),
915                duration: Duration::Quarter,
916                dot_count: 0,
917                is_rest: false,
918                tuplet: None,
919            }))
920            .unwrap();
921        engine
922            .apply(Command::AddNote(AddNoteCmd {
923                part_index: 0,
924                staff_index: 0,
925                measure_index: 0,
926                voice: 0,
927                position: 1,
928                pitch: Some(Pitch::new(Step::D, 4)),
929                duration: Duration::Quarter,
930                dot_count: 0,
931                is_rest: false,
932                tuplet: None,
933            }))
934            .unwrap();
935        let start = NoteAddr {
936            part: 0,
937            staff: 0,
938            measure: 0,
939            voice: 0,
940            note: 0,
941        };
942        let end = NoteAddr {
943            part: 0,
944            staff: 0,
945            measure: 0,
946            voice: 0,
947            note: 1,
948        };
949        engine.begin_slur(start).unwrap();
950        engine.end_slur(end).unwrap();
951        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
952        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
953    }
954
955    #[test]
956    fn end_slur_without_begin_returns_error() {
957        let mut engine = ScoreEngine::new();
958        let end = NoteAddr {
959            part: 0,
960            staff: 0,
961            measure: 0,
962            voice: 0,
963            note: 0,
964        };
965        let result = engine.end_slur(end);
966        assert!(result.is_err());
967    }
968
969    // ── SetStem ───────────────────────────────────────────────────────────────
970
971    #[test]
972    fn set_stem_sets_and_clears() {
973        use crate::model::commands::AddNoteCmd;
974        use crate::model::duration::Duration;
975        use crate::model::pitch::{Pitch, Step};
976        let mut engine = ScoreEngine::new();
977        engine
978            .apply(Command::AddNote(AddNoteCmd {
979                part_index: 0,
980                staff_index: 0,
981                measure_index: 0,
982                voice: 0,
983                position: 0,
984                pitch: Some(Pitch::new(Step::C, 4)),
985                duration: Duration::Quarter,
986                dot_count: 0,
987                is_rest: false,
988                tuplet: None,
989            }))
990            .unwrap();
991        let addr = NoteAddr {
992            part: 0,
993            staff: 0,
994            measure: 0,
995            voice: 0,
996            note: 0,
997        };
998        engine.set_stem(addr.clone(), Some(true)).unwrap();
999        assert_eq!(
1000            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1001            Some(true)
1002        );
1003        engine.set_stem(addr.clone(), Some(false)).unwrap();
1004        assert_eq!(
1005            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1006            Some(false)
1007        );
1008        engine.set_stem(addr, None).unwrap();
1009        assert_eq!(
1010            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1011            None
1012        );
1013    }
1014
1015    #[test]
1016    fn set_stem_is_undoable() {
1017        use crate::model::commands::AddNoteCmd;
1018        use crate::model::duration::Duration;
1019        use crate::model::pitch::{Pitch, Step};
1020        let mut engine = ScoreEngine::new();
1021        engine
1022            .apply(Command::AddNote(AddNoteCmd {
1023                part_index: 0,
1024                staff_index: 0,
1025                measure_index: 0,
1026                voice: 0,
1027                position: 0,
1028                pitch: Some(Pitch::new(Step::C, 4)),
1029                duration: Duration::Quarter,
1030                dot_count: 0,
1031                is_rest: false,
1032                tuplet: None,
1033            }))
1034            .unwrap();
1035        let addr = NoteAddr {
1036            part: 0,
1037            staff: 0,
1038            measure: 0,
1039            voice: 0,
1040            note: 0,
1041        };
1042        engine.set_stem(addr, Some(true)).unwrap();
1043        engine.undo().unwrap();
1044        assert_eq!(
1045            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1046            None
1047        );
1048    }
1049
1050    #[test]
1051    fn set_arpeggio_is_undoable() {
1052        use crate::model::commands::AddNoteCmd;
1053        use crate::model::duration::Duration;
1054        use crate::model::pitch::{Pitch, Step};
1055        let mut engine = ScoreEngine::new();
1056        engine
1057            .apply(Command::AddNote(AddNoteCmd {
1058                part_index: 0,
1059                staff_index: 0,
1060                measure_index: 0,
1061                voice: 0,
1062                position: 0,
1063                pitch: Some(Pitch::new(Step::C, 4)),
1064                duration: Duration::Quarter,
1065                dot_count: 0,
1066                is_rest: false,
1067                tuplet: None,
1068            }))
1069            .unwrap();
1070        let addr = NoteAddr {
1071            part: 0,
1072            staff: 0,
1073            measure: 0,
1074            voice: 0,
1075            note: 0,
1076        };
1077        engine.set_arpeggio(addr.clone(), Some(true)).unwrap();
1078        assert_eq!(
1079            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1080            Some(true)
1081        );
1082        engine.undo().unwrap();
1083        assert_eq!(
1084            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1085            None
1086        );
1087        engine.redo().unwrap();
1088        assert_eq!(
1089            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1090            Some(true)
1091        );
1092    }
1093
1094    // ── command_key ───────────────────────────────────────────────────────────
1095
1096    #[test]
1097    fn undo_key_returns_key_string() {
1098        let mut engine = ScoreEngine::new();
1099        engine
1100            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1101            .unwrap();
1102        assert_eq!(engine.undo_key(), Some("SetTempo".to_string()));
1103        assert!(engine.redo_key().is_none());
1104    }
1105
1106    #[test]
1107    fn redo_key_after_undo() {
1108        let mut engine = ScoreEngine::new();
1109        engine
1110            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1111            .unwrap();
1112        engine.undo().unwrap();
1113        assert!(engine.undo_key().is_none());
1114        assert_eq!(engine.redo_key(), Some("SetTempo".to_string()));
1115    }
1116
1117    // ── batch_apply_labeled ───────────────────────────────────────────────────
1118
1119    #[test]
1120    fn batch_apply_labeled_sets_undo_key() {
1121        let mut engine = ScoreEngine::new();
1122        engine
1123            .batch_apply_labeled(vec![Command::SetTempo(SetTempoCmd { bpm: 140 })], "ApplyAI")
1124            .unwrap();
1125        assert_eq!(engine.undo_key(), Some("ApplyAI".to_string()));
1126    }
1127
1128    #[test]
1129    fn batch_apply_labeled_empty_is_noop() {
1130        let mut engine = ScoreEngine::new();
1131        engine.batch_apply_labeled(vec![], "ApplyAI").unwrap();
1132        assert!(engine.undo_key().is_none());
1133    }
1134}