//! User-reported selection, Project deletion, and render-queue drop regressions. use effectcraft_engine::{Session, project::LayerId}; use effectcraft_ui_egui::{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 = 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 = 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 = 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); } #[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 = 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::>(), 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![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(); h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Auto; 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().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full; 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().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Auto; 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 = 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 = 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 = 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::>()})).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"); }