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