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_hidden(&mut self, addr: NoteAddr, hidden: bool) -> Result<ChangeHint, Error> {
613 self.apply(Command::SetHidden(super::commands::SetHiddenCmd {
614 part_index: addr.part,
615 staff_index: addr.staff,
616 measure_index: addr.measure,
617 voice: addr.voice,
618 note_index: addr.note,
619 hidden,
620 }))
621 }
622
623 pub fn set_unpitched(
625 &mut self,
626 addr: NoteAddr,
627 is_unpitched: bool,
628 ) -> Result<ChangeHint, Error> {
629 self.apply(Command::SetUnpitched(SetUnpitchedCmd {
630 part_index: addr.part,
631 staff_index: addr.staff,
632 measure_index: addr.measure,
633 voice: addr.voice,
634 note_index: addr.note,
635 is_unpitched,
636 }))
637 }
638
639 pub fn set_instrument_id(
641 &mut self,
642 addr: NoteAddr,
643 instrument_id: Option<String>,
644 ) -> Result<ChangeHint, Error> {
645 self.apply(Command::SetInstrumentId(SetInstrumentIdCmd {
646 part_index: addr.part,
647 staff_index: addr.staff,
648 measure_index: addr.measure,
649 voice: addr.voice,
650 note_index: addr.note,
651 instrument_id,
652 }))
653 }
654
655 pub fn set_tuplet(
657 &mut self,
658 addr: NoteAddr,
659 tuplet: Option<TupletInfo>,
660 ) -> Result<ChangeHint, Error> {
661 self.apply(Command::SetTuplet(SetTupletCmd {
662 part_index: addr.part,
663 staff_index: addr.staff,
664 measure_index: addr.measure,
665 voice_index: addr.voice,
666 note_index: addr.note,
667 tuplet,
668 }))
669 }
670
671 pub fn respell_score(&mut self, prefer_flat: bool) -> Result<ChangeHint, Error> {
673 self.apply(Command::RespellScore(RespellScoreCmd { prefer_flat }))
674 }
675
676 pub fn respell_score_to_key(&mut self) -> Result<ChangeHint, Error> {
678 self.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
679 }
680
681 pub fn respell_staff_region(
683 &mut self,
684 part_index: usize,
685 staff_index: usize,
686 start_measure: usize,
687 end_measure: usize,
688 policy: RespellPolicy,
689 ) -> Result<ChangeHint, Error> {
690 self.apply(Command::RespellStaffRegion(RespellStaffRegionCmd {
691 part_index,
692 staff_index,
693 start_measure,
694 end_measure,
695 policy,
696 }))
697 }
698
699 pub fn begin_slur(&mut self, start: NoteAddr) -> Result<(), Error> {
703 self.score
704 .parts
705 .get(start.part)
706 .ok_or(Error::PartNotFound(start.part))?
707 .staves
708 .get(start.staff)
709 .ok_or(Error::StaffNotFound(start.staff))?
710 .measures
711 .get(start.measure)
712 .ok_or(Error::MeasureNotFound(start.measure))?
713 .voices
714 .get(start.voice)
715 .ok_or(Error::VoiceOutOfRange(start.voice))?
716 .get(start.note)
717 .ok_or(Error::NoteNotFound(start.note))?;
718 self.pending_slur_start = Some(start);
719 Ok(())
720 }
721
722 pub fn end_slur(&mut self, end: NoteAddr) -> Result<ChangeHint, Error> {
726 let start = self
727 .pending_slur_start
728 .take()
729 .ok_or_else(|| Error::InvalidCommand("no slur in progress".to_string()))?;
730 self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
731 }
732}
733
734impl EngineHistory {
735 pub fn base_matches(&self, base: &Score) -> bool {
737 match (
738 serde_json::to_vec(&self.initial_score),
739 serde_json::to_vec(base),
740 ) {
741 (Ok(expected), Ok(actual)) => expected == actual,
742 _ => false,
743 }
744 }
745
746 pub fn compare(&self, other: &Self) -> HistoryRelation {
748 if !self.base_matches(&other.initial_score) {
749 return HistoryRelation::BaseMismatch;
750 }
751 let common_prefix_len = self
752 .commands
753 .iter()
754 .zip(&other.commands)
755 .take_while(|(left, right)| command_bytes(left) == command_bytes(right))
756 .count();
757 match (self.commands.len(), other.commands.len()) {
758 (left, right) if left == right && common_prefix_len == left => {
759 HistoryRelation::Equivalent
760 }
761 (left, _) if common_prefix_len == left => {
762 HistoryRelation::LeftExtends { common_prefix_len }
763 }
764 (_, right) if common_prefix_len == right => {
765 HistoryRelation::RightExtends { common_prefix_len }
766 }
767 _ => HistoryRelation::Diverged { common_prefix_len },
768 }
769 }
770
771 pub fn conflict(&self, other: &Self) -> Option<HistoryConflict> {
773 let common_prefix_len = match self.compare(other) {
774 HistoryRelation::Diverged { common_prefix_len } => common_prefix_len,
775 _ => return None,
776 };
777 let left = self.commands.get(common_prefix_len)?;
778 let right = other.commands.get(common_prefix_len)?;
779 Some(HistoryConflict {
780 common_prefix_len,
781 left_command_index: common_prefix_len,
782 right_command_index: common_prefix_len,
783 left_command_key: command_key(left),
784 right_command_key: command_key(right),
785 left_remaining_commands: self.commands.len() - common_prefix_len,
786 right_remaining_commands: other.commands.len() - common_prefix_len,
787 })
788 }
789}
790
791fn command_bytes(command: &Command) -> Option<Vec<u8>> {
792 serde_json::to_vec(command).ok()
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798 use crate::model::commands::{NewScoreCmd, SetTempoCmd, SetTempoRampAtMeasureCmd};
799
800 #[test]
801 fn new_engine_has_default_score() {
802 let engine = ScoreEngine::new();
803 assert_eq!(engine.version, 0);
804 assert_eq!(engine.score.parts.len(), 1);
805 }
806
807 #[test]
808 fn apply_increments_version() {
809 let mut engine = ScoreEngine::new();
810 engine
811 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
812 .unwrap();
813 assert_eq!(engine.version, 1);
814 }
815
816 #[test]
817 fn tempo_ramp_command_undo_redo_restores_measure_target() {
818 let mut engine = ScoreEngine::new();
819 engine
820 .apply(Command::SetTempoRampAtMeasure(SetTempoRampAtMeasureCmd {
821 measure_index: 0,
822 target_bpm: Some(84),
823 }))
824 .expect("tempo ramp applies");
825 assert_eq!(
826 engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
827 Some(84)
828 );
829 engine.undo().expect("tempo ramp undoes");
830 assert_eq!(
831 engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
832 None
833 );
834 engine.redo().expect("tempo ramp redoes");
835 assert_eq!(
836 engine.score.parts[0].staves[0].measures[0].tempo_ramp_to,
837 Some(84)
838 );
839 }
840
841 #[test]
842 fn set_hidden_hides_a_rest_and_undoes() {
843 let mut engine = ScoreEngine::new();
844 let voice = &mut engine.score.parts[0].staves[0].measures[0].voices[0];
845 if voice.is_empty() {
846 voice.push(crate::Note::rest(Duration::Whole));
847 }
848 let addr = NoteAddr {
849 part: 0,
850 staff: 0,
851 measure: 0,
852 voice: 0,
853 note: 0,
854 };
855 engine.set_hidden(addr, true).expect("hides");
856 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].hidden);
857 assert_eq!(engine.undo_label().as_deref(), Some("Hide Note"));
858 engine.undo().expect("undoes");
859 assert!(!engine.score.parts[0].staves[0].measures[0].voices[0][0].hidden);
860 }
861
862 #[test]
863 fn undo_redo_cycle() {
864 let mut engine = ScoreEngine::new();
865 engine
866 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
867 .unwrap();
868 let after_apply = engine.version;
869 engine.undo().unwrap();
870 assert_eq!(engine.score.settings.tempo_bpm, 120);
871 engine.redo().unwrap();
872 assert_eq!(engine.score.settings.tempo_bpm, 140);
873 assert!(engine.version > after_apply);
874 }
875
876 #[test]
877 fn replace_score_clears_history() {
878 let mut engine = ScoreEngine::new();
879 engine
880 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
881 .unwrap();
882 let new_score = Score::new("New", 90, 3, 4, 2, 8);
883 engine.replace_score(new_score);
884 assert!(engine.undo().is_err());
885 assert_eq!(engine.score.settings.tempo_bpm, 90);
886 }
887
888 #[test]
889 fn try_replace_score_rejects_invalid_input_without_mutation() {
890 let mut engine = ScoreEngine::new();
891 engine
892 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
893 .unwrap();
894 let original = engine.score.settings.tempo_bpm;
895 let original_version = engine.version;
896 let mut invalid = Score::default();
897 invalid.parts[0].staves[0].tablature = Some(crate::TablatureConfig {
898 lines: 0,
899 tuning_midi: Vec::new(),
900 capo: 0,
901 });
902
903 assert!(matches!(
904 engine.try_replace_score(invalid),
905 Err(Error::InvalidScore)
906 ));
907 assert_eq!(engine.score.settings.tempo_bpm, original);
908 assert_eq!(engine.version, original_version);
909 assert!(engine.commands.can_undo());
910 }
911
912 #[test]
913 fn copy_paste_voice_copies_notes() {
914 use crate::model::duration::Duration;
915 use crate::model::pitch::{Pitch, Step};
916 use crate::model::score::Note;
917 let mut engine = ScoreEngine::new();
918 engine.score.parts[0].staves[0].measures[0].voices[0] =
919 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
920 engine.copy_voice(0, 0, 0, 0).unwrap();
921 engine.paste_voice(0, 0, 0, 1).unwrap();
922 let pasted = &engine.score.parts[0].staves[0].measures[0].voices[1];
923 assert_eq!(pasted.len(), 1);
924 assert_eq!(pasted[0].pitches[0].step, Step::C);
925 }
926
927 #[test]
928 fn paste_voice_undo_restores_original() {
929 use crate::model::duration::Duration;
930 use crate::model::pitch::{Pitch, Step};
931 use crate::model::score::Note;
932 let mut engine = ScoreEngine::new();
933 engine.score.parts[0].staves[0].measures[0].voices[0] =
934 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
935 engine.copy_voice(0, 0, 0, 0).unwrap();
936 engine.paste_voice(0, 0, 0, 1).unwrap();
937 engine.undo().unwrap();
938 assert!(engine.score.parts[0].staves[0].measures[0].voices[1].is_empty());
939 }
940
941 #[test]
942 fn unpitched_flag_is_undoable_and_redoable() {
943 let mut engine = ScoreEngine::new();
944 let addr = NoteAddr {
945 part: 0,
946 staff: 0,
947 measure: 0,
948 voice: 0,
949 note: 0,
950 };
951 engine.set_unpitched(addr.clone(), true).unwrap();
952 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
953 engine.undo().unwrap();
954 assert!(!engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
955 engine.redo().unwrap();
956 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
957 engine
958 .set_instrument_id(addr, Some("P1-I2".to_string()))
959 .unwrap();
960 assert_eq!(
961 engine.score.parts[0].staves[0].measures[0].voices[0][0]
962 .instrument_id
963 .as_deref(),
964 Some("P1-I2")
965 );
966 for command in [
967 Command::SetUnpitched(super::super::commands::SetUnpitchedCmd {
968 part_index: 0,
969 staff_index: 0,
970 measure_index: 0,
971 voice: 0,
972 note_index: 0,
973 is_unpitched: false,
974 }),
975 Command::SetInstrumentId(super::super::commands::SetInstrumentIdCmd {
976 part_index: 0,
977 staff_index: 0,
978 measure_index: 0,
979 voice: 0,
980 note_index: 0,
981 instrument_id: Some("P1-I2".to_string()),
982 }),
983 ] {
984 let json = serde_json::to_string(&command).unwrap();
985 let restored: Command = serde_json::from_str(&json).unwrap();
986 assert_eq!(
987 super::super::commands::command_key(&restored),
988 super::super::commands::command_key(&command)
989 );
990 }
991 }
992
993 #[test]
994 fn paste_voice_without_copy_returns_error() {
995 let mut engine = ScoreEngine::new();
996 assert!(engine.paste_voice(0, 0, 0, 0).is_err());
997 }
998
999 #[test]
1000 fn change_hint_set_tempo_is_global() {
1001 use crate::model::change_hint::ChangeScope;
1002 let mut engine = ScoreEngine::new();
1003 let hint = engine
1004 .apply(Command::SetTempo(SetTempoCmd { bpm: 100 }))
1005 .unwrap();
1006 assert_eq!(hint.scope, ChangeScope::Global);
1007 assert!(!hint.layout_dirty);
1008 assert!(hint.playback_dirty);
1009 }
1010
1011 #[test]
1012 fn change_hint_add_note_is_measure_scope() {
1013 use crate::model::change_hint::ChangeScope;
1014 use crate::model::commands::AddNoteCmd;
1015 use crate::model::duration::Duration;
1016 use crate::model::pitch::Pitch;
1017 use crate::model::pitch::Step;
1018 let mut engine = ScoreEngine::new();
1019 let hint = engine
1020 .apply(Command::AddNote(AddNoteCmd {
1021 part_index: 0,
1022 staff_index: 0,
1023 measure_index: 0,
1024 voice: 0,
1025 position: 0,
1026 pitch: Some(Pitch::new(Step::C, 4)),
1027 duration: Duration::Quarter,
1028 dot_count: 0,
1029 is_rest: false,
1030 tuplet: None,
1031 }))
1032 .unwrap();
1033 assert_eq!(
1034 hint.scope,
1035 ChangeScope::Measures {
1036 part: 0,
1037 staff: 0,
1038 start: 0,
1039 end: 1
1040 }
1041 );
1042 assert!(!hint.layout_dirty);
1043 assert!(hint.playback_dirty);
1044 }
1045
1046 #[test]
1047 fn change_hint_set_part_name_no_dirty() {
1048 use crate::model::change_hint::ChangeScope;
1049 use crate::model::commands::SetPartNameCmd;
1050 let mut engine = ScoreEngine::new();
1051 let hint = engine
1052 .apply(Command::SetPartName(SetPartNameCmd {
1053 part_index: 0,
1054 name: "Violin".into(),
1055 short_name: "Vln.".into(),
1056 }))
1057 .unwrap();
1058 assert_eq!(hint.scope, ChangeScope::Part(0));
1059 assert!(!hint.layout_dirty);
1060 assert!(!hint.playback_dirty);
1061 }
1062
1063 #[test]
1064 fn undo_returns_change_hint() {
1065 use crate::model::change_hint::ChangeScope;
1066 let mut engine = ScoreEngine::new();
1067 engine
1068 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1069 .unwrap();
1070 let hint = engine.undo().unwrap();
1071 assert_eq!(hint.scope, ChangeScope::Global);
1072 assert!(hint.playback_dirty);
1073 }
1074
1075 #[test]
1076 fn redo_returns_change_hint() {
1077 use crate::model::change_hint::ChangeScope;
1078 let mut engine = ScoreEngine::new();
1079 engine
1080 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1081 .unwrap();
1082 engine.undo().unwrap();
1083 let hint = engine.redo().unwrap();
1084 assert_eq!(hint.scope, ChangeScope::Global);
1085 assert!(hint.playback_dirty);
1086 }
1087
1088 #[test]
1089 fn batch_apply_two_commands_single_undo() {
1090 let mut engine = ScoreEngine::new();
1091 let original = engine.score.settings.tempo_bpm;
1092 engine
1093 .batch_apply(vec![
1094 Command::SetTempo(SetTempoCmd { bpm: 160 }),
1095 Command::SetTempo(SetTempoCmd { bpm: 180 }),
1096 ])
1097 .unwrap();
1098 assert_eq!(engine.score.settings.tempo_bpm, 180);
1099 engine.undo().unwrap();
1100 assert_eq!(engine.score.settings.tempo_bpm, original);
1101 assert!(engine.undo().is_err());
1102 }
1103
1104 #[test]
1105 fn batch_apply_empty_returns_no_dirty() {
1106 let mut engine = ScoreEngine::new();
1107 let v0 = engine.version;
1108 let hint = engine.batch_apply(vec![]).unwrap();
1109 assert!(!hint.layout_dirty);
1110 assert!(!hint.playback_dirty);
1111 assert_eq!(engine.version, v0);
1112 }
1113
1114 #[test]
1115 fn batch_apply_hint_merges_scopes() {
1116 use crate::model::change_hint::ChangeScope;
1117 use crate::model::commands::{AddNoteCmd, SetTempoCmd};
1118 use crate::model::duration::Duration;
1119 use crate::model::pitch::{Pitch, Step};
1120 let mut engine = ScoreEngine::new();
1121 let hint = engine
1122 .batch_apply(vec![
1123 Command::SetTempo(SetTempoCmd { bpm: 140 }),
1124 Command::AddNote(AddNoteCmd {
1125 part_index: 0,
1126 staff_index: 0,
1127 measure_index: 0,
1128 voice: 0,
1129 position: 0,
1130 pitch: Some(Pitch::new(Step::C, 4)),
1131 duration: Duration::Quarter,
1132 dot_count: 0,
1133 is_rest: false,
1134 tuplet: None,
1135 }),
1136 ])
1137 .unwrap();
1138 assert_eq!(hint.scope, ChangeScope::Global);
1140 assert!(hint.playback_dirty);
1141 }
1142
1143 #[test]
1144 fn undo_label_none_when_empty() {
1145 let engine = ScoreEngine::new();
1146 assert!(engine.undo_label().is_none());
1147 assert!(engine.redo_label().is_none());
1148 }
1149
1150 #[test]
1151 fn undo_label_after_command() {
1152 let mut engine = ScoreEngine::new();
1153 engine
1154 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1155 .unwrap();
1156 assert_eq!(engine.undo_label(), Some("Set Tempo".to_string()));
1157 assert!(engine.redo_label().is_none());
1158 }
1159
1160 #[test]
1161 fn redo_label_after_undo() {
1162 let mut engine = ScoreEngine::new();
1163 engine
1164 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1165 .unwrap();
1166 engine.undo().unwrap();
1167 assert!(engine.undo_label().is_none());
1168 assert_eq!(engine.redo_label(), Some("Set Tempo".to_string()));
1169 }
1170
1171 #[test]
1172 fn score_fragment_paste_is_undoable_and_assigns_fresh_spanner_ids() {
1173 use crate::model::fragment::{ScoreFragmentSelection, extract_score_fragment};
1174 use crate::model::score::{NotationSpanner, NotationSpannerKind};
1175 use crate::{CrossStaff, Duration, Note, Pitch, Score, ScoreTemplate, Step};
1176
1177 let mut engine = ScoreEngine::new();
1178 engine.score = Score::template(ScoreTemplate::Piano);
1179 let mut source_note = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
1180 source_note.cross_staff = Some(CrossStaff {
1181 target_staff: 1,
1182 target_voice: Some(0),
1183 });
1184 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![source_note];
1185 engine.score.spanners.push(NotationSpanner {
1186 id: "source-slur".into(),
1187 kind: NotationSpannerKind::Slur,
1188 start: NoteAddr {
1189 part: 0,
1190 staff: 0,
1191 measure: 0,
1192 voice: 0,
1193 note: 0,
1194 },
1195 end: NoteAddr {
1196 part: 0,
1197 staff: 0,
1198 measure: 0,
1199 voice: 0,
1200 note: 0,
1201 },
1202 number: None,
1203 line_type: None,
1204 text: None,
1205 placement: None,
1206 ottava_size: None,
1207 ottava_type: None,
1208 });
1209 let selection = ScoreFragmentSelection {
1210 start: NoteAddr {
1211 part: 0,
1212 staff: 0,
1213 measure: 0,
1214 voice: 0,
1215 note: 0,
1216 },
1217 end: NoteAddr {
1218 part: 0,
1219 staff: 0,
1220 measure: 0,
1221 voice: 0,
1222 note: 0,
1223 },
1224 };
1225 let fragment = extract_score_fragment(&engine.score, &[selection]).unwrap();
1226 let source_id = engine.score.parts[0].staves[0].measures[0].voices[0][0]
1227 .id
1228 .clone();
1229 let target = NoteAddr {
1230 part: 0,
1231 staff: 0,
1232 measure: 1,
1233 voice: 0,
1234 note: 0,
1235 };
1236 engine
1237 .paste_score_fragment(fragment.clone(), target.clone())
1238 .unwrap();
1239 let pasted_id = engine.score.parts[0].staves[0].measures[1].voices[0][0]
1240 .id
1241 .clone();
1242 assert_ne!(pasted_id, source_id);
1243 assert_eq!(
1244 engine.score.parts[0].staves[0].measures[1].voices[0][0]
1245 .cross_staff
1246 .as_ref()
1247 .map(|cross_staff| cross_staff.target_staff),
1248 Some(1)
1249 );
1250 assert!(
1251 engine
1252 .score
1253 .spanners
1254 .iter()
1255 .any(|span| span.id == "source-slur-copy")
1256 );
1257
1258 engine
1259 .paste_score_fragment(
1260 fragment,
1261 NoteAddr {
1262 measure: 2,
1263 ..target.clone()
1264 },
1265 )
1266 .unwrap();
1267 assert!(
1268 engine
1269 .score
1270 .spanners
1271 .iter()
1272 .any(|span| span.id == "source-slur-copy-2")
1273 );
1274
1275 engine.undo().unwrap();
1276 assert!(
1277 engine.score.parts[0].staves[0].measures[2].voices[0]
1278 .iter()
1279 .all(|note| note.is_rest)
1280 );
1281 engine.redo().unwrap();
1282 assert_eq!(
1283 engine.score.parts[0].staves[0].measures[2].voices[0][0].pitches[0].step,
1284 Step::C
1285 );
1286
1287 let before = engine.score.clone();
1288 let history_before = engine.commands.history_commands();
1289 assert!(
1290 engine
1291 .paste_score_fragment(
1292 extract_score_fragment(
1293 &engine.score,
1294 &[ScoreFragmentSelection {
1295 start: target.clone(),
1296 end: target,
1297 }]
1298 )
1299 .unwrap(),
1300 NoteAddr {
1301 part: 99,
1302 staff: 0,
1303 measure: 0,
1304 voice: 0,
1305 note: 0,
1306 },
1307 )
1308 .is_err()
1309 );
1310 assert_eq!(
1311 serde_json::to_value(&engine.score).unwrap(),
1312 serde_json::to_value(before).unwrap()
1313 );
1314 assert_eq!(
1315 engine.commands.history_commands().len(),
1316 history_before.len()
1317 );
1318 }
1319
1320 #[test]
1321 fn score_fragment_merge_preserves_sounding_destination_lanes() {
1322 use crate::model::fragment::{ScoreFragmentSelection, extract_score_fragment};
1323 use crate::{Duration, Note, Pitch, ScoreFragmentPastePolicy, Step};
1324
1325 let mut engine = ScoreEngine::new();
1326 engine.score.parts[0].staves[0].measures[0].voices[0] =
1327 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1328 let source = NoteAddr {
1329 part: 0,
1330 staff: 0,
1331 measure: 0,
1332 voice: 0,
1333 note: 0,
1334 };
1335 let fragment = extract_score_fragment(
1336 &engine.score,
1337 &[ScoreFragmentSelection {
1338 start: source.clone(),
1339 end: source.clone(),
1340 }],
1341 )
1342 .unwrap();
1343 let rest_target = NoteAddr {
1344 measure: 1,
1345 ..source.clone()
1346 };
1347 engine
1348 .paste_score_fragment_with_policy(
1349 fragment.clone(),
1350 rest_target,
1351 ScoreFragmentPastePolicy::Merge,
1352 )
1353 .unwrap();
1354 assert_eq!(
1355 engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1356 Step::C
1357 );
1358
1359 engine.score.parts[0].staves[0].measures[2].voices[0] =
1360 vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
1361 let before = serde_json::to_value(&engine.score).unwrap();
1362 assert!(
1363 engine
1364 .paste_score_fragment_with_policy(
1365 fragment,
1366 NoteAddr {
1367 measure: 2,
1368 ..source
1369 },
1370 ScoreFragmentPastePolicy::Merge,
1371 )
1372 .is_err()
1373 );
1374 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1375 }
1376
1377 #[test]
1378 fn score_fragment_replace_preserves_measure_attributes_and_accepts_v2() {
1379 use crate::model::fragment::{
1380 ScoreFragmentMeasureAttributes, ScoreFragmentSelection, extract_score_fragment,
1381 };
1382 use crate::{Duration, KeySignature, Note, Pitch, Step};
1383
1384 let mut engine = ScoreEngine::new();
1385 let source_measure = &mut engine.score.parts[0].staves[0].measures[0];
1386 source_measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1387 source_measure.key_sig = Some(KeySignature {
1388 fifths: 2,
1389 mode: "major".into(),
1390 });
1391 source_measure.tempo = Some(96);
1392 source_measure.navigation = Some("Segno".into());
1393 source_measure.section_break = true;
1394 let source = NoteAddr {
1395 part: 0,
1396 staff: 0,
1397 measure: 0,
1398 voice: 0,
1399 note: 0,
1400 };
1401 let fragment = extract_score_fragment(
1402 &engine.score,
1403 &[ScoreFragmentSelection {
1404 start: source.clone(),
1405 end: source.clone(),
1406 }],
1407 )
1408 .unwrap();
1409 assert!(fragment.voices[0].measures[0].attributes.present);
1410 engine
1411 .paste_score_fragment(
1412 fragment.clone(),
1413 NoteAddr {
1414 measure: 1,
1415 ..source.clone()
1416 },
1417 )
1418 .unwrap();
1419 let pasted = &engine.score.parts[0].staves[0].measures[1];
1420 assert_eq!(pasted.key_sig.as_ref().map(|key| key.fifths), Some(2));
1421 assert_eq!(pasted.tempo, Some(96));
1422 assert_eq!(pasted.navigation.as_deref(), Some("Segno"));
1423 assert!(pasted.section_break);
1424
1425 let mut legacy = fragment;
1426 legacy.contract_version = 2;
1427 for measure in &mut legacy.voices[0].measures {
1428 measure.attributes = ScoreFragmentMeasureAttributes::default();
1429 }
1430 engine
1431 .paste_score_fragment(
1432 legacy,
1433 NoteAddr {
1434 measure: 2,
1435 ..source
1436 },
1437 )
1438 .unwrap();
1439 let legacy_paste = &engine.score.parts[0].staves[0].measures[2];
1440 assert_eq!(legacy_paste.key_sig, None);
1441 assert_eq!(legacy_paste.tempo, None);
1442 assert_eq!(legacy_paste.navigation, None);
1443 assert!(!legacy_paste.section_break);
1444 }
1445
1446 #[test]
1447 fn exchange_voices_preserves_source_numbers_spans_and_undo() {
1448 use crate::model::score::{NotationSpanner, NotationSpannerKind};
1449 use crate::{Duration, Note, Pitch, Step};
1450
1451 let mut engine = ScoreEngine::new();
1452 let measure = &mut engine.score.parts[0].staves[0].measures[0];
1453 measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1454 measure.voices[1] = vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
1455 measure.source_voice_numbers = [Some(1), Some(5), None, None];
1456 engine.score.spanners.push(NotationSpanner {
1457 id: "between-voices".into(),
1458 kind: NotationSpannerKind::Slur,
1459 start: NoteAddr {
1460 part: 0,
1461 staff: 0,
1462 measure: 0,
1463 voice: 0,
1464 note: 0,
1465 },
1466 end: NoteAddr {
1467 part: 0,
1468 staff: 0,
1469 measure: 0,
1470 voice: 1,
1471 note: 0,
1472 },
1473 number: None,
1474 line_type: None,
1475 text: None,
1476 placement: None,
1477 ottava_size: None,
1478 ottava_type: None,
1479 });
1480
1481 engine.exchange_voices(0, 0, 0, 0, 0, 1).unwrap();
1482 let measure = &engine.score.parts[0].staves[0].measures[0];
1483 assert_eq!(measure.voices[0][0].pitches[0].step, Step::D);
1484 assert_eq!(measure.voices[1][0].pitches[0].step, Step::C);
1485 assert_eq!(measure.source_voice_numbers, [Some(5), Some(1), None, None]);
1486 assert_eq!(engine.score.spanners[0].start.voice, 1);
1487 assert_eq!(engine.score.spanners[0].end.voice, 0);
1488
1489 engine.undo().unwrap();
1490 let measure = &engine.score.parts[0].staves[0].measures[0];
1491 assert_eq!(measure.voices[0][0].pitches[0].step, Step::C);
1492 assert_eq!(measure.source_voice_numbers, [Some(1), Some(5), None, None]);
1493 }
1494
1495 #[test]
1496 fn move_or_copy_voice_range_is_atomic_and_undoable() {
1497 use crate::MoveOrCopyVoiceRangeCmd;
1498 use crate::{Duration, Note, Pitch, Step};
1499
1500 let mut engine = ScoreEngine::new();
1501 engine.score.parts[0].staves[0].measures[0].voices[0] =
1502 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1503 let source = NoteAddr {
1504 part: 0,
1505 staff: 0,
1506 measure: 0,
1507 voice: 0,
1508 note: 0,
1509 };
1510 let target = NoteAddr {
1511 measure: 1,
1512 ..source.clone()
1513 };
1514 engine
1515 .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1516 source_start: source.clone(),
1517 source_end: source.clone(),
1518 target: target.clone(),
1519 move_source: false,
1520 }))
1521 .unwrap();
1522 assert_eq!(
1523 engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1524 Step::C
1525 );
1526 assert_eq!(
1527 engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].step,
1528 Step::C
1529 );
1530
1531 engine
1532 .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1533 source_start: source.clone(),
1534 source_end: source.clone(),
1535 target: NoteAddr {
1536 measure: 2,
1537 ..source.clone()
1538 },
1539 move_source: true,
1540 }))
1541 .unwrap();
1542 assert!(
1543 engine.score.parts[0].staves[0].measures[0].voices[0]
1544 .iter()
1545 .all(|note| note.is_rest)
1546 );
1547 assert_eq!(
1548 engine.score.parts[0].staves[0].measures[2].voices[0][0].pitches[0].step,
1549 Step::C
1550 );
1551 engine.undo().unwrap();
1552 assert_eq!(
1553 engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0].step,
1554 Step::C
1555 );
1556
1557 let before = serde_json::to_value(&engine.score).unwrap();
1558 assert!(
1559 engine
1560 .apply(Command::MoveOrCopyVoiceRange(MoveOrCopyVoiceRangeCmd {
1561 source_start: source.clone(),
1562 source_end: source.clone(),
1563 target: source,
1564 move_source: true,
1565 }))
1566 .is_err()
1567 );
1568 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1569 }
1570
1571 #[test]
1572 fn split_measure_remaps_spanners_and_is_undoable() {
1573 use crate::model::score::{NotationSpanner, NotationSpannerKind};
1574 use crate::{Duration, JoinMeasuresCmd, Note, Pitch, SplitMeasureCmd, Step};
1575
1576 let mut engine = ScoreEngine::new();
1577 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![
1578 Note::new(Pitch::new(Step::C, 4), Duration::Half),
1579 Note::new(Pitch::new(Step::D, 4), Duration::Half),
1580 ];
1581 engine.score.spanners.push(NotationSpanner {
1582 id: "across-split".into(),
1583 kind: NotationSpannerKind::Slur,
1584 start: NoteAddr {
1585 part: 0,
1586 staff: 0,
1587 measure: 0,
1588 voice: 0,
1589 note: 0,
1590 },
1591 end: NoteAddr {
1592 part: 0,
1593 staff: 0,
1594 measure: 0,
1595 voice: 0,
1596 note: 1,
1597 },
1598 number: None,
1599 line_type: None,
1600 text: None,
1601 placement: None,
1602 ottava_size: None,
1603 ottava_type: None,
1604 });
1605 engine
1606 .apply(Command::SplitMeasure(SplitMeasureCmd {
1607 measure_index: 0,
1608 split_at_beats: 2.0,
1609 }))
1610 .unwrap();
1611 assert_eq!(
1612 engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1613 1
1614 );
1615 assert_eq!(
1616 engine.score.parts[0].staves[0].measures[1].voices[0][0].pitches[0].step,
1617 Step::D
1618 );
1619 assert_eq!(engine.score.spanners[0].end.measure, 1);
1620 assert_eq!(engine.score.spanners[0].end.note, 0);
1621 engine.undo().unwrap();
1622 assert_eq!(
1623 engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1624 2
1625 );
1626 engine
1627 .apply(Command::SplitMeasure(SplitMeasureCmd {
1628 measure_index: 0,
1629 split_at_beats: 2.0,
1630 }))
1631 .unwrap();
1632 engine
1633 .apply(Command::JoinMeasures(JoinMeasuresCmd { measure_index: 0 }))
1634 .unwrap();
1635 assert_eq!(
1636 engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1637 2
1638 );
1639 assert_eq!(engine.score.spanners[0].end.measure, 0);
1640 assert_eq!(engine.score.spanners[0].end.note, 1);
1641 }
1642
1643 #[test]
1644 fn split_and_join_preserve_tuplet_tie_lyric_span_and_source_voice() {
1645 use crate::model::score::{NotationSpanner, NotationSpannerKind};
1646 use crate::{
1647 Duration, JoinMeasuresCmd, Lyric, Note, Pitch, SplitMeasureCmd, Step, TupletInfo,
1648 };
1649
1650 let mut engine = ScoreEngine::new();
1651 let ratio = TupletInfo {
1652 actual_notes: 3,
1653 normal_notes: 2,
1654 };
1655 let mut third = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
1656 third.tuplet = Some(ratio.clone());
1657 third.tie_start = true;
1658 third.lyric = Some(Lyric {
1659 text: "tri".into(),
1660 syllabic: "begin".into(),
1661 extend: false,
1662 });
1663 let mut fourth = Note::new(Pitch::new(Step::F, 4), Duration::Half);
1664 fourth.tie_end = true;
1665 fourth.lyric = Some(Lyric {
1666 text: "plet".into(),
1667 syllabic: "end".into(),
1668 extend: false,
1669 });
1670 let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1671 first.tuplet = Some(ratio.clone());
1672 let mut second = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1673 second.tuplet = Some(ratio.clone());
1674 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![first, second, third, fourth];
1675 engine.score.parts[0].staves[0].measures[0].source_voice_numbers[0] = Some(7);
1676 engine.score.spanners.push(NotationSpanner {
1677 id: "tuplet-split-span".into(),
1678 kind: NotationSpannerKind::Slur,
1679 start: NoteAddr {
1680 part: 0,
1681 staff: 0,
1682 measure: 0,
1683 voice: 0,
1684 note: 0,
1685 },
1686 end: NoteAddr {
1687 part: 0,
1688 staff: 0,
1689 measure: 0,
1690 voice: 0,
1691 note: 3,
1692 },
1693 number: None,
1694 line_type: None,
1695 text: None,
1696 placement: None,
1697 ottava_size: None,
1698 ottava_type: None,
1699 });
1700
1701 engine
1702 .apply(Command::SplitMeasure(SplitMeasureCmd {
1703 measure_index: 0,
1704 split_at_beats: 2.0,
1705 }))
1706 .unwrap();
1707 assert_eq!(
1708 engine.score.parts[0].staves[0].measures[0].voices[0].len(),
1709 3
1710 );
1711 assert_eq!(
1712 engine.score.parts[0].staves[0].measures[1].voices[0].len(),
1713 1
1714 );
1715 assert_eq!(
1716 engine.score.parts[0].staves[0].measures[1].source_voice_numbers[0],
1717 Some(7)
1718 );
1719 assert_eq!(engine.score.spanners[0].end.measure, 1);
1720 assert_eq!(engine.score.spanners[0].end.note, 0);
1721
1722 engine
1723 .apply(Command::JoinMeasures(JoinMeasuresCmd { measure_index: 0 }))
1724 .unwrap();
1725 let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1726 assert_eq!(voice.len(), 4);
1727 assert_eq!(voice[2].tuplet, Some(ratio));
1728 assert!(voice[2].tie_start);
1729 assert!(voice[3].tie_end);
1730 assert_eq!(
1731 voice[2].lyric.as_ref().map(|lyric| lyric.text.as_str()),
1732 Some("tri")
1733 );
1734 assert_eq!(
1735 voice[3].lyric.as_ref().map(|lyric| lyric.text.as_str()),
1736 Some("plet")
1737 );
1738 assert_eq!(engine.score.spanners[0].end.measure, 0);
1739 assert_eq!(engine.score.spanners[0].end.note, 3);
1740 }
1741
1742 #[test]
1743 fn implode_then_explode_preserves_voices_spans_and_source_numbers() {
1744 use crate::model::score::{NotationSpanner, NotationSpannerKind, ScoreTemplate};
1745 use crate::{Duration, Note, Pitch, Step};
1746
1747 let mut engine = ScoreEngine::new();
1748 engine.score = Score::template(ScoreTemplate::Piano);
1749 let upper = &mut engine.score.parts[0].staves[0].measures[0];
1750 upper.voices[0] = vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
1751 upper.source_voice_numbers = [Some(1), None, None, None];
1752 let lower = &mut engine.score.parts[0].staves[1].measures[0];
1753 lower.voices[0] = vec![Note::new(Pitch::new(Step::E, 3), Duration::Whole)];
1754 lower.source_voice_numbers = [Some(5), None, None, None];
1755 engine.score.spanners.push(NotationSpanner {
1756 id: "implode-span".into(),
1757 kind: NotationSpannerKind::Slur,
1758 start: NoteAddr {
1759 part: 0,
1760 staff: 0,
1761 measure: 0,
1762 voice: 0,
1763 note: 0,
1764 },
1765 end: NoteAddr {
1766 part: 0,
1767 staff: 1,
1768 measure: 0,
1769 voice: 0,
1770 note: 0,
1771 },
1772 number: None,
1773 line_type: None,
1774 text: None,
1775 placement: None,
1776 ottava_size: None,
1777 ottava_type: None,
1778 });
1779 let before = serde_json::to_value(&engine.score).unwrap();
1780
1781 engine.implode_staves(0, vec![0, 1], 0, 0, 0).unwrap();
1782 let upper = &engine.score.parts[0].staves[0].measures[0];
1783 assert_eq!(upper.voices[0][0].pitches[0].step, Step::C);
1784 assert_eq!(upper.voices[1][0].pitches[0].step, Step::E);
1785 assert_eq!(upper.source_voice_numbers, [Some(1), Some(5), None, None]);
1786 assert!(
1787 engine.score.parts[0].staves[1].measures[0].voices[0]
1788 .iter()
1789 .all(|note| note.is_rest)
1790 );
1791 assert_eq!(engine.score.spanners[0].end.staff, 0);
1792 assert_eq!(engine.score.spanners[0].end.voice, 1);
1793
1794 engine.explode_voices(0, 0, vec![0, 1], 0, 0).unwrap();
1795 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1796 engine.undo().unwrap();
1797 assert_eq!(engine.score.spanners[0].end.staff, 0);
1798 assert_eq!(engine.score.spanners[0].end.voice, 1);
1799 engine.redo().unwrap();
1800 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1801 }
1802
1803 #[test]
1804 fn explode_rejects_an_occupied_destination_without_mutation() {
1805 use crate::model::score::ScoreTemplate;
1806 use crate::{Duration, ExplodeVoicesCmd, Note, Pitch, Step};
1807
1808 let mut engine = ScoreEngine::new();
1809 engine.score = Score::template(ScoreTemplate::Piano);
1810 let source = &mut engine.score.parts[0].staves[0].measures[0];
1811 source.voices[0] = vec![Note::new(Pitch::new(Step::C, 5), Duration::Whole)];
1812 source.voices[1] = vec![Note::new(Pitch::new(Step::E, 4), Duration::Whole)];
1813 engine.score.parts[0].staves[1].measures[0].voices[0] =
1814 vec![Note::new(Pitch::new(Step::G, 3), Duration::Whole)];
1815 let before = serde_json::to_value(&engine.score).unwrap();
1816
1817 assert!(
1818 engine
1819 .apply(Command::ExplodeVoices(ExplodeVoicesCmd {
1820 part_index: 0,
1821 source_staff: 0,
1822 target_staves: vec![0, 1],
1823 start_measure: 0,
1824 end_measure: 0,
1825 }))
1826 .is_err()
1827 );
1828 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1829 }
1830
1831 #[test]
1832 fn explode_chord_pitches_distributes_pitches_and_is_undoable() {
1833 use crate::model::score::ScoreTemplate;
1834 use crate::{Duration, Note, Pitch, Step};
1835
1836 let mut engine = ScoreEngine::new();
1837 engine.score = Score::template(ScoreTemplate::Piano);
1838 let mut chord = Note::new(Pitch::new(Step::C, 5), Duration::Whole);
1839 chord.pitches.push(Pitch::new(Step::E, 4));
1840 let source_id = chord.id.clone();
1841 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
1842 let before = serde_json::to_value(&engine.score).unwrap();
1843
1844 engine
1845 .explode_chord_pitches(0, 0, vec![0, 1], 0, 0)
1846 .unwrap();
1847 let upper = &engine.score.parts[0].staves[0].measures[0].voices[0][0];
1848 let lower = &engine.score.parts[0].staves[1].measures[0].voices[0][0];
1849 assert_eq!(upper.id, source_id);
1850 assert_eq!(upper.pitches, vec![Pitch::new(Step::C, 5)]);
1851 assert_eq!(lower.pitches, vec![Pitch::new(Step::E, 4)]);
1852 assert_ne!(lower.id, source_id);
1853
1854 engine.undo().unwrap();
1855 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1856 engine.redo().unwrap();
1857 assert_eq!(
1858 engine.score.parts[0].staves[1].measures[0].voices[0][0].pitches,
1859 vec![Pitch::new(Step::E, 4)]
1860 );
1861 }
1862
1863 #[test]
1864 fn explode_chord_pitches_rejects_occupied_destination_without_mutation() {
1865 use crate::model::score::ScoreTemplate;
1866 use crate::{Duration, Note, Pitch, Step};
1867
1868 let mut engine = ScoreEngine::new();
1869 engine.score = Score::template(ScoreTemplate::Piano);
1870 let mut chord = Note::new(Pitch::new(Step::C, 5), Duration::Whole);
1871 chord.pitches.push(Pitch::new(Step::E, 4));
1872 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
1873 engine.score.parts[0].staves[1].measures[0].voices[0] =
1874 vec![Note::new(Pitch::new(Step::G, 3), Duration::Whole)];
1875 let before = serde_json::to_value(&engine.score).unwrap();
1876
1877 assert!(
1878 engine
1879 .explode_chord_pitches(0, 0, vec![0, 1], 0, 0)
1880 .is_err()
1881 );
1882 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1883 }
1884
1885 #[test]
1886 fn scale_voice_range_is_undoable_and_rejects_measure_overflow() {
1887 use crate::{Duration, DurationScale, Note, Pitch, Step};
1888
1889 let mut engine = ScoreEngine::new();
1890 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![
1891 Note::new(Pitch::new(Step::C, 4), Duration::Half),
1892 Note::new(Pitch::new(Step::D, 4), Duration::Half),
1893 ];
1894 let before = serde_json::to_value(&engine.score).unwrap();
1895
1896 engine
1897 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1898 .unwrap();
1899 let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1900 assert_eq!(voice.len(), 3);
1901 assert_eq!(voice[0].duration, Duration::Quarter);
1902 assert_eq!(voice[1].duration, Duration::Quarter);
1903 assert!(voice[2].is_rest);
1904 assert_eq!(voice[2].duration, Duration::Half);
1905 engine.undo().unwrap();
1906 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1907
1908 assert!(
1909 engine
1910 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Double)
1911 .is_err()
1912 );
1913 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1914 }
1915
1916 #[test]
1917 fn scale_voice_range_preserves_tuplet_ratio_and_undoes() {
1918 use crate::{Duration, DurationScale, Note, Pitch, Step, TupletInfo};
1919
1920 let mut engine = ScoreEngine::new();
1921 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1922 note.tuplet = Some(TupletInfo {
1923 actual_notes: 3,
1924 normal_notes: 2,
1925 });
1926 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1927 let before = serde_json::to_value(&engine.score).unwrap();
1928 engine
1929 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1930 .unwrap();
1931 let voice = &engine.score.parts[0].staves[0].measures[0].voices[0];
1932 assert_eq!(voice[0].duration, Duration::Eighth);
1933 assert_eq!(
1934 voice[0].tuplet,
1935 Some(TupletInfo {
1936 actual_notes: 3,
1937 normal_notes: 2,
1938 })
1939 );
1940 assert!(voice.iter().skip(1).all(|note| note.is_rest));
1941 engine.undo().unwrap();
1942 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1943 }
1944
1945 #[test]
1946 fn scale_voice_range_rejects_mixed_tuplet_ratios_without_mutation() {
1947 use crate::{Duration, DurationScale, Note, Pitch, Step, TupletInfo};
1948
1949 let mut engine = ScoreEngine::new();
1950 let mut triplet = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1951 triplet.tuplet = Some(TupletInfo {
1952 actual_notes: 3,
1953 normal_notes: 2,
1954 });
1955 let mut quintuplet = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1956 quintuplet.tuplet = Some(TupletInfo {
1957 actual_notes: 5,
1958 normal_notes: 4,
1959 });
1960 engine.score.parts[0].staves[0].measures[0].voices[0] = vec![triplet, quintuplet];
1961 let before = serde_json::to_value(&engine.score).unwrap();
1962
1963 let error = engine
1964 .scale_voice_range(0, 0, 0, 0, 0, DurationScale::Half)
1965 .expect_err("mixed tuplet ratios must be rejected");
1966 assert!(
1967 error
1968 .to_string()
1969 .contains("one shared tuplet ratio per voice")
1970 );
1971 assert_eq!(serde_json::to_value(&engine.score).unwrap(), before);
1972 }
1973
1974 #[test]
1975 fn copy_range_paste_range_roundtrip() {
1976 use crate::model::duration::Duration;
1977 use crate::model::pitch::{Pitch, Step};
1978 use crate::model::score::{Note, NoteAddr};
1979 let mut engine = ScoreEngine::new();
1980 engine.score.parts[0].staves[0].measures[0].voices[0] =
1982 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1983 use crate::model::commands::AddMeasureCmd;
1985 engine
1986 .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
1987 .unwrap();
1988
1989 let start = NoteAddr {
1990 part: 0,
1991 staff: 0,
1992 measure: 0,
1993 voice: 0,
1994 note: 0,
1995 };
1996 let end = NoteAddr {
1997 part: 0,
1998 staff: 0,
1999 measure: 0,
2000 voice: 0,
2001 note: 0,
2002 };
2003 engine.copy_range(start, end).unwrap();
2004
2005 let target = NoteAddr {
2006 part: 0,
2007 staff: 0,
2008 measure: 1,
2009 voice: 0,
2010 note: 0,
2011 };
2012 engine.paste_range(target).unwrap();
2013
2014 let pasted = &engine.score.parts[0].staves[0].measures[1].voices[0];
2015 assert_eq!(pasted.len(), 1);
2016 assert_eq!(pasted[0].pitches[0].step, Step::C);
2017 }
2018
2019 #[test]
2020 fn paste_range_is_undoable() {
2021 use crate::model::commands::AddMeasureCmd;
2022 use crate::model::duration::Duration;
2023 use crate::model::pitch::{Pitch, Step};
2024 use crate::model::score::{Note, NoteAddr};
2025 let mut engine = ScoreEngine::new();
2026 engine.score.parts[0].staves[0].measures[0].voices[0] =
2027 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
2028 engine
2029 .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
2030 .unwrap();
2031
2032 let start = NoteAddr {
2033 part: 0,
2034 staff: 0,
2035 measure: 0,
2036 voice: 0,
2037 note: 0,
2038 };
2039 let end = start.clone();
2040 engine.copy_range(start, end).unwrap();
2041 let target = NoteAddr {
2042 part: 0,
2043 staff: 0,
2044 measure: 1,
2045 voice: 0,
2046 note: 0,
2047 };
2048 engine.paste_range(target).unwrap();
2049
2050 engine.undo().unwrap();
2052 let restored = &engine.score.parts[0].staves[0].measures[1].voices[0];
2053 assert!(restored.iter().all(|n| n.is_rest));
2054 }
2055
2056 #[test]
2057 fn copy_range_mismatched_part_returns_error() {
2058 let engine = ScoreEngine::new();
2059 let mut e = ScoreEngine::new();
2061 use crate::model::score::NoteAddr;
2062 let start = NoteAddr {
2063 part: 0,
2064 staff: 0,
2065 measure: 0,
2066 voice: 0,
2067 note: 0,
2068 };
2069 let end = NoteAddr {
2070 part: 1,
2071 staff: 0,
2072 measure: 0,
2073 voice: 0,
2074 note: 0,
2075 };
2076 assert!(e.copy_range(start, end).is_err());
2077 let _ = engine; }
2079
2080 #[test]
2081 fn export_history_roundtrip() {
2082 let mut engine = ScoreEngine::new();
2083 engine
2084 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2085 .unwrap();
2086 engine
2087 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2088 .unwrap();
2089 let history = engine.export_history();
2090 assert_eq!(history.commands.len(), 2);
2091 let restored = ScoreEngine::from_history(history).unwrap();
2092 assert_eq!(restored.score.settings.tempo_bpm, 180);
2093 }
2094
2095 #[test]
2096 fn history_base_check_rejects_stale_collaboration_snapshot() {
2097 let mut engine = ScoreEngine::new();
2098 engine
2099 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2100 .unwrap();
2101 let history = engine.export_history();
2102 let mut unrelated = history.initial_score.clone();
2103 unrelated.metadata.title = "unrelated".to_owned();
2104
2105 assert!(!history.base_matches(&unrelated));
2106 assert!(matches!(
2107 ScoreEngine::from_history_on_base(history, unrelated),
2108 Err(Error::HistoryBaseMismatch)
2109 ));
2110 }
2111
2112 #[test]
2113 fn history_base_check_allows_replay_on_matching_snapshot() {
2114 let mut engine = ScoreEngine::new();
2115 engine
2116 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2117 .unwrap();
2118 let history = engine.export_history();
2119 let base = history.initial_score.clone();
2120
2121 assert!(history.base_matches(&base));
2122 let restored = ScoreEngine::from_history_on_base(history, base).unwrap();
2123 assert_eq!(restored.score.settings.tempo_bpm, 160);
2124 }
2125
2126 #[test]
2127 fn history_compare_reports_prefix_and_divergence() {
2128 let base_score = ScoreEngine::new().score.clone();
2129 let mut base = ScoreEngine::new();
2130 base.replace_score(base_score.clone());
2131 let mut left = ScoreEngine::new();
2132 left.replace_score(base_score.clone());
2133 left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2134 .unwrap();
2135 let mut right = ScoreEngine::new();
2136 right.replace_score(base_score.clone());
2137 right
2138 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2139 .unwrap();
2140 right
2141 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2142 .unwrap();
2143
2144 assert_eq!(
2145 base.export_history().compare(&left.export_history()),
2146 HistoryRelation::LeftExtends {
2147 common_prefix_len: 0
2148 }
2149 );
2150 assert_eq!(
2151 left.export_history().compare(&right.export_history()),
2152 HistoryRelation::LeftExtends {
2153 common_prefix_len: 1
2154 }
2155 );
2156
2157 let mut diverged = ScoreEngine::new();
2158 diverged.replace_score(base_score);
2159 diverged
2160 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2161 .unwrap();
2162 assert_eq!(
2163 left.export_history().compare(&diverged.export_history()),
2164 HistoryRelation::Diverged {
2165 common_prefix_len: 0
2166 }
2167 );
2168 }
2169
2170 #[test]
2171 fn history_compare_reports_base_mismatch() {
2172 let left = ScoreEngine::new().export_history();
2173 let mut other = ScoreEngine::new();
2174 other.replace_score(Score::new("Other", 120, 4, 4, 1, 4));
2175 assert_eq!(
2176 left.compare(&other.export_history()),
2177 HistoryRelation::BaseMismatch
2178 );
2179 }
2180
2181 #[test]
2182 fn history_conflict_reports_branch_commands_and_remaining_lengths() {
2183 let base_score = ScoreEngine::new().score.clone();
2184 let mut left = ScoreEngine::new();
2185 left.replace_score(base_score.clone());
2186 left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2187 .unwrap();
2188 let mut right = ScoreEngine::new();
2189 right.replace_score(base_score);
2190 right
2191 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2192 .unwrap();
2193 right
2194 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2195 .unwrap();
2196
2197 assert_eq!(
2198 left.export_history().conflict(&right.export_history()),
2199 Some(HistoryConflict {
2200 common_prefix_len: 0,
2201 left_command_index: 0,
2202 right_command_index: 0,
2203 left_command_key: "SetTempo".to_owned(),
2204 right_command_key: "SetTempo".to_owned(),
2205 left_remaining_commands: 1,
2206 right_remaining_commands: 2,
2207 })
2208 );
2209 }
2210
2211 #[test]
2212 fn history_conflict_is_empty_for_safe_relationships() {
2213 let history = ScoreEngine::new().export_history();
2214 assert!(history.conflict(&history).is_none());
2215 }
2216
2217 #[test]
2218 fn append_history_extension_applies_only_remote_suffix() {
2219 let base_score = ScoreEngine::new().score.clone();
2220 let mut local = ScoreEngine::new();
2221 local.replace_score(base_score.clone());
2222 local
2223 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2224 .unwrap();
2225
2226 let mut remote = ScoreEngine::new();
2227 remote.replace_score(base_score);
2228 remote
2229 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2230 .unwrap();
2231 remote
2232 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
2233 .unwrap();
2234
2235 let count = local
2236 .append_history_extension(&remote.export_history())
2237 .unwrap();
2238 assert_eq!(count, 1);
2239 assert_eq!(local.score.settings.tempo_bpm, 180);
2240 assert_eq!(
2241 local.export_history().compare(&remote.export_history()),
2242 HistoryRelation::Equivalent
2243 );
2244 }
2245
2246 #[test]
2247 fn append_history_extension_rejects_divergence_without_mutation() {
2248 let base_score = ScoreEngine::new().score.clone();
2249 let mut local = ScoreEngine::new();
2250 local.replace_score(base_score.clone());
2251 local
2252 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
2253 .unwrap();
2254 let before = local.score.settings.tempo_bpm;
2255
2256 let mut remote = ScoreEngine::new();
2257 remote.replace_score(base_score);
2258 remote
2259 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2260 .unwrap();
2261
2262 assert!(matches!(
2263 local.append_history_extension(&remote.export_history()),
2264 Err(Error::HistoryNotAppendable)
2265 ));
2266 assert_eq!(local.score.settings.tempo_bpm, before);
2267 }
2268
2269 #[test]
2270 fn export_history_empty_gives_initial_state() {
2271 let engine = ScoreEngine::new();
2272 let history = engine.export_history();
2273 assert!(history.commands.is_empty());
2274 let restored = ScoreEngine::from_history(history).unwrap();
2275 assert_eq!(restored.score.settings.tempo_bpm, 120);
2276 }
2277
2278 #[test]
2279 fn replace_score_then_export_history() {
2280 let mut engine = ScoreEngine::new();
2281 let s = Score::new("Custom", 90, 3, 4, 2, 4);
2282 engine.replace_score(s);
2283 engine
2284 .apply(Command::SetTempo(SetTempoCmd { bpm: 60 }))
2285 .unwrap();
2286 let history = engine.export_history();
2287 assert_eq!(history.initial_score.settings.tempo_bpm, 90);
2288 assert_eq!(history.commands.len(), 1);
2289 let restored = ScoreEngine::from_history(history).unwrap();
2290 assert_eq!(restored.score.settings.tempo_bpm, 60);
2291 }
2292
2293 #[test]
2294 fn new_score_command_replaces_score() {
2295 let mut engine = ScoreEngine::new();
2296 engine
2297 .apply(Command::NewScore(NewScoreCmd {
2298 title: "Sonata".into(),
2299 composer: "Bach".into(),
2300 tempo_bpm: 80,
2301 time_numerator: 3,
2302 time_denominator: 4,
2303 key_fifths: -1,
2304 measure_count: 12,
2305 template: None,
2306 }))
2307 .unwrap();
2308 assert_eq!(engine.score.metadata.title, "Sonata");
2309 assert_eq!(engine.score.measure_count(), 12);
2310 }
2311
2312 #[test]
2313 fn respell_score_to_key_uses_key_signature() {
2314 use crate::model::commands::{AddNoteCmd, RespellScoreToKeyCmd};
2315 use crate::model::notation::KeySignature;
2316 use crate::model::pitch::Step;
2317 let mut engine = ScoreEngine::new();
2318 engine.score.settings.key_signature = KeySignature {
2320 fifths: -2,
2321 mode: "major".to_string(),
2322 };
2323 engine
2324 .apply(Command::AddNote(AddNoteCmd {
2325 part_index: 0,
2326 staff_index: 0,
2327 measure_index: 0,
2328 voice: 0,
2329 position: 0,
2330 pitch: Some(crate::model::pitch::Pitch::with_alter(Step::C, 4, 1)), duration: crate::model::duration::Duration::Quarter,
2332 dot_count: 0,
2333 is_rest: false,
2334 tuplet: None,
2335 }))
2336 .unwrap();
2337 engine
2338 .apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
2339 .unwrap();
2340 let pitch = &engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
2341 assert_eq!(pitch.step, Step::D);
2342 assert_eq!(pitch.alter, -1); }
2344
2345 #[test]
2346 fn begin_end_slur_creates_slur() {
2347 use crate::model::commands::AddNoteCmd;
2348 use crate::model::duration::Duration;
2349 use crate::model::pitch::{Pitch, Step};
2350 let mut engine = ScoreEngine::new();
2351 engine
2352 .apply(Command::AddNote(AddNoteCmd {
2353 part_index: 0,
2354 staff_index: 0,
2355 measure_index: 0,
2356 voice: 0,
2357 position: 0,
2358 pitch: Some(Pitch::new(Step::C, 4)),
2359 duration: Duration::Quarter,
2360 dot_count: 0,
2361 is_rest: false,
2362 tuplet: None,
2363 }))
2364 .unwrap();
2365 engine
2366 .apply(Command::AddNote(AddNoteCmd {
2367 part_index: 0,
2368 staff_index: 0,
2369 measure_index: 0,
2370 voice: 0,
2371 position: 1,
2372 pitch: Some(Pitch::new(Step::D, 4)),
2373 duration: Duration::Quarter,
2374 dot_count: 0,
2375 is_rest: false,
2376 tuplet: None,
2377 }))
2378 .unwrap();
2379 let start = NoteAddr {
2380 part: 0,
2381 staff: 0,
2382 measure: 0,
2383 voice: 0,
2384 note: 0,
2385 };
2386 let end = NoteAddr {
2387 part: 0,
2388 staff: 0,
2389 measure: 0,
2390 voice: 0,
2391 note: 1,
2392 };
2393 engine.begin_slur(start).unwrap();
2394 engine.end_slur(end).unwrap();
2395 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
2396 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
2397 }
2398
2399 #[test]
2400 fn end_slur_without_begin_returns_error() {
2401 let mut engine = ScoreEngine::new();
2402 let end = NoteAddr {
2403 part: 0,
2404 staff: 0,
2405 measure: 0,
2406 voice: 0,
2407 note: 0,
2408 };
2409 let result = engine.end_slur(end);
2410 assert!(result.is_err());
2411 }
2412
2413 #[test]
2416 fn set_stem_sets_and_clears() {
2417 use crate::model::commands::AddNoteCmd;
2418 use crate::model::duration::Duration;
2419 use crate::model::pitch::{Pitch, Step};
2420 let mut engine = ScoreEngine::new();
2421 engine
2422 .apply(Command::AddNote(AddNoteCmd {
2423 part_index: 0,
2424 staff_index: 0,
2425 measure_index: 0,
2426 voice: 0,
2427 position: 0,
2428 pitch: Some(Pitch::new(Step::C, 4)),
2429 duration: Duration::Quarter,
2430 dot_count: 0,
2431 is_rest: false,
2432 tuplet: None,
2433 }))
2434 .unwrap();
2435 let addr = NoteAddr {
2436 part: 0,
2437 staff: 0,
2438 measure: 0,
2439 voice: 0,
2440 note: 0,
2441 };
2442 engine.set_stem(addr.clone(), Some(true)).unwrap();
2443 assert_eq!(
2444 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2445 Some(true)
2446 );
2447 engine.set_stem(addr.clone(), Some(false)).unwrap();
2448 assert_eq!(
2449 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2450 Some(false)
2451 );
2452 engine.set_stem(addr, None).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_stem_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_stem(addr, Some(true)).unwrap();
2487 engine.undo().unwrap();
2488 assert_eq!(
2489 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
2490 None
2491 );
2492 }
2493
2494 #[test]
2495 fn set_arpeggio_is_undoable() {
2496 use crate::model::commands::AddNoteCmd;
2497 use crate::model::duration::Duration;
2498 use crate::model::pitch::{Pitch, Step};
2499 let mut engine = ScoreEngine::new();
2500 engine
2501 .apply(Command::AddNote(AddNoteCmd {
2502 part_index: 0,
2503 staff_index: 0,
2504 measure_index: 0,
2505 voice: 0,
2506 position: 0,
2507 pitch: Some(Pitch::new(Step::C, 4)),
2508 duration: Duration::Quarter,
2509 dot_count: 0,
2510 is_rest: false,
2511 tuplet: None,
2512 }))
2513 .unwrap();
2514 let addr = NoteAddr {
2515 part: 0,
2516 staff: 0,
2517 measure: 0,
2518 voice: 0,
2519 note: 0,
2520 };
2521 engine.set_arpeggio(addr.clone(), Some(true)).unwrap();
2522 assert_eq!(
2523 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2524 Some(true)
2525 );
2526 engine.undo().unwrap();
2527 assert_eq!(
2528 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2529 None
2530 );
2531 engine.redo().unwrap();
2532 assert_eq!(
2533 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
2534 Some(true)
2535 );
2536 }
2537
2538 #[test]
2541 fn undo_key_returns_key_string() {
2542 let mut engine = ScoreEngine::new();
2543 engine
2544 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2545 .unwrap();
2546 assert_eq!(engine.undo_key(), Some("SetTempo".to_string()));
2547 assert!(engine.redo_key().is_none());
2548 }
2549
2550 #[test]
2551 fn redo_key_after_undo() {
2552 let mut engine = ScoreEngine::new();
2553 engine
2554 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
2555 .unwrap();
2556 engine.undo().unwrap();
2557 assert!(engine.undo_key().is_none());
2558 assert_eq!(engine.redo_key(), Some("SetTempo".to_string()));
2559 }
2560
2561 #[test]
2564 fn batch_apply_labeled_sets_undo_key() {
2565 let mut engine = ScoreEngine::new();
2566 engine
2567 .batch_apply_labeled(vec![Command::SetTempo(SetTempoCmd { bpm: 140 })], "ApplyAI")
2568 .unwrap();
2569 assert_eq!(engine.undo_key(), Some("ApplyAI".to_string()));
2570 }
2571
2572 #[test]
2573 fn batch_apply_labeled_empty_is_noop() {
2574 let mut engine = ScoreEngine::new();
2575 engine.batch_apply_labeled(vec![], "ApplyAI").unwrap();
2576 assert!(engine.undo_key().is_none());
2577 }
2578}