Files
effectcraft/crates/ui-egui/tests/it/ui_workflow_selection.rs
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

717 lines
37 KiB
Rust

//! User-reported selection, Project deletion, and render-queue drop regressions.
use effectcraft_engine::{Session, project::LayerId};
use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind};
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable as _;
use serde_json::json;
fn harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name":"Study", "width":640,"height":360,"duration":4})).unwrap();
for n in 0..4 {
s.execute("layer.newSolid", json!({"name":format!("Tile {n}"),"width":80,"height":80,"color":"#406080"})).unwrap();
}
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn pointer(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, down: bool, m: Modifiers) {
h.input_mut().events.push(Event::ModifiersChanged(m));
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: m });
h.step();
}
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, m: Modifiers) {
pointer(h, p, true, m);
pointer(h, p, false, m);
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, a: Pos2, b: Pos2, m: Modifiers) {
pointer(h, a, true, m);
for n in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(a + (b - a) * (n as f32 / 8.0)));
h.step();
}
pointer(h, b, false, m);
h.run_steps(2);
}
#[test]
fn timeline_shift_click_selects_range_and_ctrl_toggles() {
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
let point = |h: &Harness<'_, EffectcraftApp>, id: LayerId| {
let r = rect(h, &format!("timeline.layer.{}.row", id.0));
pos2(r.min.x + 210.0, r.center().y)
};
let p = point(&h, ids[0]);
click(&mut h, p, Modifiers::NONE);
let p = point(&h, ids[2]);
click(&mut h, p, Modifiers::SHIFT);
assert_eq!(h.state().session.state.selected_layers, ids[..3]);
let p = point(&h, ids[1]);
click(&mut h, p, Modifiers::COMMAND);
assert_eq!(h.state().session.state.selected_layers, vec![ids[0], ids[2]]);
}
/// #284: Shift-clicking layer bars in the time graph selects the range from the last layer
/// clicked, as Shift-clicking their names does.
#[test]
fn timeline_shift_click_on_layer_bars_selects_the_range() {
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
let bar = |h: &Harness<'_, EffectcraftApp>, id: LayerId| rect(h, &format!("timeline.layer.{}.bar", id.0)).center();
let p = bar(&h, ids[3]);
click(&mut h, p, Modifiers::NONE);
assert_eq!(h.state().session.state.selected_layers, vec![ids[3]]);
let p = bar(&h, ids[1]);
click(&mut h, p, Modifiers::SHIFT);
assert_eq!(h.state().session.state.selected_layers, ids[1..]);
// From a name to a bar too.
let r = rect(&h, &format!("timeline.layer.{}.row", ids[0].0));
click(&mut h, pos2(r.min.x + 210.0, r.center().y), Modifiers::NONE);
let p = bar(&h, ids[2]);
click(&mut h, p, Modifiers::SHIFT);
assert_eq!(h.state().session.state.selected_layers, ids[..3]);
}
#[test]
fn timeline_empty_outline_marquee_selects_layers() {
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
let area = rect(&h, "timeline.layerMarquee");
let top = rect(&h, &format!("timeline.layer.{}.row", ids[0].0));
let last = rect(&h, &format!("timeline.layer.{}.row", ids[3].0));
drag(&mut h, pos2(area.min.x + 210.0, last.max.y + 25.0), pos2(area.min.x + 215.0, top.min.y + 2.0), Modifiers::NONE);
assert_eq!(h.state().session.state.selected_layers, ids);
}
/// A plain click on empty track area (beside a layer's bar or below the last layer) deselects
/// the layers, as in After Effects; Shift / Cmd keep the selection, a click on a bar selects
/// its layer and a key marquee that catches nothing leaves nothing selected.
#[test]
fn timeline_click_on_empty_track_area_deselects_layers() {
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
let select = |h: &mut Harness<'_, EffectcraftApp>, layers: &[LayerId]| {
let layers: Vec<u64> = layers.iter().map(|l| l.0).collect();
h.state_mut().session.execute("layer.select", json!({"layers": layers})).unwrap();
h.run_steps(2);
};
h.state_mut().session.execute("layer.timing", json!({"layers": [ids[1].0], "in": 1.5, "out": 2.5})).unwrap();
select(&mut h, &ids[..2]);
let bar = rect(&h, &format!("timeline.layer.{}.bar", ids[1].0));
let before = pos2(bar.min.x - 30.0, bar.center().y);
let after = pos2(bar.max.x + 30.0, bar.center().y);
click(&mut h, before, Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "a click beside the bar deselects");
click(&mut h, bar.center(), Modifiers::NONE);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "a click on the bar selects its layer");
click(&mut h, after, Modifiers::SHIFT);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "Shift-click on empty keeps the selection");
click(&mut h, after, Modifiers::COMMAND);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "Cmd-click on empty keeps the selection");
click(&mut h, after, Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty());
// Below the last layer, in the track area and in the outline.
let last = rect(&h, &format!("timeline.layer.{}.bar", ids[3].0));
select(&mut h, &ids);
click(&mut h, pos2(last.center().x, last.max.y + 30.0), Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "a click below the last layer deselects");
let outline = rect(&h, "timeline.layerMarquee");
select(&mut h, &ids);
click(&mut h, pos2(outline.min.x + 210.0, last.max.y + 30.0), Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "so does one below the layers in the outline");
// A key marquee over empty track area that catches nothing.
select(&mut h, &ids[1..2]);
drag(&mut h, after, pos2(after.x + 60.0, after.y + 4.0), Modifiers::NONE);
assert!(h.state().session.state.selected_layers.is_empty(), "an empty marquee leaves nothing selected");
select(&mut h, &ids[1..2]);
drag(&mut h, after, pos2(after.x + 60.0, after.y + 4.0), Modifiers::SHIFT);
assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "a Shift marquee keeps the selection");
}
#[test]
fn project_clear_deletes_items_and_undo_restores_them() {
let mut h = harness();
let id = h.state().session.active_comp_id().unwrap();
h.state_mut().session.state.project_selection = vec![id];
h.state_mut().ui.focused = PanelKind::Project;
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.clear", json!({})).unwrap();
assert!(h.state().session.project.comp(id).is_none());
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert!(h.state().session.project.comp(id).is_some());
}
#[test]
fn project_comp_drop_queues_the_dragged_comp() {
let mut h = harness();
let id = h.state().session.active_comp_id().unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"renderQueue"})).unwrap();
h.run_steps(3);
let a = rect(&h, &format!("project.item.{}.name", id.0)).center();
let b = rect(&h, "renderQueue.drop").center();
drag(&mut h, a, b, Modifiers::NONE);
assert_eq!(h.state().session.project.render_queue.len(), 1);
assert_eq!(h.state().session.project.render_queue[0].comp, id);
}
#[test]
fn viewer_marquee_crosses_a_layer_interior_and_shift_preserves_selection() {
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
for (n, id) in ids.iter().enumerate() {
h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"transform/position","value":[120.0+n as f64*120.0,180.0]})).unwrap();
}
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
let screen = |x: f32, y: f32| comp.min + vec2(x, y) * (comp.width() / 640.0);
drag(&mut h, screen(60.0, 170.0), screen(180.0, 190.0), Modifiers::NONE);
assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]);
drag(&mut h, screen(200.0, 100.0), screen(300.0, 230.0), Modifiers::SHIFT);
assert!(h.state().session.state.selected_layers.contains(&ids[0]));
assert!(h.state().session.state.selected_layers.contains(&ids[1]));
drag(&mut h, screen(200.0, 100.0), screen(300.0, 230.0), Modifiers::SHIFT);
assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]);
}
#[test]
fn render_queue_delete_preserves_selected_timeline_layers() {
let mut h = harness();
let id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap();
h.state_mut().session.execute("renderQueue.add", json!({})).unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"renderQueue"})).unwrap();
h.run_steps(3);
let row = rect(&h, "renderQueue.item.1.row");
click(&mut h, pos2(row.max.x - 15.0, row.center().y), Modifiers::NONE);
assert_eq!(h.state().ui.focused, PanelKind::RenderQueue);
h.input_mut().events.push(Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert!(h.state().session.project.render_queue.is_empty());
assert!(h.state().session.active_comp().unwrap().layer(id).is_some());
assert_eq!(h.state().session.state.selected_layers, vec![id]);
}
#[test]
fn project_shift_selects_visible_range_and_ctrl_removes_item() {
let mut h = harness();
let mut ids = vec![];
for name in ["A Range", "B Range", "C Range"] {
ids.push(h.state_mut().session.execute("comp.new", json!({"name":name,"width":64,"height":64,"duration":1})).unwrap()["comp"].as_u64().unwrap());
}
h.run_steps(3);
let p = rect(&h, &format!("project.item.{}.name", ids[0])).center();
click(&mut h, p, Modifiers::NONE);
let p = rect(&h, &format!("project.item.{}.name", ids[2])).center();
click(&mut h, p, Modifiers::SHIFT);
assert_eq!(h.state().session.state.project_selection.iter().map(|i| i.0).collect::<Vec<_>>(), ids);
let p = rect(&h, &format!("project.item.{}.name", ids[1])).center();
click(&mut h, p, Modifiers::COMMAND);
assert_eq!(h.state().session.state.project_selection.iter().map(|i| i.0).collect::<Vec<_>>(), vec![ids[0], ids[2]]);
}
#[test]
fn effect_scrubbing_uses_adaptive_resolution_and_settles_on_release() {
let mut h = harness();
let id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap();
h.state_mut().session.execute("effect.apply", json!({"effect":"Gaussian Blur"})).unwrap();
h.state_mut().session.execute("view.fastPreviewMode", json!({"mode":"adaptive"})).unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"effectControls"})).unwrap();
h.run_steps(3);
let prop = h.state().session.active_comp().unwrap().layer(id).unwrap().effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().uid;
let a = rect(&h, &format!("effectControls.prop.{prop}.value")).center();
pointer(&mut h, a, true, Modifiers::NONE);
h.input_mut().events.push(Event::PointerMoved(a + vec2(30.0, 0.0)));
h.run_steps(2);
assert!(h.state().ui.viewer.property_interacting);
assert!(h.state().viewer_scale(1.0, 1.0) < 1.0);
h.state_mut().session.execute("view.res.full", json!({})).unwrap();
assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0);
pointer(&mut h, a + vec2(30.0, 0.0), false, Modifiers::NONE);
h.run_steps(2);
assert!(!h.state().ui.viewer.property_interacting);
h.state_mut().session.execute("view.res.auto", json!({})).unwrap();
assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0);
}
#[test]
fn viewer_right_click_targets_hit_and_duplicate_preserves_group() {
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
for (n, id) in ids.iter().enumerate() {
h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"transform/position","value":[120.0+n as f64*120.0,180.0]})).unwrap();
}
h.state_mut().session.execute("layer.select", json!({"layers":[ids[2].0]})).unwrap();
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
let p = comp.min + vec2(120.0, 180.0) * (comp.width() / 640.0);
let right_click = |h: &mut Harness<'_, EffectcraftApp>| {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
};
right_click(&mut h);
assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]);
if let Ok(out) = std::env::var("CONTEXT_MENU_SNAPSHOT") {
let comp = h.state().session.active_comp_id().unwrap();
let key = effectcraft_ui_egui::frames::FrameKey { frame: 0, ..h.state().shown_series(comp) };
for _ in 0..600 {
h.step();
if h.state().frames.is_cached(&key) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(h.state().frames.is_cached(&key));
h.run_steps(3);
h.render().expect("offscreen context menu render").save(out).unwrap();
}
h.get_by_label("Duplicate").click();
h.run_steps(3);
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 5);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0,ids[1].0]})).unwrap();
h.run_steps(3);
right_click(&mut h);
assert_eq!(h.state().session.state.selected_layers.len(), 2);
h.get_by_label("Duplicate").click();
h.run_steps(3);
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 6);
}
#[test]
fn viewer_context_select_reaches_an_obscured_layer() {
let mut h = harness();
let target = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Tile 1").unwrap().id;
let p = rect(&h, "viewer.comp").center();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
h.get_by_label("Select").click();
h.run_steps(3);
h.get_by_label("Tile 1").click();
h.run_steps(3);
assert_eq!(h.state().session.state.selected_layers, vec![target]);
}
/// Original four-tile UI fixture; no Adobe assets or reference screenshots.
#[test]
fn modal_blocks_clicks_on_underlying_timeline_layers() {
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0]})).unwrap();
let row = rect(&h, &format!("timeline.layer.{}.row", ids[2].0));
h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::About);
h.run_steps(3);
click(&mut h, pos2(row.min.x + 210.0, row.center().y), Modifiers::NONE);
assert_eq!(h.state().session.state.selected_layers, vec![ids[0]]);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::About));
}
#[test]
fn layer_style_escape_rolls_back_live_preview() {
let mut h = harness();
let id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap();
h.run_steps(3);
h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":25})).unwrap();
h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert!(h.state().dialog.is_none());
let styles = h.state_mut().session.execute("layer.style.list", json!({})).unwrap();
assert!(styles["styles"].as_array().unwrap().is_empty());
}
#[test]
fn style_cancel_restores_checkpoint_with_bounded_history_and_reopened_scrub() {
let mut h = harness();
let id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap();
h.state_mut().session.execute("layer.style.dropShadow", json!({})).unwrap();
h.state_mut().session.prefs.general.undo_levels = 1;
let ctx = h.ctx.clone();
let open = |h: &mut Harness<'_, EffectcraftApp>| {
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.options", json!({"style":"dropShadow"})).unwrap();
};
open(&mut h);
let before = h.state().session.project.clone();
let history = h.state().session.history.undo.len();
h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":25,"merge":"same-style-control"})).unwrap();
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), false);
assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &before));
assert_eq!(h.state().session.history.undo.len(), history);
open(&mut h);
h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":50,"merge":"same-style-control"})).unwrap();
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true);
let accepted = h.state().session.project.clone();
open(&mut h);
h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"layerStyles/dropShadow/distance","value":75,"merge":"same-style-control"})).unwrap();
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), false);
assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &accepted));
}
#[test]
fn project_edit_commands_do_not_target_timeline_layers() {
let mut h = harness();
let comp = h.state().session.active_comp_id().unwrap();
let id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap();
h.state_mut().session.execute("project.select", json!({"items":[comp.0]})).unwrap();
h.state_mut().ui.focused = PanelKind::Project;
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.duplicate", json!({})).unwrap();
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 4);
assert_ne!(h.state().session.state.project_selection, vec![comp]);
assert!(effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.cut", json!({})).is_err());
assert!(h.state().session.active_comp().unwrap().layer(id).is_some());
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.deselectAll", json!({})).unwrap();
assert!(h.state().session.state.project_selection.is_empty());
assert_eq!(h.state().session.state.selected_layers, vec![id]);
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.selectAll", json!({})).unwrap();
assert!(h.state().session.state.project_selection.contains(&comp));
assert_eq!(h.state().session.state.selected_layers, vec![id]);
}
#[test]
fn compact_settings_keeps_confirmation_buttons_clickable() {
let mut h = Harness::builder().with_size(vec2(1024.0, 768.0)).build_eframe(|_| EffectcraftApp::new(Session::default()));
h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::Settings);
h.run_steps(4);
h.get_by_label(" OK ").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
}
#[test]
fn filtered_project_select_all_does_not_delete_hidden_assets() {
let mut h = harness();
let comp = h.state().session.active_comp_id().unwrap();
let before = h.state().session.project.items.len();
h.state_mut().ui.focused = PanelKind::Project;
h.state_mut().ui.project_search = "Study".into();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.selectAll", json!({})).unwrap();
assert_eq!(h.state().session.state.project_selection, vec![comp]);
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.clear", json!({})).unwrap();
assert_eq!(h.state().session.project.items.len(), before - 1);
}
/// Original four-tile UI fixture; no Adobe assets or reference screenshots.
#[test]
#[ignore]
fn workflow_snapshot() {
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
for (n, id) in ids.iter().enumerate() {
h.state_mut().session.execute("prop.set", json!({"layer":id.0,"path":"transform/position","value":[120.0+n as f64*120.0,180.0]})).unwrap();
}
h.state_mut().session.execute("layer.select", json!({"layers":ids[..3].iter().map(|i|i.0).collect::<Vec<_>>()})).unwrap();
h.run_steps(3);
let out = std::env::var("WORKFLOW_SNAPSHOT").expect("set WORKFLOW_SNAPSHOT to the output PNG");
let comp = h.state().session.active_comp_id().unwrap();
let key = effectcraft_ui_egui::frames::FrameKey { frame: 0, ..h.state().shown_series(comp) };
for _ in 0..600 {
h.step();
if h.state().frames.is_cached(&key) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(h.state().frames.is_cached(&key), "current composition rendered before capture");
h.run_steps(3);
h.render().expect("offscreen UI render").save(out).unwrap();
}
/// Filled rects painted this frame (flattening nested shape lists).
fn filled_rects(h: &Harness<'_, EffectcraftApp>) -> Vec<(Rect, egui::Color32)> {
fn walk(s: &egui::Shape, out: &mut Vec<(Rect, egui::Color32)>) {
match s {
egui::Shape::Rect(r) => out.push((r.rect, r.fill)),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = vec![];
for c in &h.output().shapes {
walk(&c.shape, &mut out);
}
out
}
/// Issues #91 / #97: clicking an effect's name in the Timeline selects it, and its row then
/// highlights like a selected property row (it used to stay unhighlighted).
#[test]
fn timeline_selected_effect_row_highlights() {
let mut h = harness();
let id = h.state().session.active_comp().unwrap().layers[0].id;
let fx = h.state_mut().session.execute("effect.apply", json!({"layers": [id.0], "effect": "Gaussian Blur"})).unwrap()["effects"][0].as_u64().unwrap();
let fx_group = h.state().session.active_comp().unwrap().layer(id).unwrap().props.sub("effects").unwrap().uid;
h.state_mut().ui.timeline.open_layers.insert(id.0);
h.state_mut().ui.timeline.open_groups.insert(fx_group);
// `effect.apply` selects the new effect; start from no property selection.
h.state_mut().session.state.selected_props.clear();
h.run_steps(3);
let row_selected = h.state().tokens.row_selected;
let name = rect(&h, &format!("timeline.group.{fx}.name"));
let highlighted = |h: &Harness<'_, EffectcraftApp>| filled_rects(h).iter().any(|(r, c)| *c == row_selected && r.contains(name.center()));
assert!(!highlighted(&h), "not highlighted before it is selected");
click(&mut h, name.center(), Modifiers::NONE);
assert!(h.state().session.state.selected_props.contains(&(id, fx)), "the click selects the effect");
assert!(highlighted(&h), "the selected effect's row is highlighted");
}
/// Right-clicking a layer in the Timeline keeps the selection when the layer is part of it,
/// so the context menu's commands act on every selected layer; right-clicking an unselected
/// layer selects only it (After Effects). Reported on Discord: Pre-compose and Delete from the
/// menu acted on the right-clicked layer only (#878).
#[test]
fn timeline_context_menu_acts_on_the_whole_selection() {
let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
};
let row = |h: &Harness<'_, EffectcraftApp>, id: LayerId| {
let r = rect(h, &format!("timeline.layer.{}.row", id.0));
pos2(r.min.x + 210.0, r.center().y)
};
let bar = |h: &Harness<'_, EffectcraftApp>, id: LayerId| rect(h, &format!("timeline.layer.{}.bar", id.0)).center();
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
// Outline row: Delete removes both selected layers.
h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap();
h.run_steps(3);
let p = row(&h, ids[1]);
right_click(&mut h, p);
assert_eq!(h.state().session.state.selected_layers, vec![ids[0], ids[1]]);
h.get_by_label("Delete").click();
h.run_steps(3);
let left: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
assert_eq!(left, vec![ids[2], ids[3]]);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
// Layer bar: same, with Duplicate.
h.state_mut().session.execute("layer.select", json!({"layers":[ids[2].0, ids[3].0]})).unwrap();
h.run_steps(3);
let p = bar(&h, ids[3]);
right_click(&mut h, p);
h.get_by_label("Duplicate").click();
h.run_steps(3);
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 6);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
// An unselected row: right-clicking selects only it.
h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap();
h.run_steps(3);
let p = row(&h, ids[3]);
right_click(&mut h, p);
assert_eq!(h.state().session.state.selected_layers, vec![ids[3]]);
}
/// The Project panel's Move to Project Root acts on every selected item when the right-clicked
/// item is part of the selection, as Delete and Label do.
#[test]
fn project_context_move_to_root_acts_on_the_whole_selection() {
let mut h = harness();
let solids: Vec<u64> = {
let p = &h.state().session.project;
p.items.values().filter(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).map(|i| i.id.0).collect()
};
assert!(solids.len() >= 2);
let folder = h.state_mut().session.execute("project.newFolder", json!({"name":"Bin"})).unwrap()["item"].as_u64().unwrap();
h.state_mut().session.execute("project.move", json!({"items": solids, "folder": folder})).unwrap();
h.state_mut().ui.project_open_folders.insert(folder);
h.state_mut().session.execute("project.select", json!({"items": [solids[0], solids[1]]})).unwrap();
h.run_steps(3);
let p = rect(&h, &format!("project.item.{}.name", solids[1])).center();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
h.get_by_label("Move to Project Root").click();
h.run_steps(3);
let project = &h.state().session.project;
for id in &solids[..2] {
assert_eq!(project.item(effectcraft_engine::project::ItemId(*id)).unwrap().parent, None);
}
}
/// Timeline right-click "Pre-compose…" groups every selected layer into one composition,
/// like Layer > Pre-compose does, from both the outline row and the layer bar
/// (#985, #1003).
#[test]
fn timeline_context_menu_precompose_groups_the_whole_selection() {
let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
};
let row = |h: &Harness<'_, EffectcraftApp>, id: LayerId| {
let r = rect(h, &format!("timeline.layer.{}.row", id.0));
pos2(r.min.x + 210.0, r.center().y)
};
let bar = |h: &Harness<'_, EffectcraftApp>, id: LayerId| rect(h, &format!("timeline.layer.{}.bar", id.0)).center();
for via_bar in [false, true] {
let mut h = harness();
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
let mut want: Vec<String> =
h.state().session.active_comp().unwrap().layers.iter().filter(|l| l.id == ids[0] || l.id == ids[1]).map(|l| l.name.clone()).collect();
want.sort();
h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap();
h.run_steps(3);
let p = if via_bar { bar(&h, ids[1]) } else { row(&h, ids[1]) };
right_click(&mut h, p);
h.get_by_label("Pre-compose…").click();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(Dialog::Precompose), "via_bar={via_bar}: Pre-compose opens its dialog");
let ok = rect(&h, "dialog.precompose.ok").center();
click(&mut h, ok, Modifiers::NONE);
h.run_steps(3);
// The dialog opens the new composition holding both nested layers.
let mut nested: Vec<String> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect();
nested.sort();
assert_eq!(nested, want, "via_bar={via_bar}: both selected layers are nested");
}
}
/// A text layer's timeline context menu offers Create Shapes from Text and Create Masks from
/// Text (After Effects), which convert the layer; a non-text layer does not show them (#872).
#[test]
fn timeline_context_menu_creates_shapes_and_masks_from_text() {
use effectcraft_engine::project::LayerSource;
let mut s = Session::default();
s.execute("comp.new", json!({"name":"Type","width":320,"height":180,"duration":4})).unwrap();
s.execute("layer.newText", json!({"text":"Abc"})).unwrap();
s.execute("layer.newSolid", json!({"name":"Plate","width":40,"height":40,"color":"#406080"})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
let text = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Text)).unwrap().id.0;
let plate = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Plate").unwrap().id.0;
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let row = |h: &Harness<'_, EffectcraftApp>, id: u64| {
let r = rect(h, &format!("timeline.layer.{id}.row"));
pos2(r.min.x + 210.0, r.center().y)
};
let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
};
// The text layer offers both conversions; Create Shapes from Text works.
h.state_mut().session.execute("layer.select", json!({"layers": [text]})).unwrap();
h.run_steps(3);
let at = row(&h, text);
right_click(&mut h, at);
h.get_by_label("Create Shapes from Text").click();
h.run_steps(3);
let shapes = h.state().session.active_comp().unwrap().layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
assert_eq!(shapes, 1, "a shape layer was created from the text");
// A non-text layer does not show the text conversions.
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.run_steps(3);
let at = row(&h, plate);
right_click(&mut h, at);
assert!(h.query_by_label("Create Shapes from Text").is_none(), "no text conversion for a solid");
assert!(h.query_by_label("Create Masks from Text").is_none(), "no text conversion for a solid");
}
// Regression test for #979 (#958): right-clicking a vector footage layer (SVG) in the Timeline
// or the Composition viewer offered no Create Shapes from Vector Layer; raster layers must not.
#[test]
fn layer_context_menus_offer_create_shapes_from_vector_layer_for_vector_footage() {
use effectcraft_engine::project::LayerSource;
let dir = std::env::temp_dir().join(format!("ec-ui-vector-menu-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let svg = dir.join("logo.svg");
std::fs::write(
&svg,
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20"><rect width="40" height="20" fill="#ff0000"/></svg>"##,
)
.unwrap();
let png = dir.join("photo.png");
image::RgbaImage::from_pixel(16, 16, image::Rgba([0, 128, 255, 255])).save(&png).unwrap();
let mut s = effectcraft_host::session();
let item = s.execute("file.import", json!({"paths": [svg.to_string_lossy()]})).unwrap()["items"][0].as_u64().unwrap();
let raster = s.execute("file.import", json!({"paths": [png.to_string_lossy()]})).unwrap()["items"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name":"Vec","width":320,"height":180,"duration":4})).unwrap();
// The raster layer sits below the SVG layer, so the viewer click below lands on the SVG.
let photo = s.execute("layer.addItem", json!({"item": raster})).unwrap()["layer"].as_u64().unwrap();
let logo = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let plate = s.execute("layer.newSolid", json!({"name":"Plate","width":40,"height":40,"color":"#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
};
let row = |h: &Harness<'_, EffectcraftApp>, id: u64| {
let r = rect(h, &format!("timeline.layer.{id}.row"));
pos2(r.min.x + 210.0, r.center().y)
};
let label = "Create Shapes from Vector Layer";
let shapes =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
// A raster-less solid does not offer the conversion.
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.run_steps(3);
let at = row(&h, plate);
right_click(&mut h, at);
assert!(h.query_by_label(label).is_none(), "no vector conversion for a solid");
h.key_press(egui::Key::Escape);
h.run_steps(3);
// Nor does raster footage.
h.state_mut().session.execute("layer.select", json!({"layers": [photo]})).unwrap();
h.run_steps(3);
let at = row(&h, photo);
right_click(&mut h, at);
assert!(h.query_by_label(label).is_none(), "no vector conversion for raster footage");
h.key_press(egui::Key::Escape);
h.run_steps(3);
// The Timeline row of the SVG layer offers it, and it converts the layer.
h.state_mut().session.execute("layer.select", json!({"layers": [logo]})).unwrap();
h.run_steps(3);
let at = row(&h, logo);
right_click(&mut h, at);
h.get_by_label(label).click();
h.run_steps(3);
assert_eq!(shapes(&h), 1, "a shape layer was created from the SVG layer");
// The Composition viewer's menu offers it for the SVG layer under the pointer too.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": plate, "path": "transform/position", "value": [40.0, 40.0]})).unwrap();
// The solid stays selected: the menu must follow the layer under the pointer, not the selection.
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
right_click(&mut h, comp.center());
assert!(h.query_by_label(label).is_some(), "the viewer's context menu offers the vector conversion");
let _ = std::fs::remove_dir_all(&dir);
}