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#[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 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 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 pub fn undo_label(&self) -> Option<String> {
137 self.commands.undo_label()
138 }
139
140 pub fn redo_label(&self) -> Option<String> {
142 self.commands.redo_label()
143 }
144
145 pub fn undo_key(&self) -> Option<String> {
147 self.commands.undo_key()
148 }
149
150 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 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 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 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 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 pub fn toggle_slur(&mut self, start: NoteAddr, end: NoteAddr) -> Result<ChangeHint, Error> {
279 self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
280 }
281
282 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 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 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 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 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 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 pub fn set_part_group(&mut self, group: Option<PartGroup>) -> Result<ChangeHint, Error> {
365 self.apply(Command::SetPartGroup(SetPartGroupCmd { group }))
366 }
367
368 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 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 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 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 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 pub fn respell_score(&mut self, prefer_flat: bool) -> Result<ChangeHint, Error> {
439 self.apply(Command::RespellScore(RespellScoreCmd { prefer_flat }))
440 }
441
442 pub fn respell_score_to_key(&mut self) -> Result<ChangeHint, Error> {
444 self.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
445 }
446
447 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 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 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 engine.score.parts[0].staves[0].measures[0].voices[0] =
796 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
797 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 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 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; }
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 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)), 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); }
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 #[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 #[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 #[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}