#[cfg(test)]
mod tests {
use crate::app::App;
use crate::state::*;
use phosphor_core::clip::NoteSnapshot;
use phosphor_core::EngineConfig;
fn app() -> App {
App::new(EngineConfig { buffer_size: 64, sample_rate: 44100 }, false, false)
}
fn add_synth_track(app: &mut App) {
app.nav.instrument_modal.open = true;
app.nav.instrument_modal.cursor = 0; let instrument = app.nav.instrument_modal.selected();
app.nav.instrument_modal.open = false;
app.create_instrument_track(instrument);
}
fn create_clip_with_notes(app: &mut App, track_idx: usize, start_tick: i64, length_ticks: i64, notes: Vec<NoteSnapshot>) {
if let Some(track) = app.nav.tracks.get_mut(track_idx) {
let ppq = phosphor_core::transport::Transport::PPQ;
let beats = (length_ticks as f64 / ppq as f64).ceil() as u16;
track.clips.push(Clip {
number: track.clips.len() + 1,
width: beats.max(2),
has_content: !notes.is_empty(),
start_tick,
length_ticks,
notes,
hidden_notes: Vec::new(),
});
}
}
fn note(pitch: u8, start_frac: f64, duration_frac: f64) -> NoteSnapshot {
NoteSnapshot { note: pitch, velocity: 100, start_frac, duration_frac }
}
#[test]
fn duplicated_clips_have_independent_notes() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![
note(60, 0.0, 0.25), note(64, 0.25, 0.25), note(67, 0.5, 0.25),
]);
assert_eq!(app.nav.tracks[ti].clips.len(), 1);
assert_eq!(app.nav.tracks[ti].clips[0].notes.len(), 3);
app.nav.track_element = TrackElement::Clip(0);
app.duplicate_clip(0);
assert_eq!(app.nav.tracks[ti].clips.len(), 2, "should have 2 clips after duplicate");
assert_eq!(app.nav.tracks[ti].clips[0].notes.len(), 3);
assert_eq!(app.nav.tracks[ti].clips[1].notes.len(), 3);
app.nav.tracks[ti].clips[1].notes[0].note = 72;
assert_eq!(app.nav.tracks[ti].clips[0].notes[0].note, 60, "clip 0 note should be unchanged");
assert_eq!(app.nav.tracks[ti].clips[1].notes[0].note, 72, "clip 1 note should be modified");
}
#[test]
fn clip_view_target_follows_selected_clip() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![note(60, 0.0, 0.25)]);
create_clip_with_notes(&mut app, ti, 3840, 3840, vec![note(72, 0.0, 0.25)]);
app.nav.track_element = TrackElement::Clip(0);
app.nav.activate_element();
assert_eq!(app.nav.clip_view_target, Some((ti, 0)));
app.nav.clip_locked = false;
app.nav.track_selected = true;
app.nav.move_right(); assert_eq!(app.nav.track_element, TrackElement::Clip(1));
assert_eq!(app.nav.clip_view_target, Some((ti, 1)));
let active = app.nav.active_clip().unwrap();
assert_eq!(active.notes[0].note, 72, "active clip should be clip 1 with note 72");
}
#[test]
fn shrink_hides_notes_expand_restores() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
let ppq = phosphor_core::transport::Transport::PPQ;
create_clip_with_notes(&mut app, ti, 0, ppq * 4, vec![
note(60, 0.0, 0.1), note(62, 0.25, 0.1), note(64, 0.5, 0.1), note(67, 0.75, 0.1), ]);
app.nav.track_element = TrackElement::Clip(0);
app.nav.track_selected = true;
app.nav.clip_locked = true;
app.move_clip_right_edge(0, -1);
app.move_clip_right_edge(0, -1);
let clip = &app.nav.tracks[ti].clips[0];
assert_eq!(clip.length_ticks, ppq * 2, "clip should be 2 beats");
assert_eq!(clip.notes.len(), 2, "should have 2 visible notes (beats 1-2)");
assert_eq!(clip.hidden_notes.len(), 2, "should have 2 hidden notes (beats 3-4)");
app.move_clip_right_edge(0, 1);
app.move_clip_right_edge(0, 1);
let clip = &app.nav.tracks[ti].clips[0];
assert_eq!(clip.length_ticks, ppq * 4, "clip should be 4 beats again");
assert_eq!(clip.notes.len(), 4, "all 4 notes should be visible again");
assert_eq!(clip.hidden_notes.len(), 0, "no hidden notes");
}
#[test]
fn delete_clip_selects_adjacent() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![note(60, 0.0, 0.25)]);
create_clip_with_notes(&mut app, ti, 3840, 3840, vec![note(72, 0.0, 0.25)]);
create_clip_with_notes(&mut app, ti, 7680, 3840, vec![note(67, 0.0, 0.25)]);
app.nav.track_element = TrackElement::Clip(1);
app.nav.track_selected = true;
app.nav.confirm_modal.open = false;
let track = app.nav.tracks.get_mut(ti).unwrap();
track.clips.remove(1);
let remaining = track.clips.len();
assert_eq!(remaining, 2);
assert_eq!(app.nav.tracks[ti].clips[1].notes[0].note, 67);
}
#[test]
fn edit_mode_navigates_within_column() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![
note(60, 0.0, 0.25), note(64, 0.0, 0.25), note(67, 0.0, 0.25), note(72, 0.5, 0.25), ]);
app.nav.track_element = TrackElement::Clip(0);
app.nav.open_clip_view(ti, 0);
app.enter_edit_mode();
let pr = &app.nav.clip_view.piano_roll;
assert!(pr.edit_mode);
assert_eq!(app.nav.tracks[ti].clips[0].notes[pr.edit_cursor].note, 72);
app.edit_move_to_prev_column();
let pr = &app.nav.clip_view.piano_roll;
assert_eq!(app.nav.tracks[ti].clips[0].notes[pr.edit_cursor].note, 67,
"left from C5 should land on G4 (closest pitch in prev column)");
app.edit_move_down_in_column();
let pr = &app.nav.clip_view.piano_roll;
assert_eq!(app.nav.tracks[ti].clips[0].notes[pr.edit_cursor].note, 64,
"down should go to next lower note in same column");
app.edit_move_down_in_column();
let pr = &app.nav.clip_view.piano_roll;
assert_eq!(app.nav.tracks[ti].clips[0].notes[pr.edit_cursor].note, 60);
app.edit_move_to_next_column();
let pr = &app.nav.clip_view.piano_roll;
assert_eq!(app.nav.tracks[ti].clips[0].notes[pr.edit_cursor].note, 72,
"right should jump to note in next column");
}
#[test]
fn edit_mode_move_note_and_undo() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![
note(60, 0.0, 0.25),
]);
app.nav.track_element = TrackElement::Clip(0);
app.nav.open_clip_view(ti, 0);
app.enter_edit_mode();
let original_pitch = app.nav.tracks[ti].clips[0].notes[0].note;
assert_eq!(original_pitch, 60);
app.nav.clip_view.piano_roll.edit_selected.push(0);
app.nav.clip_view.piano_roll.edit_sub = EditSubMode::Moving;
app.move_selected_notes(0, 2);
assert_eq!(app.nav.tracks[ti].clips[0].notes[0].note, 62, "note should be at 62");
app.perform_undo();
assert_eq!(app.nav.tracks[ti].clips[0].notes[0].note, 60, "note should be back at 60 after undo");
}
#[test]
fn edit_mode_delete_note_and_undo() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![
note(60, 0.0, 0.25),
note(64, 0.25, 0.25),
]);
app.nav.track_element = TrackElement::Clip(0);
app.nav.open_clip_view(ti, 0);
app.enter_edit_mode();
assert_eq!(app.nav.tracks[ti].clips[0].notes.len(), 2);
app.edit_delete_cursor_note();
assert_eq!(app.nav.tracks[ti].clips[0].notes.len(), 1, "should have 1 note after delete");
app.perform_undo();
assert_eq!(app.nav.tracks[ti].clips[0].notes.len(), 2, "should have 2 notes after undo");
}
#[test]
fn clip_move_respects_collision() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
let ppq = phosphor_core::transport::Transport::PPQ;
create_clip_with_notes(&mut app, ti, 0, ppq * 4, vec![note(60, 0.0, 0.25)]);
create_clip_with_notes(&mut app, ti, ppq * 4, ppq * 4, vec![note(72, 0.0, 0.25)]);
app.nav.track_selected = true;
app.nav.track_element = TrackElement::Clip(0);
app.nav.clip_locked = true;
let start_before = app.nav.tracks[ti].clips[0].start_tick;
app.move_clip(0, 1);
let start_after = app.nav.tracks[ti].clips[0].start_tick;
assert_eq!(start_after, start_before, "clip should not overlap adjacent clip");
}
#[test]
fn recording_snapshot_merges_into_existing_clip() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![
note(60, 0.0, 0.25),
]);
assert_eq!(app.nav.tracks[ti].clips.len(), 1);
let snap = phosphor_core::clip::ClipSnapshot {
track_id: app.nav.tracks[ti].mixer_id.unwrap_or(0),
clip_index: 0,
start_tick: 0,
length_ticks: 3840,
event_count: 2,
notes: vec![note(64, 0.5, 0.25)],
};
let _ = app.nav.receive_clip_snapshot(snap, true);
assert_eq!(app.nav.tracks[ti].clips.len(), 1, "should still be 1 clip (merged)");
assert_eq!(app.nav.tracks[ti].clips[0].notes.len(), 2, "should have 2 notes after merge");
}
#[test]
fn recording_snapshot_absorbs_smaller_clips() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![note(60, 0.0, 0.25)]);
create_clip_with_notes(&mut app, ti, 3840, 3840, vec![note(72, 0.0, 0.25)]);
assert_eq!(app.nav.tracks[ti].clips.len(), 2);
let mid = app.nav.tracks[ti].mixer_id.unwrap_or(0);
let snap = phosphor_core::clip::ClipSnapshot {
track_id: mid, clip_index: 0,
start_tick: 0, length_ticks: 7680,
event_count: 4, notes: vec![note(67, 0.25, 0.1)],
};
let _ = app.nav.receive_clip_snapshot(snap, true);
assert_eq!(app.nav.tracks[ti].clips.len(), 1,
"both clips should be absorbed into the new recording");
let clip = &app.nav.tracks[ti].clips[0];
assert_eq!(clip.length_ticks, 7680);
assert!(clip.notes.len() >= 3,
"should have absorbed notes from both clips + new recording");
}
#[test]
fn stale_snapshot_ignored_when_not_recording() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![
note(60, 0.0, 0.25), note(64, 0.25, 0.25),
]);
app.nav.track_element = TrackElement::Clip(0);
app.nav.open_clip_view(ti, 0);
app.nav.tracks[ti].clips[0].notes.remove(1);
assert_eq!(app.nav.tracks[ti].clips[0].notes.len(), 1);
let mid = app.nav.tracks[ti].mixer_id.unwrap_or(0);
let snap = phosphor_core::clip::ClipSnapshot {
track_id: mid, clip_index: 0,
start_tick: 0, length_ticks: 3840,
event_count: 4, notes: vec![note(60, 0.0, 0.25), note(64, 0.25, 0.25)],
};
let _ = app.nav.receive_clip_snapshot(snap, false);
assert_eq!(app.nav.tracks[ti].clips[0].notes.len(), 1,
"stale snapshot should be ignored — notes should stay at 1");
}
#[test]
fn multi_clip_arrangement_workflow() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
let ppq = phosphor_core::transport::Transport::PPQ;
create_clip_with_notes(&mut app, ti, 0, ppq * 16, vec![
note(60, 0.0, 0.0625), note(64, 0.0, 0.0625), note(67, 0.0, 0.0625), note(65, 0.25, 0.0625), note(69, 0.25, 0.0625), note(72, 0.25, 0.0625), note(62, 0.5, 0.0625), note(67, 0.5, 0.0625), note(71, 0.5, 0.0625), note(60, 0.75, 0.0625), note(64, 0.75, 0.0625), note(67, 0.75, 0.0625), ]);
app.nav.track_element = TrackElement::Clip(0);
app.nav.track_selected = true;
app.duplicate_clip(0);
assert_eq!(app.nav.tracks[ti].clips.len(), 2);
let clip1_start = app.nav.tracks[ti].clips[1].start_tick;
assert_eq!(clip1_start, ppq * 16, "duplicate should be at tick 7680");
app.nav.open_clip_view(ti, 1);
assert_eq!(app.nav.clip_view_target, Some((ti, 1)));
let active = app.nav.active_clip().unwrap();
assert_eq!(active.notes.len(), 12, "clip 1 should have 12 notes");
app.nav.active_clip_mut().unwrap().notes[0].note = 63;
assert_eq!(app.nav.tracks[ti].clips[0].notes[0].note, 60, "clip 0 should still have C4");
assert_eq!(app.nav.tracks[ti].clips[1].notes[0].note, 63, "clip 1 should have Eb4");
}
#[test]
fn dedup_removes_phantom_at_same_position() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![note(60, 0.0, 0.25)]);
create_clip_with_notes(&mut app, ti, 0, 7680, vec![note(72, 0.0, 0.25)]);
assert_eq!(app.nav.tracks[ti].clips.len(), 2);
app.nav.dedup_clips();
assert_eq!(app.nav.tracks[ti].clips.len(), 1, "phantom should be absorbed");
assert_eq!(app.nav.tracks[ti].clips[0].length_ticks, 7680, "longer clip should survive");
assert!(app.nav.tracks[ti].clips[0].notes.len() >= 2, "absorbed notes should be merged");
}
#[test]
fn selected_indices_cleared_after_delete() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![
note(60, 0.0, 0.25), note(64, 0.25, 0.25), note(67, 0.5, 0.25),
]);
app.nav.track_element = TrackElement::Clip(0);
app.nav.open_clip_view(ti, 0);
app.nav.clip_view.piano_roll.column_count = 4;
app.nav.clip_view.piano_roll.selected_note_indices = vec![0, 1, 2];
app.delete_selected_notes(Some((0, 3)), None);
assert!(app.nav.clip_view.piano_roll.selected_note_indices.is_empty(),
"selected_note_indices must be cleared after deletion to prevent stale index access");
}
#[test]
fn absorption_returns_count_for_audio_sync() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![note(60, 0.0, 0.25)]);
create_clip_with_notes(&mut app, ti, 3840, 3840, vec![note(72, 0.0, 0.25)]);
let mid = app.nav.tracks[ti].mixer_id.unwrap_or(0);
let snap = phosphor_core::clip::ClipSnapshot {
track_id: mid, clip_index: 0,
start_tick: 0, length_ticks: 7680,
event_count: 4, notes: vec![note(67, 0.25, 0.1)],
};
let result = app.nav.receive_clip_snapshot(snap, true);
assert!(result.is_some(), "absorption should return Some((mixer_id, count))");
let (ret_mid, absorbed) = result.unwrap();
assert_eq!(ret_mid, mid);
assert_eq!(absorbed, 2, "both clips should have been absorbed");
}
#[test]
fn clip_view_target_fixed_after_absorption() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![note(60, 0.0, 0.25)]);
create_clip_with_notes(&mut app, ti, 3840, 3840, vec![note(72, 0.0, 0.25)]);
app.nav.open_clip_view(ti, 1);
assert_eq!(app.nav.clip_view_target, Some((ti, 1)));
let mid = app.nav.tracks[ti].mixer_id.unwrap_or(0);
let snap = phosphor_core::clip::ClipSnapshot {
track_id: mid, clip_index: 0,
start_tick: 0, length_ticks: 7680,
event_count: 2, notes: vec![note(67, 0.0, 0.1)],
};
let _ = app.nav.receive_clip_snapshot(snap, true);
assert_eq!(app.nav.tracks[ti].clips.len(), 1, "should be 1 clip after absorption");
let (_, ci) = app.nav.clip_view_target.unwrap();
assert_eq!(ci, 0, "clip_view_target should be fixed to 0");
}
#[test]
fn highlighted_notes_can_be_moved() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![
note(60, 0.0, 0.125),
note(64, 0.0, 0.125),
note(67, 0.0, 0.125),
note(72, 0.5, 0.125), ]);
app.nav.track_element = TrackElement::Clip(0);
app.nav.open_clip_view(ti, 0);
app.nav.clip_view.piano_roll.column_count = 4;
app.nav.clip_view.piano_roll.highlight_start = Some(0);
app.nav.clip_view.piano_roll.highlight_end = Some(0);
let original_pitches: Vec<u8> = app.nav.tracks[ti].clips[0].notes.iter()
.filter(|n| n.start_frac < 0.25)
.map(|n| n.note)
.collect();
assert_eq!(original_pitches, vec![60, 64, 67]);
app.move_highlighted_notes(0, 1);
let moved_pitches: Vec<u8> = app.nav.tracks[ti].clips[0].notes.iter()
.filter(|n| n.start_frac < 0.25)
.map(|n| n.note)
.collect();
assert_eq!(moved_pitches, vec![61, 65, 68], "all highlighted notes should move up 1 semitone");
let unmoved = app.nav.tracks[ti].clips[0].notes.iter()
.find(|n| n.start_frac >= 0.4)
.unwrap();
assert_eq!(unmoved.note, 72, "note outside highlight should be unchanged");
}
#[test]
fn highlighted_move_is_undoable() {
let mut app = app();
add_synth_track(&mut app);
let ti = app.nav.track_cursor;
create_clip_with_notes(&mut app, ti, 0, 3840, vec![
note(60, 0.0, 0.125),
]);
app.nav.track_element = TrackElement::Clip(0);
app.nav.open_clip_view(ti, 0);
app.nav.clip_view.piano_roll.column_count = 4;
app.nav.clip_view.piano_roll.highlight_start = Some(0);
app.nav.clip_view.piano_roll.highlight_end = Some(0);
app.move_highlighted_notes(0, 5); assert_eq!(app.nav.tracks[ti].clips[0].notes[0].note, 65);
app.perform_undo();
assert_eq!(app.nav.tracks[ti].clips[0].notes[0].note, 60,
"undo should restore original pitch");
}
}