1use serde::{Deserialize, Serialize};
2use super::change_hint::{ChangeHint, ChangeScope};
3use super::commands::{
4 command_hint, Command, CommandStack, PasteRangeCmd, PasteVoiceCmd,
5 RespellScoreCmd, SetArpeggioCmd, SetCueCmd, SetNoteHeadCmd, SetPartGroupCmd, SetStemCmd, SetTupletCmd, ToggleSlurCmd, ToggleTrillLineCmd, AddStaffCmd, DeleteStaffCmd,
6 RespellScoreToKeyCmd,
7};
8use super::score::PartGroup;
9use super::notation::{Clef, NoteHead, TupletInfo};
10use super::score::{Note, NoteAddr, Score};
11use crate::Error;
12
13#[derive(Debug, Serialize, Deserialize)]
22pub struct EngineHistory {
23 pub initial_score: Score,
24 pub commands: Vec<Command>,
25}
26
27#[derive(Debug, Clone)]
28struct RangeClipboard {
29 voice: usize,
30 measures: Vec<Vec<Note>>,
31}
32
33pub struct ScoreEngine {
34 pub score: Score,
35 pub commands: CommandStack,
36 pub version: u64,
37 pub clipboard: Option<Vec<Note>>,
38 range_clipboard: Option<RangeClipboard>,
39 initial_score: Score,
40 pending_slur_start: Option<NoteAddr>,
41}
42
43impl Default for ScoreEngine {
44 fn default() -> Self {
45 Self::new()
46 }
47}
48
49impl ScoreEngine {
50 pub fn new() -> Self {
51 let mut score = Score::default();
52 for part in &mut score.parts {
53 for staff in &mut part.staves {
54 for (i, m) in staff.measures.iter_mut().enumerate() {
55 m.number = i as u32 + 1;
56 }
57 }
58 }
59 let initial_score = score.clone();
60 Self { score, commands: CommandStack::new(200), version: 0, clipboard: None, range_clipboard: None, initial_score, pending_slur_start: None }
61 }
62
63 pub fn apply(&mut self, cmd: Command) -> Result<ChangeHint, Error> {
64 let hint = command_hint(&cmd);
65 self.commands.execute(cmd, &mut self.score)?;
66 self.version += 1;
67 Ok(hint)
68 }
69
70 pub fn undo(&mut self) -> Result<ChangeHint, Error> {
71 let hint = self.commands.undo(&mut self.score)?;
72 self.version += 1;
73 Ok(hint)
74 }
75
76 pub fn redo(&mut self) -> Result<ChangeHint, Error> {
77 let hint = self.commands.redo(&mut self.score)?;
78 self.version += 1;
79 Ok(hint)
80 }
81
82 pub fn batch_apply(&mut self, cmds: Vec<Command>) -> Result<ChangeHint, Error> {
84 if cmds.is_empty() {
85 return Ok(ChangeHint { scope: ChangeScope::Global, layout_dirty: false, playback_dirty: false });
86 }
87 let mut hint = command_hint(&cmds[0]);
88 for cmd in cmds.iter().skip(1) { hint = hint.merge(command_hint(cmd)); }
89 self.commands.batch_execute(cmds, &mut self.score)?;
90 self.version += 1;
91 Ok(hint)
92 }
93
94 pub fn batch_apply_labeled(&mut self, cmds: Vec<Command>, label: &str) -> Result<ChangeHint, Error> {
98 if cmds.is_empty() {
99 return Ok(ChangeHint { scope: ChangeScope::Global, layout_dirty: false, playback_dirty: false });
100 }
101 let mut hint = command_hint(&cmds[0]);
102 for cmd in cmds.iter().skip(1) { hint = hint.merge(command_hint(cmd)); }
103 self.commands.batch_execute_labeled(cmds, label.to_string(), &mut self.score)?;
104 self.version += 1;
105 Ok(hint)
106 }
107
108 pub fn undo_label(&self) -> Option<String> {
110 self.commands.undo_label()
111 }
112
113 pub fn redo_label(&self) -> Option<String> {
115 self.commands.redo_label()
116 }
117
118 pub fn undo_key(&self) -> Option<String> {
120 self.commands.undo_key()
121 }
122
123 pub fn redo_key(&self) -> Option<String> {
125 self.commands.redo_key()
126 }
127
128 pub fn replace_score(&mut self, score: Score) {
129 self.initial_score = score.clone();
130 self.score = score;
131 self.version += 1;
132 self.commands = CommandStack::new(200);
133 }
134
135 pub fn export_history(&self) -> EngineHistory {
140 EngineHistory {
141 initial_score: self.initial_score.clone(),
142 commands: self.commands.history_commands(),
143 }
144 }
145
146 pub fn from_history(history: EngineHistory) -> Result<Self, Error> {
151 let mut engine = ScoreEngine::new();
152 engine.replace_score(history.initial_score);
153 for cmd in history.commands {
154 engine.apply(cmd)?;
155 }
156 Ok(engine)
157 }
158
159 pub fn copy_voice(
160 &mut self,
161 part_index: usize,
162 staff_index: usize,
163 measure_index: usize,
164 voice_index: usize,
165 ) -> Result<(), Error> {
166 let voice = self.score.parts
167 .get(part_index).ok_or(Error::PartNotFound(part_index))?
168 .staves.get(staff_index).ok_or(Error::StaffNotFound(staff_index))?
169 .measures.get(measure_index).ok_or(Error::MeasureNotFound(measure_index))?
170 .voices.get(voice_index).ok_or(Error::VoiceOutOfRange(voice_index))?;
171 self.clipboard = Some(voice.clone());
172 Ok(())
173 }
174
175 pub fn paste_voice(
176 &mut self,
177 part_index: usize,
178 staff_index: usize,
179 measure_index: usize,
180 voice_index: usize,
181 ) -> Result<ChangeHint, Error> {
182 let notes = self.clipboard.clone().ok_or(Error::ClipboardEmpty)?;
183 self.apply(Command::PasteVoice(PasteVoiceCmd {
184 part_index,
185 staff_index,
186 measure_index,
187 voice_index,
188 notes,
189 }))
190 }
191
192 pub fn copy_range(&mut self, start: NoteAddr, end: NoteAddr) -> Result<(), Error> {
197 if start.part != end.part || start.staff != end.staff || start.voice != end.voice {
198 return Err(Error::InvalidCommand(
199 "copy_range: start and end must share the same part, staff, and voice".into(),
200 ));
201 }
202 let from = start.measure.min(end.measure);
203 let to = start.measure.max(end.measure);
204 let staff = self.score.parts
205 .get(start.part).ok_or(Error::PartNotFound(start.part))?
206 .staves.get(start.staff).ok_or(Error::StaffNotFound(start.staff))?;
207 if start.voice >= 4 { return Err(Error::VoiceOutOfRange(start.voice)); }
208 let mut measures = Vec::new();
209 for mi in from..=to {
210 let m = staff.measures.get(mi).ok_or(Error::MeasureNotFound(mi))?;
211 measures.push(m.voices[start.voice].clone());
212 }
213 self.range_clipboard = Some(RangeClipboard { voice: start.voice, measures });
214 Ok(())
215 }
216
217 pub fn paste_range(&mut self, target: NoteAddr) -> Result<ChangeHint, Error> {
221 let rc = self.range_clipboard.clone().ok_or(Error::ClipboardEmpty)?;
222 self.apply(Command::PasteRange(PasteRangeCmd {
223 part_index: target.part,
224 staff_index: target.staff,
225 voice_index: rc.voice,
226 target_measure: target.measure,
227 measures: rc.measures,
228 }))
229 }
230
231 pub fn toggle_slur(&mut self, start: NoteAddr, end: NoteAddr) -> Result<ChangeHint, Error> {
233 self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
234 }
235
236 pub fn add_staff(&mut self, part_index: usize, clef: Clef) -> Result<ChangeHint, Error> {
238 self.apply(Command::AddStaff(AddStaffCmd { part_index, clef }))
239 }
240
241 pub fn delete_staff(&mut self, part_index: usize, staff_index: usize) -> Result<ChangeHint, Error> {
243 self.apply(Command::DeleteStaff(DeleteStaffCmd { part_index, staff_index }))
244 }
245
246 pub fn set_stem(&mut self, addr: NoteAddr, stem_up: Option<bool>) -> Result<ChangeHint, Error> {
250 self.apply(Command::SetStem(SetStemCmd {
251 part_index: addr.part,
252 staff_index: addr.staff,
253 measure_index: addr.measure,
254 voice_index: addr.voice,
255 note_index: addr.note,
256 stem_up,
257 }))
258 }
259
260 pub fn set_arpeggio(&mut self, addr: NoteAddr, direction: Option<bool>) -> Result<ChangeHint, Error> {
262 self.apply(Command::SetArpeggio(SetArpeggioCmd {
263 part_index: addr.part,
264 staff_index: addr.staff,
265 measure_index: addr.measure,
266 voice_index: addr.voice,
267 note_index: addr.note,
268 direction,
269 }))
270 }
271
272 pub fn set_note_head(&mut self, addr: NoteAddr, note_head: NoteHead) -> Result<ChangeHint, Error> {
274 self.apply(Command::SetNoteHead(SetNoteHeadCmd {
275 part_index: addr.part,
276 staff_index: addr.staff,
277 measure_index: addr.measure,
278 voice: addr.voice,
279 note_index: addr.note,
280 note_head,
281 }))
282 }
283
284 pub fn set_part_group(&mut self, group: Option<PartGroup>) -> Result<ChangeHint, Error> {
286 self.apply(Command::SetPartGroup(SetPartGroupCmd { group }))
287 }
288
289 pub fn toggle_trill_line(&mut self, start: NoteAddr, end: NoteAddr) -> Result<ChangeHint, Error> {
291 self.apply(Command::ToggleTrillLine(ToggleTrillLineCmd { start, end }))
292 }
293
294 pub fn set_cue(&mut self, addr: NoteAddr, is_cue: bool) -> Result<ChangeHint, Error> {
296 self.apply(Command::SetCue(SetCueCmd {
297 part_index: addr.part,
298 staff_index: addr.staff,
299 measure_index: addr.measure,
300 voice: addr.voice,
301 note_index: addr.note,
302 is_cue,
303 }))
304 }
305
306 pub fn set_tuplet(&mut self, addr: NoteAddr, tuplet: Option<TupletInfo>) -> Result<ChangeHint, Error> {
308 self.apply(Command::SetTuplet(SetTupletCmd {
309 part_index: addr.part,
310 staff_index: addr.staff,
311 measure_index: addr.measure,
312 voice_index: addr.voice,
313 note_index: addr.note,
314 tuplet,
315 }))
316 }
317
318 pub fn respell_score(&mut self, prefer_flat: bool) -> Result<ChangeHint, Error> {
320 self.apply(Command::RespellScore(RespellScoreCmd { prefer_flat }))
321 }
322
323 pub fn respell_score_to_key(&mut self) -> Result<ChangeHint, Error> {
325 self.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {}))
326 }
327
328 pub fn begin_slur(&mut self, start: NoteAddr) -> Result<(), Error> {
332 self.score.parts.get(start.part).ok_or(Error::PartNotFound(start.part))?
333 .staves.get(start.staff).ok_or(Error::StaffNotFound(start.staff))?
334 .measures.get(start.measure).ok_or(Error::MeasureNotFound(start.measure))?
335 .voices.get(start.voice).ok_or(Error::VoiceOutOfRange(start.voice))?
336 .get(start.note).ok_or(Error::NoteNotFound(start.note))?;
337 self.pending_slur_start = Some(start);
338 Ok(())
339 }
340
341 pub fn end_slur(&mut self, end: NoteAddr) -> Result<ChangeHint, Error> {
345 let start = self.pending_slur_start.take()
346 .ok_or_else(|| Error::InvalidCommand("no slur in progress".to_string()))?;
347 self.apply(Command::ToggleSlur(ToggleSlurCmd { start, end }))
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::model::commands::{SetTempoCmd, NewScoreCmd};
355
356 #[test]
357 fn new_engine_has_default_score() {
358 let engine = ScoreEngine::new();
359 assert_eq!(engine.version, 0);
360 assert_eq!(engine.score.parts.len(), 1);
361 }
362
363 #[test]
364 fn apply_increments_version() {
365 let mut engine = ScoreEngine::new();
366 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
367 assert_eq!(engine.version, 1);
368 }
369
370 #[test]
371 fn undo_redo_cycle() {
372 let mut engine = ScoreEngine::new();
373 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
374 let after_apply = engine.version;
375 engine.undo().unwrap();
376 assert_eq!(engine.score.settings.tempo_bpm, 120);
377 engine.redo().unwrap();
378 assert_eq!(engine.score.settings.tempo_bpm, 140);
379 assert!(engine.version > after_apply);
380 }
381
382 #[test]
383 fn replace_score_clears_history() {
384 let mut engine = ScoreEngine::new();
385 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
386 let new_score = Score::new("New", 90, 3, 4, 2, 8);
387 engine.replace_score(new_score);
388 assert!(engine.undo().is_err());
389 assert_eq!(engine.score.settings.tempo_bpm, 90);
390 }
391
392 #[test]
393 fn copy_paste_voice_copies_notes() {
394 use crate::model::score::Note;
395 use crate::model::pitch::{Pitch, Step};
396 use crate::model::duration::Duration;
397 let mut engine = ScoreEngine::new();
398 engine.score.parts[0].staves[0].measures[0].voices[0] =
399 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
400 engine.copy_voice(0, 0, 0, 0).unwrap();
401 engine.paste_voice(0, 0, 0, 1).unwrap();
402 let pasted = &engine.score.parts[0].staves[0].measures[0].voices[1];
403 assert_eq!(pasted.len(), 1);
404 assert_eq!(pasted[0].pitches[0].step, Step::C);
405 }
406
407 #[test]
408 fn paste_voice_undo_restores_original() {
409 use crate::model::score::Note;
410 use crate::model::pitch::{Pitch, Step};
411 use crate::model::duration::Duration;
412 let mut engine = ScoreEngine::new();
413 engine.score.parts[0].staves[0].measures[0].voices[0] =
414 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
415 engine.copy_voice(0, 0, 0, 0).unwrap();
416 engine.paste_voice(0, 0, 0, 1).unwrap();
417 engine.undo().unwrap();
418 assert!(engine.score.parts[0].staves[0].measures[0].voices[1].is_empty());
419 }
420
421 #[test]
422 fn paste_voice_without_copy_returns_error() {
423 let mut engine = ScoreEngine::new();
424 assert!(engine.paste_voice(0, 0, 0, 0).is_err());
425 }
426
427 #[test]
428 fn change_hint_set_tempo_is_global() {
429 use crate::model::change_hint::ChangeScope;
430 let mut engine = ScoreEngine::new();
431 let hint = engine.apply(Command::SetTempo(SetTempoCmd { bpm: 100 })).unwrap();
432 assert_eq!(hint.scope, ChangeScope::Global);
433 assert!(!hint.layout_dirty);
434 assert!(hint.playback_dirty);
435 }
436
437 #[test]
438 fn change_hint_add_note_is_measure_scope() {
439 use crate::model::change_hint::ChangeScope;
440 use crate::model::commands::AddNoteCmd;
441 use crate::model::pitch::Step;
442 use crate::model::duration::Duration;
443 use crate::model::pitch::Pitch;
444 let mut engine = ScoreEngine::new();
445 let hint = engine.apply(Command::AddNote(AddNoteCmd {
446 part_index: 0, staff_index: 0, measure_index: 0, voice: 0,
447 position: 0, pitch: Some(Pitch::new(Step::C, 4)),
448 duration: Duration::Quarter, dot_count: 0, is_rest: false, tuplet: None,
449 })).unwrap();
450 assert_eq!(hint.scope, ChangeScope::Measures { part: 0, staff: 0, start: 0, end: 1 });
451 assert!(!hint.layout_dirty);
452 assert!(hint.playback_dirty);
453 }
454
455 #[test]
456 fn change_hint_set_part_name_no_dirty() {
457 use crate::model::change_hint::ChangeScope;
458 use crate::model::commands::SetPartNameCmd;
459 let mut engine = ScoreEngine::new();
460 let hint = engine.apply(Command::SetPartName(SetPartNameCmd {
461 part_index: 0, name: "Violin".into(), short_name: "Vln.".into(),
462 })).unwrap();
463 assert_eq!(hint.scope, ChangeScope::Part(0));
464 assert!(!hint.layout_dirty);
465 assert!(!hint.playback_dirty);
466 }
467
468 #[test]
469 fn undo_returns_change_hint() {
470 use crate::model::change_hint::ChangeScope;
471 let mut engine = ScoreEngine::new();
472 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 160 })).unwrap();
473 let hint = engine.undo().unwrap();
474 assert_eq!(hint.scope, ChangeScope::Global);
475 assert!(hint.playback_dirty);
476 }
477
478 #[test]
479 fn redo_returns_change_hint() {
480 use crate::model::change_hint::ChangeScope;
481 let mut engine = ScoreEngine::new();
482 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 160 })).unwrap();
483 engine.undo().unwrap();
484 let hint = engine.redo().unwrap();
485 assert_eq!(hint.scope, ChangeScope::Global);
486 assert!(hint.playback_dirty);
487 }
488
489 #[test]
490 fn batch_apply_two_commands_single_undo() {
491 let mut engine = ScoreEngine::new();
492 let original = engine.score.settings.tempo_bpm;
493 engine.batch_apply(vec![
494 Command::SetTempo(SetTempoCmd { bpm: 160 }),
495 Command::SetTempo(SetTempoCmd { bpm: 180 }),
496 ]).unwrap();
497 assert_eq!(engine.score.settings.tempo_bpm, 180);
498 engine.undo().unwrap();
499 assert_eq!(engine.score.settings.tempo_bpm, original);
500 assert!(engine.undo().is_err());
501 }
502
503 #[test]
504 fn batch_apply_empty_returns_no_dirty() {
505 let mut engine = ScoreEngine::new();
506 let v0 = engine.version;
507 let hint = engine.batch_apply(vec![]).unwrap();
508 assert!(!hint.layout_dirty);
509 assert!(!hint.playback_dirty);
510 assert_eq!(engine.version, v0);
511 }
512
513 #[test]
514 fn batch_apply_hint_merges_scopes() {
515 use crate::model::change_hint::ChangeScope;
516 use crate::model::commands::{AddNoteCmd, SetTempoCmd};
517 use crate::model::pitch::{Pitch, Step};
518 use crate::model::duration::Duration;
519 let mut engine = ScoreEngine::new();
520 let hint = engine.batch_apply(vec![
521 Command::SetTempo(SetTempoCmd { bpm: 140 }),
522 Command::AddNote(AddNoteCmd {
523 part_index: 0, staff_index: 0, measure_index: 0, voice: 0,
524 position: 0, pitch: Some(Pitch::new(Step::C, 4)),
525 duration: Duration::Quarter, dot_count: 0, is_rest: false, tuplet: None,
526 }),
527 ]).unwrap();
528 assert_eq!(hint.scope, ChangeScope::Global);
530 assert!(hint.playback_dirty);
531 }
532
533 #[test]
534 fn undo_label_none_when_empty() {
535 let engine = ScoreEngine::new();
536 assert!(engine.undo_label().is_none());
537 assert!(engine.redo_label().is_none());
538 }
539
540 #[test]
541 fn undo_label_after_command() {
542 let mut engine = ScoreEngine::new();
543 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
544 assert_eq!(engine.undo_label(), Some("Set Tempo".to_string()));
545 assert!(engine.redo_label().is_none());
546 }
547
548 #[test]
549 fn redo_label_after_undo() {
550 let mut engine = ScoreEngine::new();
551 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
552 engine.undo().unwrap();
553 assert!(engine.undo_label().is_none());
554 assert_eq!(engine.redo_label(), Some("Set Tempo".to_string()));
555 }
556
557 #[test]
558 fn copy_range_paste_range_roundtrip() {
559 use crate::model::score::{Note, NoteAddr};
560 use crate::model::pitch::{Pitch, Step};
561 use crate::model::duration::Duration;
562 let mut engine = ScoreEngine::new();
563 engine.score.parts[0].staves[0].measures[0].voices[0] =
565 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
566 use crate::model::commands::{AddMeasureCmd};
568 engine.apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 })).unwrap();
569
570 let start = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
571 let end = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
572 engine.copy_range(start, end).unwrap();
573
574 let target = NoteAddr { part: 0, staff: 0, measure: 1, voice: 0, note: 0 };
575 engine.paste_range(target).unwrap();
576
577 let pasted = &engine.score.parts[0].staves[0].measures[1].voices[0];
578 assert_eq!(pasted.len(), 1);
579 assert_eq!(pasted[0].pitches[0].step, Step::C);
580 }
581
582 #[test]
583 fn paste_range_is_undoable() {
584 use crate::model::score::{Note, NoteAddr};
585 use crate::model::pitch::{Pitch, Step};
586 use crate::model::duration::Duration;
587 use crate::model::commands::AddMeasureCmd;
588 let mut engine = ScoreEngine::new();
589 engine.score.parts[0].staves[0].measures[0].voices[0] =
590 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
591 engine.apply(Command::AddMeasure(AddMeasureCmd { after_index: 0 })).unwrap();
592
593 let start = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
594 let end = start.clone();
595 engine.copy_range(start, end).unwrap();
596 let target = NoteAddr { part: 0, staff: 0, measure: 1, voice: 0, note: 0 };
597 engine.paste_range(target).unwrap();
598
599 engine.undo().unwrap();
601 let restored = &engine.score.parts[0].staves[0].measures[1].voices[0];
602 assert!(restored.iter().all(|n| n.is_rest));
603 }
604
605 #[test]
606 fn copy_range_mismatched_part_returns_error() {
607 let engine = ScoreEngine::new();
608 let mut e = ScoreEngine::new();
610 use crate::model::score::NoteAddr;
611 let start = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
612 let end = NoteAddr { part: 1, staff: 0, measure: 0, voice: 0, note: 0 };
613 assert!(e.copy_range(start, end).is_err());
614 let _ = engine; }
616
617 #[test]
618 fn export_history_roundtrip() {
619 let mut engine = ScoreEngine::new();
620 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 160 })).unwrap();
621 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 180 })).unwrap();
622 let history = engine.export_history();
623 assert_eq!(history.commands.len(), 2);
624 let restored = ScoreEngine::from_history(history).unwrap();
625 assert_eq!(restored.score.settings.tempo_bpm, 180);
626 }
627
628 #[test]
629 fn export_history_empty_gives_initial_state() {
630 let engine = ScoreEngine::new();
631 let history = engine.export_history();
632 assert!(history.commands.is_empty());
633 let restored = ScoreEngine::from_history(history).unwrap();
634 assert_eq!(restored.score.settings.tempo_bpm, 120);
635 }
636
637 #[test]
638 fn replace_score_then_export_history() {
639 let mut engine = ScoreEngine::new();
640 let s = Score::new("Custom", 90, 3, 4, 2, 4);
641 engine.replace_score(s);
642 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 60 })).unwrap();
643 let history = engine.export_history();
644 assert_eq!(history.initial_score.settings.tempo_bpm, 90);
645 assert_eq!(history.commands.len(), 1);
646 let restored = ScoreEngine::from_history(history).unwrap();
647 assert_eq!(restored.score.settings.tempo_bpm, 60);
648 }
649
650 #[test]
651 fn new_score_command_replaces_score() {
652 let mut engine = ScoreEngine::new();
653 engine.apply(Command::NewScore(NewScoreCmd {
654 title: "Sonata".into(),
655 composer: "Bach".into(),
656 tempo_bpm: 80,
657 time_numerator: 3,
658 time_denominator: 4,
659 key_fifths: -1,
660 measure_count: 12,
661 template: None,
662 })).unwrap();
663 assert_eq!(engine.score.metadata.title, "Sonata");
664 assert_eq!(engine.score.measure_count(), 12);
665 }
666
667 #[test]
668 fn respell_score_to_key_uses_key_signature() {
669 use crate::model::commands::{AddNoteCmd, RespellScoreToKeyCmd};
670 use crate::model::pitch::Step;
671 use crate::model::notation::KeySignature;
672 let mut engine = ScoreEngine::new();
673 engine.score.settings.key_signature = KeySignature { fifths: -2, mode: "major".to_string() };
675 engine.apply(Command::AddNote(AddNoteCmd {
676 part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 0,
677 pitch: Some(crate::model::pitch::Pitch::with_alter(Step::C, 4, 1)), duration: crate::model::duration::Duration::Quarter,
679 dot_count: 0, is_rest: false, tuplet: None,
680 })).unwrap();
681 engine.apply(Command::RespellScoreToKey(RespellScoreToKeyCmd {})).unwrap();
682 let pitch = &engine.score.parts[0].staves[0].measures[0].voices[0][0].pitches[0];
683 assert_eq!(pitch.step, Step::D);
684 assert_eq!(pitch.alter, -1); }
686
687 #[test]
688 fn begin_end_slur_creates_slur() {
689 use crate::model::commands::AddNoteCmd;
690 use crate::model::pitch::{Pitch, Step};
691 use crate::model::duration::Duration;
692 let mut engine = ScoreEngine::new();
693 engine.apply(Command::AddNote(AddNoteCmd {
694 part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 0,
695 pitch: Some(Pitch::new(Step::C, 4)), duration: Duration::Quarter,
696 dot_count: 0, is_rest: false, tuplet: None,
697 })).unwrap();
698 engine.apply(Command::AddNote(AddNoteCmd {
699 part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 1,
700 pitch: Some(Pitch::new(Step::D, 4)), duration: Duration::Quarter,
701 dot_count: 0, is_rest: false, tuplet: None,
702 })).unwrap();
703 let start = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
704 let end = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 1 };
705 engine.begin_slur(start).unwrap();
706 engine.end_slur(end).unwrap();
707 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][0].slur_start);
708 assert!(engine.score.parts[0].staves[0].measures[0].voices[0][1].slur_end);
709 }
710
711 #[test]
712 fn end_slur_without_begin_returns_error() {
713 let mut engine = ScoreEngine::new();
714 let end = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
715 let result = engine.end_slur(end);
716 assert!(result.is_err());
717 }
718
719 #[test]
722 fn set_stem_sets_and_clears() {
723 use crate::model::commands::AddNoteCmd;
724 use crate::model::pitch::{Pitch, Step};
725 use crate::model::duration::Duration;
726 let mut engine = ScoreEngine::new();
727 engine.apply(Command::AddNote(AddNoteCmd {
728 part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 0,
729 pitch: Some(Pitch::new(Step::C, 4)), duration: Duration::Quarter,
730 dot_count: 0, is_rest: false, tuplet: None,
731 })).unwrap();
732 let addr = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
733 engine.set_stem(addr.clone(), Some(true)).unwrap();
734 assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up, Some(true));
735 engine.set_stem(addr.clone(), Some(false)).unwrap();
736 assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up, Some(false));
737 engine.set_stem(addr, None).unwrap();
738 assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up, None);
739 }
740
741 #[test]
742 fn set_stem_is_undoable() {
743 use crate::model::commands::AddNoteCmd;
744 use crate::model::pitch::{Pitch, Step};
745 use crate::model::duration::Duration;
746 let mut engine = ScoreEngine::new();
747 engine.apply(Command::AddNote(AddNoteCmd {
748 part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 0,
749 pitch: Some(Pitch::new(Step::C, 4)), duration: Duration::Quarter,
750 dot_count: 0, is_rest: false, tuplet: None,
751 })).unwrap();
752 let addr = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
753 engine.set_stem(addr, Some(true)).unwrap();
754 engine.undo().unwrap();
755 assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].stem_up, None);
756 }
757
758 #[test]
759 fn set_arpeggio_is_undoable() {
760 use crate::model::commands::AddNoteCmd;
761 use crate::model::pitch::{Pitch, Step};
762 use crate::model::duration::Duration;
763 let mut engine = ScoreEngine::new();
764 engine.apply(Command::AddNote(AddNoteCmd {
765 part_index: 0, staff_index: 0, measure_index: 0, voice: 0, position: 0,
766 pitch: Some(Pitch::new(Step::C, 4)), duration: Duration::Quarter,
767 dot_count: 0, is_rest: false, tuplet: None,
768 })).unwrap();
769 let addr = NoteAddr { part: 0, staff: 0, measure: 0, voice: 0, note: 0 };
770 engine.set_arpeggio(addr.clone(), Some(true)).unwrap();
771 assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate, Some(true));
772 engine.undo().unwrap();
773 assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate, None);
774 engine.redo().unwrap();
775 assert_eq!(engine.score.parts[0].staves[0].measures[0].voices[0][0].arpeggiate, Some(true));
776 }
777
778 #[test]
781 fn undo_key_returns_key_string() {
782 let mut engine = ScoreEngine::new();
783 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
784 assert_eq!(engine.undo_key(), Some("SetTempo".to_string()));
785 assert!(engine.redo_key().is_none());
786 }
787
788 #[test]
789 fn redo_key_after_undo() {
790 let mut engine = ScoreEngine::new();
791 engine.apply(Command::SetTempo(SetTempoCmd { bpm: 140 })).unwrap();
792 engine.undo().unwrap();
793 assert!(engine.undo_key().is_none());
794 assert_eq!(engine.redo_key(), Some("SetTempo".to_string()));
795 }
796
797 #[test]
800 fn batch_apply_labeled_sets_undo_key() {
801 let mut engine = ScoreEngine::new();
802 engine.batch_apply_labeled(
803 vec![Command::SetTempo(SetTempoCmd { bpm: 140 })],
804 "ApplyAI",
805 ).unwrap();
806 assert_eq!(engine.undo_key(), Some("ApplyAI".to_string()));
807 }
808
809 #[test]
810 fn batch_apply_labeled_empty_is_noop() {
811 let mut engine = ScoreEngine::new();
812 engine.batch_apply_labeled(vec![], "ApplyAI").unwrap();
813 assert!(engine.undo_key().is_none());
814 }
815}