Files
effectcraft/crates/ui-egui/tests/ui_tail.rs

245 lines
13 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Headless checks for the M13.5 long-tail UI: horizontal scrolling of the Timeline outline and
//! Project panel columns (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn harness(w: f32) -> Harness<'static, EffectcraftApp> {
harness_at(vec2(w, 900.0))
}
fn harness_at(size: egui::Vec2) -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Tail", "width": 640, "height": 360, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap();
let mut h = Harness::builder().with_size(size).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!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
#[test]
fn timeline_outline_columns_scroll_horizontally() {
let mut h = harness(1100.0);
// Every column on: wider than the outline pane.
let ctx = h.ctx.clone();
for c in ["keys", "comment", "modes", "parent", "in", "out", "duration", "stretch"] {
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.column", json!({"column": c, "visible": true})).unwrap();
}
h.run_steps(3);
let bar = rect(&h, "timeline.outlineScroll");
let name0 = rect(&h, "timeline.header.name").min.x;
assert_eq!(h.state().ui.timeline.outline_scroll, 0.0);
drag(&mut h, pos2(bar.min.x + 8.0, bar.center().y), pos2(bar.max.x + 50.0, bar.center().y));
h.run_steps(2);
let s = h.state().ui.timeline.outline_scroll;
assert!(s > 50.0, "{s}");
let name1 = rect(&h, "timeline.header.name").min.x;
assert!((name0 - name1 - s).abs() < 1.0, "the columns moved by the scroll: {name0} → {name1} ({s})");
// The scroll is clamped to the overflow and resets when the columns fit again.
for c in ["keys", "comment", "modes", "parent", "in", "out", "duration", "stretch"] {
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.column", json!({"column": c, "visible": false})).unwrap();
}
h.run_steps(3);
assert_eq!(h.state().ui.timeline.outline_scroll, 0.0);
assert!(h.state().auto.find("timeline.outlineScroll").is_none());
}
fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(at));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(2);
}
fn styles(h: &mut Harness<'_, EffectcraftApp>) -> Vec<String> {
let v = h.state_mut().session.execute("layer.style.list", json!({})).unwrap();
v["styles"].as_array().unwrap().iter().map(|s| s["style"].as_str().unwrap().to_string()).collect()
}
#[test]
fn layer_style_dialog_adds_previews_and_cancels() {
let mut h = harness(1400.0);
let lid = h.state().session.active_comp().unwrap().layers[0].id.0;
h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap();
let ctx = h.ctx.clone();
// Layer ▸ Layer Styles ▸ Drop Shadow (from the menu): adds the style and opens the dialog on it.
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles));
assert_eq!(styles(&mut h), ["dropShadow"]);
assert!(h.state().auto.find("dialog.layerStyle.prop.dropShadow/distance").is_some(), "the style's controls are shown");
// Live preview: the controls edit the real property (here through the same command).
h.state_mut().session.execute("prop.set", json!({"layer": lid, "path": "layerStyles/dropShadow/distance", "value": 25, "merge": "x"})).unwrap();
// Pick Stroke in the list (its page: not applied), then Cancel: everything done in the
// dialog is rolled back. (Dialog buttons are driven through the dialog's functions:
// kittest pointer events don't reach Foreground areas here.)
effectcraft_ui_egui::panels::layer_styles_dialog::select(h.state_mut(), "stroke");
h.run_steps(2);
assert_eq!(h.state().auto.find("dialog.layerStyle.page").unwrap().label, "stroke");
assert!(h.state().auto.find("dialog.layerStyle.prop.dropShadow/distance").is_none());
assert!(h.state().auto.find("dialog.layerStyle.cancel").is_some());
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), false);
h.run_steps(2);
assert!(h.state().dialog.is_none());
assert!(styles(&mut h).is_empty(), "Cancel removes the style added from the menu");
// Layer Style Options… on Stroke, add it (the list check box runs layer.style.add), OK keeps it.
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.options", json!({"style": "stroke"})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find("dialog.layerStyle.prop.stroke/size").is_none(), "not applied yet");
h.state_mut().session.execute("layer.style.add", json!({"style": "stroke"})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find("dialog.layerStyle.prop.stroke/size").is_some(), "its controls appear");
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true);
assert!(h.state().dialog.is_none());
assert_eq!(styles(&mut h), ["stroke"]);
}
/// The Layer Style dialog on the solid, opened from Layer ▸ Layer Styles ▸ Drop Shadow (which
/// adds the style) at `scale` % UI Scale. Returns the layer.
fn open_drop_shadow(h: &mut Harness<'_, EffectcraftApp>, scale: u32) -> u64 {
let lid = h.state().session.active_comp().unwrap().layers[0].id.0;
h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap();
h.state_mut().set_pref("appearance.uiScale", json!(scale)).unwrap();
h.run_steps(3);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap();
// (Its layout settles a few frames after it opens.)
h.run_steps(5);
lid
}
/// `id` whole: inside the window, and painted with none of it clipped away.
fn in_view(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let r = rect(h, id);
// (A shape it paints: the topmost lying inside it, not the panels behind the dialog.)
let clip = h.output().shapes.iter().rev().find(|c| matches!(&c.shape, egui::Shape::Rect(s) if r.expand(1.0).contains_rect(s.rect))).map(|c| c.clip_rect);
assert!(h.ctx.content_rect().contains_rect(r) && clip.is_some_and(|c| c.contains_rect(r)), "{id} is cut off: {r:?} in {clip:?}");
r.center()
}
fn style_value(h: &mut Harness<'_, EffectcraftApp>, lid: u64, path: &str) -> f64 {
h.state_mut().session.execute("prop.get", json!({"layer": lid, "path": format!("layerStyles/dropShadow/{path}")})).unwrap()["value"].as_f64().unwrap()
}
/// #1062: the Layer Style dialog's OK and Cancel stay in view below the list and the style's
/// controls, at 100 % and 200 % UI Scale (they were laid out past its bottom edge, under the fold
/// of its scroll area, so a click on them did nothing until the dialog was scrolled). OK keeps an
/// edit; Cancel puts the project and its undo history back as they were before the dialog.
#[test]
fn layer_style_buttons_stay_in_view() {
for scale in [100, 200] {
for ok in [true, false] {
let mut h = harness(1400.0);
let before = (h.state().session.project.clone(), h.state().session.history.undo.len());
let lid = open_drop_shadow(&mut h, scale);
let buttons = [in_view(&h, "dialog.layerStyle.ok"), in_view(&h, "dialog.layerStyle.cancel")];
// An edit, as the dialog's controls make them.
h.state_mut()
.session
.execute("prop.set", json!({"layer": lid, "path": "layerStyles/dropShadow/distance", "value": 40, "merge": "layer-style-dialog"}))
.unwrap();
h.run_steps(2);
click(&mut h, buttons[if ok { 0 } else { 1 }]);
let case = format!("{scale} %, {}", if ok { "OK" } else { "Cancel" });
assert_eq!(h.state().dialog, None, "{case} closed the dialog");
if ok {
assert_eq!(style_value(&mut h, lid, "distance"), 40.0, "{case} kept the edit");
} else {
let after = (&h.state().session.project, h.state().session.history.undo.len());
assert!(after.0.same_content(&before.0) && after.1 == before.1, "{case} put back the project and its history");
}
}
}
}
/// #1062: with the buttons kept at the bottom, the style's controls still scroll both ways, so
/// every one of them can be reached in the smallest window (960×600) at 200 % UI Scale, and in a
/// dialog opened in a large window that then narrows: here Noise, near the end of Drop Shadow,
/// is scrolled to, seen whole and dragged.
#[test]
fn layer_style_controls_stay_reachable_in_a_small_window() {
for narrowed in [false, true] {
let mut h = harness_at(if narrowed { vec2(1600.0, 1000.0) } else { vec2(960.0, 600.0) });
let lid = open_drop_shadow(&mut h, if narrowed { 100 } else { 200 });
if narrowed {
h.set_size(vec2(960.0, 600.0));
h.state_mut().set_pref("appearance.uiScale", json!(200)).unwrap();
h.run_steps(5);
}
// Scroll the controls (under their heading) to their far end, both ways.
let over = rect(&h, "dialog.layerStyle.page").left_bottom() + vec2(10.0, 16.0);
h.input_mut().events.push(Event::PointerMoved(over));
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(-2000.0, -2000.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
});
h.run_steps(5);
let at = in_view(&h, "dialog.layerStyle.value.dropShadow/noise");
let slider = rect(&h, "dialog.layerStyle.value.dropShadow/noise").left_center();
drag(&mut h, slider + vec2(8.0, 0.0), slider + vec2(60.0, 0.0));
assert!(style_value(&mut h, lid, "noise") > 0.0, "narrowed {narrowed}: Noise was dragged (at {at:?})");
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles));
}
}
#[test]
fn layer_style_dialog_moves_by_its_title() {
let mut h = harness(1400.0);
let lid = h.state().session.active_comp().unwrap().layers[0].id.0;
h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.dropShadow", json!({})).unwrap();
h.run_steps(3);
let list0 = rect(&h, "dialog.layerStyle.list.blendingOptions");
// The title row sits just above the style list.
let grab = pos2(list0.center().x, list0.min.y - 20.0);
drag(&mut h, grab, grab + vec2(-150.0, 60.0));
h.run_steps(2);
let list1 = rect(&h, "dialog.layerStyle.list.blendingOptions");
assert!((list1.min - list0.min - vec2(-150.0, 60.0)).length() < 2.0, "the dialog followed the drag: {:?} → {:?}", list0.min, list1.min);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles), "dragging does not close it");
}
#[test]
fn project_panel_columns_scroll_horizontally() {
let mut h = harness(1400.0);
for c in ["type", "size", "duration", "fps", "path", "comment"] {
effectcraft_ui_egui::panels::project::set_column(&mut h.state_mut().ui.project_columns, c, true);
}
h.run_steps(3);
let bar = rect(&h, "project.hscroll");
let x0 = rect(&h, "project.sort.comment").min.x;
let name0 = rect(&h, "project.sort.name").min.x;
drag(&mut h, pos2(bar.min.x + 4.0, bar.center().y), pos2(bar.max.x + 40.0, bar.center().y));
h.run_steps(2);
let s = h.state().ui.project_hscroll;
assert!(s > 20.0, "{s}");
assert!(rect(&h, "project.sort.comment").min.x < x0 - 20.0);
assert_eq!(rect(&h, "project.sort.name").min.x, name0, "Name stays frozen");
}