Skip to main content

acorde_core/model/
engine.rs

1use super::change_hint::{ChangeHint, ChangeScope};
2use super::commands::{
3    AddStaffCmd, Command, CommandStack, DeleteStaffCmd, PasteRangeCmd, PasteVoiceCmd,
4    RespellScoreCmd, RespellScoreToKeyCmd, SetArpeggioCmd, SetCueCmd, SetDurationCmd,
5    SetInstrumentIdCmd, SetNoteHeadCmd, SetPartGroupCmd, SetStemCmd, SetTupletCmd, SetUnpitchedCmd,
6    ToggleSlurCmd, ToggleTrillLineCmd, command_hint,
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 unpitched flag while retaining display placement.
390    pub fn set_unpitched(
391        &mut self,
392        addr: NoteAddr,
393        is_unpitched: bool,
394    ) -> Result<ChangeHint, Error> {
395        self.apply(Command::SetUnpitched(SetUnpitchedCmd {
396            part_index: addr.part,
397            staff_index: addr.staff,
398            measure_index: addr.measure,
399            voice: addr.voice,
400            note_index: addr.note,
401            is_unpitched,
402        }))
403    }
404
405    /// Set or clear a source instrument identifier attached to a note.
406    pub fn set_instrument_id(
407        &mut self,
408        addr: NoteAddr,
409        instrument_id: Option<String>,
410    ) -> Result<ChangeHint, Error> {
411        self.apply(Command::SetInstrumentId(SetInstrumentIdCmd {
412            part_index: addr.part,
413            staff_index: addr.staff,
414            measure_index: addr.measure,
415            voice: addr.voice,
416            note_index: addr.note,
417            instrument_id,
418        }))
419    }
420
421    /// Set or clear the tuplet on an existing note (undo-able).
422    pub fn set_tuplet(
423        &mut self,
424        addr: NoteAddr,
425        tuplet: Option<TupletInfo>,
426    ) -> Result<ChangeHint, Error> {
427        self.apply(Command::SetTuplet(SetTupletCmd {
428            part_index: addr.part,
429            staff_index: addr.staff,
430            measure_index: addr.measure,
431            voice_index: addr.voice,
432            note_index: addr.note,
433            tuplet,
434        }))
435    }
436
437    /// Respell all pitches in the score (undo-able).
438    pub fn respell_score(&mut self, prefer_flat: bool) -> Result<ChangeHint, Error> {
439        self.apply(Command::RespellScore(RespellScoreCmd { prefer_flat }))
440    }
441
442    /// Respell all pitches to match the score's key signature (undo-able).
443    pub fn respell_score_to_key(&mut self) -> Result<ChangeHint, Error> {
444        self.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
445    }
446
447    /// Begin a two-step slur: record `start` and wait for [`end_slur`](Self::end_slur).
448    ///
449    /// Returns an error if `start` does not point to a valid note.
450    pub fn begin_slur(&mut self, start: NoteAddr) -> Result<(), Error> {
451        self.score
452            .parts
453            .get(start.part)
454            .ok_or(Error::PartNotFound(start.part))?
455            .staves
456            .get(start.staff)
457            .ok_or(Error::StaffNotFound(start.staff))?
458            .measures
459            .get(start.measure)
460            .ok_or(Error::MeasureNotFound(start.measure))?
461            .voices
462            .get(start.voice)
463            .ok_or(Error::VoiceOutOfRange(start.voice))?
464            .get(start.note)
465            .ok_or(Error::NoteNotFound(start.note))?;
466        self.pending_slur_start = Some(start);
467        Ok(())
468    }
469
470    /// Complete the slur started by [`begin_slur`](Self::begin_slur) (undo-able).
471    ///
472    /// Returns `Error::InvalidCommand` if `begin_slur` has not been called.
473    pub fn end_slur(&mut self, end: NoteAddr) -> Result<ChangeHint, Error> {
474        let start = self
475            .pending_slur_start
476            .take()
477            .ok_or_else(|| Error::InvalidCommand("no slur in progress".to_string()))?;
478        self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::model::commands::{NewScoreCmd, SetTempoCmd};
486
487    #[test]
488    fn new_engine_has_default_score() {
489        let engine = ScoreEngine::new();
490        assert_eq!(engine.version, 0);
491        assert_eq!(engine.score.parts.len(), 1);
492    }
493
494    #[test]
495    fn apply_increments_version() {
496        let mut engine = ScoreEngine::new();
497        engine
498            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
499            .unwrap();
500        assert_eq!(engine.version, 1);
501    }
502
503    #[test]
504    fn undo_redo_cycle() {
505        let mut engine = ScoreEngine::new();
506        engine
507            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
508            .unwrap();
509        let after_apply = engine.version;
510        engine.undo().unwrap();
511        assert_eq!(engine.score.settings.tempo_bpm, 120);
512        engine.redo().unwrap();
513        assert_eq!(engine.score.settings.tempo_bpm, 140);
514        assert!(engine.version > after_apply);
515    }
516
517    #[test]
518    fn replace_score_clears_history() {
519        let mut engine = ScoreEngine::new();
520        engine
521            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
522            .unwrap();
523        let new_score = Score::new("New", 90, 3, 4, 2, 8);
524        engine.replace_score(new_score);
525        assert!(engine.undo().is_err());
526        assert_eq!(engine.score.settings.tempo_bpm, 90);
527    }
528
529    #[test]
530    fn copy_paste_voice_copies_notes() {
531        use crate::model::duration::Duration;
532        use crate::model::pitch::{Pitch, Step};
533        use crate::model::score::Note;
534        let mut engine = ScoreEngine::new();
535        engine.score.parts[0].staves[0].measures[0].voices[0] =
536            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
537        engine.copy_voice(0, 0, 0, 0).unwrap();
538        engine.paste_voice(0, 0, 0, 1).unwrap();
539        let pasted = &engine.score.parts[0].staves[0].measures[0].voices[1];
540        assert_eq!(pasted.len(), 1);
541        assert_eq!(pasted[0].pitches[0].step, Step::C);
542    }
543
544    #[test]
545    fn paste_voice_undo_restores_original() {
546        use crate::model::duration::Duration;
547        use crate::model::pitch::{Pitch, Step};
548        use crate::model::score::Note;
549        let mut engine = ScoreEngine::new();
550        engine.score.parts[0].staves[0].measures[0].voices[0] =
551            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
552        engine.copy_voice(0, 0, 0, 0).unwrap();
553        engine.paste_voice(0, 0, 0, 1).unwrap();
554        engine.undo().unwrap();
555        assert!(engine.score.parts[0].staves[0].measures[0].voices[1].is_empty());
556    }
557
558    #[test]
559    fn unpitched_flag_is_undoable_and_redoable() {
560        let mut engine = ScoreEngine::new();
561        let addr = NoteAddr {
562            part: 0,
563            staff: 0,
564            measure: 0,
565            voice: 0,
566            note: 0,
567        };
568        engine.set_unpitched(addr.clone(), true).unwrap();
569        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
570        engine.undo().unwrap();
571        assert!(!engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
572        engine.redo().unwrap();
573        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
574        engine
575            .set_instrument_id(addr, Some("P1-I2".to_string()))
576            .unwrap();
577        assert_eq!(
578            engine.score.parts[0].staves[0].measures[0].voices[0][0]
579                .instrument_id
580                .as_deref(),
581            Some("P1-I2")
582        );
583        for command in [
584            Command::SetUnpitched(super::super::commands::SetUnpitchedCmd {
585                part_index: 0,
586                staff_index: 0,
587                measure_index: 0,
588                voice: 0,
589                note_index: 0,
590                is_unpitched: false,
591            }),
592            Command::SetInstrumentId(super::super::commands::SetInstrumentIdCmd {
593                part_index: 0,
594                staff_index: 0,
595                measure_index: 0,
596                voice: 0,
597                note_index: 0,
598                instrument_id: Some("P1-I2".to_string()),
599            }),
600        ] {
601            let json = serde_json::to_string(&command).unwrap();
602            let restored: Command = serde_json::from_str(&json).unwrap();
603            assert_eq!(
604                super::super::commands::command_key(&restored),
605                super::super::commands::command_key(&command)
606            );
607        }
608    }
609
610    #[test]
611    fn paste_voice_without_copy_returns_error() {
612        let mut engine = ScoreEngine::new();
613        assert!(engine.paste_voice(0, 0, 0, 0).is_err());
614    }
615
616    #[test]
617    fn change_hint_set_tempo_is_global() {
618        use crate::model::change_hint::ChangeScope;
619        let mut engine = ScoreEngine::new();
620        let hint = engine
621            .apply(Command::SetTempo(SetTempoCmd { bpm: 100 }))
622            .unwrap();
623        assert_eq!(hint.scope, ChangeScope::Global);
624        assert!(!hint.layout_dirty);
625        assert!(hint.playback_dirty);
626    }
627
628    #[test]
629    fn change_hint_add_note_is_measure_scope() {
630        use crate::model::change_hint::ChangeScope;
631        use crate::model::commands::AddNoteCmd;
632        use crate::model::duration::Duration;
633        use crate::model::pitch::Pitch;
634        use crate::model::pitch::Step;
635        let mut engine = ScoreEngine::new();
636        let hint = engine
637            .apply(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            .unwrap();
650        assert_eq!(
651            hint.scope,
652            ChangeScope::Measures {
653                part: 0,
654                staff: 0,
655                start: 0,
656                end: 1
657            }
658        );
659        assert!(!hint.layout_dirty);
660        assert!(hint.playback_dirty);
661    }
662
663    #[test]
664    fn change_hint_set_part_name_no_dirty() {
665        use crate::model::change_hint::ChangeScope;
666        use crate::model::commands::SetPartNameCmd;
667        let mut engine = ScoreEngine::new();
668        let hint = engine
669            .apply(Command::SetPartName(SetPartNameCmd {
670                part_index: 0,
671                name: "Violin".into(),
672                short_name: "Vln.".into(),
673            }))
674            .unwrap();
675        assert_eq!(hint.scope, ChangeScope::Part(0));
676        assert!(!hint.layout_dirty);
677        assert!(!hint.playback_dirty);
678    }
679
680    #[test]
681    fn undo_returns_change_hint() {
682        use crate::model::change_hint::ChangeScope;
683        let mut engine = ScoreEngine::new();
684        engine
685            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
686            .unwrap();
687        let hint = engine.undo().unwrap();
688        assert_eq!(hint.scope, ChangeScope::Global);
689        assert!(hint.playback_dirty);
690    }
691
692    #[test]
693    fn redo_returns_change_hint() {
694        use crate::model::change_hint::ChangeScope;
695        let mut engine = ScoreEngine::new();
696        engine
697            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
698            .unwrap();
699        engine.undo().unwrap();
700        let hint = engine.redo().unwrap();
701        assert_eq!(hint.scope, ChangeScope::Global);
702        assert!(hint.playback_dirty);
703    }
704
705    #[test]
706    fn batch_apply_two_commands_single_undo() {
707        let mut engine = ScoreEngine::new();
708        let original = engine.score.settings.tempo_bpm;
709        engine
710            .batch_apply(vec![
711                Command::SetTempo(SetTempoCmd { bpm: 160 }),
712                Command::SetTempo(SetTempoCmd { bpm: 180 }),
713            ])
714            .unwrap();
715        assert_eq!(engine.score.settings.tempo_bpm, 180);
716        engine.undo().unwrap();
717        assert_eq!(engine.score.settings.tempo_bpm, original);
718        assert!(engine.undo().is_err());
719    }
720
721    #[test]
722    fn batch_apply_empty_returns_no_dirty() {
723        let mut engine = ScoreEngine::new();
724        let v0 = engine.version;
725        let hint = engine.batch_apply(vec![]).unwrap();
726        assert!(!hint.layout_dirty);
727        assert!(!hint.playback_dirty);
728        assert_eq!(engine.version, v0);
729    }
730
731    #[test]
732    fn batch_apply_hint_merges_scopes() {
733        use crate::model::change_hint::ChangeScope;
734        use crate::model::commands::{AddNoteCmd, SetTempoCmd};
735        use crate::model::duration::Duration;
736        use crate::model::pitch::{Pitch, Step};
737        let mut engine = ScoreEngine::new();
738        let hint = engine
739            .batch_apply(vec![
740                Command::SetTempo(SetTempoCmd { bpm: 140 }),
741                Command::AddNote(AddNoteCmd {
742                    part_index: 0,
743                    staff_index: 0,
744                    measure_index: 0,
745                    voice: 0,
746                    position: 0,
747                    pitch: Some(Pitch::new(Step::C, 4)),
748                    duration: Duration::Quarter,
749                    dot_count: 0,
750                    is_rest: false,
751                    tuplet: None,
752                }),
753            ])
754            .unwrap();
755        // SetTempo = Global; merged with Measures = Global
756        assert_eq!(hint.scope, ChangeScope::Global);
757        assert!(hint.playback_dirty);
758    }
759
760    #[test]
761    fn undo_label_none_when_empty() {
762        let engine = ScoreEngine::new();
763        assert!(engine.undo_label().is_none());
764        assert!(engine.redo_label().is_none());
765    }
766
767    #[test]
768    fn undo_label_after_command() {
769        let mut engine = ScoreEngine::new();
770        engine
771            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
772            .unwrap();
773        assert_eq!(engine.undo_label(), Some("Set Tempo".to_string()));
774        assert!(engine.redo_label().is_none());
775    }
776
777    #[test]
778    fn redo_label_after_undo() {
779        let mut engine = ScoreEngine::new();
780        engine
781            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
782            .unwrap();
783        engine.undo().unwrap();
784        assert!(engine.undo_label().is_none());
785        assert_eq!(engine.redo_label(), Some("Set Tempo".to_string()));
786    }
787
788    #[test]
789    fn copy_range_paste_range_roundtrip() {
790        use crate::model::duration::Duration;
791        use crate::model::pitch::{Pitch, Step};
792        use crate::model::score::{Note, NoteAddr};
793        let mut engine = ScoreEngine::new();
794        // Put a note in measure 0
795        engine.score.parts[0].staves[0].measures[0].voices[0] =
796            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
797        // Need measure 1: add one
798        use crate::model::commands::AddMeasureCmd;
799        engine
800            .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
801            .unwrap();
802
803        let start = NoteAddr {
804            part: 0,
805            staff: 0,
806            measure: 0,
807            voice: 0,
808            note: 0,
809        };
810        let end = NoteAddr {
811            part: 0,
812            staff: 0,
813            measure: 0,
814            voice: 0,
815            note: 0,
816        };
817        engine.copy_range(start, end).unwrap();
818
819        let target = NoteAddr {
820            part: 0,
821            staff: 0,
822            measure: 1,
823            voice: 0,
824            note: 0,
825        };
826        engine.paste_range(target).unwrap();
827
828        let pasted = &engine.score.parts[0].staves[0].measures[1].voices[0];
829        assert_eq!(pasted.len(), 1);
830        assert_eq!(pasted[0].pitches[0].step, Step::C);
831    }
832
833    #[test]
834    fn paste_range_is_undoable() {
835        use crate::model::commands::AddMeasureCmd;
836        use crate::model::duration::Duration;
837        use crate::model::pitch::{Pitch, Step};
838        use crate::model::score::{Note, NoteAddr};
839        let mut engine = ScoreEngine::new();
840        engine.score.parts[0].staves[0].measures[0].voices[0] =
841            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
842        engine
843            .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
844            .unwrap();
845
846        let start = NoteAddr {
847            part: 0,
848            staff: 0,
849            measure: 0,
850            voice: 0,
851            note: 0,
852        };
853        let end = start.clone();
854        engine.copy_range(start, end).unwrap();
855        let target = NoteAddr {
856            part: 0,
857            staff: 0,
858            measure: 1,
859            voice: 0,
860            note: 0,
861        };
862        engine.paste_range(target).unwrap();
863
864        // Undo should restore measure 1 to its pre-paste state
865        engine.undo().unwrap();
866        let restored = &engine.score.parts[0].staves[0].measures[1].voices[0];
867        assert!(restored.iter().all(|n| n.is_rest));
868    }
869
870    #[test]
871    fn copy_range_mismatched_part_returns_error() {
872        let engine = ScoreEngine::new();
873        // Can't call copy_range mutably here since we need &mut, so test via a new engine
874        let mut e = ScoreEngine::new();
875        use crate::model::score::NoteAddr;
876        let start = NoteAddr {
877            part: 0,
878            staff: 0,
879            measure: 0,
880            voice: 0,
881            note: 0,
882        };
883        let end = NoteAddr {
884            part: 1,
885            staff: 0,
886            measure: 0,
887            voice: 0,
888            note: 0,
889        };
890        assert!(e.copy_range(start, end).is_err());
891        let _ = engine; // suppress unused warning
892    }
893
894    #[test]
895    fn export_history_roundtrip() {
896        let mut engine = ScoreEngine::new();
897        engine
898            .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
899            .unwrap();
900        engine
901            .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
902            .unwrap();
903        let history = engine.export_history();
904        assert_eq!(history.commands.len(), 2);
905        let restored = ScoreEngine::from_history(history).unwrap();
906        assert_eq!(restored.score.settings.tempo_bpm, 180);
907    }
908
909    #[test]
910    fn export_history_empty_gives_initial_state() {
911        let engine = ScoreEngine::new();
912        let history = engine.export_history();
913        assert!(history.commands.is_empty());
914        let restored = ScoreEngine::from_history(history).unwrap();
915        assert_eq!(restored.score.settings.tempo_bpm, 120);
916    }
917
918    #[test]
919    fn replace_score_then_export_history() {
920        let mut engine = ScoreEngine::new();
921        let s = Score::new("Custom", 90, 3, 4, 2, 4);
922        engine.replace_score(s);
923        engine
924            .apply(Command::SetTempo(SetTempoCmd { bpm: 60 }))
925            .unwrap();
926        let history = engine.export_history();
927        assert_eq!(history.initial_score.settings.tempo_bpm, 90);
928        assert_eq!(history.commands.len(), 1);
929        let restored = ScoreEngine::from_history(history).unwrap();
930        assert_eq!(restored.score.settings.tempo_bpm, 60);
931    }
932
933    #[test]
934    fn new_score_command_replaces_score() {
935        let mut engine = ScoreEngine::new();
936        engine
937            .apply(Command::NewScore(NewScoreCmd {
938                title: "Sonata".into(),
939                composer: "Bach".into(),
940                tempo_bpm: 80,
941                time_numerator: 3,
942                time_denominator: 4,
943                key_fifths: -1,
944                measure_count: 12,
945                template: None,
946            }))
947            .unwrap();
948        assert_eq!(engine.score.metadata.title, "Sonata");
949        assert_eq!(engine.score.measure_count(), 12);
950    }
951
952    #[test]
953    fn respell_score_to_key_uses_key_signature() {
954        use crate::model::commands::{AddNoteCmd, RespellScoreToKeyCmd};
955        use crate::model::notation::KeySignature;
956        use crate::model::pitch::Step;
957        let mut engine = ScoreEngine::new();
958        // Set Bb major (2 flats, fifths = -2) → prefer_flat = true
959        engine.score.settings.key_signature = KeySignature {
960            fifths: -2,
961            mode: "major".to_string(),
962        };
963        engine
964            .apply(Command::AddNote(AddNoteCmd {
965                part_index: 0,
966                staff_index: 0,
967                measure_index: 0,
968                voice: 0,
969                position: 0,
970                pitch: Some(crate::model::pitch::Pitch::with_alter(Step::C, 4, 1)), // C#4
971                duration: crate::model::duration::Duration::Quarter,
972                dot_count: 0,
973                is_rest: false,
974                tuplet: None,
975            }))
976            .unwrap();
977        engine
978            .apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
979            .unwrap();
980        let pitch = &engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
981        assert_eq!(pitch.step, Step::D);
982        assert_eq!(pitch.alter, -1); // Db4
983    }
984
985    #[test]
986    fn begin_end_slur_creates_slur() {
987        use crate::model::commands::AddNoteCmd;
988        use crate::model::duration::Duration;
989        use crate::model::pitch::{Pitch, Step};
990        let mut engine = ScoreEngine::new();
991        engine
992            .apply(Command::AddNote(AddNoteCmd {
993                part_index: 0,
994                staff_index: 0,
995                measure_index: 0,
996                voice: 0,
997                position: 0,
998                pitch: Some(Pitch::new(Step::C, 4)),
999                duration: Duration::Quarter,
1000                dot_count: 0,
1001                is_rest: false,
1002                tuplet: None,
1003            }))
1004            .unwrap();
1005        engine
1006            .apply(Command::AddNote(AddNoteCmd {
1007                part_index: 0,
1008                staff_index: 0,
1009                measure_index: 0,
1010                voice: 0,
1011                position: 1,
1012                pitch: Some(Pitch::new(Step::D, 4)),
1013                duration: Duration::Quarter,
1014                dot_count: 0,
1015                is_rest: false,
1016                tuplet: None,
1017            }))
1018            .unwrap();
1019        let start = NoteAddr {
1020            part: 0,
1021            staff: 0,
1022            measure: 0,
1023            voice: 0,
1024            note: 0,
1025        };
1026        let end = NoteAddr {
1027            part: 0,
1028            staff: 0,
1029            measure: 0,
1030            voice: 0,
1031            note: 1,
1032        };
1033        engine.begin_slur(start).unwrap();
1034        engine.end_slur(end).unwrap();
1035        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
1036        assert!(engine.score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
1037    }
1038
1039    #[test]
1040    fn end_slur_without_begin_returns_error() {
1041        let mut engine = ScoreEngine::new();
1042        let end = NoteAddr {
1043            part: 0,
1044            staff: 0,
1045            measure: 0,
1046            voice: 0,
1047            note: 0,
1048        };
1049        let result = engine.end_slur(end);
1050        assert!(result.is_err());
1051    }
1052
1053    // ── SetStem ───────────────────────────────────────────────────────────────
1054
1055    #[test]
1056    fn set_stem_sets_and_clears() {
1057        use crate::model::commands::AddNoteCmd;
1058        use crate::model::duration::Duration;
1059        use crate::model::pitch::{Pitch, Step};
1060        let mut engine = ScoreEngine::new();
1061        engine
1062            .apply(Command::AddNote(AddNoteCmd {
1063                part_index: 0,
1064                staff_index: 0,
1065                measure_index: 0,
1066                voice: 0,
1067                position: 0,
1068                pitch: Some(Pitch::new(Step::C, 4)),
1069                duration: Duration::Quarter,
1070                dot_count: 0,
1071                is_rest: false,
1072                tuplet: None,
1073            }))
1074            .unwrap();
1075        let addr = NoteAddr {
1076            part: 0,
1077            staff: 0,
1078            measure: 0,
1079            voice: 0,
1080            note: 0,
1081        };
1082        engine.set_stem(addr.clone(), Some(true)).unwrap();
1083        assert_eq!(
1084            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1085            Some(true)
1086        );
1087        engine.set_stem(addr.clone(), Some(false)).unwrap();
1088        assert_eq!(
1089            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1090            Some(false)
1091        );
1092        engine.set_stem(addr, None).unwrap();
1093        assert_eq!(
1094            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1095            None
1096        );
1097    }
1098
1099    #[test]
1100    fn set_stem_is_undoable() {
1101        use crate::model::commands::AddNoteCmd;
1102        use crate::model::duration::Duration;
1103        use crate::model::pitch::{Pitch, Step};
1104        let mut engine = ScoreEngine::new();
1105        engine
1106            .apply(Command::AddNote(AddNoteCmd {
1107                part_index: 0,
1108                staff_index: 0,
1109                measure_index: 0,
1110                voice: 0,
1111                position: 0,
1112                pitch: Some(Pitch::new(Step::C, 4)),
1113                duration: Duration::Quarter,
1114                dot_count: 0,
1115                is_rest: false,
1116                tuplet: None,
1117            }))
1118            .unwrap();
1119        let addr = NoteAddr {
1120            part: 0,
1121            staff: 0,
1122            measure: 0,
1123            voice: 0,
1124            note: 0,
1125        };
1126        engine.set_stem(addr, Some(true)).unwrap();
1127        engine.undo().unwrap();
1128        assert_eq!(
1129            engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1130            None
1131        );
1132    }
1133
1134    #[test]
1135    fn set_arpeggio_is_undoable() {
1136        use crate::model::commands::AddNoteCmd;
1137        use crate::model::duration::Duration;
1138        use crate::model::pitch::{Pitch, Step};
1139        let mut engine = ScoreEngine::new();
1140        engine
1141            .apply(Command::AddNote(AddNoteCmd {
1142                part_index: 0,
1143                staff_index: 0,
1144                measure_index: 0,
1145                voice: 0,
1146                position: 0,
1147                pitch: Some(Pitch::new(Step::C, 4)),
1148                duration: Duration::Quarter,
1149                dot_count: 0,
1150                is_rest: false,
1151                tuplet: None,
1152            }))
1153            .unwrap();
1154        let addr = NoteAddr {
1155            part: 0,
1156            staff: 0,
1157            measure: 0,
1158            voice: 0,
1159            note: 0,
1160        };
1161        engine.set_arpeggio(addr.clone(), Some(true)).unwrap();
1162        assert_eq!(
1163            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1164            Some(true)
1165        );
1166        engine.undo().unwrap();
1167        assert_eq!(
1168            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1169            None
1170        );
1171        engine.redo().unwrap();
1172        assert_eq!(
1173            engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1174            Some(true)
1175        );
1176    }
1177
1178    // ── command_key ───────────────────────────────────────────────────────────
1179
1180    #[test]
1181    fn undo_key_returns_key_string() {
1182        let mut engine = ScoreEngine::new();
1183        engine
1184            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1185            .unwrap();
1186        assert_eq!(engine.undo_key(), Some("SetTempo".to_string()));
1187        assert!(engine.redo_key().is_none());
1188    }
1189
1190    #[test]
1191    fn redo_key_after_undo() {
1192        let mut engine = ScoreEngine::new();
1193        engine
1194            .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1195            .unwrap();
1196        engine.undo().unwrap();
1197        assert!(engine.undo_key().is_none());
1198        assert_eq!(engine.redo_key(), Some("SetTempo".to_string()));
1199    }
1200
1201    // ── batch_apply_labeled ───────────────────────────────────────────────────
1202
1203    #[test]
1204    fn batch_apply_labeled_sets_undo_key() {
1205        let mut engine = ScoreEngine::new();
1206        engine
1207            .batch_apply_labeled(vec![Command::SetTempo(SetTempoCmd { bpm: 140 })], "ApplyAI")
1208            .unwrap();
1209        assert_eq!(engine.undo_key(), Some("ApplyAI".to_string()));
1210    }
1211
1212    #[test]
1213    fn batch_apply_labeled_empty_is_noop() {
1214        let mut engine = ScoreEngine::new();
1215        engine.batch_apply_labeled(vec![], "ApplyAI").unwrap();
1216        assert!(engine.undo_key().is_none());
1217    }
1218}