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 extend: false,
1629 });
1630 let mut fourth = Note::new(Pitch::new(Step::F, 4), Duration::Half);
1631 fourth.tie_end = true;
1632 fourth.lyric = Some(Lyric {
1633 text: "plet".into(),
1634 syllabic: "end".into(),
1635 extend: false,
1636 });
1637 let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1638 first.tuplet = Some(ratio.clone());
1639 let mut second = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1640 second.tuplet = Some(ratio.clone());
1641 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![first, second, third, fourth];
1642 engine.score.parts[0].staves[0].measures[0].source_voice_numbers[0] = Some(7);
1643 engine.score.spanners.push(NotationSpanner {
1644 id: "tuplet-split-span".into(),
1645 kind: NotationSpannerKind::Slur,
1646 start: NoteAddr {
1647 part: 0,
1648 staff: 0,
1649 measure: 0,
1650 voice: 0,
1651 note: 0,
1652 },
1653 end: NoteAddr {
1654 part: 0,
1655 staff: 0,
1656 measure: 0,
1657 voice: 0,
1658 note: 3,
1659 },
1660 number: None,
1661 line_type: None,
1662 text: None,
1663 placement: None,
1664 ottava_size: None,
1665 ottava_type: None,
1666 });
1667
1668 engine
1669 .apply(Command::SplitMeasure(SplitMeasureCmd {
1670 measure_index: 0,
1671 split_at_beats: 2.0,
1672 }))
1673 .unwrap();
1674 assert_eq!(
1675 engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1676 3
1677 );
1678 assert_eq!(
1679 engine.score.parts[0].staves[0].measures[1].voices[0].len(),
1680 1
1681 );
1682 assert_eq!(
1683 engine.score.parts[0].staves[0].measures[1].source_voice_numbers[0],
1684 Some(7)
1685 );
1686 assert_eq!(engine.score.spanners[0].end.measure, 1);
1687 assert_eq!(engine.score.spanners[0].end.note, 0);
1688
1689 engine
1690 .apply(Command::JoinMeasures(JoinMeasuresCmd { measure_index: 0 }))
1691 .unwrap();
1692 let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1693 assert_eq!(voice.len(), 4);
1694 assert_eq!(voice[2].tuplet, Some(ratio));
1695 assert!(voice[2].tie_start);
1696 assert!(voice[3].tie_end);
1697 assert_eq!(
1698 voice[2].lyric.as_ref().map(|lyric| lyric.text.as_str()),
1699 Some("tri")
1700 );
1701 assert_eq!(
1702 voice[3].lyric.as_ref().map(|lyric| lyric.text.as_str()),
1703 Some("plet")
1704 );
1705 assert_eq!(engine.score.spanners[0].end.measure, 0);
1706 assert_eq!(engine.score.spanners[0].end.note, 3);
1707 }
1708
1709 #[test]
1710 fn implode_then_explode_preserves_voices_spans_and_source_numbers() {
1711 use crate::model::score::{NotationSpanner, NotationSpannerKind, ScoreTemplate};
1712 use crate::{Duration, Note, Pitch, Step};
1713
1714 let mut engine = ScoreEngine::new();
1715 engine.score = Score::template(ScoreTemplate::Piano);
1716 let upper = &mut engine.score.parts[0].staves[0].measures[0];
1717 upper.voices[0] = vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
1718 upper.source_voice_numbers = [Some(1), None, None, None];
1719 let lower = &mut engine.score.parts[0].staves[1].measures[0];
1720 lower.voices[0] = vec![Note::new(Pitch::new(Step::E, 3), Duration::Whole)];
1721 lower.source_voice_numbers = [Some(5), None, None, None];
1722 engine.score.spanners.push(NotationSpanner {
1723 id: "implode-span".into(),
1724 kind: NotationSpannerKind::Slur,
1725 start: NoteAddr {
1726 part: 0,
1727 staff: 0,
1728 measure: 0,
1729 voice: 0,
1730 note: 0,
1731 },
1732 end: NoteAddr {
1733 part: 0,
1734 staff: 1,
1735 measure: 0,
1736 voice: 0,
1737 note: 0,
1738 },
1739 number: None,
1740 line_type: None,
1741 text: None,
1742 placement: None,
1743 ottava_size: None,
1744 ottava_type: None,
1745 });
1746 let before = serde_json::to_value(&engine.score).unwrap();
1747
1748 engine.implode_staves(0, vec![0, 1], 0, 0, 0).unwrap();
1749 let upper = &engine.score.parts[0].staves[0].measures[0];
1750 assert_eq!(upper.voices[0][0].pitches[0].step, Step::C);
1751 assert_eq!(upper.voices[1][0].pitches[0].step, Step::E);
1752 assert_eq!(upper.source_voice_numbers, [Some(1), Some(5), None, None]);
1753 assert!(
1754 engine.score.parts[0].staves[1].measures[0].voices[0]
1755 .iter()
1756 .all(|note| note.is_rest)
1757 );
1758 assert_eq!(engine.score.spanners[0].end.staff, 0);
1759 assert_eq!(engine.score.spanners[0].end.voice, 1);
1760
1761 engine.explode_voices(0, 0, vec![0, 1], 0, 0).unwrap();
1762 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1763 engine.undo().unwrap();
1764 assert_eq!(engine.score.spanners[0].end.staff, 0);
1765 assert_eq!(engine.score.spanners[0].end.voice, 1);
1766 engine.redo().unwrap();
1767 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1768 }
1769
1770 #[test]
1771 fn explode_rejects_an_occupied_destination_without_mutation() {
1772 use crate::model::score::ScoreTemplate;
1773 use crate::{Duration, ExplodeVoicesCmd, Note, Pitch, Step};
1774
1775 let mut engine = ScoreEngine::new();
1776 engine.score = Score::template(ScoreTemplate::Piano);
1777 let source = &mut engine.score.parts[0].staves[0].measures[0];
1778 source.voices[0] = vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
1779 source.voices[1] = vec![Note::new(Pitch::new(Step::E, 4), Duration::Whole)];
1780 engine.score.parts[0].staves[1].measures[0].voices[0] =
1781 vec![Note::new(Pitch::new(Step::G, 3), Duration::Whole)];
1782 let before = serde_json::to_value(&engine.score).unwrap();
1783
1784 assert!(
1785 engine
1786 .apply(Command::ExplodeVoices(ExplodeVoicesCmd {
1787 part_index: 0,
1788 source_staff: 0,
1789 target_staves: vec![0, 1],
1790 start_measure: 0,
1791 end_measure: 0,
1792 }))
1793 .is_err()
1794 );
1795 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1796 }
1797
1798 #[test]
1799 fn explode_chord_pitches_distributes_pitches_and_is_undoable() {
1800 use crate::model::score::ScoreTemplate;
1801 use crate::{Duration, Note, Pitch, Step};
1802
1803 let mut engine = ScoreEngine::new();
1804 engine.score = Score::template(ScoreTemplate::Piano);
1805 let mut chord = Note::new(Pitch::new(Step::C, 5), Duration::Whole);
1806 chord.pitches.push(Pitch::new(Step::E, 4));
1807 let source_id = chord.id.clone();
1808 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
1809 let before = serde_json::to_value(&engine.score).unwrap();
1810
1811 engine
1812 .explode_chord_pitches(0, 0, vec![0, 1], 0, 0)
1813 .unwrap();
1814 let upper = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
1815 let lower = &engine.score.parts[0].staves[1].measures[0].voices[0][0];
1816 assert_eq!(upper.id, source_id);
1817 assert_eq!(upper.pitches, vec![Pitch::new(Step::C, 5)]);
1818 assert_eq!(lower.pitches, vec![Pitch::new(Step::E, 4)]);
1819 assert_ne!(lower.id, source_id);
1820
1821 engine.undo().unwrap();
1822 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1823 engine.redo().unwrap();
1824 assert_eq!(
1825 engine.score.parts[0].staves[1].measures[0].voices[0][0].pitches,
1826 vec![Pitch::new(Step::E, 4)]
1827 );
1828 }
1829
1830 #[test]
1831 fn explode_chord_pitches_rejects_occupied_destination_without_mutation() {
1832 use crate::model::score::ScoreTemplate;
1833 use crate::{Duration, Note, Pitch, Step};
1834
1835 let mut engine = ScoreEngine::new();
1836 engine.score = Score::template(ScoreTemplate::Piano);
1837 let mut chord = Note::new(Pitch::new(Step::C, 5), Duration::Whole);
1838 chord.pitches.push(Pitch::new(Step::E, 4));
1839 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
1840 engine.score.parts[0].staves[1].measures[0].voices[0] =
1841 vec![Note::new(Pitch::new(Step::G, 3), Duration::Whole)];
1842 let before = serde_json::to_value(&engine.score).unwrap();
1843
1844 assert!(
1845 engine
1846 .explode_chord_pitches(0, 0, vec![0, 1], 0, 0)
1847 .is_err()
1848 );
1849 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1850 }
1851
1852 #[test]
1853 fn scale_voice_range_is_undoable_and_rejects_measure_overflow() {
1854 use crate::{Duration, DurationScale, Note, Pitch, Step};
1855
1856 let mut engine = ScoreEngine::new();
1857 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![
1858 Note::new(Pitch::new(Step::C, 4), Duration::Half),
1859 Note::new(Pitch::new(Step::D, 4), Duration::Half),
1860 ];
1861 let before = serde_json::to_value(&engine.score).unwrap();
1862
1863 engine
1864 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1865 .unwrap();
1866 let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1867 assert_eq!(voice.len(), 3);
1868 assert_eq!(voice[0].duration, Duration::Quarter);
1869 assert_eq!(voice[1].duration, Duration::Quarter);
1870 assert!(voice[2].is_rest);
1871 assert_eq!(voice[2].duration, Duration::Half);
1872 engine.undo().unwrap();
1873 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1874
1875 assert!(
1876 engine
1877 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Double)
1878 .is_err()
1879 );
1880 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1881 }
1882
1883 #[test]
1884 fn scale_voice_range_preserves_tuplet_ratio_and_undoes() {
1885 use crate::{Duration, DurationScale, Note, Pitch, Step, TupletInfo};
1886
1887 let mut engine = ScoreEngine::new();
1888 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1889 note.tuplet = Some(TupletInfo {
1890 actual_notes: 3,
1891 normal_notes: 2,
1892 });
1893 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1894 let before = serde_json::to_value(&engine.score).unwrap();
1895 engine
1896 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1897 .unwrap();
1898 let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1899 assert_eq!(voice[0].duration, Duration::Eighth);
1900 assert_eq!(
1901 voice[0].tuplet,
1902 Some(TupletInfo {
1903 actual_notes: 3,
1904 normal_notes: 2,
1905 })
1906 );
1907 assert!(voice.iter().skip(1).all(|note| note.is_rest));
1908 engine.undo().unwrap();
1909 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1910 }
1911
1912 #[test]
1913 fn scale_voice_range_rejects_mixed_tuplet_ratios_without_mutation() {
1914 use crate::{Duration, DurationScale, Note, Pitch, Step, TupletInfo};
1915
1916 let mut engine = ScoreEngine::new();
1917 let mut triplet = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1918 triplet.tuplet = Some(TupletInfo {
1919 actual_notes: 3,
1920 normal_notes: 2,
1921 });
1922 let mut quintuplet = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1923 quintuplet.tuplet = Some(TupletInfo {
1924 actual_notes: 5,
1925 normal_notes: 4,
1926 });
1927 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![triplet, quintuplet];
1928 let before = serde_json::to_value(&engine.score).unwrap();
1929
1930 let error = engine
1931 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1932 .expect_err("mixed tuplet ratios must be rejected");
1933 assert!(
1934 error
1935 .to_string()
1936 .contains("one shared tuplet ratio per voice")
1937 );
1938 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1939 }
1940
1941 #[test]
1942 fn copy_range_paste_range_roundtrip() {
1943 use crate::model::duration::Duration;
1944 use crate::model::pitch::{Pitch, Step};
1945 use crate::model::score::{Note, NoteAddr};
1946 let mut engine = ScoreEngine::new();
1947 engine.score.parts[0].staves[0].measures[0].voices[0] =
1949 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1950 use crate::model::commands::AddMeasureCmd;
1952 engine
1953 .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
1954 .unwrap();
1955
1956 let start = NoteAddr {
1957 part: 0,
1958 staff: 0,
1959 measure: 0,
1960 voice: 0,
1961 note: 0,
1962 };
1963 let end = NoteAddr {
1964 part: 0,
1965 staff: 0,
1966 measure: 0,
1967 voice: 0,
1968 note: 0,
1969 };
1970 engine.copy_range(start, end).unwrap();
1971
1972 let target = NoteAddr {
1973 part: 0,
1974 staff: 0,
1975 measure: 1,
1976 voice: 0,
1977 note: 0,
1978 };
1979 engine.paste_range(target).unwrap();
1980
1981 let pasted = &engine.score.parts[0].staves[0].measures[1].voices[0];
1982 assert_eq!(pasted.len(), 1);
1983 assert_eq!(pasted[0].pitches[0].step, Step::C);
1984 }
1985
1986 #[test]
1987 fn paste_range_is_undoable() {
1988 use crate::model::commands::AddMeasureCmd;
1989 use crate::model::duration::Duration;
1990 use crate::model::pitch::{Pitch, Step};
1991 use crate::model::score::{Note, NoteAddr};
1992 let mut engine = ScoreEngine::new();
1993 engine.score.parts[0].staves[0].measures[0].voices[0] =
1994 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1995 engine
1996 .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
1997 .unwrap();
1998
1999 let start = NoteAddr {
2000 part: 0,
2001 staff: 0,
2002 measure: 0,
2003 voice: 0,
2004 note: 0,
2005 };
2006 let end = start.clone();
2007 engine.copy_range(start, end).unwrap();
2008 let target = NoteAddr {
2009 part: 0,
2010 staff: 0,
2011 measure: 1,
2012 voice: 0,
2013 note: 0,
2014 };
2015 engine.paste_range(target).unwrap();
2016
2017 engine.undo().unwrap();
2019 let restored = &engine.score.parts[0].staves[0].measures[1].voices[0];
2020 assert!(restored.iter().all(|n| n.is_rest));
2021 }
2022
2023 #[test]
2024 fn copy_range_mismatched_part_returns_error() {
2025 let engine = ScoreEngine::new();
2026 let mut e = ScoreEngine::new();
2028 use crate::model::score::NoteAddr;
2029 let start = NoteAddr {
2030 part: 0,
2031 staff: 0,
2032 measure: 0,
2033 voice: 0,
2034 note: 0,
2035 };
2036 let end = NoteAddr {
2037 part: 1,
2038 staff: 0,
2039 measure: 0,
2040 voice: 0,
2041 note: 0,
2042 };
2043 assert!(e.copy_range(start, end).is_err());
2044 let _ = engine; }
2046
2047 #[test]
2048 fn export_history_roundtrip() {
2049 let mut engine = ScoreEngine::new();
2050 engine
2051 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2052 .unwrap();
2053 engine
2054 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2055 .unwrap();
2056 let history = engine.export_history();
2057 assert_eq!(history.commands.len(), 2);
2058 let restored = ScoreEngine::from_history(history).unwrap();
2059 assert_eq!(restored.score.settings.tempo_bpm, 180);
2060 }
2061
2062 #[test]
2063 fn history_base_check_rejects_stale_collaboration_snapshot() {
2064 let mut engine = ScoreEngine::new();
2065 engine
2066 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2067 .unwrap();
2068 let history = engine.export_history();
2069 let mut unrelated = history.initial_score.clone();
2070 unrelated.metadata.title = "unrelated".to_owned();
2071
2072 assert!(!history.base_matches(&unrelated));
2073 assert!(matches!(
2074 ScoreEngine::from_history_on_base(history, unrelated),
2075 Err(Error::HistoryBaseMismatch)
2076 ));
2077 }
2078
2079 #[test]
2080 fn history_base_check_allows_replay_on_matching_snapshot() {
2081 let mut engine = ScoreEngine::new();
2082 engine
2083 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2084 .unwrap();
2085 let history = engine.export_history();
2086 let base = history.initial_score.clone();
2087
2088 assert!(history.base_matches(&base));
2089 let restored = ScoreEngine::from_history_on_base(history, base).unwrap();
2090 assert_eq!(restored.score.settings.tempo_bpm, 160);
2091 }
2092
2093 #[test]
2094 fn history_compare_reports_prefix_and_divergence() {
2095 let base_score = ScoreEngine::new().score.clone();
2096 let mut base = ScoreEngine::new();
2097 base.replace_score(base_score.clone());
2098 let mut left = ScoreEngine::new();
2099 left.replace_score(base_score.clone());
2100 left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2101 .unwrap();
2102 let mut right = ScoreEngine::new();
2103 right.replace_score(base_score.clone());
2104 right
2105 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2106 .unwrap();
2107 right
2108 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2109 .unwrap();
2110
2111 assert_eq!(
2112 base.export_history().compare(&left.export_history()),
2113 HistoryRelation::LeftExtends {
2114 common_prefix_len: 0
2115 }
2116 );
2117 assert_eq!(
2118 left.export_history().compare(&right.export_history()),
2119 HistoryRelation::LeftExtends {
2120 common_prefix_len: 1
2121 }
2122 );
2123
2124 let mut diverged = ScoreEngine::new();
2125 diverged.replace_score(base_score);
2126 diverged
2127 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2128 .unwrap();
2129 assert_eq!(
2130 left.export_history().compare(&diverged.export_history()),
2131 HistoryRelation::Diverged {
2132 common_prefix_len: 0
2133 }
2134 );
2135 }
2136
2137 #[test]
2138 fn history_compare_reports_base_mismatch() {
2139 let left = ScoreEngine::new().export_history();
2140 let mut other = ScoreEngine::new();
2141 other.replace_score(Score::new("Other", 120, 4, 4, 1, 4));
2142 assert_eq!(
2143 left.compare(&other.export_history()),
2144 HistoryRelation::BaseMismatch
2145 );
2146 }
2147
2148 #[test]
2149 fn history_conflict_reports_branch_commands_and_remaining_lengths() {
2150 let base_score = ScoreEngine::new().score.clone();
2151 let mut left = ScoreEngine::new();
2152 left.replace_score(base_score.clone());
2153 left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2154 .unwrap();
2155 let mut right = ScoreEngine::new();
2156 right.replace_score(base_score);
2157 right
2158 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2159 .unwrap();
2160 right
2161 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2162 .unwrap();
2163
2164 assert_eq!(
2165 left.export_history().conflict(&right.export_history()),
2166 Some(HistoryConflict {
2167 common_prefix_len: 0,
2168 left_command_index: 0,
2169 right_command_index: 0,
2170 left_command_key: "SetTempo".to_owned(),
2171 right_command_key: "SetTempo".to_owned(),
2172 left_remaining_commands: 1,
2173 right_remaining_commands: 2,
2174 })
2175 );
2176 }
2177
2178 #[test]
2179 fn history_conflict_is_empty_for_safe_relationships() {
2180 let history = ScoreEngine::new().export_history();
2181 assert!(history.conflict(&history).is_none());
2182 }
2183
2184 #[test]
2185 fn append_history_extension_applies_only_remote_suffix() {
2186 let base_score = ScoreEngine::new().score.clone();
2187 let mut local = ScoreEngine::new();
2188 local.replace_score(base_score.clone());
2189 local
2190 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2191 .unwrap();
2192
2193 let mut remote = ScoreEngine::new();
2194 remote.replace_score(base_score);
2195 remote
2196 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2197 .unwrap();
2198 remote
2199 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2200 .unwrap();
2201
2202 let count = local
2203 .append_history_extension(&remote.export_history())
2204 .unwrap();
2205 assert_eq!(count, 1);
2206 assert_eq!(local.score.settings.tempo_bpm, 180);
2207 assert_eq!(
2208 local.export_history().compare(&remote.export_history()),
2209 HistoryRelation::Equivalent
2210 );
2211 }
2212
2213 #[test]
2214 fn append_history_extension_rejects_divergence_without_mutation() {
2215 let base_score = ScoreEngine::new().score.clone();
2216 let mut local = ScoreEngine::new();
2217 local.replace_score(base_score.clone());
2218 local
2219 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2220 .unwrap();
2221 let before = local.score.settings.tempo_bpm;
2222
2223 let mut remote = ScoreEngine::new();
2224 remote.replace_score(base_score);
2225 remote
2226 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2227 .unwrap();
2228
2229 assert!(matches!(
2230 local.append_history_extension(&remote.export_history()),
2231 Err(Error::HistoryNotAppendable)
2232 ));
2233 assert_eq!(local.score.settings.tempo_bpm, before);
2234 }
2235
2236 #[test]
2237 fn export_history_empty_gives_initial_state() {
2238 let engine = ScoreEngine::new();
2239 let history = engine.export_history();
2240 assert!(history.commands.is_empty());
2241 let restored = ScoreEngine::from_history(history).unwrap();
2242 assert_eq!(restored.score.settings.tempo_bpm, 120);
2243 }
2244
2245 #[test]
2246 fn replace_score_then_export_history() {
2247 let mut engine = ScoreEngine::new();
2248 let s = Score::new("Custom", 90, 3, 4, 2, 4);
2249 engine.replace_score(s);
2250 engine
2251 .apply(Command::SetTempo(SetTempoCmd { bpm: 60 }))
2252 .unwrap();
2253 let history = engine.export_history();
2254 assert_eq!(history.initial_score.settings.tempo_bpm, 90);
2255 assert_eq!(history.commands.len(), 1);
2256 let restored = ScoreEngine::from_history(history).unwrap();
2257 assert_eq!(restored.score.settings.tempo_bpm, 60);
2258 }
2259
2260 #[test]
2261 fn new_score_command_replaces_score() {
2262 let mut engine = ScoreEngine::new();
2263 engine
2264 .apply(Command::NewScore(NewScoreCmd {
2265 title: "Sonata".into(),
2266 composer: "Bach".into(),
2267 tempo_bpm: 80,
2268 time_numerator: 3,
2269 time_denominator: 4,
2270 key_fifths: -1,
2271 measure_count: 12,
2272 template: None,
2273 }))
2274 .unwrap();
2275 assert_eq!(engine.score.metadata.title, "Sonata");
2276 assert_eq!(engine.score.measure_count(), 12);
2277 }
2278
2279 #[test]
2280 fn respell_score_to_key_uses_key_signature() {
2281 use crate::model::commands::{AddNoteCmd, RespellScoreToKeyCmd};
2282 use crate::model::notation::KeySignature;
2283 use crate::model::pitch::Step;
2284 let mut engine = ScoreEngine::new();
2285 engine.score.settings.key_signature = KeySignature {
2287 fifths: -2,
2288 mode: "major".to_string(),
2289 };
2290 engine
2291 .apply(Command::AddNote(AddNoteCmd {
2292 part_index: 0,
2293 staff_index: 0,
2294 measure_index: 0,
2295 voice: 0,
2296 position: 0,
2297 pitch: Some(crate::model::pitch::Pitch::with_alter(Step::C, 4, 1)), duration: crate::model::duration::Duration::Quarter,
2299 dot_count: 0,
2300 is_rest: false,
2301 tuplet: None,
2302 }))
2303 .unwrap();
2304 engine
2305 .apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
2306 .unwrap();
2307 let pitch = &engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
2308 assert_eq!(pitch.step, Step::D);
2309 assert_eq!(pitch.alter, -1); }
2311
2312 #[test]
2313 fn begin_end_slur_creates_slur() {
2314 use crate::model::commands::AddNoteCmd;
2315 use crate::model::duration::Duration;
2316 use crate::model::pitch::{Pitch, Step};
2317 let mut engine = ScoreEngine::new();
2318 engine
2319 .apply(Command::AddNote(AddNoteCmd {
2320 part_index: 0,
2321 staff_index: 0,
2322 measure_index: 0,
2323 voice: 0,
2324 position: 0,
2325 pitch: Some(Pitch::new(Step::C, 4)),
2326 duration: Duration::Quarter,
2327 dot_count: 0,
2328 is_rest: false,
2329 tuplet: None,
2330 }))
2331 .unwrap();
2332 engine
2333 .apply(Command::AddNote(AddNoteCmd {
2334 part_index: 0,
2335 staff_index: 0,
2336 measure_index: 0,
2337 voice: 0,
2338 position: 1,
2339 pitch: Some(Pitch::new(Step::D, 4)),
2340 duration: Duration::Quarter,
2341 dot_count: 0,
2342 is_rest: false,
2343 tuplet: None,
2344 }))
2345 .unwrap();
2346 let start = NoteAddr {
2347 part: 0,
2348 staff: 0,
2349 measure: 0,
2350 voice: 0,
2351 note: 0,
2352 };
2353 let end = NoteAddr {
2354 part: 0,
2355 staff: 0,
2356 measure: 0,
2357 voice: 0,
2358 note: 1,
2359 };
2360 engine.begin_slur(start).unwrap();
2361 engine.end_slur(end).unwrap();
2362 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
2363 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
2364 }
2365
2366 #[test]
2367 fn end_slur_without_begin_returns_error() {
2368 let mut engine = ScoreEngine::new();
2369 let end = NoteAddr {
2370 part: 0,
2371 staff: 0,
2372 measure: 0,
2373 voice: 0,
2374 note: 0,
2375 };
2376 let result = engine.end_slur(end);
2377 assert!(result.is_err());
2378 }
2379
2380 #[test]
2383 fn set_stem_sets_and_clears() {
2384 use crate::model::commands::AddNoteCmd;
2385 use crate::model::duration::Duration;
2386 use crate::model::pitch::{Pitch, Step};
2387 let mut engine = ScoreEngine::new();
2388 engine
2389 .apply(Command::AddNote(AddNoteCmd {
2390 part_index: 0,
2391 staff_index: 0,
2392 measure_index: 0,
2393 voice: 0,
2394 position: 0,
2395 pitch: Some(Pitch::new(Step::C, 4)),
2396 duration: Duration::Quarter,
2397 dot_count: 0,
2398 is_rest: false,
2399 tuplet: None,
2400 }))
2401 .unwrap();
2402 let addr = NoteAddr {
2403 part: 0,
2404 staff: 0,
2405 measure: 0,
2406 voice: 0,
2407 note: 0,
2408 };
2409 engine.set_stem(addr.clone(), Some(true)).unwrap();
2410 assert_eq!(
2411 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2412 Some(true)
2413 );
2414 engine.set_stem(addr.clone(), Some(false)).unwrap();
2415 assert_eq!(
2416 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2417 Some(false)
2418 );
2419 engine.set_stem(addr, None).unwrap();
2420 assert_eq!(
2421 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2422 None
2423 );
2424 }
2425
2426 #[test]
2427 fn set_stem_is_undoable() {
2428 use crate::model::commands::AddNoteCmd;
2429 use crate::model::duration::Duration;
2430 use crate::model::pitch::{Pitch, Step};
2431 let mut engine = ScoreEngine::new();
2432 engine
2433 .apply(Command::AddNote(AddNoteCmd {
2434 part_index: 0,
2435 staff_index: 0,
2436 measure_index: 0,
2437 voice: 0,
2438 position: 0,
2439 pitch: Some(Pitch::new(Step::C, 4)),
2440 duration: Duration::Quarter,
2441 dot_count: 0,
2442 is_rest: false,
2443 tuplet: None,
2444 }))
2445 .unwrap();
2446 let addr = NoteAddr {
2447 part: 0,
2448 staff: 0,
2449 measure: 0,
2450 voice: 0,
2451 note: 0,
2452 };
2453 engine.set_stem(addr, Some(true)).unwrap();
2454 engine.undo().unwrap();
2455 assert_eq!(
2456 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2457 None
2458 );
2459 }
2460
2461 #[test]
2462 fn set_arpeggio_is_undoable() {
2463 use crate::model::commands::AddNoteCmd;
2464 use crate::model::duration::Duration;
2465 use crate::model::pitch::{Pitch, Step};
2466 let mut engine = ScoreEngine::new();
2467 engine
2468 .apply(Command::AddNote(AddNoteCmd {
2469 part_index: 0,
2470 staff_index: 0,
2471 measure_index: 0,
2472 voice: 0,
2473 position: 0,
2474 pitch: Some(Pitch::new(Step::C, 4)),
2475 duration: Duration::Quarter,
2476 dot_count: 0,
2477 is_rest: false,
2478 tuplet: None,
2479 }))
2480 .unwrap();
2481 let addr = NoteAddr {
2482 part: 0,
2483 staff: 0,
2484 measure: 0,
2485 voice: 0,
2486 note: 0,
2487 };
2488 engine.set_arpeggio(addr.clone(), Some(true)).unwrap();
2489 assert_eq!(
2490 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2491 Some(true)
2492 );
2493 engine.undo().unwrap();
2494 assert_eq!(
2495 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2496 None
2497 );
2498 engine.redo().unwrap();
2499 assert_eq!(
2500 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2501 Some(true)
2502 );
2503 }
2504
2505 #[test]
2508 fn undo_key_returns_key_string() {
2509 let mut engine = ScoreEngine::new();
2510 engine
2511 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2512 .unwrap();
2513 assert_eq!(engine.undo_key(), Some("SetTempo".to_string()));
2514 assert!(engine.redo_key().is_none());
2515 }
2516
2517 #[test]
2518 fn redo_key_after_undo() {
2519 let mut engine = ScoreEngine::new();
2520 engine
2521 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2522 .unwrap();
2523 engine.undo().unwrap();
2524 assert!(engine.undo_key().is_none());
2525 assert_eq!(engine.redo_key(), Some("SetTempo".to_string()));
2526 }
2527
2528 #[test]
2531 fn batch_apply_labeled_sets_undo_key() {
2532 let mut engine = ScoreEngine::new();
2533 engine
2534 .batch_apply_labeled(vec![Command::SetTempo(SetTempoCmd { bpm: 140 })], "ApplyAI")
2535 .unwrap();
2536 assert_eq!(engine.undo_key(), Some("ApplyAI".to_string()));
2537 }
2538
2539 #[test]
2540 fn batch_apply_labeled_empty_is_noop() {
2541 let mut engine = ScoreEngine::new();
2542 engine.batch_apply_labeled(vec![], "ApplyAI").unwrap();
2543 assert!(engine.undo_key().is_none());
2544 }
2545}