111 lines
3.9 KiB
Rust
111 lines
3.9 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] = &[
|
|
"automation.clear",
|
|
"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.instrument_param",
|
|
"mix.phase_invert",
|
|
"mix.sampler_clear",
|
|
"mix.sampler_load",
|
|
"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.elastic",
|
|
"track.load_preset",
|
|
"track.pin",
|
|
"track.timebase",
|
|
"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");
|
|
}
|