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