mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
173 lines
8.7 KiB
Rust
173 lines
8.7 KiB
Rust
//! #2207: Duplicate is reachable from a Project row's context menu, with the same targets,
|
|
//! naming, undo and shortcut preset as Edit > Duplicate.
|
|
|
|
use effectcraft_engine::Session;
|
|
use effectcraft_engine::project::ItemId;
|
|
use effectcraft_ui_egui::EffectcraftApp;
|
|
use effectcraft_ui_egui::dock::PanelKind;
|
|
use effectcraft_ui_egui::menus::{parse_shortcut, shortcut_text};
|
|
use egui::{Event, Key, Modifiers, PointerButton, Rect, Shape, pos2};
|
|
use egui_kittest::Harness;
|
|
use serde_json::json;
|
|
|
|
const DUPLICATE: &str = "project.context.duplicate";
|
|
|
|
fn harness() -> (Harness<'static, EffectcraftApp>, ItemId, ItemId, ItemId) {
|
|
let mut s = Session::default();
|
|
let mut comp =
|
|
|name: &str| ItemId(s.execute("comp.new", json!({"name": name, "width": 64, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap());
|
|
let main = comp("Main");
|
|
let other = comp("Other");
|
|
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.run_steps(3);
|
|
(h, main, other, 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(h: &mut Harness<'_, EffectcraftApp>, id: &str, button: PointerButton, modifiers: Modifiers) {
|
|
let p = rect(h, id).center();
|
|
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
|
h.input_mut().events.push(Event::PointerMoved(p));
|
|
h.step();
|
|
h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: true, modifiers });
|
|
h.step();
|
|
h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: false, modifiers });
|
|
h.run_steps(3);
|
|
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
|
h.step();
|
|
}
|
|
|
|
fn row(h: &mut Harness<'_, EffectcraftApp>, item: ItemId, button: PointerButton, modifiers: Modifiers) {
|
|
click(h, &format!("project.item.{}.name", item.0), button, modifiers);
|
|
}
|
|
|
|
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.step();
|
|
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
|
|
h.run_steps(2);
|
|
}
|
|
|
|
fn copy_name(h: &Harness<'_, EffectcraftApp>, id: ItemId) -> String {
|
|
h.state().session.project.item(id).unwrap().name.clone()
|
|
}
|
|
|
|
fn menu_text(h: &Harness<'_, EffectcraftApp>, text: &str) -> bool {
|
|
fn contains(shape: &Shape, bounds: Rect, text: &str) -> bool {
|
|
match shape {
|
|
Shape::Text(t) => bounds.contains(t.pos) && t.galley.text() == text,
|
|
Shape::Vec(shapes) => shapes.iter().any(|s| contains(s, bounds, text)),
|
|
_ => false,
|
|
}
|
|
}
|
|
let bounds = rect(h, DUPLICATE);
|
|
h.output().shapes.iter().any(|s| contains(&s.shape, bounds, text))
|
|
}
|
|
|
|
#[test]
|
|
fn unselected_row_duplicates_only_itself_and_undo_restores_the_project() {
|
|
let (mut h, main, other, _) = harness();
|
|
row(&mut h, other, PointerButton::Primary, Modifiers::NONE);
|
|
let before = h.state().session.project.clone();
|
|
let undo = h.state().session.history.undo.len();
|
|
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
|
|
assert_eq!(h.state().session.state.project_selection, [main]);
|
|
assert!(menu_text(&h, &shortcut_text("Cmd+D")), "the default platform shortcut is shown");
|
|
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
|
|
let selected = h.state().session.state.project_selection.clone();
|
|
let [copy] = selected.as_slice() else { panic!("one selected copy") };
|
|
assert!(!before.items.contains_key(copy), "the copy has a fresh ID");
|
|
assert_eq!(copy_name(&h, *copy), "Main 2");
|
|
assert_eq!(copy_name(&h, other), "Other");
|
|
assert_eq!(h.state().session.project.items.len(), before.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, before);
|
|
}
|
|
|
|
#[test]
|
|
fn selected_rows_duplicate_together_in_selection_order_with_numbered_names() {
|
|
let (mut h, main, other, _) = harness();
|
|
// Deliberately reverse alphabetical row order: command targets retain selection order.
|
|
row(&mut h, other, PointerButton::Primary, Modifiers::NONE);
|
|
row(&mut h, main, PointerButton::Primary, Modifiers::COMMAND);
|
|
assert_eq!(h.state().session.state.project_selection, [other, main]);
|
|
let before = h.state().session.project.clone();
|
|
let undo = h.state().session.history.undo.len();
|
|
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
|
|
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
|
|
let selected = h.state().session.state.project_selection.clone();
|
|
let [a, b] = selected.as_slice() else { panic!("two selected copies") };
|
|
assert_ne!(a, b);
|
|
assert!(selected.iter().all(|id| !before.items.contains_key(id)));
|
|
assert_eq!((copy_name(&h, *a), copy_name(&h, *b)), ("Other 2".to_string(), "Main 2".to_string()));
|
|
assert_eq!(h.state().session.project.items.len(), before.items.len() + 2);
|
|
assert_eq!(h.state().session.history.undo.len(), undo + 1);
|
|
// Duplicate the selected copies again: the existing engine numbering continues.
|
|
row(&mut h, *b, PointerButton::Secondary, Modifiers::NONE);
|
|
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
|
|
let names: Vec<_> = h.state().session.state.project_selection.iter().map(|id| copy_name(&h, *id)).collect();
|
|
assert_eq!(names, ["Other 3", "Main 3"]);
|
|
}
|
|
|
|
#[test]
|
|
fn folders_and_mixed_selections_disable_duplicate_without_partial_changes() {
|
|
for mixed in [false, true] {
|
|
let (mut h, main, _, folder) = harness();
|
|
row(&mut h, main, PointerButton::Primary, Modifiers::NONE);
|
|
if mixed {
|
|
row(&mut h, folder, PointerButton::Primary, Modifiers::COMMAND);
|
|
}
|
|
// An unselected folder replaces the selection; a selected non-folder keeps the mixed set.
|
|
row(&mut h, if mixed { main } else { folder }, PointerButton::Secondary, Modifiers::NONE);
|
|
let before = h.state().session.project.clone();
|
|
let selected = h.state().session.state.project_selection.clone();
|
|
assert!(selected.contains(&folder));
|
|
let undo = h.state().session.history.undo.len();
|
|
let status = h.state().ui.status.clone();
|
|
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
|
|
assert_eq!(h.state().session.project, before, "mixed={mixed}: no partial copy");
|
|
assert_eq!(h.state().session.state.project_selection, selected);
|
|
assert_eq!(h.state().session.history.undo.len(), undo);
|
|
assert_eq!(h.state().ui.status, status, "no failing engine command was dispatched");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_shortcut_still_dispatches_and_the_menu_tracks_rebinding_and_unbinding() {
|
|
let (mut h, main, _, _) = harness();
|
|
row(&mut h, main, PointerButton::Primary, Modifiers::NONE);
|
|
assert_eq!(h.state().ui.focused, PanelKind::Project);
|
|
let (d, command) = parse_shortcut("Cmd+D").unwrap();
|
|
key(&mut h, command, d);
|
|
let selected = h.state().session.state.project_selection.clone();
|
|
let [copy] = selected.as_slice() else { panic!("one shortcut copy") };
|
|
assert_ne!(*copy, main);
|
|
assert_eq!(copy_name(&h, *copy), "Main 2");
|
|
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
|
h.run_steps(2);
|
|
h.state_mut().session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": "F6"})).unwrap();
|
|
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
|
|
assert!(menu_text(&h, "F6"));
|
|
assert!(!menu_text(&h, &shortcut_text("Cmd+D")));
|
|
key(&mut h, Key::Escape, Modifiers::NONE);
|
|
row(&mut h, main, PointerButton::Primary, Modifiers::NONE);
|
|
let before = h.state().session.project.clone();
|
|
key(&mut h, command, d);
|
|
assert_eq!(h.state().session.project, before, "the former shortcut stays unbound");
|
|
key(&mut h, Key::F6, Modifiers::NONE);
|
|
assert_eq!(h.state().session.project.items.len(), before.items.len() + 1);
|
|
h.state_mut().session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": null})).unwrap();
|
|
row(&mut h, main, PointerButton::Secondary, Modifiers::NONE);
|
|
assert!(!menu_text(&h, "F6"));
|
|
assert!(!menu_text(&h, &shortcut_text("Cmd+D")));
|
|
click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE);
|
|
assert_eq!(h.state().session.project.items.len(), before.items.len() + 2, "unbinding keeps the menu action usable");
|
|
}
|