Files
soundcraft/crates/ui-egui/tests/it/midi_panel.rs
T
aiongg e8abd44712 CI: one test binary per crate, nextest, line tables only (#985)
* Build: line tables only for workspace crates, no debug info for dependencies

Full debug info made most of the dev and test target size and link time. Line tables keep file and line numbers in backtraces; dependency frames lose them.

* Tests: build each crate's integration tests as one binary

Each tests/*.rs file was its own test binary: 98 binaries, each linking the crate and its dependencies. The files are now modules of tests/it/main.rs, one binary per crate, which cuts link time and target size. Filter by module: `cargo test -p <crate> --test it <module>::`.

Binaries with their own main (harness = false) in clap-host and vst3-host stay separate, as do the ui-egui tests that switch the process-wide Light/Dark palette and recovery.rs, whose autosave locks leak into child processes other tests start.

* Xtask: run tests with nextest, doctests apart, and no examples in the test build

cargo xtask ci runs the tests with cargo nextest when it is installed, else cargo test with a notice, and doctests with cargo test --doc. The test build leaves out examples (--lib --bins --tests); clippy --all-targets still compiles them. Test binaries with harness = false run under cargo test: their own main cannot answer nextest's --list. clippy and the wasm check keep going past a failing crate. cargo xtask test runs the test step alone.
2026-10-23 14:00:38 -04:00

204 lines
8.4 KiB
Rust

use egui::{Event, Id, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, midi_editor};
fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) {
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
})
.textures_delta
.clear();
}
fn panel(ctx: &egui::Context) -> Rect {
ctx.data(|data| data.get_temp::<Rect>(Id::new("soundcraft-midi-body"))).unwrap()
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
fn check_resize(with_clip: bool) {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.show_midi_editor = true;
if with_clip {
app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap();
}
assert_eq!(midi_editor::target_clip(&app).is_some(), with_clip);
let ctx = egui::Context::default();
for _ in 0..6 {
frame(&ctx, &mut app, vec![]);
}
let initial = panel(&ctx);
assert!((initial.height() - 320.0).abs() < 1.0, "initial panel: {initial:?}");
let notes = app.engine.session().clone();
// Drag the shared boundary up and down, then allow several idle frames
// to catch a panel that looks resized but snaps back after release.
for height in [440.0, 280.0, 200.0] {
let before = panel(&ctx);
let start = pos2(before.center().x, before.top() - 26.0 - 2.0);
let end = pos2(start.x, before.bottom() - height - 26.0 - 2.0);
frame(&ctx, &mut app, vec![Event::PointerMoved(start)]);
frame(&ctx, &mut app, vec![button(start, true)]);
for step in 1..=6 {
frame(&ctx, &mut app, vec![Event::PointerMoved(start.lerp(end, step as f32 / 6.0))]);
}
frame(&ctx, &mut app, vec![button(end, false)]);
for _ in 0..4 {
frame(&ctx, &mut app, vec![]);
}
let after = panel(&ctx);
assert!((after.height() - height.max(260.0)).abs() < 1.0, "requested {height}, panel: {after:?}");
assert!((after.bottom() - initial.bottom()).abs() < 1.0);
assert!(app.edit_layout.timeline[3] <= after.top());
}
// Closing and reopening the dock keeps the chosen height.
let resized = panel(&ctx);
app.run("window.midi_editor", json!({})).unwrap();
frame(&ctx, &mut app, vec![]);
app.run("window.midi_editor", json!({})).unwrap();
for _ in 0..4 {
frame(&ctx, &mut app, vec![]);
}
assert_eq!(panel(&ctx), resized);
let saved = serde_json::to_string(&app.ui).unwrap();
app.ui = serde_json::from_str(&saved).unwrap();
for _ in 0..4 {
frame(&ctx, &mut app, vec![]);
}
assert_eq!(panel(&ctx), resized);
assert_eq!(*app.engine.session(), notes);
}
#[test]
fn midi_panel_resizes_with_a_selected_clip() {
check_resize(true);
}
#[test]
fn midi_panel_resizes_without_a_selected_clip() {
check_resize(false);
}
#[test]
fn layout_reset_cancels_a_held_midi_divider_without_editing_the_session() {
check_held_replacement("reset");
}
#[test]
fn arrange_cancels_a_held_midi_divider_without_editing_the_session() {
check_held_replacement("arrange");
}
#[test]
fn legacy_visibility_sync_cancels_a_held_midi_divider_without_editing_the_session() {
check_held_replacement("visibility");
}
fn check_held_replacement(operation: &str) {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.show_midi_editor = true;
app.run("edit.select", json!({"tracks":["Keys"]})).unwrap();
app.ui.show_memory_locations = operation == "arrange";
let session = app.engine.session().clone();
let ctx = egui::Context::default();
for _ in 0..6 {
frame(&ctx, &mut app, vec![]);
}
let initial = panel(&ctx);
let start = pos2(initial.center().x, initial.top() - 28.0);
frame(&ctx, &mut app, vec![Event::PointerMoved(start)]);
frame(&ctx, &mut app, vec![button(start, true)]);
let first = start - vec2(0.0, 80.0);
frame(&ctx, &mut app, vec![Event::PointerMoved(first)]);
frame(&ctx, &mut app, vec![]);
assert!((panel(&ctx).height() - 400.0).abs() < 1.0, "first drag must resize");
let second = start - vec2(0.0, 120.0);
frame(&ctx, &mut app, vec![Event::PointerMoved(second)]);
frame(&ctx, &mut app, vec![]);
assert!((panel(&ctx).height() - 440.0).abs() < 1.0, "a normal second held move must continue resizing");
match operation {
"reset" => {
app.run("ui.dock.reset", json!({})).unwrap();
}
"arrange" => {
app.run("window.arrange_cascade", json!({})).unwrap();
}
"visibility" => {
app.run("window.midi_editor", json!({})).unwrap();
frame(&ctx, &mut app, vec![]);
app.run("window.midi_editor", json!({})).unwrap();
frame(&ctx, &mut app, vec![]);
}
_ => panic!("unknown synthetic operation"),
}
let reset = serde_json::to_value(&app.ui.docking).unwrap();
let after_reset = start - vec2(0.0, 160.0);
frame(&ctx, &mut app, vec![Event::PointerMoved(after_reset)]);
frame(&ctx, &mut app, vec![]);
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset, "old held divider must not resize the replacement layout");
let expected = if operation == "reset" { 320.0 } else { 440.0 };
assert!((panel(&ctx).height() - expected).abs() < 1.0);
frame(&ctx, &mut app, vec![button(after_reset, false)]);
for _ in 0..4 {
frame(&ctx, &mut app, vec![]);
}
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset);
assert_eq!(*app.engine.session(), session);
}
#[test]
fn a_selected_video_clip_is_not_a_midi_target() {
use soundcraft_model::{ChannelFormat, Clip, ClipContent, SourceId, Track, TrackKind};
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap();
let midi = midi_editor::target_clip(&app).unwrap();
let s = app.engine.session_mut();
let track = s.add_track(TrackKind::Video, ChannelFormat::Mono, None);
let video = s.new_clip_id();
let source = SourceId(s.alloc());
s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(Clip::video(video, "picture", source, 0, 0, 48_000));
app.run("edit.select", json!({"clips": [video.0]})).unwrap();
assert_eq!(midi_editor::target_clip(&app), None, "a video clip alone leaves the MIDI editors without a target");
app.run("edit.select", json!({"clips": [video.0, midi.0]})).unwrap();
assert_eq!(midi_editor::target_clip(&app), Some(midi), "the MIDI clip after a video clip is the target");
let (_, clip) = app.engine.session().find_clip(midi).unwrap();
assert!(matches!(clip.content, ClipContent::Midi { .. }));
}
#[test]
fn only_pencil_inserts_notes_in_the_midi_roll() {
use soundcraft_model::{ClipContent, Tool};
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.show_midi_editor = true;
let created = app.run("midi.new_clip", json!({"track": "Keys", "start": 0, "length": 96000})).unwrap();
let clip = created["clip"].as_u64().unwrap();
app.run("edit.select", json!({"clips": [clip]})).unwrap();
let ctx = egui::Context::default();
for _ in 0..6 {
frame(&ctx, &mut app, vec![]);
}
let body = panel(&ctx);
let point = pos2(body.left() + 200.0, body.top() + 60.0);
for tool in Tool::ALL.into_iter().filter(|tool| *tool != Tool::Pencil).chain([Tool::Pencil]) {
app.run("edit.tool", json!({"tool": tool.id()})).unwrap();
let before = app.engine.session().clone();
frame(&ctx, &mut app, vec![Event::PointerMoved(point)]);
frame(&ctx, &mut app, vec![button(point, true)]);
frame(&ctx, &mut app, vec![button(point, false)]);
let (_, edited) = app.engine.session().find_clip(soundcraft_model::ClipId(clip)).unwrap();
let ClipContent::Midi { sequence } = &edited.content else { panic!("MIDI clip required") };
if tool == Tool::Pencil {
assert_eq!(sequence.notes.len(), 1);
app.run("edit.undo", json!({})).unwrap();
}
assert!(*app.engine.session() == before, "{tool:?} click must not change the session");
}
}