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