1use super::change_hint::{ChangeHint, ChangeScope};
2use super::commands::{
3 AddStaffCmd, Command, CommandStack, DeleteStaffCmd, DurationScale, ExchangeVoicesCmd,
4 ExplodeChordPitchesCmd, ExplodeVoicesCmd, ImplodeStavesCmd, PasteRangeCmd,
5 PasteScoreFragmentCmd, PasteVoiceCmd, RespellScoreCmd, RespellScoreToKeyCmd,
6 ScaleVoiceRangeCmd, ScoreFragmentPastePolicy, SetArpeggioCmd, SetCueCmd, SetDurationCmd,
7 SetInstrumentIdCmd, SetNoteHeadCmd, SetNotePlacementCmd, SetPartGroupCmd, SetStemCmd,
8 SetTupletCmd, SetUnpitchedCmd, ToggleSlurCmd, ToggleTrillLineCmd, command_hint, command_key,
9};
10use super::duration::Duration;
11use super::fragment::ScoreFragment;
12use super::notation::{Clef, NoteHead, TupletInfo};
13use super::score::PartGroup;
14use super::score::{Note, NoteAddr, Score};
15use crate::Error;
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EngineHistory {
28 pub initial_score: Score,
29 pub commands: Vec<Command>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub enum HistoryRelation {
35 Equivalent,
36 LeftExtends { common_prefix_len: usize },
37 RightExtends { common_prefix_len: usize },
38 Diverged { common_prefix_len: usize },
39 BaseMismatch,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct HistoryConflict {
45 pub common_prefix_len: usize,
46 pub left_command_index: usize,
47 pub right_command_index: usize,
48 pub left_command_key: String,
49 pub right_command_key: String,
50 pub left_remaining_commands: usize,
51 pub right_remaining_commands: usize,
52}
53
54#[derive(Debug, Clone)]
55struct RangeClipboard {
56 voice: usize,
57 measures: Vec<Vec<Note>>,
58}
59
60pub struct ScoreEngine {
61 pub score: Score,
62 pub commands: CommandStack,
63 pub version: u64,
64 pub clipboard: Option<Vec<Note>>,
65 range_clipboard: Option<RangeClipboard>,
66 initial_score: Score,
67 pending_slur_start: Option<NoteAddr>,
68}
69
70impl Default for ScoreEngine {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76impl ScoreEngine {
77 pub fn new() -> Self {
78 let mut score = Score::default();
79 for part in &mut score.parts {
80 for staff in &mut part.staves {
81 for (i, m) in staff.measures.iter_mut().enumerate() {
82 m.number = i as u32 + 1;
83 }
84 }
85 }
86 let initial_score = score.clone();
87 Self {
88 score,
89 commands: CommandStack::new(200),
90 version: 0,
91 clipboard: None,
92 range_clipboard: None,
93 initial_score,
94 pending_slur_start: None,
95 }
96 }
97
98 pub fn apply(&mut self, cmd: Command) -> Result<ChangeHint, Error> {
99 let hint = command_hint(&cmd);
100 self.commands.execute(cmd, &mut self.score)?;
101 self.version += 1;
102 Ok(hint)
103 }
104
105 pub fn undo(&mut self) -> Result<ChangeHint, Error> {
106 let hint = self.commands.undo(&mut self.score)?;
107 self.version += 1;
108 Ok(hint)
109 }
110
111 pub fn redo(&mut self) -> Result<ChangeHint, Error> {
112 let hint = self.commands.redo(&mut self.score)?;
113 self.version += 1;
114 Ok(hint)
115 }
116
117 pub fn batch_apply(&mut self, cmds: Vec<Command>) -> Result<ChangeHint, Error> {
119 if cmds.is_empty() {
120 return Ok(ChangeHint {
121 scope: ChangeScope::Global,
122 layout_dirty: false,
123 playback_dirty: false,
124 });
125 }
126 let mut hint = command_hint(&cmds[0]);
127 for cmd in cmds.iter().skip(1) {
128 hint = hint.merge(command_hint(cmd));
129 }
130 self.commands.batch_execute(cmds, &mut self.score)?;
131 self.version += 1;
132 Ok(hint)
133 }
134
135 pub fn batch_apply_labeled(
139 &mut self,
140 cmds: Vec<Command>,
141 label: &str,
142 ) -> Result<ChangeHint, Error> {
143 if cmds.is_empty() {
144 return Ok(ChangeHint {
145 scope: ChangeScope::Global,
146 layout_dirty: false,
147 playback_dirty: false,
148 });
149 }
150 let mut hint = command_hint(&cmds[0]);
151 for cmd in cmds.iter().skip(1) {
152 hint = hint.merge(command_hint(cmd));
153 }
154 self.commands
155 .batch_execute_labeled(cmds, label.to_string(), &mut self.score)?;
156 self.version += 1;
157 Ok(hint)
158 }
159
160 pub fn undo_label(&self) -> Option<String> {
162 self.commands.undo_label()
163 }
164
165 pub fn redo_label(&self) -> Option<String> {
167 self.commands.redo_label()
168 }
169
170 pub fn undo_key(&self) -> Option<String> {
172 self.commands.undo_key()
173 }
174
175 pub fn redo_key(&self) -> Option<String> {
177 self.commands.redo_key()
178 }
179
180 pub fn replace_score(&mut self, score: Score) {
184 self.initial_score = score.clone();
185 self.score = score;
186 self.version += 1;
187 self.commands = CommandStack::new(200);
188 }
189
190 pub fn try_replace_score(&mut self, score: Score) -> Result<(), Error> {
196 if !super::validate::validate(&score).is_valid() {
197 return Err(Error::InvalidScore);
198 }
199 self.replace_score(score);
200 Ok(())
201 }
202
203 pub fn export_history(&self) -> EngineHistory {
208 EngineHistory {
209 initial_score: self.initial_score.clone(),
210 commands: self.commands.history_commands(),
211 }
212 }
213
214 pub fn from_history(history: EngineHistory) -> Result<Self, Error> {
219 let mut engine = ScoreEngine::new();
220 engine.try_replace_score(history.initial_score)?;
221 for cmd in history.commands {
222 engine.apply(cmd)?;
223 }
224 Ok(engine)
225 }
226
227 pub fn from_history_on_base(history: EngineHistory, base: Score) -> Result<Self, Error> {
232 if !history.base_matches(&base) {
233 return Err(Error::HistoryBaseMismatch);
234 }
235 Self::from_history(history)
236 }
237
238 pub fn append_history_extension(&mut self, incoming: &EngineHistory) -> Result<usize, Error> {
243 let local = self.export_history();
244 let common_prefix_len = match local.compare(incoming) {
245 HistoryRelation::LeftExtends { common_prefix_len } => common_prefix_len,
246 _ => return Err(Error::HistoryNotAppendable),
247 };
248 let count = incoming.commands.len() - common_prefix_len;
249 if count == 0 {
250 return Ok(0);
251 }
252 let candidate = Self::from_history(incoming.clone())?;
253 *self = candidate;
254 Ok(count)
255 }
256
257 pub fn copy_voice(
258 &mut self,
259 part_index: usize,
260 staff_index: usize,
261 measure_index: usize,
262 voice_index: usize,
263 ) -> Result<(), Error> {
264 let voice = self
265 .score
266 .parts
267 .get(part_index)
268 .ok_or(Error::PartNotFound(part_index))?
269 .staves
270 .get(staff_index)
271 .ok_or(Error::StaffNotFound(staff_index))?
272 .measures
273 .get(measure_index)
274 .ok_or(Error::MeasureNotFound(measure_index))?
275 .voices
276 .get(voice_index)
277 .ok_or(Error::VoiceOutOfRange(voice_index))?;
278 self.clipboard = Some(voice.clone());
279 Ok(())
280 }
281
282 pub fn paste_voice(
283 &mut self,
284 part_index: usize,
285 staff_index: usize,
286 measure_index: usize,
287 voice_index: usize,
288 ) -> Result<ChangeHint, Error> {
289 let notes = self.clipboard.clone().ok_or(Error::ClipboardEmpty)?;
290 self.apply(Command::PasteVoice(PasteVoiceCmd {
291 part_index,
292 staff_index,
293 measure_index,
294 voice_index,
295 notes,
296 }))
297 }
298
299 pub fn copy_range(&mut self, start: NoteAddr, end: NoteAddr) -> Result<(), Error> {
304 if start.part != end.part || start.staff != end.staff || start.voice != end.voice {
305 return Err(Error::InvalidCommand(
306 "copy_range: start and end must share the same part, staff, and voice".into(),
307 ));
308 }
309 let from = start.measure.min(end.measure);
310 let to = start.measure.max(end.measure);
311 let staff = self
312 .score
313 .parts
314 .get(start.part)
315 .ok_or(Error::PartNotFound(start.part))?
316 .staves
317 .get(start.staff)
318 .ok_or(Error::StaffNotFound(start.staff))?;
319 if start.voice >= 4 {
320 return Err(Error::VoiceOutOfRange(start.voice));
321 }
322 let mut measures = Vec::new();
323 for mi in from..=to {
324 let m = staff.measures.get(mi).ok_or(Error::MeasureNotFound(mi))?;
325 measures.push(m.voices[start.voice].clone());
326 }
327 self.range_clipboard = Some(RangeClipboard {
328 voice: start.voice,
329 measures,
330 });
331 Ok(())
332 }
333
334 pub fn paste_range(&mut self, target: NoteAddr) -> Result<ChangeHint, Error> {
338 let rc = self.range_clipboard.clone().ok_or(Error::ClipboardEmpty)?;
339 self.apply(Command::PasteRange(PasteRangeCmd {
340 part_index: target.part,
341 staff_index: target.staff,
342 voice_index: rc.voice,
343 target_measure: target.measure,
344 measures: rc.measures,
345 }))
346 }
347
348 pub fn paste_score_fragment(
351 &mut self,
352 fragment: ScoreFragment,
353 target: NoteAddr,
354 ) -> Result<ChangeHint, Error> {
355 self.apply(Command::PasteScoreFragment(PasteScoreFragmentCmd {
356 fragment,
357 target,
358 policy: ScoreFragmentPastePolicy::Replace,
359 }))
360 }
361
362 pub fn paste_score_fragment_with_policy(
364 &mut self,
365 fragment: ScoreFragment,
366 target: NoteAddr,
367 policy: ScoreFragmentPastePolicy,
368 ) -> Result<ChangeHint, Error> {
369 self.apply(Command::PasteScoreFragment(PasteScoreFragmentCmd {
370 fragment,
371 target,
372 policy,
373 }))
374 }
375
376 pub fn exchange_voices(
379 &mut self,
380 part_index: usize,
381 staff_index: usize,
382 start_measure: usize,
383 end_measure: usize,
384 first_voice: usize,
385 second_voice: usize,
386 ) -> Result<ChangeHint, Error> {
387 self.apply(Command::ExchangeVoices(ExchangeVoicesCmd {
388 part_index,
389 staff_index,
390 start_measure,
391 end_measure,
392 first_voice,
393 second_voice,
394 }))
395 }
396
397 pub fn implode_staves(
400 &mut self,
401 part_index: usize,
402 source_staves: Vec<usize>,
403 target_staff: usize,
404 start_measure: usize,
405 end_measure: usize,
406 ) -> Result<ChangeHint, Error> {
407 self.apply(Command::ImplodeStaves(ImplodeStavesCmd {
408 part_index,
409 source_staves,
410 target_staff,
411 start_measure,
412 end_measure,
413 }))
414 }
415
416 pub fn explode_voices(
419 &mut self,
420 part_index: usize,
421 source_staff: usize,
422 target_staves: Vec<usize>,
423 start_measure: usize,
424 end_measure: usize,
425 ) -> Result<ChangeHint, Error> {
426 self.apply(Command::ExplodeVoices(ExplodeVoicesCmd {
427 part_index,
428 source_staff,
429 target_staves,
430 start_measure,
431 end_measure,
432 }))
433 }
434
435 pub fn explode_chord_pitches(
438 &mut self,
439 part_index: usize,
440 source_staff: usize,
441 target_staves: Vec<usize>,
442 start_measure: usize,
443 end_measure: usize,
444 ) -> Result<ChangeHint, Error> {
445 self.apply(Command::ExplodeChordPitches(ExplodeChordPitchesCmd {
446 part_index,
447 source_staff,
448 target_staves,
449 start_measure,
450 end_measure,
451 }))
452 }
453
454 pub fn scale_voice_range(
457 &mut self,
458 part_index: usize,
459 staff_index: usize,
460 voice: usize,
461 start_measure: usize,
462 end_measure: usize,
463 scale: DurationScale,
464 ) -> Result<ChangeHint, Error> {
465 self.apply(Command::ScaleVoiceRange(ScaleVoiceRangeCmd {
466 part_index,
467 staff_index,
468 voice,
469 start_measure,
470 end_measure,
471 scale,
472 tuplet_policy: super::commands::TupletScalePolicy::PreserveRatio,
473 }))
474 }
475
476 pub fn toggle_slur(&mut self, start: NoteAddr, end: NoteAddr) -> Result<ChangeHint, Error> {
478 self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
479 }
480
481 pub fn add_staff(&mut self, part_index: usize, clef: Clef) -> Result<ChangeHint, Error> {
483 self.apply(Command::AddStaff(AddStaffCmd { part_index, clef }))
484 }
485
486 pub fn delete_staff(
488 &mut self,
489 part_index: usize,
490 staff_index: usize,
491 ) -> Result<ChangeHint, Error> {
492 self.apply(Command::DeleteStaff(DeleteStaffCmd {
493 part_index,
494 staff_index,
495 }))
496 }
497
498 pub fn set_stem(&mut self, addr: NoteAddr, stem_up: Option<bool>) -> Result<ChangeHint, Error> {
502 self.apply(Command::SetStem(SetStemCmd {
503 part_index: addr.part,
504 staff_index: addr.staff,
505 measure_index: addr.measure,
506 voice_index: addr.voice,
507 note_index: addr.note,
508 stem_up,
509 }))
510 }
511
512 pub fn set_note_placement(
514 &mut self,
515 addr: NoteAddr,
516 offset_x: Option<f64>,
517 offset_y: Option<f64>,
518 relative_x: Option<f64>,
519 relative_y: Option<f64>,
520 ) -> Result<ChangeHint, Error> {
521 self.apply(Command::SetNotePlacement(SetNotePlacementCmd {
522 part_index: addr.part,
523 staff_index: addr.staff,
524 measure_index: addr.measure,
525 voice: addr.voice,
526 note_index: addr.note,
527 offset_x,
528 offset_y,
529 relative_x,
530 relative_y,
531 }))
532 }
533
534 pub fn set_duration(
536 &mut self,
537 addr: NoteAddr,
538 duration: Duration,
539 dot_count: u8,
540 ) -> Result<ChangeHint, Error> {
541 self.apply(Command::SetDuration(SetDurationCmd {
542 part_index: addr.part,
543 staff_index: addr.staff,
544 measure_index: addr.measure,
545 voice: addr.voice,
546 note_index: addr.note,
547 duration,
548 dot_count,
549 }))
550 }
551
552 pub fn set_arpeggio(
554 &mut self,
555 addr: NoteAddr,
556 direction: Option<bool>,
557 ) -> Result<ChangeHint, Error> {
558 self.apply(Command::SetArpeggio(SetArpeggioCmd {
559 part_index: addr.part,
560 staff_index: addr.staff,
561 measure_index: addr.measure,
562 voice_index: addr.voice,
563 note_index: addr.note,
564 direction,
565 }))
566 }
567
568 pub fn set_note_head(
570 &mut self,
571 addr: NoteAddr,
572 note_head: NoteHead,
573 ) -> Result<ChangeHint, Error> {
574 self.apply(Command::SetNoteHead(SetNoteHeadCmd {
575 part_index: addr.part,
576 staff_index: addr.staff,
577 measure_index: addr.measure,
578 voice: addr.voice,
579 note_index: addr.note,
580 note_head,
581 }))
582 }
583
584 pub fn set_part_group(&mut self, group: Option<PartGroup>) -> Result<ChangeHint, Error> {
586 self.apply(Command::SetPartGroup(SetPartGroupCmd { group }))
587 }
588
589 pub fn toggle_trill_line(
591 &mut self,
592 start: NoteAddr,
593 end: NoteAddr,
594 ) -> Result<ChangeHint, Error> {
595 self.apply(Command::ToggleTrillLine(ToggleTrillLineCmd { start, end }))
596 }
597
598 pub fn set_cue(&mut self, addr: NoteAddr, is_cue: bool) -> Result<ChangeHint, Error> {
600 self.apply(Command::SetCue(SetCueCmd {
601 part_index: addr.part,
602 staff_index: addr.staff,
603 measure_index: addr.measure,
604 voice: addr.voice,
605 note_index: addr.note,
606 is_cue,
607 }))
608 }
609
610 pub fn set_unpitched(
612 &mut self,
613 addr: NoteAddr,
614 is_unpitched: bool,
615 ) -> Result<ChangeHint, Error> {
616 self.apply(Command::SetUnpitched(SetUnpitchedCmd {
617 part_index: addr.part,
618 staff_index: addr.staff,
619 measure_index: addr.measure,
620 voice: addr.voice,
621 note_index: addr.note,
622 is_unpitched,
623 }))
624 }
625
626 pub fn set_instrument_id(
628 &mut self,
629 addr: NoteAddr,
630 instrument_id: Option<String>,
631 ) -> Result<ChangeHint, Error> {
632 self.apply(Command::SetInstrumentId(SetInstrumentIdCmd {
633 part_index: addr.part,
634 staff_index: addr.staff,
635 measure_index: addr.measure,
636 voice: addr.voice,
637 note_index: addr.note,
638 instrument_id,
639 }))
640 }
641
642 pub fn set_tuplet(
644 &mut self,
645 addr: NoteAddr,
646 tuplet: Option<TupletInfo>,
647 ) -> Result<ChangeHint, Error> {
648 self.apply(Command::SetTuplet(SetTupletCmd {
649 part_index: addr.part,
650 staff_index: addr.staff,
651 measure_index: addr.measure,
652 voice_index: addr.voice,
653 note_index: addr.note,
654 tuplet,
655 }))
656 }
657
658 pub fn respell_score(&mut self, prefer_flat: bool) -> Result<ChangeHint, Error> {
660 self.apply(Command::RespellScore(RespellScoreCmd { prefer_flat }))
661 }
662
663 pub fn respell_score_to_key(&mut self) -> Result<ChangeHint, Error> {
665 self.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
666 }
667
668 pub fn begin_slur(&mut self, start: NoteAddr) -> Result<(), Error> {
672 self.score
673 .parts
674 .get(start.part)
675 .ok_or(Error::PartNotFound(start.part))?
676 .staves
677 .get(start.staff)
678 .ok_or(Error::StaffNotFound(start.staff))?
679 .measures
680 .get(start.measure)
681 .ok_or(Error::MeasureNotFound(start.measure))?
682 .voices
683 .get(start.voice)
684 .ok_or(Error::VoiceOutOfRange(start.voice))?
685 .get(start.note)
686 .ok_or(Error::NoteNotFound(start.note))?;
687 self.pending_slur_start = Some(start);
688 Ok(())
689 }
690
691 pub fn end_slur(&mut self, end: NoteAddr) -> Result<ChangeHint, Error> {
695 let start = self
696 .pending_slur_start
697 .take()
698 .ok_or_else(|| Error::InvalidCommand("no slur in progress".to_string()))?;
699 self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
700 }
701}
702
703impl EngineHistory {
704 pub fn base_matches(&self, base: &Score) -> bool {
706 match (
707 serde_json::to_vec(&self.initial_score),
708 serde_json::to_vec(base),
709 ) {
710 (Ok(expected), Ok(actual)) => expected == actual,
711 _ => false,
712 }
713 }
714
715 pub fn compare(&self, other: &Self) -> HistoryRelation {
717 if !self.base_matches(&other.initial_score) {
718 return HistoryRelation::BaseMismatch;
719 }
720 let common_prefix_len = self
721 .commands
722 .iter()
723 .zip(&other.commands)
724 .take_while(|(left, right)| command_bytes(left) == command_bytes(right))
725 .count();
726 match (self.commands.len(), other.commands.len()) {
727 (left, right) if left == right && common_prefix_len == left => {
728 HistoryRelation::Equivalent
729 }
730 (left, _) if common_prefix_len == left => {
731 HistoryRelation::LeftExtends { common_prefix_len }
732 }
733 (_, right) if common_prefix_len == right => {
734 HistoryRelation::RightExtends { common_prefix_len }
735 }
736 _ => HistoryRelation::Diverged { common_prefix_len },
737 }
738 }
739
740 pub fn conflict(&self, other: &Self) -> Option<HistoryConflict> {
742 let common_prefix_len = match self.compare(other) {
743 HistoryRelation::Diverged { common_prefix_len } => common_prefix_len,
744 _ => return None,
745 };
746 let left = self.commands.get(common_prefix_len)?;
747 let right = other.commands.get(common_prefix_len)?;
748 Some(HistoryConflict {
749 common_prefix_len,
750 left_command_index: common_prefix_len,
751 right_command_index: common_prefix_len,
752 left_command_key: command_key(left),
753 right_command_key: command_key(right),
754 left_remaining_commands: self.commands.len() - common_prefix_len,
755 right_remaining_commands: other.commands.len() - common_prefix_len,
756 })
757 }
758}
759
760fn command_bytes(command: &Command) -> Option<Vec<u8>> {
761 serde_json::to_vec(command).ok()
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::model::commands::{NewScoreCmd, SetTempoCmd, SetTempoRampAtMeasureCmd};
768
769 #[test]
770 fn new_engine_has_default_score() {
771 let engine = ScoreEngine::new();
772 assert_eq!(engine.version, 0);
773 assert_eq!(engine.score.parts.len(), 1);
774 }
775
776 #[test]
777 fn apply_increments_version() {
778 let mut engine = ScoreEngine::new();
779 engine
780 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
781 .unwrap();
782 assert_eq!(engine.version, 1);
783 }
784
785 #[test]
786 fn tempo_ramp_command_undo_redo_restores_measure_target() {
787 let mut engine = ScoreEngine::new();
788 engine
789 .apply(Command::SetTempoRampAtMeasure(SetTempoRampAtMeasureCmd {
790 measure_index: 0,
791 target_bpm: Some(84),
792 }))
793 .expect("tempo ramp applies");
794 assert_eq!(
795 engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
796 Some(84)
797 );
798 engine.undo().expect("tempo ramp undoes");
799 assert_eq!(
800 engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
801 None
802 );
803 engine.redo().expect("tempo ramp redoes");
804 assert_eq!(
805 engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
806 Some(84)
807 );
808 }
809
810 #[test]
811 fn undo_redo_cycle() {
812 let mut engine = ScoreEngine::new();
813 engine
814 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
815 .unwrap();
816 let after_apply = engine.version;
817 engine.undo().unwrap();
818 assert_eq!(engine.score.settings.tempo_bpm, 120);
819 engine.redo().unwrap();
820 assert_eq!(engine.score.settings.tempo_bpm, 140);
821 assert!(engine.version > after_apply);
822 }
823
824 #[test]
825 fn replace_score_clears_history() {
826 let mut engine = ScoreEngine::new();
827 engine
828 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
829 .unwrap();
830 let new_score = Score::new("New", 90, 3, 4, 2, 8);
831 engine.replace_score(new_score);
832 assert!(engine.undo().is_err());
833 assert_eq!(engine.score.settings.tempo_bpm, 90);
834 }
835
836 #[test]
837 fn try_replace_score_rejects_invalid_input_without_mutation() {
838 let mut engine = ScoreEngine::new();
839 engine
840 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
841 .unwrap();
842 let original = engine.score.settings.tempo_bpm;
843 let original_version = engine.version;
844 let mut invalid = Score::default();
845 invalid.parts[0].staves[0].tablature = Some(crate::TablatureConfig {
846 lines: 0,
847 tuning_midi: Vec::new(),
848 capo: 0,
849 });
850
851 assert!(matches!(
852 engine.try_replace_score(invalid),
853 Err(Error::InvalidScore)
854 ));
855 assert_eq!(engine.score.settings.tempo_bpm, original);
856 assert_eq!(engine.version, original_version);
857 assert!(engine.commands.can_undo());
858 }
859
860 #[test]
861 fn copy_paste_voice_copies_notes() {
862 use crate::model::duration::Duration;
863 use crate::model::pitch::{Pitch, Step};
864 use crate::model::score::Note;
865 let mut engine = ScoreEngine::new();
866 engine.score.parts[0].staves[0].measures[0].voices[0] =
867 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
868 engine.copy_voice(0, 0, 0, 0).unwrap();
869 engine.paste_voice(0, 0, 0, 1).unwrap();
870 let pasted = &engine.score.parts[0].staves[0].measures[0].voices[1];
871 assert_eq!(pasted.len(), 1);
872 assert_eq!(pasted[0].pitches[0].step, Step::C);
873 }
874
875 #[test]
876 fn paste_voice_undo_restores_original() {
877 use crate::model::duration::Duration;
878 use crate::model::pitch::{Pitch, Step};
879 use crate::model::score::Note;
880 let mut engine = ScoreEngine::new();
881 engine.score.parts[0].staves[0].measures[0].voices[0] =
882 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
883 engine.copy_voice(0, 0, 0, 0).unwrap();
884 engine.paste_voice(0, 0, 0, 1).unwrap();
885 engine.undo().unwrap();
886 assert!(engine.score.parts[0].staves[0].measures[0].voices[1].is_empty());
887 }
888
889 #[test]
890 fn unpitched_flag_is_undoable_and_redoable() {
891 let mut engine = ScoreEngine::new();
892 let addr = NoteAddr {
893 part: 0,
894 staff: 0,
895 measure: 0,
896 voice: 0,
897 note: 0,
898 };
899 engine.set_unpitched(addr.clone(), true).unwrap();
900 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
901 engine.undo().unwrap();
902 assert!(!engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
903 engine.redo().unwrap();
904 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
905 engine
906 .set_instrument_id(addr, Some("P1-I2".to_string()))
907 .unwrap();
908 assert_eq!(
909 engine.score.parts[0].staves[0].measures[0].voices[0][0]
910 .instrument_id
911 .as_deref(),
912 Some("P1-I2")
913 );
914 for command in [
915 Command::SetUnpitched(super::super::commands::SetUnpitchedCmd {
916 part_index: 0,
917 staff_index: 0,
918 measure_index: 0,
919 voice: 0,
920 note_index: 0,
921 is_unpitched: false,
922 }),
923 Command::SetInstrumentId(super::super::commands::SetInstrumentIdCmd {
924 part_index: 0,
925 staff_index: 0,
926 measure_index: 0,
927 voice: 0,
928 note_index: 0,
929 instrument_id: Some("P1-I2".to_string()),
930 }),
931 ] {
932 let json = serde_json::to_string(&command).unwrap();
933 let restored: Command = serde_json::from_str(&json).unwrap();
934 assert_eq!(
935 super::super::commands::command_key(&restored),
936 super::super::commands::command_key(&command)
937 );
938 }
939 }
940
941 #[test]
942 fn paste_voice_without_copy_returns_error() {
943 let mut engine = ScoreEngine::new();
944 assert!(engine.paste_voice(0, 0, 0, 0).is_err());
945 }
946
947 #[test]
948 fn change_hint_set_tempo_is_global() {
949 use crate::model::change_hint::ChangeScope;
950 let mut engine = ScoreEngine::new();
951 let hint = engine
952 .apply(Command::SetTempo(SetTempoCmd { bpm: 100 }))
953 .unwrap();
954 assert_eq!(hint.scope, ChangeScope::Global);
955 assert!(!hint.layout_dirty);
956 assert!(hint.playback_dirty);
957 }
958
959 #[test]
960 fn change_hint_add_note_is_measure_scope() {
961 use crate::model::change_hint::ChangeScope;
962 use crate::model::commands::AddNoteCmd;
963 use crate::model::duration::Duration;
964 use crate::model::pitch::Pitch;
965 use crate::model::pitch::Step;
966 let mut engine = ScoreEngine::new();
967 let hint = engine
968 .apply(Command::AddNote(AddNoteCmd {
969 part_index: 0,
970 staff_index: 0,
971 measure_index: 0,
972 voice: 0,
973 position: 0,
974 pitch: Some(Pitch::new(Step::C, 4)),
975 duration: Duration::Quarter,
976 dot_count: 0,
977 is_rest: false,
978 tuplet: None,
979 }))
980 .unwrap();
981 assert_eq!(
982 hint.scope,
983 ChangeScope::Measures {
984 part: 0,
985 staff: 0,
986 start: 0,
987 end: 1
988 }
989 );
990 assert!(!hint.layout_dirty);
991 assert!(hint.playback_dirty);
992 }
993
994 #[test]
995 fn change_hint_set_part_name_no_dirty() {
996 use crate::model::change_hint::ChangeScope;
997 use crate::model::commands::SetPartNameCmd;
998 let mut engine = ScoreEngine::new();
999 let hint = engine
1000 .apply(Command::SetPartName(SetPartNameCmd {
1001 part_index: 0,
1002 name: "Violin".into(),
1003 short_name: "Vln.".into(),
1004 }))
1005 .unwrap();
1006 assert_eq!(hint.scope, ChangeScope::Part(0));
1007 assert!(!hint.layout_dirty);
1008 assert!(!hint.playback_dirty);
1009 }
1010
1011 #[test]
1012 fn undo_returns_change_hint() {
1013 use crate::model::change_hint::ChangeScope;
1014 let mut engine = ScoreEngine::new();
1015 engine
1016 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1017 .unwrap();
1018 let hint = engine.undo().unwrap();
1019 assert_eq!(hint.scope, ChangeScope::Global);
1020 assert!(hint.playback_dirty);
1021 }
1022
1023 #[test]
1024 fn redo_returns_change_hint() {
1025 use crate::model::change_hint::ChangeScope;
1026 let mut engine = ScoreEngine::new();
1027 engine
1028 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1029 .unwrap();
1030 engine.undo().unwrap();
1031 let hint = engine.redo().unwrap();
1032 assert_eq!(hint.scope, ChangeScope::Global);
1033 assert!(hint.playback_dirty);
1034 }
1035
1036 #[test]
1037 fn batch_apply_two_commands_single_undo() {
1038 let mut engine = ScoreEngine::new();
1039 let original = engine.score.settings.tempo_bpm;
1040 engine
1041 .batch_apply(vec![
1042 Command::SetTempo(SetTempoCmd { bpm: 160 }),
1043 Command::SetTempo(SetTempoCmd { bpm: 180 }),
1044 ])
1045 .unwrap();
1046 assert_eq!(engine.score.settings.tempo_bpm, 180);
1047 engine.undo().unwrap();
1048 assert_eq!(engine.score.settings.tempo_bpm, original);
1049 assert!(engine.undo().is_err());
1050 }
1051
1052 #[test]
1053 fn batch_apply_empty_returns_no_dirty() {
1054 let mut engine = ScoreEngine::new();
1055 let v0 = engine.version;
1056 let hint = engine.batch_apply(vec![]).unwrap();
1057 assert!(!hint.layout_dirty);
1058 assert!(!hint.playback_dirty);
1059 assert_eq!(engine.version, v0);
1060 }
1061
1062 #[test]
1063 fn batch_apply_hint_merges_scopes() {
1064 use crate::model::change_hint::ChangeScope;
1065 use crate::model::commands::{AddNoteCmd, SetTempoCmd};
1066 use crate::model::duration::Duration;
1067 use crate::model::pitch::{Pitch, Step};
1068 let mut engine = ScoreEngine::new();
1069 let hint = engine
1070 .batch_apply(vec![
1071 Command::SetTempo(SetTempoCmd { bpm: 140 }),
1072 Command::AddNote(AddNoteCmd {
1073 part_index: 0,
1074 staff_index: 0,
1075 measure_index: 0,
1076 voice: 0,
1077 position: 0,
1078 pitch: Some(Pitch::new(Step::C, 4)),
1079 duration: Duration::Quarter,
1080 dot_count: 0,
1081 is_rest: false,
1082 tuplet: None,
1083 }),
1084 ])
1085 .unwrap();
1086 assert_eq!(hint.scope, ChangeScope::Global);
1088 assert!(hint.playback_dirty);
1089 }
1090
1091 #[test]
1092 fn undo_label_none_when_empty() {
1093 let engine = ScoreEngine::new();
1094 assert!(engine.undo_label().is_none());
1095 assert!(engine.redo_label().is_none());
1096 }
1097
1098 #[test]
1099 fn undo_label_after_command() {
1100 let mut engine = ScoreEngine::new();
1101 engine
1102 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1103 .unwrap();
1104 assert_eq!(engine.undo_label(), Some("Set Tempo".to_string()));
1105 assert!(engine.redo_label().is_none());
1106 }
1107
1108 #[test]
1109 fn redo_label_after_undo() {
1110 let mut engine = ScoreEngine::new();
1111 engine
1112 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1113 .unwrap();
1114 engine.undo().unwrap();
1115 assert!(engine.undo_label().is_none());
1116 assert_eq!(engine.redo_label(), Some("Set Tempo".to_string()));
1117 }
1118
1119 #[test]
1120 fn score_fragment_paste_is_undoable_and_assigns_fresh_spanner_ids() {
1121 use crate::model::fragment::{ScoreFragmentSelection, extract_score_fragment};
1122 use crate::model::score::{NotationSpanner, NotationSpannerKind};
1123 use crate::{CrossStaff, Duration, Note, Pitch, Score, ScoreTemplate, Step};
1124
1125 let mut engine = ScoreEngine::new();
1126 engine.score = Score::template(ScoreTemplate::Piano);
1127 let mut source_note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
1128 source_note.cross_staff = Some(CrossStaff {
1129 target_staff: 1,
1130 target_voice: Some(0),
1131 });
1132 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![source_note];
1133 engine.score.spanners.push(NotationSpanner {
1134 id: "source-slur".into(),
1135 kind: NotationSpannerKind::Slur,
1136 start: NoteAddr {
1137 part: 0,
1138 staff: 0,
1139 measure: 0,
1140 voice: 0,
1141 note: 0,
1142 },
1143 end: NoteAddr {
1144 part: 0,
1145 staff: 0,
1146 measure: 0,
1147 voice: 0,
1148 note: 0,
1149 },
1150 number: None,
1151 line_type: None,
1152 text: None,
1153 placement: None,
1154 ottava_size: None,
1155 ottava_type: None,
1156 });
1157 let selection = ScoreFragmentSelection {
1158 start: NoteAddr {
1159 part: 0,
1160 staff: 0,
1161 measure: 0,
1162 voice: 0,
1163 note: 0,
1164 },
1165 end: NoteAddr {
1166 part: 0,
1167 staff: 0,
1168 measure: 0,
1169 voice: 0,
1170 note: 0,
1171 },
1172 };
1173 let fragment = extract_score_fragment(&engine.score, &[selection]).unwrap();
1174 let source_id = engine.score.parts[0].staves[0].measures[0].voices[0][0]
1175 .id
1176 .clone();
1177 let target = NoteAddr {
1178 part: 0,
1179 staff: 0,
1180 measure: 1,
1181 voice: 0,
1182 note: 0,
1183 };
1184 engine
1185 .paste_score_fragment(fragment.clone(), target.clone())
1186 .unwrap();
1187 let pasted_id = engine.score.parts[0].staves[0].measures[1].voices[0][0]
1188 .id
1189 .clone();
1190 assert_ne!(pasted_id, source_id);
1191 assert_eq!(
1192 engine.score.parts[0].staves[0].measures[1].voices[0][0]
1193 .cross_staff
1194 .as_ref()
1195 .map(|cross_staff| cross_staff.target_staff),
1196 Some(1)
1197 );
1198 assert!(
1199 engine
1200 .score
1201 .spanners
1202 .iter()
1203 .any(|span| span.id == "source-slur-copy")
1204 );
1205
1206 engine
1207 .paste_score_fragment(
1208 fragment,
1209 NoteAddr {
1210 measure: 2,
1211 ..target.clone()
1212 },
1213 )
1214 .unwrap();
1215 assert!(
1216 engine
1217 .score
1218 .spanners
1219 .iter()
1220 .any(|span| span.id == "source-slur-copy-2")
1221 );
1222
1223 engine.undo().unwrap();
1224 assert!(
1225 engine.score.parts[0].staves[0].measures[2].voices[0]
1226 .iter()
1227 .all(|note| note.is_rest)
1228 );
1229 engine.redo().unwrap();
1230 assert_eq!(
1231 engine.score.parts[0].staves[0].measures[2].voices[0][0].pitches[0].step,
1232 Step::C
1233 );
1234
1235 let before = engine.score.clone();
1236 let history_before = engine.commands.history_commands();
1237 assert!(
1238 engine
1239 .paste_score_fragment(
1240 extract_score_fragment(
1241 &engine.score,
1242 &[ScoreFragmentSelection {
1243 start: target.clone(),
1244 end: target,
1245 }]
1246 )
1247 .unwrap(),
1248 NoteAddr {
1249 part: 99,
1250 staff: 0,
1251 measure: 0,
1252 voice: 0,
1253 note: 0,
1254 },
1255 )
1256 .is_err()
1257 );
1258 assert_eq!(
1259 serde_json::to_value(&engine.score).unwrap(),
1260 serde_json::to_value(before).unwrap()
1261 );
1262 assert_eq!(
1263 engine.commands.history_commands().len(),
1264 history_before.len()
1265 );
1266 }
1267
1268 #[test]
1269 fn score_fragment_merge_preserves_sounding_destination_lanes() {
1270 use crate::model::fragment::{ScoreFragmentSelection, extract_score_fragment};
1271 use crate::{Duration, Note, Pitch, ScoreFragmentPastePolicy, Step};
1272
1273 let mut engine = ScoreEngine::new();
1274 engine.score.parts[0].staves[0].measures[0].voices[0] =
1275 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1276 let source = NoteAddr {
1277 part: 0,
1278 staff: 0,
1279 measure: 0,
1280 voice: 0,
1281 note: 0,
1282 };
1283 let fragment = extract_score_fragment(
1284 &engine.score,
1285 &[ScoreFragmentSelection {
1286 start: source.clone(),
1287 end: source.clone(),
1288 }],
1289 )
1290 .unwrap();
1291 let rest_target = NoteAddr {
1292 measure: 1,
1293 ..source.clone()
1294 };
1295 engine
1296 .paste_score_fragment_with_policy(
1297 fragment.clone(),
1298 rest_target,
1299 ScoreFragmentPastePolicy::Merge,
1300 )
1301 .unwrap();
1302 assert_eq!(
1303 engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1304 Step::C
1305 );
1306
1307 engine.score.parts[0].staves[0].measures[2].voices[0] =
1308 vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
1309 let before = serde_json::to_value(&engine.score).unwrap();
1310 assert!(
1311 engine
1312 .paste_score_fragment_with_policy(
1313 fragment,
1314 NoteAddr {
1315 measure: 2,
1316 ..source
1317 },
1318 ScoreFragmentPastePolicy::Merge,
1319 )
1320 .is_err()
1321 );
1322 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1323 }
1324
1325 #[test]
1326 fn score_fragment_replace_preserves_measure_attributes_and_accepts_v2() {
1327 use crate::model::fragment::{
1328 ScoreFragmentMeasureAttributes, ScoreFragmentSelection, extract_score_fragment,
1329 };
1330 use crate::{Duration, KeySignature, Note, Pitch, Step};
1331
1332 let mut engine = ScoreEngine::new();
1333 let source_measure = &mut engine.score.parts[0].staves[0].measures[0];
1334 source_measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1335 source_measure.key_sig = Some(KeySignature {
1336 fifths: 2,
1337 mode: "major".into(),
1338 });
1339 source_measure.tempo = Some(96);
1340 source_measure.navigation = Some("Segno".into());
1341 source_measure.section_break = true;
1342 let source = NoteAddr {
1343 part: 0,
1344 staff: 0,
1345 measure: 0,
1346 voice: 0,
1347 note: 0,
1348 };
1349 let fragment = extract_score_fragment(
1350 &engine.score,
1351 &[ScoreFragmentSelection {
1352 start: source.clone(),
1353 end: source.clone(),
1354 }],
1355 )
1356 .unwrap();
1357 assert!(fragment.voices[0].measures[0].attributes.present);
1358 engine
1359 .paste_score_fragment(
1360 fragment.clone(),
1361 NoteAddr {
1362 measure: 1,
1363 ..source.clone()
1364 },
1365 )
1366 .unwrap();
1367 let pasted = &engine.score.parts[0].staves[0].measures[1];
1368 assert_eq!(pasted.key_sig.as_ref().map(|key| key.fifths), Some(2));
1369 assert_eq!(pasted.tempo, Some(96));
1370 assert_eq!(pasted.navigation.as_deref(), Some("Segno"));
1371 assert!(pasted.section_break);
1372
1373 let mut legacy = fragment;
1374 legacy.contract_version = 2;
1375 for measure in &mut legacy.voices[0].measures {
1376 measure.attributes = ScoreFragmentMeasureAttributes::default();
1377 }
1378 engine
1379 .paste_score_fragment(
1380 legacy,
1381 NoteAddr {
1382 measure: 2,
1383 ..source
1384 },
1385 )
1386 .unwrap();
1387 let legacy_paste = &engine.score.parts[0].staves[0].measures[2];
1388 assert_eq!(legacy_paste.key_sig, None);
1389 assert_eq!(legacy_paste.tempo, None);
1390 assert_eq!(legacy_paste.navigation, None);
1391 assert!(!legacy_paste.section_break);
1392 }
1393
1394 #[test]
1395 fn exchange_voices_preserves_source_numbers_spans_and_undo() {
1396 use crate::model::score::{NotationSpanner, NotationSpannerKind};
1397 use crate::{Duration, Note, Pitch, Step};
1398
1399 let mut engine = ScoreEngine::new();
1400 let measure = &mut engine.score.parts[0].staves[0].measures[0];
1401 measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1402 measure.voices[1] = vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
1403 measure.source_voice_numbers = [Some(1), Some(5), None, None];
1404 engine.score.spanners.push(NotationSpanner {
1405 id: "between-voices".into(),
1406 kind: NotationSpannerKind::Slur,
1407 start: NoteAddr {
1408 part: 0,
1409 staff: 0,
1410 measure: 0,
1411 voice: 0,
1412 note: 0,
1413 },
1414 end: NoteAddr {
1415 part: 0,
1416 staff: 0,
1417 measure: 0,
1418 voice: 1,
1419 note: 0,
1420 },
1421 number: None,
1422 line_type: None,
1423 text: None,
1424 placement: None,
1425 ottava_size: None,
1426 ottava_type: None,
1427 });
1428
1429 engine.exchange_voices(0, 0, 0, 0, 0, 1).unwrap();
1430 let measure = &engine.score.parts[0].staves[0].measures[0];
1431 assert_eq!(measure.voices[0][0].pitches[0].step, Step::D);
1432 assert_eq!(measure.voices[1][0].pitches[0].step, Step::C);
1433 assert_eq!(measure.source_voice_numbers, [Some(5), Some(1), None, None]);
1434 assert_eq!(engine.score.spanners[0].start.voice, 1);
1435 assert_eq!(engine.score.spanners[0].end.voice, 0);
1436
1437 engine.undo().unwrap();
1438 let measure = &engine.score.parts[0].staves[0].measures[0];
1439 assert_eq!(measure.voices[0][0].pitches[0].step, Step::C);
1440 assert_eq!(measure.source_voice_numbers, [Some(1), Some(5), None, None]);
1441 }
1442
1443 #[test]
1444 fn move_or_copy_voice_range_is_atomic_and_undoable() {
1445 use crate::MoveOrCopyVoiceRangeCmd;
1446 use crate::{Duration, Note, Pitch, Step};
1447
1448 let mut engine = ScoreEngine::new();
1449 engine.score.parts[0].staves[0].measures[0].voices[0] =
1450 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1451 let source = NoteAddr {
1452 part: 0,
1453 staff: 0,
1454 measure: 0,
1455 voice: 0,
1456 note: 0,
1457 };
1458 let target = NoteAddr {
1459 measure: 1,
1460 ..source.clone()
1461 };
1462 engine
1463 .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1464 source_start: source.clone(),
1465 source_end: source.clone(),
1466 target: target.clone(),
1467 move_source: false,
1468 }))
1469 .unwrap();
1470 assert_eq!(
1471 engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1472 Step::C
1473 );
1474 assert_eq!(
1475 engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].step,
1476 Step::C
1477 );
1478
1479 engine
1480 .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1481 source_start: source.clone(),
1482 source_end: source.clone(),
1483 target: NoteAddr {
1484 measure: 2,
1485 ..source.clone()
1486 },
1487 move_source: true,
1488 }))
1489 .unwrap();
1490 assert!(
1491 engine.score.parts[0].staves[0].measures[0].voices[0]
1492 .iter()
1493 .all(|note| note.is_rest)
1494 );
1495 assert_eq!(
1496 engine.score.parts[0].staves[0].measures[2].voices[0][0].pitches[0].step,
1497 Step::C
1498 );
1499 engine.undo().unwrap();
1500 assert_eq!(
1501 engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].step,
1502 Step::C
1503 );
1504
1505 let before = serde_json::to_value(&engine.score).unwrap();
1506 assert!(
1507 engine
1508 .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1509 source_start: source.clone(),
1510 source_end: source.clone(),
1511 target: source,
1512 move_source: true,
1513 }))
1514 .is_err()
1515 );
1516 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1517 }
1518
1519 #[test]
1520 fn split_measure_remaps_spanners_and_is_undoable() {
1521 use crate::model::score::{NotationSpanner, NotationSpannerKind};
1522 use crate::{Duration, JoinMeasuresCmd, Note, Pitch, SplitMeasureCmd, Step};
1523
1524 let mut engine = ScoreEngine::new();
1525 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![
1526 Note::new(Pitch::new(Step::C, 4), Duration::Half),
1527 Note::new(Pitch::new(Step::D, 4), Duration::Half),
1528 ];
1529 engine.score.spanners.push(NotationSpanner {
1530 id: "across-split".into(),
1531 kind: NotationSpannerKind::Slur,
1532 start: NoteAddr {
1533 part: 0,
1534 staff: 0,
1535 measure: 0,
1536 voice: 0,
1537 note: 0,
1538 },
1539 end: NoteAddr {
1540 part: 0,
1541 staff: 0,
1542 measure: 0,
1543 voice: 0,
1544 note: 1,
1545 },
1546 number: None,
1547 line_type: None,
1548 text: None,
1549 placement: None,
1550 ottava_size: None,
1551 ottava_type: None,
1552 });
1553 engine
1554 .apply(Command::SplitMeasure(SplitMeasureCmd {
1555 measure_index: 0,
1556 split_at_beats: 2.0,
1557 }))
1558 .unwrap();
1559 assert_eq!(
1560 engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1561 1
1562 );
1563 assert_eq!(
1564 engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1565 Step::D
1566 );
1567 assert_eq!(engine.score.spanners[0].end.measure, 1);
1568 assert_eq!(engine.score.spanners[0].end.note, 0);
1569 engine.undo().unwrap();
1570 assert_eq!(
1571 engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1572 2
1573 );
1574 engine
1575 .apply(Command::SplitMeasure(SplitMeasureCmd {
1576 measure_index: 0,
1577 split_at_beats: 2.0,
1578 }))
1579 .unwrap();
1580 engine
1581 .apply(Command::JoinMeasures(JoinMeasuresCmd { measure_index: 0 }))
1582 .unwrap();
1583 assert_eq!(
1584 engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1585 2
1586 );
1587 assert_eq!(engine.score.spanners[0].end.measure, 0);
1588 assert_eq!(engine.score.spanners[0].end.note, 1);
1589 }
1590
1591 #[test]
1592 fn split_and_join_preserve_tuplet_tie_lyric_span_and_source_voice() {
1593 use crate::model::score::{NotationSpanner, NotationSpannerKind};
1594 use crate::{
1595 Duration, JoinMeasuresCmd, Lyric, Note, Pitch, SplitMeasureCmd, Step, TupletInfo,
1596 };
1597
1598 let mut engine = ScoreEngine::new();
1599 let ratio = TupletInfo {
1600 actual_notes: 3,
1601 normal_notes: 2,
1602 };
1603 let mut third = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
1604 third.tuplet = Some(ratio.clone());
1605 third.tie_start = true;
1606 third.lyric = Some(Lyric {
1607 text: "tri".into(),
1608 syllabic: "begin".into(),
1609 });
1610 let mut fourth = Note::new(Pitch::new(Step::F, 4), Duration::Half);
1611 fourth.tie_end = true;
1612 fourth.lyric = Some(Lyric {
1613 text: "plet".into(),
1614 syllabic: "end".into(),
1615 });
1616 let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1617 first.tuplet = Some(ratio.clone());
1618 let mut second = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1619 second.tuplet = Some(ratio.clone());
1620 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![first, second, third, fourth];
1621 engine.score.parts[0].staves[0].measures[0].source_voice_numbers[0] = Some(7);
1622 engine.score.spanners.push(NotationSpanner {
1623 id: "tuplet-split-span".into(),
1624 kind: NotationSpannerKind::Slur,
1625 start: NoteAddr {
1626 part: 0,
1627 staff: 0,
1628 measure: 0,
1629 voice: 0,
1630 note: 0,
1631 },
1632 end: NoteAddr {
1633 part: 0,
1634 staff: 0,
1635 measure: 0,
1636 voice: 0,
1637 note: 3,
1638 },
1639 number: None,
1640 line_type: None,
1641 text: None,
1642 placement: None,
1643 ottava_size: None,
1644 ottava_type: None,
1645 });
1646
1647 engine
1648 .apply(Command::SplitMeasure(SplitMeasureCmd {
1649 measure_index: 0,
1650 split_at_beats: 2.0,
1651 }))
1652 .unwrap();
1653 assert_eq!(
1654 engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1655 3
1656 );
1657 assert_eq!(
1658 engine.score.parts[0].staves[0].measures[1].voices[0].len(),
1659 1
1660 );
1661 assert_eq!(
1662 engine.score.parts[0].staves[0].measures[1].source_voice_numbers[0],
1663 Some(7)
1664 );
1665 assert_eq!(engine.score.spanners[0].end.measure, 1);
1666 assert_eq!(engine.score.spanners[0].end.note, 0);
1667
1668 engine
1669 .apply(Command::JoinMeasures(JoinMeasuresCmd { measure_index: 0 }))
1670 .unwrap();
1671 let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1672 assert_eq!(voice.len(), 4);
1673 assert_eq!(voice[2].tuplet, Some(ratio));
1674 assert!(voice[2].tie_start);
1675 assert!(voice[3].tie_end);
1676 assert_eq!(
1677 voice[2].lyric.as_ref().map(|lyric| lyric.text.as_str()),
1678 Some("tri")
1679 );
1680 assert_eq!(
1681 voice[3].lyric.as_ref().map(|lyric| lyric.text.as_str()),
1682 Some("plet")
1683 );
1684 assert_eq!(engine.score.spanners[0].end.measure, 0);
1685 assert_eq!(engine.score.spanners[0].end.note, 3);
1686 }
1687
1688 #[test]
1689 fn implode_then_explode_preserves_voices_spans_and_source_numbers() {
1690 use crate::model::score::{NotationSpanner, NotationSpannerKind, ScoreTemplate};
1691 use crate::{Duration, Note, Pitch, Step};
1692
1693 let mut engine = ScoreEngine::new();
1694 engine.score = Score::template(ScoreTemplate::Piano);
1695 let upper = &mut engine.score.parts[0].staves[0].measures[0];
1696 upper.voices[0] = vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
1697 upper.source_voice_numbers = [Some(1), None, None, None];
1698 let lower = &mut engine.score.parts[0].staves[1].measures[0];
1699 lower.voices[0] = vec![Note::new(Pitch::new(Step::E, 3), Duration::Whole)];
1700 lower.source_voice_numbers = [Some(5), None, None, None];
1701 engine.score.spanners.push(NotationSpanner {
1702 id: "implode-span".into(),
1703 kind: NotationSpannerKind::Slur,
1704 start: NoteAddr {
1705 part: 0,
1706 staff: 0,
1707 measure: 0,
1708 voice: 0,
1709 note: 0,
1710 },
1711 end: NoteAddr {
1712 part: 0,
1713 staff: 1,
1714 measure: 0,
1715 voice: 0,
1716 note: 0,
1717 },
1718 number: None,
1719 line_type: None,
1720 text: None,
1721 placement: None,
1722 ottava_size: None,
1723 ottava_type: None,
1724 });
1725 let before = serde_json::to_value(&engine.score).unwrap();
1726
1727 engine.implode_staves(0, vec![0, 1], 0, 0, 0).unwrap();
1728 let upper = &engine.score.parts[0].staves[0].measures[0];
1729 assert_eq!(upper.voices[0][0].pitches[0].step, Step::C);
1730 assert_eq!(upper.voices[1][0].pitches[0].step, Step::E);
1731 assert_eq!(upper.source_voice_numbers, [Some(1), Some(5), None, None]);
1732 assert!(
1733 engine.score.parts[0].staves[1].measures[0].voices[0]
1734 .iter()
1735 .all(|note| note.is_rest)
1736 );
1737 assert_eq!(engine.score.spanners[0].end.staff, 0);
1738 assert_eq!(engine.score.spanners[0].end.voice, 1);
1739
1740 engine.explode_voices(0, 0, vec![0, 1], 0, 0).unwrap();
1741 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1742 engine.undo().unwrap();
1743 assert_eq!(engine.score.spanners[0].end.staff, 0);
1744 assert_eq!(engine.score.spanners[0].end.voice, 1);
1745 engine.redo().unwrap();
1746 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1747 }
1748
1749 #[test]
1750 fn explode_rejects_an_occupied_destination_without_mutation() {
1751 use crate::model::score::ScoreTemplate;
1752 use crate::{Duration, ExplodeVoicesCmd, Note, Pitch, Step};
1753
1754 let mut engine = ScoreEngine::new();
1755 engine.score = Score::template(ScoreTemplate::Piano);
1756 let source = &mut engine.score.parts[0].staves[0].measures[0];
1757 source.voices[0] = vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
1758 source.voices[1] = vec![Note::new(Pitch::new(Step::E, 4), Duration::Whole)];
1759 engine.score.parts[0].staves[1].measures[0].voices[0] =
1760 vec![Note::new(Pitch::new(Step::G, 3), Duration::Whole)];
1761 let before = serde_json::to_value(&engine.score).unwrap();
1762
1763 assert!(
1764 engine
1765 .apply(Command::ExplodeVoices(ExplodeVoicesCmd {
1766 part_index: 0,
1767 source_staff: 0,
1768 target_staves: vec![0, 1],
1769 start_measure: 0,
1770 end_measure: 0,
1771 }))
1772 .is_err()
1773 );
1774 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1775 }
1776
1777 #[test]
1778 fn explode_chord_pitches_distributes_pitches_and_is_undoable() {
1779 use crate::model::score::ScoreTemplate;
1780 use crate::{Duration, Note, Pitch, Step};
1781
1782 let mut engine = ScoreEngine::new();
1783 engine.score = Score::template(ScoreTemplate::Piano);
1784 let mut chord = Note::new(Pitch::new(Step::C, 5), Duration::Whole);
1785 chord.pitches.push(Pitch::new(Step::E, 4));
1786 let source_id = chord.id.clone();
1787 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
1788 let before = serde_json::to_value(&engine.score).unwrap();
1789
1790 engine
1791 .explode_chord_pitches(0, 0, vec![0, 1], 0, 0)
1792 .unwrap();
1793 let upper = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
1794 let lower = &engine.score.parts[0].staves[1].measures[0].voices[0][0];
1795 assert_eq!(upper.id, source_id);
1796 assert_eq!(upper.pitches, vec![Pitch::new(Step::C, 5)]);
1797 assert_eq!(lower.pitches, vec![Pitch::new(Step::E, 4)]);
1798 assert_ne!(lower.id, source_id);
1799
1800 engine.undo().unwrap();
1801 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1802 engine.redo().unwrap();
1803 assert_eq!(
1804 engine.score.parts[0].staves[1].measures[0].voices[0][0].pitches,
1805 vec![Pitch::new(Step::E, 4)]
1806 );
1807 }
1808
1809 #[test]
1810 fn explode_chord_pitches_rejects_occupied_destination_without_mutation() {
1811 use crate::model::score::ScoreTemplate;
1812 use crate::{Duration, Note, Pitch, Step};
1813
1814 let mut engine = ScoreEngine::new();
1815 engine.score = Score::template(ScoreTemplate::Piano);
1816 let mut chord = Note::new(Pitch::new(Step::C, 5), Duration::Whole);
1817 chord.pitches.push(Pitch::new(Step::E, 4));
1818 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
1819 engine.score.parts[0].staves[1].measures[0].voices[0] =
1820 vec![Note::new(Pitch::new(Step::G, 3), Duration::Whole)];
1821 let before = serde_json::to_value(&engine.score).unwrap();
1822
1823 assert!(
1824 engine
1825 .explode_chord_pitches(0, 0, vec![0, 1], 0, 0)
1826 .is_err()
1827 );
1828 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1829 }
1830
1831 #[test]
1832 fn scale_voice_range_is_undoable_and_rejects_measure_overflow() {
1833 use crate::{Duration, DurationScale, Note, Pitch, Step};
1834
1835 let mut engine = ScoreEngine::new();
1836 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![
1837 Note::new(Pitch::new(Step::C, 4), Duration::Half),
1838 Note::new(Pitch::new(Step::D, 4), Duration::Half),
1839 ];
1840 let before = serde_json::to_value(&engine.score).unwrap();
1841
1842 engine
1843 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1844 .unwrap();
1845 let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1846 assert_eq!(voice.len(), 3);
1847 assert_eq!(voice[0].duration, Duration::Quarter);
1848 assert_eq!(voice[1].duration, Duration::Quarter);
1849 assert!(voice[2].is_rest);
1850 assert_eq!(voice[2].duration, Duration::Half);
1851 engine.undo().unwrap();
1852 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1853
1854 assert!(
1855 engine
1856 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Double)
1857 .is_err()
1858 );
1859 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1860 }
1861
1862 #[test]
1863 fn scale_voice_range_preserves_tuplet_ratio_and_undoes() {
1864 use crate::{Duration, DurationScale, Note, Pitch, Step, TupletInfo};
1865
1866 let mut engine = ScoreEngine::new();
1867 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1868 note.tuplet = Some(TupletInfo {
1869 actual_notes: 3,
1870 normal_notes: 2,
1871 });
1872 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1873 let before = serde_json::to_value(&engine.score).unwrap();
1874 engine
1875 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1876 .unwrap();
1877 let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1878 assert_eq!(voice[0].duration, Duration::Eighth);
1879 assert_eq!(
1880 voice[0].tuplet,
1881 Some(TupletInfo {
1882 actual_notes: 3,
1883 normal_notes: 2,
1884 })
1885 );
1886 assert!(voice.iter().skip(1).all(|note| note.is_rest));
1887 engine.undo().unwrap();
1888 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1889 }
1890
1891 #[test]
1892 fn scale_voice_range_rejects_mixed_tuplet_ratios_without_mutation() {
1893 use crate::{Duration, DurationScale, Note, Pitch, Step, TupletInfo};
1894
1895 let mut engine = ScoreEngine::new();
1896 let mut triplet = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1897 triplet.tuplet = Some(TupletInfo {
1898 actual_notes: 3,
1899 normal_notes: 2,
1900 });
1901 let mut quintuplet = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1902 quintuplet.tuplet = Some(TupletInfo {
1903 actual_notes: 5,
1904 normal_notes: 4,
1905 });
1906 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![triplet, quintuplet];
1907 let before = serde_json::to_value(&engine.score).unwrap();
1908
1909 let error = engine
1910 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1911 .expect_err("mixed tuplet ratios must be rejected");
1912 assert!(
1913 error
1914 .to_string()
1915 .contains("one shared tuplet ratio per voice")
1916 );
1917 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1918 }
1919
1920 #[test]
1921 fn copy_range_paste_range_roundtrip() {
1922 use crate::model::duration::Duration;
1923 use crate::model::pitch::{Pitch, Step};
1924 use crate::model::score::{Note, NoteAddr};
1925 let mut engine = ScoreEngine::new();
1926 engine.score.parts[0].staves[0].measures[0].voices[0] =
1928 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1929 use crate::model::commands::AddMeasureCmd;
1931 engine
1932 .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
1933 .unwrap();
1934
1935 let start = NoteAddr {
1936 part: 0,
1937 staff: 0,
1938 measure: 0,
1939 voice: 0,
1940 note: 0,
1941 };
1942 let end = NoteAddr {
1943 part: 0,
1944 staff: 0,
1945 measure: 0,
1946 voice: 0,
1947 note: 0,
1948 };
1949 engine.copy_range(start, end).unwrap();
1950
1951 let target = NoteAddr {
1952 part: 0,
1953 staff: 0,
1954 measure: 1,
1955 voice: 0,
1956 note: 0,
1957 };
1958 engine.paste_range(target).unwrap();
1959
1960 let pasted = &engine.score.parts[0].staves[0].measures[1].voices[0];
1961 assert_eq!(pasted.len(), 1);
1962 assert_eq!(pasted[0].pitches[0].step, Step::C);
1963 }
1964
1965 #[test]
1966 fn paste_range_is_undoable() {
1967 use crate::model::commands::AddMeasureCmd;
1968 use crate::model::duration::Duration;
1969 use crate::model::pitch::{Pitch, Step};
1970 use crate::model::score::{Note, NoteAddr};
1971 let mut engine = ScoreEngine::new();
1972 engine.score.parts[0].staves[0].measures[0].voices[0] =
1973 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1974 engine
1975 .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
1976 .unwrap();
1977
1978 let start = NoteAddr {
1979 part: 0,
1980 staff: 0,
1981 measure: 0,
1982 voice: 0,
1983 note: 0,
1984 };
1985 let end = start.clone();
1986 engine.copy_range(start, end).unwrap();
1987 let target = NoteAddr {
1988 part: 0,
1989 staff: 0,
1990 measure: 1,
1991 voice: 0,
1992 note: 0,
1993 };
1994 engine.paste_range(target).unwrap();
1995
1996 engine.undo().unwrap();
1998 let restored = &engine.score.parts[0].staves[0].measures[1].voices[0];
1999 assert!(restored.iter().all(|n| n.is_rest));
2000 }
2001
2002 #[test]
2003 fn copy_range_mismatched_part_returns_error() {
2004 let engine = ScoreEngine::new();
2005 let mut e = ScoreEngine::new();
2007 use crate::model::score::NoteAddr;
2008 let start = NoteAddr {
2009 part: 0,
2010 staff: 0,
2011 measure: 0,
2012 voice: 0,
2013 note: 0,
2014 };
2015 let end = NoteAddr {
2016 part: 1,
2017 staff: 0,
2018 measure: 0,
2019 voice: 0,
2020 note: 0,
2021 };
2022 assert!(e.copy_range(start, end).is_err());
2023 let _ = engine; }
2025
2026 #[test]
2027 fn export_history_roundtrip() {
2028 let mut engine = ScoreEngine::new();
2029 engine
2030 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2031 .unwrap();
2032 engine
2033 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2034 .unwrap();
2035 let history = engine.export_history();
2036 assert_eq!(history.commands.len(), 2);
2037 let restored = ScoreEngine::from_history(history).unwrap();
2038 assert_eq!(restored.score.settings.tempo_bpm, 180);
2039 }
2040
2041 #[test]
2042 fn history_base_check_rejects_stale_collaboration_snapshot() {
2043 let mut engine = ScoreEngine::new();
2044 engine
2045 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2046 .unwrap();
2047 let history = engine.export_history();
2048 let mut unrelated = history.initial_score.clone();
2049 unrelated.metadata.title = "unrelated".to_owned();
2050
2051 assert!(!history.base_matches(&unrelated));
2052 assert!(matches!(
2053 ScoreEngine::from_history_on_base(history, unrelated),
2054 Err(Error::HistoryBaseMismatch)
2055 ));
2056 }
2057
2058 #[test]
2059 fn history_base_check_allows_replay_on_matching_snapshot() {
2060 let mut engine = ScoreEngine::new();
2061 engine
2062 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2063 .unwrap();
2064 let history = engine.export_history();
2065 let base = history.initial_score.clone();
2066
2067 assert!(history.base_matches(&base));
2068 let restored = ScoreEngine::from_history_on_base(history, base).unwrap();
2069 assert_eq!(restored.score.settings.tempo_bpm, 160);
2070 }
2071
2072 #[test]
2073 fn history_compare_reports_prefix_and_divergence() {
2074 let base_score = ScoreEngine::new().score.clone();
2075 let mut base = ScoreEngine::new();
2076 base.replace_score(base_score.clone());
2077 let mut left = ScoreEngine::new();
2078 left.replace_score(base_score.clone());
2079 left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2080 .unwrap();
2081 let mut right = ScoreEngine::new();
2082 right.replace_score(base_score.clone());
2083 right
2084 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2085 .unwrap();
2086 right
2087 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2088 .unwrap();
2089
2090 assert_eq!(
2091 base.export_history().compare(&left.export_history()),
2092 HistoryRelation::LeftExtends {
2093 common_prefix_len: 0
2094 }
2095 );
2096 assert_eq!(
2097 left.export_history().compare(&right.export_history()),
2098 HistoryRelation::LeftExtends {
2099 common_prefix_len: 1
2100 }
2101 );
2102
2103 let mut diverged = ScoreEngine::new();
2104 diverged.replace_score(base_score);
2105 diverged
2106 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2107 .unwrap();
2108 assert_eq!(
2109 left.export_history().compare(&diverged.export_history()),
2110 HistoryRelation::Diverged {
2111 common_prefix_len: 0
2112 }
2113 );
2114 }
2115
2116 #[test]
2117 fn history_compare_reports_base_mismatch() {
2118 let left = ScoreEngine::new().export_history();
2119 let mut other = ScoreEngine::new();
2120 other.replace_score(Score::new("Other", 120, 4, 4, 1, 4));
2121 assert_eq!(
2122 left.compare(&other.export_history()),
2123 HistoryRelation::BaseMismatch
2124 );
2125 }
2126
2127 #[test]
2128 fn history_conflict_reports_branch_commands_and_remaining_lengths() {
2129 let base_score = ScoreEngine::new().score.clone();
2130 let mut left = ScoreEngine::new();
2131 left.replace_score(base_score.clone());
2132 left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2133 .unwrap();
2134 let mut right = ScoreEngine::new();
2135 right.replace_score(base_score);
2136 right
2137 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2138 .unwrap();
2139 right
2140 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2141 .unwrap();
2142
2143 assert_eq!(
2144 left.export_history().conflict(&right.export_history()),
2145 Some(HistoryConflict {
2146 common_prefix_len: 0,
2147 left_command_index: 0,
2148 right_command_index: 0,
2149 left_command_key: "SetTempo".to_owned(),
2150 right_command_key: "SetTempo".to_owned(),
2151 left_remaining_commands: 1,
2152 right_remaining_commands: 2,
2153 })
2154 );
2155 }
2156
2157 #[test]
2158 fn history_conflict_is_empty_for_safe_relationships() {
2159 let history = ScoreEngine::new().export_history();
2160 assert!(history.conflict(&history).is_none());
2161 }
2162
2163 #[test]
2164 fn append_history_extension_applies_only_remote_suffix() {
2165 let base_score = ScoreEngine::new().score.clone();
2166 let mut local = ScoreEngine::new();
2167 local.replace_score(base_score.clone());
2168 local
2169 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2170 .unwrap();
2171
2172 let mut remote = ScoreEngine::new();
2173 remote.replace_score(base_score);
2174 remote
2175 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2176 .unwrap();
2177 remote
2178 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2179 .unwrap();
2180
2181 let count = local
2182 .append_history_extension(&remote.export_history())
2183 .unwrap();
2184 assert_eq!(count, 1);
2185 assert_eq!(local.score.settings.tempo_bpm, 180);
2186 assert_eq!(
2187 local.export_history().compare(&remote.export_history()),
2188 HistoryRelation::Equivalent
2189 );
2190 }
2191
2192 #[test]
2193 fn append_history_extension_rejects_divergence_without_mutation() {
2194 let base_score = ScoreEngine::new().score.clone();
2195 let mut local = ScoreEngine::new();
2196 local.replace_score(base_score.clone());
2197 local
2198 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2199 .unwrap();
2200 let before = local.score.settings.tempo_bpm;
2201
2202 let mut remote = ScoreEngine::new();
2203 remote.replace_score(base_score);
2204 remote
2205 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2206 .unwrap();
2207
2208 assert!(matches!(
2209 local.append_history_extension(&remote.export_history()),
2210 Err(Error::HistoryNotAppendable)
2211 ));
2212 assert_eq!(local.score.settings.tempo_bpm, before);
2213 }
2214
2215 #[test]
2216 fn export_history_empty_gives_initial_state() {
2217 let engine = ScoreEngine::new();
2218 let history = engine.export_history();
2219 assert!(history.commands.is_empty());
2220 let restored = ScoreEngine::from_history(history).unwrap();
2221 assert_eq!(restored.score.settings.tempo_bpm, 120);
2222 }
2223
2224 #[test]
2225 fn replace_score_then_export_history() {
2226 let mut engine = ScoreEngine::new();
2227 let s = Score::new("Custom", 90, 3, 4, 2, 4);
2228 engine.replace_score(s);
2229 engine
2230 .apply(Command::SetTempo(SetTempoCmd { bpm: 60 }))
2231 .unwrap();
2232 let history = engine.export_history();
2233 assert_eq!(history.initial_score.settings.tempo_bpm, 90);
2234 assert_eq!(history.commands.len(), 1);
2235 let restored = ScoreEngine::from_history(history).unwrap();
2236 assert_eq!(restored.score.settings.tempo_bpm, 60);
2237 }
2238
2239 #[test]
2240 fn new_score_command_replaces_score() {
2241 let mut engine = ScoreEngine::new();
2242 engine
2243 .apply(Command::NewScore(NewScoreCmd {
2244 title: "Sonata".into(),
2245 composer: "Bach".into(),
2246 tempo_bpm: 80,
2247 time_numerator: 3,
2248 time_denominator: 4,
2249 key_fifths: -1,
2250 measure_count: 12,
2251 template: None,
2252 }))
2253 .unwrap();
2254 assert_eq!(engine.score.metadata.title, "Sonata");
2255 assert_eq!(engine.score.measure_count(), 12);
2256 }
2257
2258 #[test]
2259 fn respell_score_to_key_uses_key_signature() {
2260 use crate::model::commands::{AddNoteCmd, RespellScoreToKeyCmd};
2261 use crate::model::notation::KeySignature;
2262 use crate::model::pitch::Step;
2263 let mut engine = ScoreEngine::new();
2264 engine.score.settings.key_signature = KeySignature {
2266 fifths: -2,
2267 mode: "major".to_string(),
2268 };
2269 engine
2270 .apply(Command::AddNote(AddNoteCmd {
2271 part_index: 0,
2272 staff_index: 0,
2273 measure_index: 0,
2274 voice: 0,
2275 position: 0,
2276 pitch: Some(crate::model::pitch::Pitch::with_alter(Step::C, 4, 1)), duration: crate::model::duration::Duration::Quarter,
2278 dot_count: 0,
2279 is_rest: false,
2280 tuplet: None,
2281 }))
2282 .unwrap();
2283 engine
2284 .apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
2285 .unwrap();
2286 let pitch = &engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
2287 assert_eq!(pitch.step, Step::D);
2288 assert_eq!(pitch.alter, -1); }
2290
2291 #[test]
2292 fn begin_end_slur_creates_slur() {
2293 use crate::model::commands::AddNoteCmd;
2294 use crate::model::duration::Duration;
2295 use crate::model::pitch::{Pitch, Step};
2296 let mut engine = ScoreEngine::new();
2297 engine
2298 .apply(Command::AddNote(AddNoteCmd {
2299 part_index: 0,
2300 staff_index: 0,
2301 measure_index: 0,
2302 voice: 0,
2303 position: 0,
2304 pitch: Some(Pitch::new(Step::C, 4)),
2305 duration: Duration::Quarter,
2306 dot_count: 0,
2307 is_rest: false,
2308 tuplet: None,
2309 }))
2310 .unwrap();
2311 engine
2312 .apply(Command::AddNote(AddNoteCmd {
2313 part_index: 0,
2314 staff_index: 0,
2315 measure_index: 0,
2316 voice: 0,
2317 position: 1,
2318 pitch: Some(Pitch::new(Step::D, 4)),
2319 duration: Duration::Quarter,
2320 dot_count: 0,
2321 is_rest: false,
2322 tuplet: None,
2323 }))
2324 .unwrap();
2325 let start = NoteAddr {
2326 part: 0,
2327 staff: 0,
2328 measure: 0,
2329 voice: 0,
2330 note: 0,
2331 };
2332 let end = NoteAddr {
2333 part: 0,
2334 staff: 0,
2335 measure: 0,
2336 voice: 0,
2337 note: 1,
2338 };
2339 engine.begin_slur(start).unwrap();
2340 engine.end_slur(end).unwrap();
2341 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
2342 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
2343 }
2344
2345 #[test]
2346 fn end_slur_without_begin_returns_error() {
2347 let mut engine = ScoreEngine::new();
2348 let end = NoteAddr {
2349 part: 0,
2350 staff: 0,
2351 measure: 0,
2352 voice: 0,
2353 note: 0,
2354 };
2355 let result = engine.end_slur(end);
2356 assert!(result.is_err());
2357 }
2358
2359 #[test]
2362 fn set_stem_sets_and_clears() {
2363 use crate::model::commands::AddNoteCmd;
2364 use crate::model::duration::Duration;
2365 use crate::model::pitch::{Pitch, Step};
2366 let mut engine = ScoreEngine::new();
2367 engine
2368 .apply(Command::AddNote(AddNoteCmd {
2369 part_index: 0,
2370 staff_index: 0,
2371 measure_index: 0,
2372 voice: 0,
2373 position: 0,
2374 pitch: Some(Pitch::new(Step::C, 4)),
2375 duration: Duration::Quarter,
2376 dot_count: 0,
2377 is_rest: false,
2378 tuplet: None,
2379 }))
2380 .unwrap();
2381 let addr = NoteAddr {
2382 part: 0,
2383 staff: 0,
2384 measure: 0,
2385 voice: 0,
2386 note: 0,
2387 };
2388 engine.set_stem(addr.clone(), Some(true)).unwrap();
2389 assert_eq!(
2390 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2391 Some(true)
2392 );
2393 engine.set_stem(addr.clone(), Some(false)).unwrap();
2394 assert_eq!(
2395 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2396 Some(false)
2397 );
2398 engine.set_stem(addr, None).unwrap();
2399 assert_eq!(
2400 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2401 None
2402 );
2403 }
2404
2405 #[test]
2406 fn set_stem_is_undoable() {
2407 use crate::model::commands::AddNoteCmd;
2408 use crate::model::duration::Duration;
2409 use crate::model::pitch::{Pitch, Step};
2410 let mut engine = ScoreEngine::new();
2411 engine
2412 .apply(Command::AddNote(AddNoteCmd {
2413 part_index: 0,
2414 staff_index: 0,
2415 measure_index: 0,
2416 voice: 0,
2417 position: 0,
2418 pitch: Some(Pitch::new(Step::C, 4)),
2419 duration: Duration::Quarter,
2420 dot_count: 0,
2421 is_rest: false,
2422 tuplet: None,
2423 }))
2424 .unwrap();
2425 let addr = NoteAddr {
2426 part: 0,
2427 staff: 0,
2428 measure: 0,
2429 voice: 0,
2430 note: 0,
2431 };
2432 engine.set_stem(addr, Some(true)).unwrap();
2433 engine.undo().unwrap();
2434 assert_eq!(
2435 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2436 None
2437 );
2438 }
2439
2440 #[test]
2441 fn set_arpeggio_is_undoable() {
2442 use crate::model::commands::AddNoteCmd;
2443 use crate::model::duration::Duration;
2444 use crate::model::pitch::{Pitch, Step};
2445 let mut engine = ScoreEngine::new();
2446 engine
2447 .apply(Command::AddNote(AddNoteCmd {
2448 part_index: 0,
2449 staff_index: 0,
2450 measure_index: 0,
2451 voice: 0,
2452 position: 0,
2453 pitch: Some(Pitch::new(Step::C, 4)),
2454 duration: Duration::Quarter,
2455 dot_count: 0,
2456 is_rest: false,
2457 tuplet: None,
2458 }))
2459 .unwrap();
2460 let addr = NoteAddr {
2461 part: 0,
2462 staff: 0,
2463 measure: 0,
2464 voice: 0,
2465 note: 0,
2466 };
2467 engine.set_arpeggio(addr.clone(), Some(true)).unwrap();
2468 assert_eq!(
2469 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2470 Some(true)
2471 );
2472 engine.undo().unwrap();
2473 assert_eq!(
2474 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2475 None
2476 );
2477 engine.redo().unwrap();
2478 assert_eq!(
2479 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2480 Some(true)
2481 );
2482 }
2483
2484 #[test]
2487 fn undo_key_returns_key_string() {
2488 let mut engine = ScoreEngine::new();
2489 engine
2490 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2491 .unwrap();
2492 assert_eq!(engine.undo_key(), Some("SetTempo".to_string()));
2493 assert!(engine.redo_key().is_none());
2494 }
2495
2496 #[test]
2497 fn redo_key_after_undo() {
2498 let mut engine = ScoreEngine::new();
2499 engine
2500 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2501 .unwrap();
2502 engine.undo().unwrap();
2503 assert!(engine.undo_key().is_none());
2504 assert_eq!(engine.redo_key(), Some("SetTempo".to_string()));
2505 }
2506
2507 #[test]
2510 fn batch_apply_labeled_sets_undo_key() {
2511 let mut engine = ScoreEngine::new();
2512 engine
2513 .batch_apply_labeled(vec![Command::SetTempo(SetTempoCmd { bpm: 140 })], "ApplyAI")
2514 .unwrap();
2515 assert_eq!(engine.undo_key(), Some("ApplyAI".to_string()));
2516 }
2517
2518 #[test]
2519 fn batch_apply_labeled_empty_is_noop() {
2520 let mut engine = ScoreEngine::new();
2521 engine.batch_apply_labeled(vec![], "ApplyAI").unwrap();
2522 assert!(engine.undo_key().is_none());
2523 }
2524}