Files
effectcraft/crates/ui-egui/tests/ui_project_delete.rs
T
bflatasticandClaude Opus 5.5 9624024dc1 M3.16: deleting Project items that compositions use asks first
As in After Effects, the Delete key and the Project panel's trash button ask before deleting
items that layers in other compositions use, saying how many layers in how many compositions go
with them (Delete / Cancel; Escape cancels). Unused items are deleted at once; agents running
project.delete directly are not asked. project.usage {items?} reports the count, sharing the
deletion's item and usage helpers. Follow-up to #72.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:35:57 +07:00

190 lines
9.7 KiB
Rust

//! Project-panel deletion uses its selection, even when a Timeline layer is still selected.
use effectcraft_engine::Session;
use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind, LayerId};
use effectcraft_ui_egui::dock::PanelKind;
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui::{Event, Key, Modifiers, pos2};
use egui_kittest::Harness;
use serde_json::json;
fn harness() -> (Harness<'static, EffectcraftApp>, Vec<ItemId>, LayerId) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Delete me", "width": 320, "height": 180, "duration": 4})).unwrap();
let comp = s.active_comp_id().unwrap();
s.execute("comp.new", json!({"name": "Working composition", "width": 320, "height": 180, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap();
let solid = s.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id;
let footage = std::sync::Arc::make_mut(&mut s.project).add_item(
"Data asset",
Default::default(),
None,
ItemKind::Footage(Footage { kind: FootageKind::Data, path: "original.json".into(), data: Some("{}".into()), ..Default::default() }),
);
let folder = ItemId(s.execute("project.newFolder", json!({"name": "Folder"})).unwrap()["item"].as_u64().unwrap());
let keep = LayerId(s.execute("layer.newNull", json!({"name": "Keep this layer"})).unwrap()["layer"].as_u64().unwrap());
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
let folders: Vec<u64> = h.state().session.project.items.values().filter(|i| i.is_folder()).map(|i| i.id.0).collect();
h.state_mut().ui.project_open_folders.extend(folders);
h.run_steps(3);
(h, vec![comp, solid, footage, folder], keep)
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, modifiers: Modifiers) {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
let p = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.run_steps(2);
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
h.run_steps(2);
}
/// Items that compositions use ask first, as in After Effects: answer Delete when asked.
/// Returns whether it asked.
fn confirm_if_asked(h: &mut Harness<'_, EffectcraftApp>) -> bool {
if h.state().dialog != Some(Dialog::DeleteItems) {
return false;
}
click(h, "dialog.deleteItems.delete", Modifiers::NONE);
true
}
#[test]
fn delete_and_backspace_remove_project_items_and_undo() {
for delete_key in [Key::Delete, Key::Backspace] {
for target in 0..4 {
let (mut h, items, keep) = harness();
let item = items[target];
click(&mut h, &format!("project.item.{}.name", item.0), Modifiers::NONE);
assert_eq!(h.state().ui.focused, PanelKind::Project);
assert_eq!(h.state().session.state.project_selection, vec![item]);
assert_eq!(h.state().session.state.selected_layers, vec![keep]);
let project = h.state().session.project.clone();
let undo = h.state().session.history.undo.len();
key(&mut h, delete_key, Modifiers::NONE);
// Only the solid is used by a layer: only it asks first.
assert_eq!(confirm_if_asked(&mut h), target == 1, "item {target}");
assert!(h.state().session.project.item(item).is_none(), "{delete_key:?} should delete project item {target}");
assert!(h.state().session.active_comp().unwrap().layer(keep).is_some(), "the unrelated Timeline layer stays");
assert!(h.state().session.state.project_selection.is_empty());
assert_eq!(h.state().session.project.items.len(), project.items.len() - 1);
assert_eq!(h.state().session.history.undo.len(), undo + 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.project, project, "one undo restores the item and its references");
}
}
}
#[test]
fn project_multi_selection_deletes_together_and_undoes_once() {
let (mut h, items, keep) = harness();
for (i, item) in items.iter().enumerate() {
click(&mut h, &format!("project.item.{}.name", item.0), if i == 0 { Modifiers::NONE } else { Modifiers::SHIFT });
}
assert_eq!(h.state().session.state.project_selection, items);
let project = h.state().session.project.clone();
let undo = h.state().session.history.undo.len();
key(&mut h, Key::Backspace, Modifiers::NONE);
assert!(confirm_if_asked(&mut h), "the solid is in use");
assert!(items.iter().all(|i| h.state().session.project.item(*i).is_none()));
assert!(h.state().session.active_comp().unwrap().layer(keep).is_some());
assert_eq!(h.state().session.history.undo.len(), undo + 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(h.state().session.project, project);
}
#[test]
fn empty_project_selection_does_not_delete_a_timeline_layer() {
let (mut h, items, keep) = harness();
click(&mut h, &format!("project.item.{}.name", items[0].0), Modifiers::NONE);
h.state_mut().session.execute("project.select", json!({"items": []})).unwrap();
let undo = h.state().session.history.undo.len();
for delete_key in [Key::Delete, Key::Backspace] {
key(&mut h, delete_key, Modifiers::NONE);
assert!(h.state().session.active_comp().unwrap().layer(keep).is_some());
assert_eq!(h.state().session.history.undo.len(), undo);
}
}
#[test]
fn viewer_focus_keeps_layer_deletion_with_a_project_selection() {
for delete_key in [Key::Delete, Key::Backspace] {
let (mut h, items, keep) = harness();
click(&mut h, &format!("project.item.{}.name", items[0].0), Modifiers::NONE);
click(&mut h, "viewer.comp", Modifiers::NONE);
h.state_mut().session.execute("layer.select", json!({"layers": [keep.0]})).unwrap();
assert_eq!(h.state().ui.focused, PanelKind::Composition);
key(&mut h, delete_key, Modifiers::NONE);
assert!(h.state().session.active_comp().unwrap().layer(keep).is_none());
assert!(h.state().session.project.item(items[0]).is_some());
}
}
#[test]
fn typing_dialogs_and_modified_keys_do_not_delete_project_items() {
let (mut h, items, _) = harness();
let item = items[0];
click(&mut h, &format!("project.item.{}.name", item.0), Modifiers::NONE);
let project = h.state().session.project.clone();
for delete_key in [Key::Delete, Key::Backspace] {
key(&mut h, delete_key, Modifiers::SHIFT);
assert_eq!(h.state().session.project, project);
}
h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::About);
key(&mut h, Key::Delete, Modifiers::NONE);
assert_eq!(h.state().session.project, project);
h.state_mut().dialog = None;
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
h.run_steps(2);
assert!(h.ctx.egui_wants_keyboard_input(), "the inline rename field has focus");
for delete_key in [Key::Backspace, Key::Delete] {
key(&mut h, delete_key, Modifiers::NONE);
assert_eq!(h.state().session.project, project, "typing must not delete the item");
}
}
/// Deleting an item a composition uses asks first, as in After Effects, and says what goes with
/// it; Cancel and Escape keep everything, Delete removes the item and the layers using it in one
/// undo step. Agents running the command directly are not asked.
#[test]
fn deleting_items_in_use_asks_first() {
let (mut h, items, _) = harness();
let solid = items[1];
let usage = h.state_mut().session.execute("project.usage", json!({"items": [solid.0]})).unwrap();
assert_eq!(usage, json!({"items": 1, "layers": 1, "comps": 1}));
let uses = |h: &Harness<'_, EffectcraftApp>| {
h.state().session.project.comps().flat_map(|(_, c)| c.layers.iter()).filter(|l| l.source.item() == Some(solid)).count()
};
click(&mut h, &format!("project.item.{}.name", solid.0), Modifiers::NONE);
for cancel in ["button", "escape"] {
key(&mut h, Key::Delete, Modifiers::NONE);
assert_eq!(h.state().dialog, Some(Dialog::DeleteItems));
if cancel == "button" {
click(&mut h, "dialog.deleteItems.cancel", Modifiers::NONE);
} else {
key(&mut h, Key::Escape, Modifiers::NONE);
}
assert_eq!(h.state().dialog, None, "{cancel}");
assert!(h.state().session.project.item(solid).is_some() && uses(&h) == 1, "{cancel} keeps the item and its layer");
}
let undo = h.state().session.history.undo.len();
key(&mut h, Key::Delete, Modifiers::NONE);
assert!(confirm_if_asked(&mut h));
assert!(h.state().session.project.item(solid).is_none() && uses(&h) == 0);
assert_eq!(h.state().session.history.undo.len(), undo + 1);
// The command itself (agents, scripts) deletes without asking.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.state_mut().session.execute("project.delete", json!({"items": [solid.0]})).unwrap();
assert!(h.state().session.project.item(solid).is_none());
}