mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
- Integrate main (render queue, timeline/keys, time remap, masks/pen, 3D, MCP/CLI). - Menu tree now runs renderQueue.add, layer.enableTimeRemap, layer.timeReverse, layer.freezeFrame/OnLastFrame, prop.separateDimensions, mask.setClosed, layer.cameraSettings, view.3d.* / view.3d.last / view.reset3DView, main's layer.autoOrient and keyframe velocity/interpolation/time-stretch dialogs; matching stubs deleted (view.3d.default stays disabled). - Keyframe clipboard is main's (keys.copy/paste); Paste Reversed Keyframes reuses it. Animation-menu commands moved to commands/animation.rs. - Main's spec menu paths/labels aligned with AE (Camera Settings under Layer > Camera, "..." labels, Freeze On Last Frame). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
6.4 KiB
Rust
158 lines
6.4 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::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());
|
|
}
|