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