1use super::change_hint::{ChangeHint, ChangeScope};
2use super::commands::{
3 AddStaffCmd, Command, CommandStack, DeleteStaffCmd, PasteRangeCmd, PasteVoiceCmd,
4 RespellScoreCmd, RespellScoreToKeyCmd, SetArpeggioCmd, SetCueCmd, SetDurationCmd,
5 SetInstrumentIdCmd, SetNoteHeadCmd, SetPartGroupCmd, SetStemCmd, SetTupletCmd, SetUnpitchedCmd,
6 ToggleSlurCmd, ToggleTrillLineCmd, command_hint, command_key,
7};
8use super::duration::Duration;
9use super::notation::{Clef, NoteHead, TupletInfo};
10use super::score::PartGroup;
11use super::score::{Note, NoteAddr, Score};
12use crate::Error;
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct EngineHistory {
25 pub initial_score: Score,
26 pub commands: Vec<Command>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub enum HistoryRelation {
32 Equivalent,
33 LeftExtends { common_prefix_len: usize },
34 RightExtends { common_prefix_len: usize },
35 Diverged { common_prefix_len: usize },
36 BaseMismatch,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct HistoryConflict {
42 pub common_prefix_len: usize,
43 pub left_command_index: usize,
44 pub right_command_index: usize,
45 pub left_command_key: String,
46 pub right_command_key: String,
47 pub left_remaining_commands: usize,
48 pub right_remaining_commands: usize,
49}
50
51#[derive(Debug, Clone)]
52struct RangeClipboard {
53 voice: usize,
54 measures: Vec<Vec<Note>>,
55}
56
57pub struct ScoreEngine {
58 pub score: Score,
59 pub commands: CommandStack,
60 pub version: u64,
61 pub clipboard: Option<Vec<Note>>,
62 range_clipboard: Option<RangeClipboard>,
63 initial_score: Score,
64 pending_slur_start: Option<NoteAddr>,
65}
66
67impl Default for ScoreEngine {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl ScoreEngine {
74 pub fn new() -> Self {
75 let mut score = Score::default();
76 for part in &mut score.parts {
77 for staff in &mut part.staves {
78 for (i, m) in staff.measures.iter_mut().enumerate() {
79 m.number = i as u32 + 1;
80 }
81 }
82 }
83 let initial_score = score.clone();
84 Self {
85 score,
86 commands: CommandStack::new(200),
87 version: 0,
88 clipboard: None,
89 range_clipboard: None,
90 initial_score,
91 pending_slur_start: None,
92 }
93 }
94
95 pub fn apply(&mut self, cmd: Command) -> Result<ChangeHint, Error> {
96 let hint = command_hint(&cmd);
97 self.commands.execute(cmd, &mut self.score)?;
98 self.version += 1;
99 Ok(hint)
100 }
101
102 pub fn undo(&mut self) -> Result<ChangeHint, Error> {
103 let hint = self.commands.undo(&mut self.score)?;
104 self.version += 1;
105 Ok(hint)
106 }
107
108 pub fn redo(&mut self) -> Result<ChangeHint, Error> {
109 let hint = self.commands.redo(&mut self.score)?;
110 self.version += 1;
111 Ok(hint)
112 }
113
114 pub fn batch_apply(&mut self, cmds: Vec<Command>) -> Result<ChangeHint, Error> {
116 if cmds.is_empty() {
117 return Ok(ChangeHint {
118 scope: ChangeScope::Global,
119 layout_dirty: false,
120 playback_dirty: false,
121 });
122 }
123 let mut hint = command_hint(&cmds[0]);
124 for cmd in cmds.iter().skip(1) {
125 hint = hint.merge(command_hint(cmd));
126 }
127 self.commands.batch_execute(cmds, &mut self.score)?;
128 self.version += 1;
129 Ok(hint)
130 }
131
132 pub fn batch_apply_labeled(
136 &mut self,
137 cmds: Vec<Command>,
138 label: &str,
139 ) -> Result<ChangeHint, Error> {
140 if cmds.is_empty() {
141 return Ok(ChangeHint {
142 scope: ChangeScope::Global,
143 layout_dirty: false,
144 playback_dirty: false,
145 });
146 }
147 let mut hint = command_hint(&cmds[0]);
148 for cmd in cmds.iter().skip(1) {
149 hint = hint.merge(command_hint(cmd));
150 }
151 self.commands
152 .batch_execute_labeled(cmds, label.to_string(), &mut self.score)?;
153 self.version += 1;
154 Ok(hint)
155 }
156
157 pub fn undo_label(&self) -> Option<String> {
159 self.commands.undo_label()
160 }
161
162 pub fn redo_label(&self) -> Option<String> {
164 self.commands.redo_label()
165 }
166
167 pub fn undo_key(&self) -> Option<String> {
169 self.commands.undo_key()
170 }
171
172 pub fn redo_key(&self) -> Option<String> {
174 self.commands.redo_key()
175 }
176
177 pub fn replace_score(&mut self, score: Score) {
178 self.initial_score = score.clone();
179 self.score = score;
180 self.version += 1;
181 self.commands = CommandStack::new(200);
182 }
183
184 pub fn export_history(&self) -> EngineHistory {
189 EngineHistory {
190 initial_score: self.initial_score.clone(),
191 commands: self.commands.history_commands(),
192 }
193 }
194
195 pub fn from_history(history: EngineHistory) -> Result<Self, Error> {
200 let mut engine = ScoreEngine::new();
201 engine.replace_score(history.initial_score);
202 for cmd in history.commands {
203 engine.apply(cmd)?;
204 }
205 Ok(engine)
206 }
207
208 pub fn from_history_on_base(history: EngineHistory, base: Score) -> Result<Self, Error> {
213 if !history.base_matches(&base) {
214 return Err(Error::HistoryBaseMismatch);
215 }
216 Self::from_history(history)
217 }
218
219 pub fn append_history_extension(&mut self, incoming: &EngineHistory) -> Result<usize, Error> {
224 let local = self.export_history();
225 let common_prefix_len = match local.compare(incoming) {
226 HistoryRelation::LeftExtends { common_prefix_len } => common_prefix_len,
227 _ => return Err(Error::HistoryNotAppendable),
228 };
229 let count = incoming.commands.len() - common_prefix_len;
230 if count == 0 {
231 return Ok(0);
232 }
233 let candidate = Self::from_history(incoming.clone())?;
234 *self = candidate;
235 Ok(count)
236 }
237
238 pub fn copy_voice(
239 &mut self,
240 part_index: usize,
241 staff_index: usize,
242 measure_index: usize,
243 voice_index: usize,
244 ) -> Result<(), Error> {
245 let voice = self
246 .score
247 .parts
248 .get(part_index)
249 .ok_or(Error::PartNotFound(part_index))?
250 .staves
251 .get(staff_index)
252 .ok_or(Error::StaffNotFound(staff_index))?
253 .measures
254 .get(measure_index)
255 .ok_or(Error::MeasureNotFound(measure_index))?
256 .voices
257 .get(voice_index)
258 .ok_or(Error::VoiceOutOfRange(voice_index))?;
259 self.clipboard = Some(voice.clone());
260 Ok(())
261 }
262
263 pub fn paste_voice(
264 &mut self,
265 part_index: usize,
266 staff_index: usize,
267 measure_index: usize,
268 voice_index: usize,
269 ) -> Result<ChangeHint, Error> {
270 let notes = self.clipboard.clone().ok_or(Error::ClipboardEmpty)?;
271 self.apply(Command::PasteVoice(PasteVoiceCmd {
272 part_index,
273 staff_index,
274 measure_index,
275 voice_index,
276 notes,
277 }))
278 }
279
280 pub fn copy_range(&mut self, start: NoteAddr, end: NoteAddr) -> Result<(), Error> {
285 if start.part != end.part || start.staff != end.staff || start.voice != end.voice {
286 return Err(Error::InvalidCommand(
287 "copy_range: start and end must share the same part, staff, and voice".into(),
288 ));
289 }
290 let from = start.measure.min(end.measure);
291 let to = start.measure.max(end.measure);
292 let staff = self
293 .score
294 .parts
295 .get(start.part)
296 .ok_or(Error::PartNotFound(start.part))?
297 .staves
298 .get(start.staff)
299 .ok_or(Error::StaffNotFound(start.staff))?;
300 if start.voice >= 4 {
301 return Err(Error::VoiceOutOfRange(start.voice));
302 }
303 let mut measures = Vec::new();
304 for mi in from..=to {
305 let m = staff.measures.get(mi).ok_or(Error::MeasureNotFound(mi))?;
306 measures.push(m.voices[start.voice].clone());
307 }
308 self.range_clipboard = Some(RangeClipboard {
309 voice: start.voice,
310 measures,
311 });
312 Ok(())
313 }
314
315 pub fn paste_range(&mut self, target: NoteAddr) -> Result<ChangeHint, Error> {
319 let rc = self.range_clipboard.clone().ok_or(Error::ClipboardEmpty)?;
320 self.apply(Command::PasteRange(PasteRangeCmd {
321 part_index: target.part,
322 staff_index: target.staff,
323 voice_index: rc.voice,
324 target_measure: target.measure,
325 measures: rc.measures,
326 }))
327 }
328
329 pub fn toggle_slur(&mut self, start: NoteAddr, end: NoteAddr) -> Result<ChangeHint, Error> {
331 self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
332 }
333
334 pub fn add_staff(&mut self, part_index: usize, clef: Clef) -> Result<ChangeHint, Error> {
336 self.apply(Command::AddStaff(AddStaffCmd { part_index, clef }))
337 }
338
339 pub fn delete_staff(
341 &mut self,
342 part_index: usize,
343 staff_index: usize,
344 ) -> Result<ChangeHint, Error> {
345 self.apply(Command::DeleteStaff(DeleteStaffCmd {
346 part_index,
347 staff_index,
348 }))
349 }
350
351 pub fn set_stem(&mut self, addr: NoteAddr, stem_up: Option<bool>) -> Result<ChangeHint, Error> {
355 self.apply(Command::SetStem(SetStemCmd {
356 part_index: addr.part,
357 staff_index: addr.staff,
358 measure_index: addr.measure,
359 voice_index: addr.voice,
360 note_index: addr.note,
361 stem_up,
362 }))
363 }
364
365 pub fn set_duration(
367 &mut self,
368 addr: NoteAddr,
369 duration: Duration,
370 dot_count: u8,
371 ) -> Result<ChangeHint, Error> {
372 self.apply(Command::SetDuration(SetDurationCmd {
373 part_index: addr.part,
374 staff_index: addr.staff,
375 measure_index: addr.measure,
376 voice: addr.voice,
377 note_index: addr.note,
378 duration,
379 dot_count,
380 }))
381 }
382
383 pub fn set_arpeggio(
385 &mut self,
386 addr: NoteAddr,
387 direction: Option<bool>,
388 ) -> Result<ChangeHint, Error> {
389 self.apply(Command::SetArpeggio(SetArpeggioCmd {
390 part_index: addr.part,
391 staff_index: addr.staff,
392 measure_index: addr.measure,
393 voice_index: addr.voice,
394 note_index: addr.note,
395 direction,
396 }))
397 }
398
399 pub fn set_note_head(
401 &mut self,
402 addr: NoteAddr,
403 note_head: NoteHead,
404 ) -> Result<ChangeHint, Error> {
405 self.apply(Command::SetNoteHead(SetNoteHeadCmd {
406 part_index: addr.part,
407 staff_index: addr.staff,
408 measure_index: addr.measure,
409 voice: addr.voice,
410 note_index: addr.note,
411 note_head,
412 }))
413 }
414
415 pub fn set_part_group(&mut self, group: Option<PartGroup>) -> Result<ChangeHint, Error> {
417 self.apply(Command::SetPartGroup(SetPartGroupCmd { group }))
418 }
419
420 pub fn toggle_trill_line(
422 &mut self,
423 start: NoteAddr,
424 end: NoteAddr,
425 ) -> Result<ChangeHint, Error> {
426 self.apply(Command::ToggleTrillLine(ToggleTrillLineCmd { start, end }))
427 }
428
429 pub fn set_cue(&mut self, addr: NoteAddr, is_cue: bool) -> Result<ChangeHint, Error> {
431 self.apply(Command::SetCue(SetCueCmd {
432 part_index: addr.part,
433 staff_index: addr.staff,
434 measure_index: addr.measure,
435 voice: addr.voice,
436 note_index: addr.note,
437 is_cue,
438 }))
439 }
440
441 pub fn set_unpitched(
443 &mut self,
444 addr: NoteAddr,
445 is_unpitched: bool,
446 ) -> Result<ChangeHint, Error> {
447 self.apply(Command::SetUnpitched(SetUnpitchedCmd {
448 part_index: addr.part,
449 staff_index: addr.staff,
450 measure_index: addr.measure,
451 voice: addr.voice,
452 note_index: addr.note,
453 is_unpitched,
454 }))
455 }
456
457 pub fn set_instrument_id(
459 &mut self,
460 addr: NoteAddr,
461 instrument_id: Option<String>,
462 ) -> Result<ChangeHint, Error> {
463 self.apply(Command::SetInstrumentId(SetInstrumentIdCmd {
464 part_index: addr.part,
465 staff_index: addr.staff,
466 measure_index: addr.measure,
467 voice: addr.voice,
468 note_index: addr.note,
469 instrument_id,
470 }))
471 }
472
473 pub fn set_tuplet(
475 &mut self,
476 addr: NoteAddr,
477 tuplet: Option<TupletInfo>,
478 ) -> Result<ChangeHint, Error> {
479 self.apply(Command::SetTuplet(SetTupletCmd {
480 part_index: addr.part,
481 staff_index: addr.staff,
482 measure_index: addr.measure,
483 voice_index: addr.voice,
484 note_index: addr.note,
485 tuplet,
486 }))
487 }
488
489 pub fn respell_score(&mut self, prefer_flat: bool) -> Result<ChangeHint, Error> {
491 self.apply(Command::RespellScore(RespellScoreCmd { prefer_flat }))
492 }
493
494 pub fn respell_score_to_key(&mut self) -> Result<ChangeHint, Error> {
496 self.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
497 }
498
499 pub fn begin_slur(&mut self, start: NoteAddr) -> Result<(), Error> {
503 self.score
504 .parts
505 .get(start.part)
506 .ok_or(Error::PartNotFound(start.part))?
507 .staves
508 .get(start.staff)
509 .ok_or(Error::StaffNotFound(start.staff))?
510 .measures
511 .get(start.measure)
512 .ok_or(Error::MeasureNotFound(start.measure))?
513 .voices
514 .get(start.voice)
515 .ok_or(Error::VoiceOutOfRange(start.voice))?
516 .get(start.note)
517 .ok_or(Error::NoteNotFound(start.note))?;
518 self.pending_slur_start = Some(start);
519 Ok(())
520 }
521
522 pub fn end_slur(&mut self, end: NoteAddr) -> Result<ChangeHint, Error> {
526 let start = self
527 .pending_slur_start
528 .take()
529 .ok_or_else(|| Error::InvalidCommand("no slur in progress".to_string()))?;
530 self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
531 }
532}
533
534impl EngineHistory {
535 pub fn base_matches(&self, base: &Score) -> bool {
537 match (
538 serde_json::to_vec(&self.initial_score),
539 serde_json::to_vec(base),
540 ) {
541 (Ok(expected), Ok(actual)) => expected == actual,
542 _ => false,
543 }
544 }
545
546 pub fn compare(&self, other: &Self) -> HistoryRelation {
548 if !self.base_matches(&other.initial_score) {
549 return HistoryRelation::BaseMismatch;
550 }
551 let common_prefix_len = self
552 .commands
553 .iter()
554 .zip(&other.commands)
555 .take_while(|(left, right)| command_bytes(left) == command_bytes(right))
556 .count();
557 match (self.commands.len(), other.commands.len()) {
558 (left, right) if left == right && common_prefix_len == left => {
559 HistoryRelation::Equivalent
560 }
561 (left, _) if common_prefix_len == left => {
562 HistoryRelation::LeftExtends { common_prefix_len }
563 }
564 (_, right) if common_prefix_len == right => {
565 HistoryRelation::RightExtends { common_prefix_len }
566 }
567 _ => HistoryRelation::Diverged { common_prefix_len },
568 }
569 }
570
571 pub fn conflict(&self, other: &Self) -> Option<HistoryConflict> {
573 let common_prefix_len = match self.compare(other) {
574 HistoryRelation::Diverged { common_prefix_len } => common_prefix_len,
575 _ => return None,
576 };
577 let left = self.commands.get(common_prefix_len)?;
578 let right = other.commands.get(common_prefix_len)?;
579 Some(HistoryConflict {
580 common_prefix_len,
581 left_command_index: common_prefix_len,
582 right_command_index: common_prefix_len,
583 left_command_key: command_key(left),
584 right_command_key: command_key(right),
585 left_remaining_commands: self.commands.len() - common_prefix_len,
586 right_remaining_commands: other.commands.len() - common_prefix_len,
587 })
588 }
589}
590
591fn command_bytes(command: &Command) -> Option<Vec<u8>> {
592 serde_json::to_vec(command).ok()
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598 use crate::model::commands::{NewScoreCmd, SetTempoCmd};
599
600 #[test]
601 fn new_engine_has_default_score() {
602 let engine = ScoreEngine::new();
603 assert_eq!(engine.version, 0);
604 assert_eq!(engine.score.parts.len(), 1);
605 }
606
607 #[test]
608 fn apply_increments_version() {
609 let mut engine = ScoreEngine::new();
610 engine
611 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
612 .unwrap();
613 assert_eq!(engine.version, 1);
614 }
615
616 #[test]
617 fn undo_redo_cycle() {
618 let mut engine = ScoreEngine::new();
619 engine
620 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
621 .unwrap();
622 let after_apply = engine.version;
623 engine.undo().unwrap();
624 assert_eq!(engine.score.settings.tempo_bpm, 120);
625 engine.redo().unwrap();
626 assert_eq!(engine.score.settings.tempo_bpm, 140);
627 assert!(engine.version > after_apply);
628 }
629
630 #[test]
631 fn replace_score_clears_history() {
632 let mut engine = ScoreEngine::new();
633 engine
634 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
635 .unwrap();
636 let new_score = Score::new("New", 90, 3, 4, 2, 8);
637 engine.replace_score(new_score);
638 assert!(engine.undo().is_err());
639 assert_eq!(engine.score.settings.tempo_bpm, 90);
640 }
641
642 #[test]
643 fn copy_paste_voice_copies_notes() {
644 use crate::model::duration::Duration;
645 use crate::model::pitch::{Pitch, Step};
646 use crate::model::score::Note;
647 let mut engine = ScoreEngine::new();
648 engine.score.parts[0].staves[0].measures[0].voices[0] =
649 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
650 engine.copy_voice(0, 0, 0, 0).unwrap();
651 engine.paste_voice(0, 0, 0, 1).unwrap();
652 let pasted = &engine.score.parts[0].staves[0].measures[0].voices[1];
653 assert_eq!(pasted.len(), 1);
654 assert_eq!(pasted[0].pitches[0].step, Step::C);
655 }
656
657 #[test]
658 fn paste_voice_undo_restores_original() {
659 use crate::model::duration::Duration;
660 use crate::model::pitch::{Pitch, Step};
661 use crate::model::score::Note;
662 let mut engine = ScoreEngine::new();
663 engine.score.parts[0].staves[0].measures[0].voices[0] =
664 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
665 engine.copy_voice(0, 0, 0, 0).unwrap();
666 engine.paste_voice(0, 0, 0, 1).unwrap();
667 engine.undo().unwrap();
668 assert!(engine.score.parts[0].staves[0].measures[0].voices[1].is_empty());
669 }
670
671 #[test]
672 fn unpitched_flag_is_undoable_and_redoable() {
673 let mut engine = ScoreEngine::new();
674 let addr = NoteAddr {
675 part: 0,
676 staff: 0,
677 measure: 0,
678 voice: 0,
679 note: 0,
680 };
681 engine.set_unpitched(addr.clone(), true).unwrap();
682 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
683 engine.undo().unwrap();
684 assert!(!engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
685 engine.redo().unwrap();
686 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].is_unpitched);
687 engine
688 .set_instrument_id(addr, Some("P1-I2".to_string()))
689 .unwrap();
690 assert_eq!(
691 engine.score.parts[0].staves[0].measures[0].voices[0][0]
692 .instrument_id
693 .as_deref(),
694 Some("P1-I2")
695 );
696 for command in [
697 Command::SetUnpitched(super::super::commands::SetUnpitchedCmd {
698 part_index: 0,
699 staff_index: 0,
700 measure_index: 0,
701 voice: 0,
702 note_index: 0,
703 is_unpitched: false,
704 }),
705 Command::SetInstrumentId(super::super::commands::SetInstrumentIdCmd {
706 part_index: 0,
707 staff_index: 0,
708 measure_index: 0,
709 voice: 0,
710 note_index: 0,
711 instrument_id: Some("P1-I2".to_string()),
712 }),
713 ] {
714 let json = serde_json::to_string(&command).unwrap();
715 let restored: Command = serde_json::from_str(&json).unwrap();
716 assert_eq!(
717 super::super::commands::command_key(&restored),
718 super::super::commands::command_key(&command)
719 );
720 }
721 }
722
723 #[test]
724 fn paste_voice_without_copy_returns_error() {
725 let mut engine = ScoreEngine::new();
726 assert!(engine.paste_voice(0, 0, 0, 0).is_err());
727 }
728
729 #[test]
730 fn change_hint_set_tempo_is_global() {
731 use crate::model::change_hint::ChangeScope;
732 let mut engine = ScoreEngine::new();
733 let hint = engine
734 .apply(Command::SetTempo(SetTempoCmd { bpm: 100 }))
735 .unwrap();
736 assert_eq!(hint.scope, ChangeScope::Global);
737 assert!(!hint.layout_dirty);
738 assert!(hint.playback_dirty);
739 }
740
741 #[test]
742 fn change_hint_add_note_is_measure_scope() {
743 use crate::model::change_hint::ChangeScope;
744 use crate::model::commands::AddNoteCmd;
745 use crate::model::duration::Duration;
746 use crate::model::pitch::Pitch;
747 use crate::model::pitch::Step;
748 let mut engine = ScoreEngine::new();
749 let hint = engine
750 .apply(Command::AddNote(AddNoteCmd {
751 part_index: 0,
752 staff_index: 0,
753 measure_index: 0,
754 voice: 0,
755 position: 0,
756 pitch: Some(Pitch::new(Step::C, 4)),
757 duration: Duration::Quarter,
758 dot_count: 0,
759 is_rest: false,
760 tuplet: None,
761 }))
762 .unwrap();
763 assert_eq!(
764 hint.scope,
765 ChangeScope::Measures {
766 part: 0,
767 staff: 0,
768 start: 0,
769 end: 1
770 }
771 );
772 assert!(!hint.layout_dirty);
773 assert!(hint.playback_dirty);
774 }
775
776 #[test]
777 fn change_hint_set_part_name_no_dirty() {
778 use crate::model::change_hint::ChangeScope;
779 use crate::model::commands::SetPartNameCmd;
780 let mut engine = ScoreEngine::new();
781 let hint = engine
782 .apply(Command::SetPartName(SetPartNameCmd {
783 part_index: 0,
784 name: "Violin".into(),
785 short_name: "Vln.".into(),
786 }))
787 .unwrap();
788 assert_eq!(hint.scope, ChangeScope::Part(0));
789 assert!(!hint.layout_dirty);
790 assert!(!hint.playback_dirty);
791 }
792
793 #[test]
794 fn undo_returns_change_hint() {
795 use crate::model::change_hint::ChangeScope;
796 let mut engine = ScoreEngine::new();
797 engine
798 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
799 .unwrap();
800 let hint = engine.undo().unwrap();
801 assert_eq!(hint.scope, ChangeScope::Global);
802 assert!(hint.playback_dirty);
803 }
804
805 #[test]
806 fn redo_returns_change_hint() {
807 use crate::model::change_hint::ChangeScope;
808 let mut engine = ScoreEngine::new();
809 engine
810 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
811 .unwrap();
812 engine.undo().unwrap();
813 let hint = engine.redo().unwrap();
814 assert_eq!(hint.scope, ChangeScope::Global);
815 assert!(hint.playback_dirty);
816 }
817
818 #[test]
819 fn batch_apply_two_commands_single_undo() {
820 let mut engine = ScoreEngine::new();
821 let original = engine.score.settings.tempo_bpm;
822 engine
823 .batch_apply(vec![
824 Command::SetTempo(SetTempoCmd { bpm: 160 }),
825 Command::SetTempo(SetTempoCmd { bpm: 180 }),
826 ])
827 .unwrap();
828 assert_eq!(engine.score.settings.tempo_bpm, 180);
829 engine.undo().unwrap();
830 assert_eq!(engine.score.settings.tempo_bpm, original);
831 assert!(engine.undo().is_err());
832 }
833
834 #[test]
835 fn batch_apply_empty_returns_no_dirty() {
836 let mut engine = ScoreEngine::new();
837 let v0 = engine.version;
838 let hint = engine.batch_apply(vec![]).unwrap();
839 assert!(!hint.layout_dirty);
840 assert!(!hint.playback_dirty);
841 assert_eq!(engine.version, v0);
842 }
843
844 #[test]
845 fn batch_apply_hint_merges_scopes() {
846 use crate::model::change_hint::ChangeScope;
847 use crate::model::commands::{AddNoteCmd, SetTempoCmd};
848 use crate::model::duration::Duration;
849 use crate::model::pitch::{Pitch, Step};
850 let mut engine = ScoreEngine::new();
851 let hint = engine
852 .batch_apply(vec![
853 Command::SetTempo(SetTempoCmd { bpm: 140 }),
854 Command::AddNote(AddNoteCmd {
855 part_index: 0,
856 staff_index: 0,
857 measure_index: 0,
858 voice: 0,
859 position: 0,
860 pitch: Some(Pitch::new(Step::C, 4)),
861 duration: Duration::Quarter,
862 dot_count: 0,
863 is_rest: false,
864 tuplet: None,
865 }),
866 ])
867 .unwrap();
868 assert_eq!(hint.scope, ChangeScope::Global);
870 assert!(hint.playback_dirty);
871 }
872
873 #[test]
874 fn undo_label_none_when_empty() {
875 let engine = ScoreEngine::new();
876 assert!(engine.undo_label().is_none());
877 assert!(engine.redo_label().is_none());
878 }
879
880 #[test]
881 fn undo_label_after_command() {
882 let mut engine = ScoreEngine::new();
883 engine
884 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
885 .unwrap();
886 assert_eq!(engine.undo_label(), Some("Set Tempo".to_string()));
887 assert!(engine.redo_label().is_none());
888 }
889
890 #[test]
891 fn redo_label_after_undo() {
892 let mut engine = ScoreEngine::new();
893 engine
894 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
895 .unwrap();
896 engine.undo().unwrap();
897 assert!(engine.undo_label().is_none());
898 assert_eq!(engine.redo_label(), Some("Set Tempo".to_string()));
899 }
900
901 #[test]
902 fn copy_range_paste_range_roundtrip() {
903 use crate::model::duration::Duration;
904 use crate::model::pitch::{Pitch, Step};
905 use crate::model::score::{Note, NoteAddr};
906 let mut engine = ScoreEngine::new();
907 engine.score.parts[0].staves[0].measures[0].voices[0] =
909 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
910 use crate::model::commands::AddMeasureCmd;
912 engine
913 .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
914 .unwrap();
915
916 let start = NoteAddr {
917 part: 0,
918 staff: 0,
919 measure: 0,
920 voice: 0,
921 note: 0,
922 };
923 let end = NoteAddr {
924 part: 0,
925 staff: 0,
926 measure: 0,
927 voice: 0,
928 note: 0,
929 };
930 engine.copy_range(start, end).unwrap();
931
932 let target = NoteAddr {
933 part: 0,
934 staff: 0,
935 measure: 1,
936 voice: 0,
937 note: 0,
938 };
939 engine.paste_range(target).unwrap();
940
941 let pasted = &engine.score.parts[0].staves[0].measures[1].voices[0];
942 assert_eq!(pasted.len(), 1);
943 assert_eq!(pasted[0].pitches[0].step, Step::C);
944 }
945
946 #[test]
947 fn paste_range_is_undoable() {
948 use crate::model::commands::AddMeasureCmd;
949 use crate::model::duration::Duration;
950 use crate::model::pitch::{Pitch, Step};
951 use crate::model::score::{Note, NoteAddr};
952 let mut engine = ScoreEngine::new();
953 engine.score.parts[0].staves[0].measures[0].voices[0] =
954 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
955 engine
956 .apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 }))
957 .unwrap();
958
959 let start = NoteAddr {
960 part: 0,
961 staff: 0,
962 measure: 0,
963 voice: 0,
964 note: 0,
965 };
966 let end = start.clone();
967 engine.copy_range(start, end).unwrap();
968 let target = NoteAddr {
969 part: 0,
970 staff: 0,
971 measure: 1,
972 voice: 0,
973 note: 0,
974 };
975 engine.paste_range(target).unwrap();
976
977 engine.undo().unwrap();
979 let restored = &engine.score.parts[0].staves[0].measures[1].voices[0];
980 assert!(restored.iter().all(|n| n.is_rest));
981 }
982
983 #[test]
984 fn copy_range_mismatched_part_returns_error() {
985 let engine = ScoreEngine::new();
986 let mut e = ScoreEngine::new();
988 use crate::model::score::NoteAddr;
989 let start = NoteAddr {
990 part: 0,
991 staff: 0,
992 measure: 0,
993 voice: 0,
994 note: 0,
995 };
996 let end = NoteAddr {
997 part: 1,
998 staff: 0,
999 measure: 0,
1000 voice: 0,
1001 note: 0,
1002 };
1003 assert!(e.copy_range(start, end).is_err());
1004 let _ = engine; }
1006
1007 #[test]
1008 fn export_history_roundtrip() {
1009 let mut engine = ScoreEngine::new();
1010 engine
1011 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1012 .unwrap();
1013 engine
1014 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
1015 .unwrap();
1016 let history = engine.export_history();
1017 assert_eq!(history.commands.len(), 2);
1018 let restored = ScoreEngine::from_history(history).unwrap();
1019 assert_eq!(restored.score.settings.tempo_bpm, 180);
1020 }
1021
1022 #[test]
1023 fn history_base_check_rejects_stale_collaboration_snapshot() {
1024 let mut engine = ScoreEngine::new();
1025 engine
1026 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1027 .unwrap();
1028 let history = engine.export_history();
1029 let mut unrelated = history.initial_score.clone();
1030 unrelated.metadata.title = "unrelated".to_owned();
1031
1032 assert!(!history.base_matches(&unrelated));
1033 assert!(matches!(
1034 ScoreEngine::from_history_on_base(history, unrelated),
1035 Err(Error::HistoryBaseMismatch)
1036 ));
1037 }
1038
1039 #[test]
1040 fn history_base_check_allows_replay_on_matching_snapshot() {
1041 let mut engine = ScoreEngine::new();
1042 engine
1043 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1044 .unwrap();
1045 let history = engine.export_history();
1046 let base = history.initial_score.clone();
1047
1048 assert!(history.base_matches(&base));
1049 let restored = ScoreEngine::from_history_on_base(history, base).unwrap();
1050 assert_eq!(restored.score.settings.tempo_bpm, 160);
1051 }
1052
1053 #[test]
1054 fn history_compare_reports_prefix_and_divergence() {
1055 let base_score = ScoreEngine::new().score.clone();
1056 let mut base = ScoreEngine::new();
1057 base.replace_score(base_score.clone());
1058 let mut left = ScoreEngine::new();
1059 left.replace_score(base_score.clone());
1060 left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1061 .unwrap();
1062 let mut right = ScoreEngine::new();
1063 right.replace_score(base_score.clone());
1064 right
1065 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1066 .unwrap();
1067 right
1068 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
1069 .unwrap();
1070
1071 assert_eq!(
1072 base.export_history().compare(&left.export_history()),
1073 HistoryRelation::LeftExtends {
1074 common_prefix_len: 0
1075 }
1076 );
1077 assert_eq!(
1078 left.export_history().compare(&right.export_history()),
1079 HistoryRelation::LeftExtends {
1080 common_prefix_len: 1
1081 }
1082 );
1083
1084 let mut diverged = ScoreEngine::new();
1085 diverged.replace_score(base_score);
1086 diverged
1087 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1088 .unwrap();
1089 assert_eq!(
1090 left.export_history().compare(&diverged.export_history()),
1091 HistoryRelation::Diverged {
1092 common_prefix_len: 0
1093 }
1094 );
1095 }
1096
1097 #[test]
1098 fn history_compare_reports_base_mismatch() {
1099 let left = ScoreEngine::new().export_history();
1100 let mut other = ScoreEngine::new();
1101 other.replace_score(Score::new("Other", 120, 4, 4, 1, 4));
1102 assert_eq!(
1103 left.compare(&other.export_history()),
1104 HistoryRelation::BaseMismatch
1105 );
1106 }
1107
1108 #[test]
1109 fn history_conflict_reports_branch_commands_and_remaining_lengths() {
1110 let base_score = ScoreEngine::new().score.clone();
1111 let mut left = ScoreEngine::new();
1112 left.replace_score(base_score.clone());
1113 left.apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1114 .unwrap();
1115 let mut right = ScoreEngine::new();
1116 right.replace_score(base_score);
1117 right
1118 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1119 .unwrap();
1120 right
1121 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
1122 .unwrap();
1123
1124 assert_eq!(
1125 left.export_history().conflict(&right.export_history()),
1126 Some(HistoryConflict {
1127 common_prefix_len: 0,
1128 left_command_index: 0,
1129 right_command_index: 0,
1130 left_command_key: "SetTempo".to_owned(),
1131 right_command_key: "SetTempo".to_owned(),
1132 left_remaining_commands: 1,
1133 right_remaining_commands: 2,
1134 })
1135 );
1136 }
1137
1138 #[test]
1139 fn history_conflict_is_empty_for_safe_relationships() {
1140 let history = ScoreEngine::new().export_history();
1141 assert!(history.conflict(&history).is_none());
1142 }
1143
1144 #[test]
1145 fn append_history_extension_applies_only_remote_suffix() {
1146 let base_score = ScoreEngine::new().score.clone();
1147 let mut local = ScoreEngine::new();
1148 local.replace_score(base_score.clone());
1149 local
1150 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1151 .unwrap();
1152
1153 let mut remote = ScoreEngine::new();
1154 remote.replace_score(base_score);
1155 remote
1156 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1157 .unwrap();
1158 remote
1159 .apply(Command::SetTempo(SetTempoCmd { bpm: 180 }))
1160 .unwrap();
1161
1162 let count = local
1163 .append_history_extension(&remote.export_history())
1164 .unwrap();
1165 assert_eq!(count, 1);
1166 assert_eq!(local.score.settings.tempo_bpm, 180);
1167 assert_eq!(
1168 local.export_history().compare(&remote.export_history()),
1169 HistoryRelation::Equivalent
1170 );
1171 }
1172
1173 #[test]
1174 fn append_history_extension_rejects_divergence_without_mutation() {
1175 let base_score = ScoreEngine::new().score.clone();
1176 let mut local = ScoreEngine::new();
1177 local.replace_score(base_score.clone());
1178 local
1179 .apply(Command::SetTempo(SetTempoCmd { bpm: 160 }))
1180 .unwrap();
1181 let before = local.score.settings.tempo_bpm;
1182
1183 let mut remote = ScoreEngine::new();
1184 remote.replace_score(base_score);
1185 remote
1186 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1187 .unwrap();
1188
1189 assert!(matches!(
1190 local.append_history_extension(&remote.export_history()),
1191 Err(Error::HistoryNotAppendable)
1192 ));
1193 assert_eq!(local.score.settings.tempo_bpm, before);
1194 }
1195
1196 #[test]
1197 fn export_history_empty_gives_initial_state() {
1198 let engine = ScoreEngine::new();
1199 let history = engine.export_history();
1200 assert!(history.commands.is_empty());
1201 let restored = ScoreEngine::from_history(history).unwrap();
1202 assert_eq!(restored.score.settings.tempo_bpm, 120);
1203 }
1204
1205 #[test]
1206 fn replace_score_then_export_history() {
1207 let mut engine = ScoreEngine::new();
1208 let s = Score::new("Custom", 90, 3, 4, 2, 4);
1209 engine.replace_score(s);
1210 engine
1211 .apply(Command::SetTempo(SetTempoCmd { bpm: 60 }))
1212 .unwrap();
1213 let history = engine.export_history();
1214 assert_eq!(history.initial_score.settings.tempo_bpm, 90);
1215 assert_eq!(history.commands.len(), 1);
1216 let restored = ScoreEngine::from_history(history).unwrap();
1217 assert_eq!(restored.score.settings.tempo_bpm, 60);
1218 }
1219
1220 #[test]
1221 fn new_score_command_replaces_score() {
1222 let mut engine = ScoreEngine::new();
1223 engine
1224 .apply(Command::NewScore(NewScoreCmd {
1225 title: "Sonata".into(),
1226 composer: "Bach".into(),
1227 tempo_bpm: 80,
1228 time_numerator: 3,
1229 time_denominator: 4,
1230 key_fifths: -1,
1231 measure_count: 12,
1232 template: None,
1233 }))
1234 .unwrap();
1235 assert_eq!(engine.score.metadata.title, "Sonata");
1236 assert_eq!(engine.score.measure_count(), 12);
1237 }
1238
1239 #[test]
1240 fn respell_score_to_key_uses_key_signature() {
1241 use crate::model::commands::{AddNoteCmd, RespellScoreToKeyCmd};
1242 use crate::model::notation::KeySignature;
1243 use crate::model::pitch::Step;
1244 let mut engine = ScoreEngine::new();
1245 engine.score.settings.key_signature = KeySignature {
1247 fifths: -2,
1248 mode: "major".to_string(),
1249 };
1250 engine
1251 .apply(Command::AddNote(AddNoteCmd {
1252 part_index: 0,
1253 staff_index: 0,
1254 measure_index: 0,
1255 voice: 0,
1256 position: 0,
1257 pitch: Some(crate::model::pitch::Pitch::with_alter(Step::C, 4, 1)), duration: crate::model::duration::Duration::Quarter,
1259 dot_count: 0,
1260 is_rest: false,
1261 tuplet: None,
1262 }))
1263 .unwrap();
1264 engine
1265 .apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
1266 .unwrap();
1267 let pitch = &engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
1268 assert_eq!(pitch.step, Step::D);
1269 assert_eq!(pitch.alter, -1); }
1271
1272 #[test]
1273 fn begin_end_slur_creates_slur() {
1274 use crate::model::commands::AddNoteCmd;
1275 use crate::model::duration::Duration;
1276 use crate::model::pitch::{Pitch, Step};
1277 let mut engine = ScoreEngine::new();
1278 engine
1279 .apply(Command::AddNote(AddNoteCmd {
1280 part_index: 0,
1281 staff_index: 0,
1282 measure_index: 0,
1283 voice: 0,
1284 position: 0,
1285 pitch: Some(Pitch::new(Step::C, 4)),
1286 duration: Duration::Quarter,
1287 dot_count: 0,
1288 is_rest: false,
1289 tuplet: None,
1290 }))
1291 .unwrap();
1292 engine
1293 .apply(Command::AddNote(AddNoteCmd {
1294 part_index: 0,
1295 staff_index: 0,
1296 measure_index: 0,
1297 voice: 0,
1298 position: 1,
1299 pitch: Some(Pitch::new(Step::D, 4)),
1300 duration: Duration::Quarter,
1301 dot_count: 0,
1302 is_rest: false,
1303 tuplet: None,
1304 }))
1305 .unwrap();
1306 let start = NoteAddr {
1307 part: 0,
1308 staff: 0,
1309 measure: 0,
1310 voice: 0,
1311 note: 0,
1312 };
1313 let end = NoteAddr {
1314 part: 0,
1315 staff: 0,
1316 measure: 0,
1317 voice: 0,
1318 note: 1,
1319 };
1320 engine.begin_slur(start).unwrap();
1321 engine.end_slur(end).unwrap();
1322 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
1323 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
1324 }
1325
1326 #[test]
1327 fn end_slur_without_begin_returns_error() {
1328 let mut engine = ScoreEngine::new();
1329 let end = NoteAddr {
1330 part: 0,
1331 staff: 0,
1332 measure: 0,
1333 voice: 0,
1334 note: 0,
1335 };
1336 let result = engine.end_slur(end);
1337 assert!(result.is_err());
1338 }
1339
1340 #[test]
1343 fn set_stem_sets_and_clears() {
1344 use crate::model::commands::AddNoteCmd;
1345 use crate::model::duration::Duration;
1346 use crate::model::pitch::{Pitch, Step};
1347 let mut engine = ScoreEngine::new();
1348 engine
1349 .apply(Command::AddNote(AddNoteCmd {
1350 part_index: 0,
1351 staff_index: 0,
1352 measure_index: 0,
1353 voice: 0,
1354 position: 0,
1355 pitch: Some(Pitch::new(Step::C, 4)),
1356 duration: Duration::Quarter,
1357 dot_count: 0,
1358 is_rest: false,
1359 tuplet: None,
1360 }))
1361 .unwrap();
1362 let addr = NoteAddr {
1363 part: 0,
1364 staff: 0,
1365 measure: 0,
1366 voice: 0,
1367 note: 0,
1368 };
1369 engine.set_stem(addr.clone(), Some(true)).unwrap();
1370 assert_eq!(
1371 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1372 Some(true)
1373 );
1374 engine.set_stem(addr.clone(), Some(false)).unwrap();
1375 assert_eq!(
1376 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1377 Some(false)
1378 );
1379 engine.set_stem(addr, None).unwrap();
1380 assert_eq!(
1381 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1382 None
1383 );
1384 }
1385
1386 #[test]
1387 fn set_stem_is_undoable() {
1388 use crate::model::commands::AddNoteCmd;
1389 use crate::model::duration::Duration;
1390 use crate::model::pitch::{Pitch, Step};
1391 let mut engine = ScoreEngine::new();
1392 engine
1393 .apply(Command::AddNote(AddNoteCmd {
1394 part_index: 0,
1395 staff_index: 0,
1396 measure_index: 0,
1397 voice: 0,
1398 position: 0,
1399 pitch: Some(Pitch::new(Step::C, 4)),
1400 duration: Duration::Quarter,
1401 dot_count: 0,
1402 is_rest: false,
1403 tuplet: None,
1404 }))
1405 .unwrap();
1406 let addr = NoteAddr {
1407 part: 0,
1408 staff: 0,
1409 measure: 0,
1410 voice: 0,
1411 note: 0,
1412 };
1413 engine.set_stem(addr, Some(true)).unwrap();
1414 engine.undo().unwrap();
1415 assert_eq!(
1416 engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up,
1417 None
1418 );
1419 }
1420
1421 #[test]
1422 fn set_arpeggio_is_undoable() {
1423 use crate::model::commands::AddNoteCmd;
1424 use crate::model::duration::Duration;
1425 use crate::model::pitch::{Pitch, Step};
1426 let mut engine = ScoreEngine::new();
1427 engine
1428 .apply(Command::AddNote(AddNoteCmd {
1429 part_index: 0,
1430 staff_index: 0,
1431 measure_index: 0,
1432 voice: 0,
1433 position: 0,
1434 pitch: Some(Pitch::new(Step::C, 4)),
1435 duration: Duration::Quarter,
1436 dot_count: 0,
1437 is_rest: false,
1438 tuplet: None,
1439 }))
1440 .unwrap();
1441 let addr = NoteAddr {
1442 part: 0,
1443 staff: 0,
1444 measure: 0,
1445 voice: 0,
1446 note: 0,
1447 };
1448 engine.set_arpeggio(addr.clone(), Some(true)).unwrap();
1449 assert_eq!(
1450 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1451 Some(true)
1452 );
1453 engine.undo().unwrap();
1454 assert_eq!(
1455 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1456 None
1457 );
1458 engine.redo().unwrap();
1459 assert_eq!(
1460 engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate,
1461 Some(true)
1462 );
1463 }
1464
1465 #[test]
1468 fn undo_key_returns_key_string() {
1469 let mut engine = ScoreEngine::new();
1470 engine
1471 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1472 .unwrap();
1473 assert_eq!(engine.undo_key(), Some("SetTempo".to_string()));
1474 assert!(engine.redo_key().is_none());
1475 }
1476
1477 #[test]
1478 fn redo_key_after_undo() {
1479 let mut engine = ScoreEngine::new();
1480 engine
1481 .apply(Command::SetTempo(SetTempoCmd { bpm: 140 }))
1482 .unwrap();
1483 engine.undo().unwrap();
1484 assert!(engine.undo_key().is_none());
1485 assert_eq!(engine.redo_key(), Some("SetTempo".to_string()));
1486 }
1487
1488 #[test]
1491 fn batch_apply_labeled_sets_undo_key() {
1492 let mut engine = ScoreEngine::new();
1493 engine
1494 .batch_apply_labeled(vec![Command::SetTempo(SetTempoCmd { bpm: 140 })], "ApplyAI")
1495 .unwrap();
1496 assert_eq!(engine.undo_key(), Some("ApplyAI".to_string()));
1497 }
1498
1499 #[test]
1500 fn batch_apply_labeled_empty_is_noop() {
1501 let mut engine = ScoreEngine::new();
1502 engine.batch_apply_labeled(vec![], "ApplyAI").unwrap();
1503 assert!(engine.undo_key().is_none());
1504 }
1505}