Files
soundcraft/crates/ui-egui/tests/it/command_reachability.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

123 lines
4.8 KiB
Rust

//! Every user-facing engine command must be reachable from the GUI: through a menu, a shortcut,
//! or a widget that names it in `src/`. Commands that only work from the control channel / CLI /
//! MCP left users stuck (#282: `midi.new_clip` had no UI path, so an empty instrument track could
//! never get a MIDI clip).
use soundcraft_engine::{catalog, cmd::command_specs};
use soundcraft_ui_egui::menus::{self, MenuNode};
use std::collections::HashSet;
use std::path::Path;
/// Known gaps: commands with no menu, shortcut or literal reference in the UI source today.
/// Give each one a UI path and remove it from this list; don't add to it.
const KNOWN_UNREACHED: &[&str] = &[
"edit.paste_clip_effects",
"edit.paste_clip_gain",
"event.midi_note",
"file.bounce_stems",
"markers.next",
"markers.previous",
"mix.clear_mutes",
"mix.clear_solos",
"mix.insert_move",
"mix.phase_invert",
"mix.send_surround_pan",
"mix.solo_safe",
"options.post_roll",
"options.pre_roll",
"setup.hardware", // menu opens the Playback Engine window, which doesn't run it
"setup.playback_engine", // same
"setup.reset_edit_state",
"track.load_preset",
"track.pin",
"track.trim_automation_write",
"track.vca",
"transport.pause",
"transport.status",
"video.offset",
"view.sub_counter",
];
fn ui_source() -> String {
fn walk(dir: &Path, out: &mut String) {
for entry in std::fs::read_dir(dir).unwrap() {
let p = entry.unwrap().path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|e| e == "rs") {
out.push_str(&std::fs::read_to_string(&p).unwrap());
}
}
}
let mut s = String::new();
walk(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut s);
s
}
/// Command ids behind the leaves of the menu bar actually built by the app. A spec's own `menu`
/// field is not enough: the bar is built from the catalog, so an off-catalog path never shows.
fn menu_commands() -> HashSet<String> {
fn walk(nodes: &[MenuNode], extra: &[(&str, &str)], out: &mut HashSet<String>) {
for n in nodes {
if n.children.is_empty() {
out.extend(catalog::implemented_by(&n.path, extra));
}
walk(&n.children, extra, out);
}
}
let mut out = HashSet::new();
walk(&menus::tree(), &menus::ui_aliases(), &mut out);
out
}
#[test]
fn every_command_has_a_ui_path() {
let src = ui_source();
let in_menus = menu_commands();
let mut unreached = Vec::new();
let mut stale = Vec::new();
for c in command_specs() {
// Queries and view-only commands are for agents / the UI's own reads.
if !c.journal || c.view {
continue;
}
let reached = in_menus.contains(c.id) || c.shortcut.is_some() || src.contains(&format!("\"{}\"", c.id));
let known = KNOWN_UNREACHED.contains(&c.id);
if !reached && !known {
unreached.push(c.id);
} else if reached && known {
stale.push(c.id);
}
}
assert!(unreached.is_empty(), "commands with no menu, shortcut or UI reference: {unreached:?}");
assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}");
}
#[test]
fn new_midi_clip_is_in_the_clip_menu() {
let extra = menus::ui_aliases();
assert_eq!(catalog::implemented_by("Clip > New MIDI Clip", &extra).as_deref(), Some("midi.new_clip"));
assert!(menu_commands().contains("midi.new_clip"));
let tree = menus::tree();
let clip = tree.iter().find(|n| n.label == "Clip").unwrap();
assert_eq!(clip.children[0].label, "New MIDI Clip", "creates rather than edits, so it comes first");
}
#[test]
fn remove_sync_point_follows_identify_and_is_greyed_out_without_a_point() {
let tree = menus::tree();
let clip = tree.iter().find(|n| n.label == "Clip").unwrap();
let labels: Vec<&str> = clip.children.iter().map(|n| n.label.as_str()).collect();
let at = labels.iter().position(|l| *l == "Identify Sync Point").unwrap();
assert_eq!(labels.get(at + 1), Some(&"Remove Sync Point"));
let path = &clip.children[at + 1].path;
let id = menus::command_for_path(path, &menus::ui_aliases());
assert_eq!(id.as_deref(), Some("clip.remove_sync_point"));
let mut app = soundcraft_ui_egui::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, soundcraft_ui_egui::Services::default());
app.engine.execute("edit.select", &serde_json::json!({"tracks": ["Kick"], "start": {"seconds": 12.0}})).unwrap();
assert!(!menus::item_state(&app, path, id.as_deref()).0, "the selected Kick clip has no sync point yet");
app.engine.execute("clip.identify_sync_point", &serde_json::json!({})).unwrap();
assert!(menus::item_state(&app, path, id.as_deref()).0);
}