effectcraft/crates/engine/src/menus/tests.rs

182 lines
8.1 KiB
Rust

use std::collections::BTreeMap;
use super::*;
fn all_entries(nodes: &[MenuNode], path: &mut Vec<String>, out: &mut Vec<(Vec<String>, MenuEntry)>) {
for n in nodes {
match n {
MenuNode::Item(e) => out.push((path.clone(), e.clone())),
MenuNode::Separator | MenuNode::Dynamic { .. } => {}
MenuNode::Submenu { label, children } => {
path.push(label.clone());
all_entries(children, path, out);
path.pop();
}
}
}
}
fn platform_entries(mac: bool) -> Vec<(Vec<String>, MenuEntry)> {
let tree = parse(TREE, mac).unwrap();
let mut out = vec![];
all_entries(&tree, &mut vec![], &mut out);
out
}
#[test]
fn top_level_order_matches_after_effects() {
let mac: Vec<String> =
parse(TREE, true).unwrap().iter().filter_map(|n| if let MenuNode::Submenu { label, .. } = n { Some(label.clone()) } else { None }).collect();
assert_eq!(mac, ["EffectCraft", "File", "Edit", "Composition", "Layer", "Effect", "Animation", "View", "Window", "Help"]);
let other: Vec<String> =
parse(TREE, false).unwrap().iter().filter_map(|n| if let MenuNode::Submenu { label, .. } = n { Some(label.clone()) } else { None }).collect();
assert_eq!(other, ["File", "Edit", "Composition", "Layer", "Effect", "Animation", "View", "Window", "Help"]);
// The live bar is one of the two.
assert!(top_level().len() == mac.len() || top_level().len() == other.len());
}
#[test]
fn every_menu_entry_resolves_to_a_registered_command() {
for mac in [true, false] {
for (path, e) in platform_entries(mac) {
assert!(crate::commands::find(&e.command).is_some(), "{} ▸ {}: unknown command `{}`", path.join(" ▸ "), e.label, e.command);
assert!(e.params.is_null() || e.params.is_object(), "{}: params must be an object", e.label);
}
}
}
#[test]
fn command_menu_paths_and_labels_agree_with_the_tree() {
let entries = platform_entries(true);
let mut by_cmd: BTreeMap<&str, Vec<&(Vec<String>, MenuEntry)>> = BTreeMap::new();
for e in &entries {
by_cmd.entry(e.1.command.as_str()).or_default().push(e);
}
for spec in crate::command_specs() {
if spec.menu.is_empty() {
continue;
}
let places = by_cmd.get(spec.id).unwrap_or_else(|| panic!("`{}` declares menu {:?} but is not in the menu tree", spec.id, spec.menu));
assert!(
places.iter().any(|(p, _)| p.iter().map(String::as_str).eq(spec.menu.iter().copied())),
"`{}` declares menu {:?}; the tree has it at {:?}",
spec.id,
spec.menu,
places.iter().map(|(p, _)| p.join(" ▸ ")).collect::<Vec<_>>()
);
for (p, e) in places {
if e.params.is_null() && p.iter().map(String::as_str).eq(spec.menu.iter().copied()) {
assert_eq!(e.label, spec.label, "label of `{}`", spec.id);
}
}
}
}
#[test]
fn dialog_labels_use_ascii_ellipsis() {
for (_, e) in platform_entries(true) {
assert!(!e.label.contains('…'), "`{}` uses a Unicode ellipsis; AE's menus use \"...\"", e.label);
}
}
#[test]
fn shortcuts_are_unambiguous() {
// shortcut → (command, params) from command defaults and menu entries.
let mut seen: BTreeMap<String, (String, String)> = BTreeMap::new();
let mut add = |sc: &str, id: &str, params: String| {
if sc == "Num*" {
return;
}
if let Some(prev) = seen.get(sc) {
assert_eq!(prev, &(id.to_string(), params.clone()), "shortcut {sc} is bound twice");
} else {
seen.insert(sc.to_string(), (id.to_string(), params));
}
};
for (_, e) in platform_entries(true) {
if let Some(sc) = &e.shortcut {
add(sc, &e.command, e.params.to_string());
}
}
for c in crate::command_specs() {
// Defaults of commands whose menu entries bind params are carried by the entries.
if let Some(sc) = c.shortcut
&& !platform_entries(true).iter().any(|(_, e)| e.command == c.id && !e.params.is_null())
{
add(sc, c.id, "null".into());
}
}
}
#[test]
fn menus_cover_after_effects_structure() {
let entries = platform_entries(true);
let count = |m: &str| entries.iter().filter(|(p, _)| p[0] == m).count();
// Floors (AE 2026 minus Adobe-service, history and user-specific entries).
assert!(count("File") >= 40, "File {}", count("File"));
assert!(count("Edit") >= 54, "Edit {}", count("Edit"));
assert!(count("Composition") >= 19, "Composition {}", count("Composition"));
assert!(count("Layer") >= 197, "Layer {}", count("Layer"));
assert!(count("Animation") >= 53, "Animation {}", count("Animation"));
assert!(count("View") >= 59, "View {}", count("View"));
assert!(count("Window") >= 43, "Window {}", count("Window"));
// 38 blending modes + next/previous.
let modes = entries.iter().filter(|(p, e)| p.last().map(String::as_str) == Some("Blending Mode") && e.command == "layer.setBlendMode").count();
assert_eq!(modes, 38);
// 16 label colours + None.
let labels = entries.iter().filter(|(_, e)| e.command == "edit.label").count();
assert_eq!(labels, 17);
}
#[test]
fn checked_reflects_layer_state() {
let mut s = crate::Session::default();
s.execute("comp.new", serde_json::json!({"width": 320, "height": 180})).unwrap();
s.execute("layer.newSolid", serde_json::json!({})).unwrap();
s.execute("layer.setBlendMode", serde_json::json!({"mode": "Screen"})).unwrap();
assert_eq!(checked(&s, "layer.setBlendMode", &serde_json::json!({"mode": "Screen"})), Some(true));
assert_eq!(checked(&s, "layer.setBlendMode", &serde_json::json!({"mode": "Normal"})), Some(false));
assert_eq!(checked(&s, "layer.quality", &serde_json::json!({"quality": "best"})), Some(true));
assert_eq!(checked(&s, "file.save", &serde_json::json!({})), None);
}
/// `cargo test -p effectcraft-engine dump_specs -- --ignored --nocapture`: every command as TSV.
#[test]
#[ignore]
fn dump_specs() {
for c in crate::command_specs() {
eprintln!("SPEC\t{}\t{}\t{}\t{}\t{}", c.id, c.label, c.menu.join(" > "), c.shortcut.unwrap_or(""), c.params);
}
}
#[test]
fn parse_rejects_bad_trees() {
assert!(parse("File\n Empty\n", true).is_err());
assert!(parse("File\n Odd | file.save\n", true).is_err());
assert!(parse("File\n Bad | file.save {nope}\n", true).is_err());
}
#[test]
fn m3_13_entries_follow_after_effects() {
let find = |mac: bool, path: &[&str], label: &str| platform_entries(mac).into_iter().find(|(p, e)| p == path && e.label == label).map(|(_, e)| e);
// Group / Ungroup Shapes sit in the Layer menu itself.
for mac in [true, false] {
assert_eq!(find(mac, &["Layer"], "Group Shapes").unwrap().shortcut.as_deref(), Some("Cmd+G"));
assert_eq!(find(mac, &["Layer"], "Ungroup Shapes").unwrap().shortcut.as_deref(), Some("Cmd+Shift+G"));
assert!(find(mac, &["Layer", "Mask and Shape Path"], "Group Shapes").is_none());
// Composition Flowchart Ctrl+Shift+F11, Window ▸ Flowchart Ctrl+F11; Window ▸ Learn.
assert_eq!(find(mac, &["Composition"], "Composition Flowchart").unwrap().shortcut.as_deref(), Some("Cmd+Shift+F11"));
assert_eq!(find(mac, &["Window"], "Flowchart").unwrap().shortcut.as_deref(), Some("Cmd+F11"));
assert_eq!(find(mac, &["Window"], "Learn").unwrap().command, "help.inAppTutorials");
assert_eq!(find(mac, &["Layer", "Transform"], "Center In View").unwrap().shortcut.as_deref(), Some("Cmd+Home"));
}
// "Reveal in Finder" names the platform's file browser.
assert!(find(true, &["File"], "Reveal in Finder").is_some());
let other = if cfg!(target_os = "windows") { "Reveal in Explorer" } else { "Reveal in File Manager" };
for path in [&["File"][..], &["Layer"], &["Layer", "Reveal"]] {
assert!(find(false, path, other).is_some(), "{path:?}");
assert!(find(false, path, "Reveal in Finder").is_none(), "{path:?}");
}
assert_eq!(platform_label("Reveal Layer Source in Project", false), "Reveal Layer Source in Project");
}