Files
effectcraft/crates/ui-egui/tests/it/ui_project_new_folder.rs
T
skylartaylor 9ee853715d M0.37: migrate EffectCraft to egui 0.37
Reconcile main 991904888a and preserve the reviewed lifecycle, IME, rendering and lockfile changes.

Formatting, strict lint, layers, assets and WASM passed. Full CI retained 3,961 passes, 14 baseline-matching GPU failures and 31 ignored executions. The local review commit has explicit approval to retain these documented failures; native/browser acceptance and paired app performance remain open.
2026-10-25 02:01:16 +01:00

160 lines
7.4 KiB
Rust

//! #1658: a folder made from the interface (the Project panel's New Folder button and context
//! menu, File ▸ New ▸ New Folder and its shortcut) opens with its name ready to type, as in After
//! Effects: Enter keeps the typed name, Escape the default one. `engine.execute` (agents, MCP,
//! scripts) keeps the headless default and only creates the folder.
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::control::{self, ControlRequest};
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, pos2};
use egui_kittest::Harness;
use serde_json::json;
/// A comp and an open folder holding a solid, nothing selected.
fn harness(comps: usize) -> (Harness<'static, EffectcraftApp>, ItemId) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
for i in 0..comps {
s.execute("comp.new", json!({"name": format!("Comp {i:03}"), "width": 64, "height": 64, "duration": 1})).unwrap();
}
let folder = ItemId(s.execute("project.newFolder", json!({"name": "Bin"})).unwrap()["item"].as_u64().unwrap());
s.execute("project.select", json!({"items": []})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.state_mut().ui.project_open_folders.insert(folder.0);
h.run_steps(3);
(h, folder)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
}
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: PointerButton) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) {
assert!(h.ctx.egui_wants_keyboard_input(), "the name field has the keyboard");
h.input_mut().events.push(Event::Text(text.into()));
h.step();
}
/// The folders not in `before`.
fn new_folders(h: &Harness<'_, EffectcraftApp>, before: &[ItemId]) -> Vec<ItemId> {
h.state().session.project.items.values().filter(|i| i.is_folder() && !before.contains(&i.id)).map(|i| i.id).collect()
}
fn folders(h: &Harness<'_, EffectcraftApp>) -> Vec<ItemId> {
new_folders(h, &[])
}
fn name(h: &Harness<'_, EffectcraftApp>, id: ItemId) -> Option<String> {
h.state().session.project.item(id).map(|i| i.name.clone())
}
fn renaming(h: &Harness<'_, EffectcraftApp>, id: ItemId) -> bool {
let edit = format!("project.item.{}.nameEdit", id.0);
h.state().auto.elements.iter().any(|e| e.id == edit)
}
/// The panel's button, with a folder selected: the new folder opens its name, Enter keeps the
/// typed one, and undo takes back the rename, then the folder (two steps, like any rename).
#[test]
fn the_new_folder_button_opens_the_name_and_enter_keeps_it() {
let (mut h, bin) = harness(0);
let p = rect(&h, &format!("project.item.{}.name", bin.0)).center();
click_at(&mut h, p, PointerButton::Primary);
assert_eq!(h.state().session.state.project_selection, [bin]);
let before = folders(&h);
let p = rect(&h, "project.newFolder").center();
click_at(&mut h, p, PointerButton::Primary);
let [folder] = new_folders(&h, &before)[..] else { panic!("one new folder") };
assert_eq!(h.state().session.state.project_selection, [folder], "the new folder is selected");
assert!(renaming(&h, folder), "its name is open for editing");
type_text(&mut h, "Shots");
key(&mut h, Key::Enter);
assert_eq!(name(&h, folder).as_deref(), Some("Shots"), "Enter keeps the typed name");
assert!(!renaming(&h, folder), "Enter closes the field");
assert!(!h.ctx.egui_wants_keyboard_input(), "and gives the keyboard back");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(name(&h, folder).as_deref(), Some("Untitled Folder"), "the first undo takes back the rename");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(name(&h, folder), None, "the second, the folder");
}
/// Escape keeps the default name: the folder stays, with one undo step.
#[test]
fn escape_keeps_the_default_name() {
let (mut h, _) = harness(0);
let before = folders(&h);
let p = rect(&h, "project.newFolder").center();
click_at(&mut h, p, PointerButton::Primary);
let [folder] = new_folders(&h, &before)[..] else { panic!("one new folder") };
type_text(&mut h, "Never mind");
key(&mut h, Key::Escape);
assert_eq!(name(&h, folder).as_deref(), Some("Untitled Folder"));
assert!(!renaming(&h, folder));
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(name(&h, folder), None, "one undo removes the folder");
}
/// The context menu of the list's empty area.
#[test]
fn the_context_menu_opens_the_name() {
let (mut h, _) = harness(0);
let list = rect(&h, "project.empty");
let before = folders(&h);
click_at(&mut h, pos2(list.center().x, list.max.y - 4.0), PointerButton::Secondary);
let p = rect(&h, "project.context.newFolder").center();
click_at(&mut h, p, PointerButton::Primary);
let [folder] = new_folders(&h, &before)[..] else { panic!("one new folder") };
assert!(renaming(&h, folder), "the context menu's New Folder opens the name");
}
/// File ▸ New ▸ New Folder (the menu dispatcher, which its shortcut goes through too): a folder
/// sorted below the visible rows scrolls into view with its name open.
#[test]
fn the_file_menu_opens_the_name_in_view() {
let (mut h, _) = harness(80);
let list = rect(&h, "project.empty");
let before = folders(&h);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "project.newFolder", json!({})).unwrap();
h.run_steps(3);
let [folder] = new_folders(&h, &before)[..] else { panic!("one new folder") };
assert!(renaming(&h, folder), "File ▸ New ▸ New Folder opens the name");
let field = rect(&h, &format!("project.item.{}.nameEdit", folder.0));
assert!(list.contains_rect(field), "the field is in view: {field:?} in {list:?}");
type_text(&mut h, "Renders");
key(&mut h, Key::Enter);
assert_eq!(name(&h, folder).as_deref(), Some("Renders"));
}
/// Agents and scripts: `engine.execute` only creates the folder.
#[test]
fn engine_execute_only_creates_the_folder() {
let (mut h, _) = harness(0);
let before = folders(&h);
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new("engine.execute", json!({"command": "project.newFolder"}));
let now = h.state().now();
let _ = control::handle(h.state_mut(), &ctx, &req, now);
h.run_steps(3);
let [folder] = new_folders(&h, &before)[..] else { panic!("one new folder") };
assert!(!renaming(&h, folder), "no name field for an agent's folder");
assert!(!h.ctx.egui_wants_keyboard_input());
}