Skip to main content

acorde_core/model/
engine.rs

1use serde::{Deserialize, Serialize};
2use super::change_hint::{ChangeHint, ChangeScope};
3use super::commands::{
4    command_hint, Command, CommandStack, PasteRangeCmd, PasteVoiceCmd,
5    RespellScoreCmd, SetArpeggioCmd, SetCueCmd, SetNoteHeadCmd, SetPartGroupCmd, SetStemCmd, SetTupletCmd, ToggleSlurCmd, ToggleTrillLineCmd, AddStaffCmd, DeleteStaffCmd,
6    RespellScoreToKeyCmd,
7};
8use super::score::PartGroup;
9use super::notation::{Clef, NoteHead, TupletInfo};
10use super::score::{Note, NoteAddr, Score};
11use crate::Error;
12
13/// Serialisable snapshot of a [`ScoreEngine`]'s command history for crash recovery or replay.
14///
15/// `initial_score` is the state of the score before any commands were applied (i.e. the base
16/// loaded via [`ScoreEngine::replace_score`] or the built-in default).
17/// `commands` are the commands applied after that, in execution order.
18///
19/// Round-trip: `ScoreEngine::from_history(engine.export_history())` produces an engine whose
20/// score and version match the original.
21#[derive(Debug, Serialize, Deserialize)]
22pub struct EngineHistory {
23    pub initial_score: Score,
24    pub commands: Vec<Command>,
25}
26
27#[derive(Debug, Clone)]
28struct RangeClipboard {
29    voice: usize,
30    measures: Vec<Vec<Note>>,
31}
32
33pub struct ScoreEngine {
34    pub score: Score,
35    pub commands: CommandStack,
36    pub version: u64,
37    pub clipboard: Option<Vec<Note>>,
38    range_clipboard: Option<RangeClipboard>,
39    initial_score: Score,
40    pending_slur_start: Option<NoteAddr>,
41}
42
43impl Default for ScoreEngine {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl ScoreEngine {
50    pub fn new() -> Self {
51        let mut score = Score::default();
52        for part in &mut score.parts {
53            for staff in &mut part.staves {
54                for (i, m) in staff.measures.iter_mut().enumerate() {
55                    m.number = i as u32 + 1;
56                }
57            }
58        }
59        let initial_score = score.clone();
60        Self { score, commands: CommandStack::new(200), version: 0, clipboard: None, range_clipboard: None, initial_score, pending_slur_start: None }
61    }
62
63    pub fn apply(&mut self, cmd: Command) -> Result<ChangeHint, Error> {
64        let hint = command_hint(&cmd);
65        self.commands.execute(cmd, &mut self.score)?;
66        self.version += 1;
67        Ok(hint)
68    }
69
70    pub fn undo(&mut self) -> Result<ChangeHint, Error> {
71        let hint = self.commands.undo(&mut self.score)?;
72        self.version += 1;
73        Ok(hint)
74    }
75
76    pub fn redo(&mut self) -> Result<ChangeHint, Error> {
77        let hint = self.commands.redo(&mut self.score)?;
78        self.version += 1;
79        Ok(hint)
80    }
81
82    /// Apply multiple commands as a single undo entry.
83    pub fn batch_apply(&mut self, cmds: Vec<Command>) -> Result<ChangeHint, Error> {
84        if cmds.is_empty() {
85            return Ok(ChangeHint { scope: ChangeScope::Global, layout_dirty: false, playback_dirty: false });
86        }
87        let mut hint = command_hint(&cmds[0]);
88        for cmd in cmds.iter().skip(1) { hint = hint.merge(command_hint(cmd)); }
89        self.commands.batch_execute(cmds, &mut self.score)?;
90        self.version += 1;
91        Ok(hint)
92    }
93
94    /// Apply a batch of commands as a single undo entry with an explicit label.
95    ///
96    /// The `label` appears as the [`command_key`] in undo/redo UI (e.g. `"ApplyAI"`).
97    pub fn batch_apply_labeled(&mut self, cmds: Vec<Command>, label: &str) -> Result<ChangeHint, Error> {
98        if cmds.is_empty() {
99            return Ok(ChangeHint { scope: ChangeScope::Global, layout_dirty: false, playback_dirty: false });
100        }
101        let mut hint = command_hint(&cmds[0]);
102        for cmd in cmds.iter().skip(1) { hint = hint.merge(command_hint(cmd)); }
103        self.commands.batch_execute_labeled(cmds, label.to_string(), &mut self.score)?;
104        self.version += 1;
105        Ok(hint)
106    }
107
108    /// Label of the next undoable command (for "Undo: Add Note" menu items).
109    pub fn undo_label(&self) -> Option<String> {
110        self.commands.undo_label()
111    }
112
113    /// Label of the next redoable command (for "Redo: Add Note" menu items).
114    pub fn redo_label(&self) -> Option<String> {
115        self.commands.redo_label()
116    }
117
118    /// i18n key of the next undoable command (e.g. `"SetTempo"`).
119    pub fn undo_key(&self) -> Option<String> {
120        self.commands.undo_key()
121    }
122
123    /// i18n key of the next redoable command.
124    pub fn redo_key(&self) -> Option<String> {
125        self.commands.redo_key()
126    }
127
128    pub fn replace_score(&mut self, score: Score) {
129        self.initial_score = score.clone();
130        self.score = score;
131        self.version += 1;
132        self.commands = CommandStack::new(200);
133    }
134
135    /// Export the command history for serialization (crash recovery, AI replay).
136    ///
137    /// The returned [`EngineHistory`] can be stored as JSON and later restored with
138    /// [`ScoreEngine::from_history`].
139    pub fn export_history(&self) -> EngineHistory {
140        EngineHistory {
141            initial_score: self.initial_score.clone(),
142            commands: self.commands.history_commands(),
143        }
144    }
145
146    /// Reconstruct an engine from a previously exported [`EngineHistory`].
147    ///
148    /// Replays all commands against `history.initial_score` in order.
149    /// Returns an error if any command fails (e.g. index out of bounds due to stale data).
150    pub fn from_history(history: EngineHistory) -> Result<Self, Error> {
151        let mut engine = ScoreEngine::new();
152        engine.replace_score(history.initial_score);
153        for cmd in history.commands {
154            engine.apply(cmd)?;
155        }
156        Ok(engine)
157    }
158
159    pub fn copy_voice(
160        &mut self,
161        part_index: usize,
162        staff_index: usize,
163        measure_index: usize,
164        voice_index: usize,
165    ) -> Result<(), Error> {
166        let voice = self.score.parts
167            .get(part_index).ok_or(Error::PartNotFound(part_index))?
168            .staves.get(staff_index).ok_or(Error::StaffNotFound(staff_index))?
169            .measures.get(measure_index).ok_or(Error::MeasureNotFound(measure_index))?
170            .voices.get(voice_index).ok_or(Error::VoiceOutOfRange(voice_index))?;
171        self.clipboard = Some(voice.clone());
172        Ok(())
173    }
174
175    pub fn paste_voice(
176        &mut self,
177        part_index: usize,
178        staff_index: usize,
179        measure_index: usize,
180        voice_index: usize,
181    ) -> Result<ChangeHint, Error> {
182        let notes = self.clipboard.clone().ok_or(Error::ClipboardEmpty)?;
183        self.apply(Command::PasteVoice(PasteVoiceCmd {
184            part_index,
185            staff_index,
186            measure_index,
187            voice_index,
188            notes,
189        }))
190    }
191
192    /// Copy a range of measures from a single voice into the range clipboard.
193    ///
194    /// The range is inclusive: all measures from `start.measure` to `end.measure`.
195    /// `start` and `end` must share the same `part`, `staff`, and `voice`.
196    pub fn copy_range(&mut self, start: NoteAddr, end: NoteAddr) -> Result<(), Error> {
197        if start.part != end.part || start.staff != end.staff || start.voice != end.voice {
198            return Err(Error::InvalidCommand(
199                "copy_range: start and end must share the same part, staff, and voice".into(),
200            ));
201        }
202        let from = start.measure.min(end.measure);
203        let to   = start.measure.max(end.measure);
204        let staff = self.score.parts
205            .get(start.part).ok_or(Error::PartNotFound(start.part))?
206            .staves.get(start.staff).ok_or(Error::StaffNotFound(start.staff))?;
207        if start.voice >= 4 { return Err(Error::VoiceOutOfRange(start.voice)); }
208        let mut measures = Vec::new();
209        for mi in from..=to {
210            let m = staff.measures.get(mi).ok_or(Error::MeasureNotFound(mi))?;
211            measures.push(m.voices[start.voice].clone());
212        }
213        self.range_clipboard = Some(RangeClipboard { voice: start.voice, measures });
214        Ok(())
215    }
216
217    /// Paste the range clipboard starting at `target`, creating an undo-able command.
218    ///
219    /// The voice index from the original `copy_range` call is used; `target.voice` is ignored.
220    pub fn paste_range(&mut self, target: NoteAddr) -> Result<ChangeHint, Error> {
221        let rc = self.range_clipboard.clone().ok_or(Error::ClipboardEmpty)?;
222        self.apply(Command::PasteRange(PasteRangeCmd {
223            part_index: target.part,
224            staff_index: target.staff,
225            voice_index: rc.voice,
226            target_measure: target.measure,
227            measures: rc.measures,
228        }))
229    }
230
231    /// Toggle the slur between two notes (undo-able).
232    pub fn toggle_slur(&mut self, start: NoteAddr, end: NoteAddr) -> Result<ChangeHint, Error> {
233        self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
234    }
235
236    /// Add a staff to a part (undo-able).
237    pub fn add_staff(&mut self, part_index: usize, clef: Clef) -> Result<ChangeHint, Error> {
238        self.apply(Command::AddStaff(AddStaffCmd { part_index, clef }))
239    }
240
241    /// Remove a staff from a part (undo-able). Fails if it is the last remaining staff.
242    pub fn delete_staff(&mut self, part_index: usize, staff_index: usize) -> Result<ChangeHint, Error> {
243        self.apply(Command::DeleteStaff(DeleteStaffCmd { part_index, staff_index }))
244    }
245
246    /// Set or clear the stem direction on an existing note (undo-able).
247    ///
248    /// `stem_up`: `None` = auto, `Some(true)` = up, `Some(false)` = down.
249    pub fn set_stem(&mut self, addr: NoteAddr, stem_up: Option<bool>) -> Result<ChangeHint, Error> {
250        self.apply(Command::SetStem(SetStemCmd {
251            part_index:    addr.part,
252            staff_index:   addr.staff,
253            measure_index: addr.measure,
254            voice_index:   addr.voice,
255            note_index:    addr.note,
256            stem_up,
257        }))
258    }
259
260    /// Set or clear the arpeggio direction on an existing note (undo-able).
261    pub fn set_arpeggio(&mut self, addr: NoteAddr, direction: Option<bool>) -> Result<ChangeHint, Error> {
262        self.apply(Command::SetArpeggio(SetArpeggioCmd {
263            part_index:    addr.part,
264            staff_index:   addr.staff,
265            measure_index: addr.measure,
266            voice_index:   addr.voice,
267            note_index:    addr.note,
268            direction,
269        }))
270    }
271
272    /// Set the note head shape on an existing note (undo-able).
273    pub fn set_note_head(&mut self, addr: NoteAddr, note_head: NoteHead) -> Result<ChangeHint, Error> {
274        self.apply(Command::SetNoteHead(SetNoteHeadCmd {
275            part_index:    addr.part,
276            staff_index:   addr.staff,
277            measure_index: addr.measure,
278            voice:         addr.voice,
279            note_index:    addr.note,
280            note_head,
281        }))
282    }
283
284    /// Add or replace a part group (undo-able). Pass `None` to clear all groups.
285    pub fn set_part_group(&mut self, group: Option<PartGroup>) -> Result<ChangeHint, Error> {
286        self.apply(Command::SetPartGroup(SetPartGroupCmd { group }))
287    }
288
289    /// Toggle a trill line span between two notes (undo-able).
290    pub fn toggle_trill_line(&mut self, start: NoteAddr, end: NoteAddr) -> Result<ChangeHint, Error> {
291        self.apply(Command::ToggleTrillLine(ToggleTrillLineCmd { start, end }))
292    }
293
294    /// Set or clear the cue flag on a note (undo-able). Cue notes have zero beats.
295    pub fn set_cue(&mut self, addr: NoteAddr, is_cue: bool) -> Result<ChangeHint, Error> {
296        self.apply(Command::SetCue(SetCueCmd {
297            part_index:    addr.part,
298            staff_index:   addr.staff,
299            measure_index: addr.measure,
300            voice:         addr.voice,
301            note_index:    addr.note,
302            is_cue,
303        }))
304    }
305
306    /// Set or clear the tuplet on an existing note (undo-able).
307    pub fn set_tuplet(&mut self, addr: NoteAddr, tuplet: Option<TupletInfo>) -> Result<ChangeHint, Error> {
308        self.apply(Command::SetTuplet(SetTupletCmd {
309            part_index: addr.part,
310            staff_index: addr.staff,
311            measure_index: addr.measure,
312            voice_index: addr.voice,
313            note_index: addr.note,
314            tuplet,
315        }))
316    }
317
318    /// Respell all pitches in the score (undo-able).
319    pub fn respell_score(&mut self, prefer_flat: bool) -> Result<ChangeHint, Error> {
320        self.apply(Command::RespellScore(RespellScoreCmd { prefer_flat }))
321    }
322
323    /// Respell all pitches to match the score's key signature (undo-able).
324    pub fn respell_score_to_key(&mut self) -> Result<ChangeHint, Error> {
325        self.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
326    }
327
328    /// Begin a two-step slur: record `start` and wait for [`end_slur`](Self::end_slur).
329    ///
330    /// Returns an error if `start` does not point to a valid note.
331    pub fn begin_slur(&mut self, start: NoteAddr) -> Result<(), Error> {
332        self.score.parts.get(start.part).ok_or(Error::PartNotFound(start.part))?
333            .staves.get(start.staff).ok_or(Error::StaffNotFound(start.staff))?
334            .measures.get(start.measure).ok_or(Error::MeasureNotFound(start.measure))?
335            .voices.get(start.voice).ok_or(Error::VoiceOutOfRange(start.voice))?
336            .get(start.note).ok_or(Error::NoteNotFound(start.note))?;
337        self.pending_slur_start = Some(start);
338        Ok(())
339    }
340
341    /// Complete the slur started by [`begin_slur`](Self::begin_slur) (undo-able).
342    ///
343    /// Returns `Error::InvalidCommand` if `begin_slur` has not been called.
344    pub fn end_slur(&mut self, end: NoteAddr) -> Result<ChangeHint, Error> {
345        let start = self.pending_slur_start.take()
346            .ok_or_else(|| Error::InvalidCommand("no slur in progress".to_string()))?;
347        self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::model::commands::{SetTempoCmd, NewScoreCmd};
355
356    #[test]
357    fn new_engine_has_default_score() {
358        let engine = ScoreEngine::new();
359        assert_eq!(engine.version, 0);
360        assert_eq!(engine.score.parts.len(), 1);
361    }
362
363    #[test]
364    fn apply_increments_version() {
365        let mut engine = ScoreEngine::new();
366        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
367        assert_eq!(engine.version, 1);
368    }
369
370    #[test]
371    fn undo_redo_cycle() {
372        let mut engine = ScoreEngine::new();
373        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
374        let after_apply = engine.version;
375        engine.undo().unwrap();
376        assert_eq!(engine.score.settings.tempo_bpm, 120);
377        engine.redo().unwrap();
378        assert_eq!(engine.score.settings.tempo_bpm, 140);
379        assert!(engine.version > after_apply);
380    }
381
382    #[test]
383    fn replace_score_clears_history() {
384        let mut engine = ScoreEngine::new();
385        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
386        let new_score = Score::new("New", 90, 3, 4, 2, 8);
387        engine.replace_score(new_score);
388        assert!(engine.undo().is_err());
389        assert_eq!(engine.score.settings.tempo_bpm, 90);
390    }
391
392    #[test]
393    fn copy_paste_voice_copies_notes() {
394        use crate::model::score::Note;
395        use crate::model::pitch::{Pitch, Step};
396        use crate::model::duration::Duration;
397        let mut engine = ScoreEngine::new();
398        engine.score.parts[0].staves[0].measures[0].voices[0] =
399            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
400        engine.copy_voice(0, 0, 0, 0).unwrap();
401        engine.paste_voice(0, 0, 0, 1).unwrap();
402        let pasted = &engine.score.parts[0].staves[0].measures[0].voices[1];
403        assert_eq!(pasted.len(), 1);
404        assert_eq!(pasted[0].pitches[0].step, Step::C);
405    }
406
407    #[test]
408    fn paste_voice_undo_restores_original() {
409        use crate::model::score::Note;
410        use crate::model::pitch::{Pitch, Step};
411        use crate::model::duration::Duration;
412        let mut engine = ScoreEngine::new();
413        engine.score.parts[0].staves[0].measures[0].voices[0] =
414            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
415        engine.copy_voice(0, 0, 0, 0).unwrap();
416        engine.paste_voice(0, 0, 0, 1).unwrap();
417        engine.undo().unwrap();
418        assert!(engine.score.parts[0].staves[0].measures[0].voices[1].is_empty());
419    }
420
421    #[test]
422    fn paste_voice_without_copy_returns_error() {
423        let mut engine = ScoreEngine::new();
424        assert!(engine.paste_voice(0, 0, 0, 0).is_err());
425    }
426
427    #[test]
428    fn change_hint_set_tempo_is_global() {
429        use crate::model::change_hint::ChangeScope;
430        let mut engine = ScoreEngine::new();
431        let hint = engine.apply(Command::SetTempo(SetTempoCmd { bpm: 100 })).unwrap();
432        assert_eq!(hint.scope, ChangeScope::Global);
433        assert!(!hint.layout_dirty);
434        assert!(hint.playback_dirty);
435    }
436
437    #[test]
438    fn change_hint_add_note_is_measure_scope() {
439        use crate::model::change_hint::ChangeScope;
440        use crate::model::commands::AddNoteCmd;
441        use crate::model::pitch::Step;
442        use crate::model::duration::Duration;
443        use crate::model::pitch::Pitch;
444        let mut engine = ScoreEngine::new();
445        let hint = engine.apply(Command::AddNote(AddNoteCmd {
446            part_index: 0, staff_index: 0, measure_index: 0, voice: 0,
447            position: 0, pitch: Some(Pitch::new(Step::C, 4)),
448            duration: Duration::Quarter, dot_count: 0, is_rest: false, tuplet: None,
449        })).unwrap();
450        assert_eq!(hint.scope, ChangeScope::Measures { part: 0, staff: 0, start: 0, end: 1 });
451        assert!(!hint.layout_dirty);
452        assert!(hint.playback_dirty);
453    }
454
455    #[test]
456    fn change_hint_set_part_name_no_dirty() {
457        use crate::model::change_hint::ChangeScope;
458        use crate::model::commands::SetPartNameCmd;
459        let mut engine = ScoreEngine::new();
460        let hint = engine.apply(Command::SetPartName(SetPartNameCmd {
461            part_index: 0, name: "Violin".into(), short_name: "Vln.".into(),
462        })).unwrap();
463        assert_eq!(hint.scope, ChangeScope::Part(0));
464        assert!(!hint.layout_dirty);
465        assert!(!hint.playback_dirty);
466    }
467
468    #[test]
469    fn undo_returns_change_hint() {
470        use crate::model::change_hint::ChangeScope;
471        let mut engine = ScoreEngine::new();
472        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 160 })).unwrap();
473        let hint = engine.undo().unwrap();
474        assert_eq!(hint.scope, ChangeScope::Global);
475        assert!(hint.playback_dirty);
476    }
477
478    #[test]
479    fn redo_returns_change_hint() {
480        use crate::model::change_hint::ChangeScope;
481        let mut engine = ScoreEngine::new();
482        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 160 })).unwrap();
483        engine.undo().unwrap();
484        let hint = engine.redo().unwrap();
485        assert_eq!(hint.scope, ChangeScope::Global);
486        assert!(hint.playback_dirty);
487    }
488
489    #[test]
490    fn batch_apply_two_commands_single_undo() {
491        let mut engine = ScoreEngine::new();
492        let original = engine.score.settings.tempo_bpm;
493        engine.batch_apply(vec![
494            Command::SetTempo(SetTempoCmd { bpm: 160 }),
495            Command::SetTempo(SetTempoCmd { bpm: 180 }),
496        ]).unwrap();
497        assert_eq!(engine.score.settings.tempo_bpm, 180);
498        engine.undo().unwrap();
499        assert_eq!(engine.score.settings.tempo_bpm, original);
500        assert!(engine.undo().is_err());
501    }
502
503    #[test]
504    fn batch_apply_empty_returns_no_dirty() {
505        let mut engine = ScoreEngine::new();
506        let v0 = engine.version;
507        let hint = engine.batch_apply(vec![]).unwrap();
508        assert!(!hint.layout_dirty);
509        assert!(!hint.playback_dirty);
510        assert_eq!(engine.version, v0);
511    }
512
513    #[test]
514    fn batch_apply_hint_merges_scopes() {
515        use crate::model::change_hint::ChangeScope;
516        use crate::model::commands::{AddNoteCmd, SetTempoCmd};
517        use crate::model::pitch::{Pitch, Step};
518        use crate::model::duration::Duration;
519        let mut engine = ScoreEngine::new();
520        let hint = engine.batch_apply(vec![
521            Command::SetTempo(SetTempoCmd { bpm: 140 }),
522            Command::AddNote(AddNoteCmd {
523                part_index: 0, staff_index: 0, measure_index: 0, voice: 0,
524                position: 0, pitch: Some(Pitch::new(Step::C, 4)),
525                duration: Duration::Quarter, dot_count: 0, is_rest: false, tuplet: None,
526            }),
527        ]).unwrap();
528        // SetTempo = Global; merged with Measures = Global
529        assert_eq!(hint.scope, ChangeScope::Global);
530        assert!(hint.playback_dirty);
531    }
532
533    #[test]
534    fn undo_label_none_when_empty() {
535        let engine = ScoreEngine::new();
536        assert!(engine.undo_label().is_none());
537        assert!(engine.redo_label().is_none());
538    }
539
540    #[test]
541    fn undo_label_after_command() {
542        let mut engine = ScoreEngine::new();
543        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
544        assert_eq!(engine.undo_label(), Some("Set Tempo".to_string()));
545        assert!(engine.redo_label().is_none());
546    }
547
548    #[test]
549    fn redo_label_after_undo() {
550        let mut engine = ScoreEngine::new();
551        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
552        engine.undo().unwrap();
553        assert!(engine.undo_label().is_none());
554        assert_eq!(engine.redo_label(), Some("Set Tempo".to_string()));
555    }
556
557    #[test]
558    fn copy_range_paste_range_roundtrip() {
559        use crate::model::score::{Note, NoteAddr};
560        use crate::model::pitch::{Pitch, Step};
561        use crate::model::duration::Duration;
562        let mut engine = ScoreEngine::new();
563        // Put a note in measure 0
564        engine.score.parts[0].staves[0].measures[0].voices[0] =
565            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
566        // Need measure 1: add one
567        use crate::model::commands::{AddMeasureCmd};
568        engine.apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 })).unwrap();
569
570        let start = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
571        let end   = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
572        engine.copy_range(start, end).unwrap();
573
574        let target = NoteAddr { part: 0, staff: 0, measure: 1, voice: 0, note: 0 };
575        engine.paste_range(target).unwrap();
576
577        let pasted = &engine.score.parts[0].staves[0].measures[1].voices[0];
578        assert_eq!(pasted.len(), 1);
579        assert_eq!(pasted[0].pitches[0].step, Step::C);
580    }
581
582    #[test]
583    fn paste_range_is_undoable() {
584        use crate::model::score::{Note, NoteAddr};
585        use crate::model::pitch::{Pitch, Step};
586        use crate::model::duration::Duration;
587        use crate::model::commands::AddMeasureCmd;
588        let mut engine = ScoreEngine::new();
589        engine.score.parts[0].staves[0].measures[0].voices[0] =
590            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
591        engine.apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 })).unwrap();
592
593        let start = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
594        let end = start.clone();
595        engine.copy_range(start, end).unwrap();
596        let target = NoteAddr { part: 0, staff: 0, measure: 1, voice: 0, note: 0 };
597        engine.paste_range(target).unwrap();
598
599        // Undo should restore measure 1 to its pre-paste state
600        engine.undo().unwrap();
601        let restored = &engine.score.parts[0].staves[0].measures[1].voices[0];
602        assert!(restored.iter().all(|n| n.is_rest));
603    }
604
605    #[test]
606    fn copy_range_mismatched_part_returns_error() {
607        let engine = ScoreEngine::new();
608        // Can't call copy_range mutably here since we need &mut, so test via a new engine
609        let mut e = ScoreEngine::new();
610        use crate::model::score::NoteAddr;
611        let start = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
612        let end   = NoteAddr { part: 1, staff: 0, measure: 0, voice: 0, note: 0 };
613        assert!(e.copy_range(start, end).is_err());
614        let _ = engine; // suppress unused warning
615    }
616
617    #[test]
618    fn export_history_roundtrip() {
619        let mut engine = ScoreEngine::new();
620        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 160 })).unwrap();
621        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 180 })).unwrap();
622        let history = engine.export_history();
623        assert_eq!(history.commands.len(), 2);
624        let restored = ScoreEngine::from_history(history).unwrap();
625        assert_eq!(restored.score.settings.tempo_bpm, 180);
626    }
627
628    #[test]
629    fn export_history_empty_gives_initial_state() {
630        let engine = ScoreEngine::new();
631        let history = engine.export_history();
632        assert!(history.commands.is_empty());
633        let restored = ScoreEngine::from_history(history).unwrap();
634        assert_eq!(restored.score.settings.tempo_bpm, 120);
635    }
636
637    #[test]
638    fn replace_score_then_export_history() {
639        let mut engine = ScoreEngine::new();
640        let s = Score::new("Custom", 90, 3, 4, 2, 4);
641        engine.replace_score(s);
642        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 60 })).unwrap();
643        let history = engine.export_history();
644        assert_eq!(history.initial_score.settings.tempo_bpm, 90);
645        assert_eq!(history.commands.len(), 1);
646        let restored = ScoreEngine::from_history(history).unwrap();
647        assert_eq!(restored.score.settings.tempo_bpm, 60);
648    }
649
650    #[test]
651    fn new_score_command_replaces_score() {
652        let mut engine = ScoreEngine::new();
653        engine.apply(Command::NewScore(NewScoreCmd {
654            title: "Sonata".into(),
655            composer: "Bach".into(),
656            tempo_bpm: 80,
657            time_numerator: 3,
658            time_denominator: 4,
659            key_fifths: -1,
660            measure_count: 12,
661            template: None,
662        })).unwrap();
663        assert_eq!(engine.score.metadata.title, "Sonata");
664        assert_eq!(engine.score.measure_count(), 12);
665    }
666
667    #[test]
668    fn respell_score_to_key_uses_key_signature() {
669        use crate::model::commands::{AddNoteCmd, RespellScoreToKeyCmd};
670        use crate::model::pitch::Step;
671        use crate::model::notation::KeySignature;
672        let mut engine = ScoreEngine::new();
673        // Set Bb major (2 flats, fifths = -2) → prefer_flat = true
674        engine.score.settings.key_signature = KeySignature { fifths: -2, mode: "major".to_string() };
675        engine.apply(Command::AddNote(AddNoteCmd {
676            part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 0,
677            pitch: Some(crate::model::pitch::Pitch::with_alter(Step::C, 4, 1)), // C#4
678            duration: crate::model::duration::Duration::Quarter,
679            dot_count: 0, is_rest: false, tuplet: None,
680        })).unwrap();
681        engine.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {})).unwrap();
682        let pitch = &engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
683        assert_eq!(pitch.step, Step::D);
684        assert_eq!(pitch.alter, -1); // Db4
685    }
686
687    #[test]
688    fn begin_end_slur_creates_slur() {
689        use crate::model::commands::AddNoteCmd;
690        use crate::model::pitch::{Pitch, Step};
691        use crate::model::duration::Duration;
692        let mut engine = ScoreEngine::new();
693        engine.apply(Command::AddNote(AddNoteCmd {
694            part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 0,
695            pitch: Some(Pitch::new(Step::C, 4)), duration: Duration::Quarter,
696            dot_count: 0, is_rest: false, tuplet: None,
697        })).unwrap();
698        engine.apply(Command::AddNote(AddNoteCmd {
699            part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 1,
700            pitch: Some(Pitch::new(Step::D, 4)), duration: Duration::Quarter,
701            dot_count: 0, is_rest: false, tuplet: None,
702        })).unwrap();
703        let start = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
704        let end   = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 1 };
705        engine.begin_slur(start).unwrap();
706        engine.end_slur(end).unwrap();
707        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
708        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
709    }
710
711    #[test]
712    fn end_slur_without_begin_returns_error() {
713        let mut engine = ScoreEngine::new();
714        let end = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
715        let result = engine.end_slur(end);
716        assert!(result.is_err());
717    }
718
719    // ── SetStem ───────────────────────────────────────────────────────────────
720
721    #[test]
722    fn set_stem_sets_and_clears() {
723        use crate::model::commands::AddNoteCmd;
724        use crate::model::pitch::{Pitch, Step};
725        use crate::model::duration::Duration;
726        let mut engine = ScoreEngine::new();
727        engine.apply(Command::AddNote(AddNoteCmd {
728            part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 0,
729            pitch: Some(Pitch::new(Step::C, 4)), duration: Duration::Quarter,
730            dot_count: 0, is_rest: false, tuplet: None,
731        })).unwrap();
732        let addr = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
733        engine.set_stem(addr.clone(), Some(true)).unwrap();
734        assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up, Some(true));
735        engine.set_stem(addr.clone(), Some(false)).unwrap();
736        assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up, Some(false));
737        engine.set_stem(addr, None).unwrap();
738        assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up, None);
739    }
740
741    #[test]
742    fn set_stem_is_undoable() {
743        use crate::model::commands::AddNoteCmd;
744        use crate::model::pitch::{Pitch, Step};
745        use crate::model::duration::Duration;
746        let mut engine = ScoreEngine::new();
747        engine.apply(Command::AddNote(AddNoteCmd {
748            part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 0,
749            pitch: Some(Pitch::new(Step::C, 4)), duration: Duration::Quarter,
750            dot_count: 0, is_rest: false, tuplet: None,
751        })).unwrap();
752        let addr = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
753        engine.set_stem(addr, Some(true)).unwrap();
754        engine.undo().unwrap();
755        assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up, None);
756    }
757
758    #[test]
759    fn set_arpeggio_is_undoable() {
760        use crate::model::commands::AddNoteCmd;
761        use crate::model::pitch::{Pitch, Step};
762        use crate::model::duration::Duration;
763        let mut engine = ScoreEngine::new();
764        engine.apply(Command::AddNote(AddNoteCmd {
765            part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 0,
766            pitch: Some(Pitch::new(Step::C, 4)), duration: Duration::Quarter,
767            dot_count: 0, is_rest: false, tuplet: None,
768        })).unwrap();
769        let addr = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
770        engine.set_arpeggio(addr.clone(), Some(true)).unwrap();
771        assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate, Some(true));
772        engine.undo().unwrap();
773        assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate, None);
774        engine.redo().unwrap();
775        assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate, Some(true));
776    }
777
778    // ── command_key ───────────────────────────────────────────────────────────
779
780    #[test]
781    fn undo_key_returns_key_string() {
782        let mut engine = ScoreEngine::new();
783        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
784        assert_eq!(engine.undo_key(), Some("SetTempo".to_string()));
785        assert!(engine.redo_key().is_none());
786    }
787
788    #[test]
789    fn redo_key_after_undo() {
790        let mut engine = ScoreEngine::new();
791        engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
792        engine.undo().unwrap();
793        assert!(engine.undo_key().is_none());
794        assert_eq!(engine.redo_key(), Some("SetTempo".to_string()));
795    }
796
797    // ── batch_apply_labeled ───────────────────────────────────────────────────
798
799    #[test]
800    fn batch_apply_labeled_sets_undo_key() {
801        let mut engine = ScoreEngine::new();
802        engine.batch_apply_labeled(
803            vec![Command::SetTempo(SetTempoCmd { bpm: 140 })],
804            "ApplyAI",
805        ).unwrap();
806        assert_eq!(engine.undo_key(), Some("ApplyAI".to_string()));
807    }
808
809    #[test]
810    fn batch_apply_labeled_empty_is_noop() {
811        let mut engine = ScoreEngine::new();
812        engine.batch_apply_labeled(vec![], "ApplyAI").unwrap();
813        assert!(engine.undo_key().is_none());
814    }
815}