mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06:00
UI: expression value colour tests require every part, hover in every panel and a drag (#1306)
Each checked property now names the panels that must show it: the layer's Position, Rotation and Opacity in the Timeline and Properties panel, the Transform effect's in Effect Controls. Every component rectangle is required, and the painted text must include both numbers of a vector (and the Timeline's comma), the revolutions, sign and degrees of an angle, and the number of a scalar. These presence checks run in both themes and after every expression state change. Hover now covers all three panels in dark and light themes, and a new test scrubs Opacity in the Timeline and Effect Controls, confirming a drag is active before checking the colour. All four tests fail on main.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
//! #1306 (from #1072): a property driven by an expression shows its value in red, the expression
|
||||
//! colour, in the Timeline, Effect Controls and the Properties panel, as in After Effects: scalars,
|
||||
//! every part of a vector (with its separator) and both parts of an angle, hovered too, in dark and
|
||||
//! light themes. A disabled, removed or empty expression shows the ordinary colour again. The
|
||||
//! tests read the colour the value text is painted with.
|
||||
//! every part of a vector (with its separator) and both parts of an angle (with its sign), hovered
|
||||
//! and dragged too, in dark and light themes. A disabled, removed or empty expression shows the
|
||||
//! ordinary colour again. The tests read the colour the value text is painted with (the frame's
|
||||
//! text shapes: the numbers are painted with an override colour, the separator and sign as plain
|
||||
//! text), and require every expected part to be there.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::{LayerId, PropGroup};
|
||||
@@ -10,19 +12,39 @@ use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use effectcraft_ui_egui::theme::ThemeKind;
|
||||
use egui::epaint::Shape;
|
||||
use egui::{Color32, Event, Pos2, Rect, pos2, vec2};
|
||||
use egui::{Color32, Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
const PANELS: [&str; 3] = ["timeline.prop", "effectControls.prop", "properties.prop"];
|
||||
const TIMELINE: &str = "timeline.prop";
|
||||
const EFFECT_CONTROLS: &str = "effectControls.prop";
|
||||
const PROPERTIES: &str = "properties.prop";
|
||||
|
||||
/// A comp with one solid: (layer, transform Position / Rotation / Opacity / Scale uids, the
|
||||
/// Transform effect's Position / Rotation / Opacity uids).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
/// Two components (X, Y).
|
||||
Vector,
|
||||
/// Revolutions, sign and degrees.
|
||||
Angle,
|
||||
Scalar,
|
||||
}
|
||||
|
||||
/// A property whose value the tests check, and the panels that must show it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Checked {
|
||||
uid: u64,
|
||||
kind: Kind,
|
||||
panels: &'static [&'static str],
|
||||
}
|
||||
|
||||
/// A comp with one solid and the Transform effect on it.
|
||||
struct Ids {
|
||||
layer: u64,
|
||||
layer_props: [u64; 3],
|
||||
scale: u64,
|
||||
effect_props: [u64; 3],
|
||||
/// Transform Position, Rotation and Opacity (Timeline and Properties panel), and the Transform
|
||||
/// effect's Position, Rotation and Opacity (Effect Controls).
|
||||
driven: [Checked; 6],
|
||||
/// Transform Scale: no expression.
|
||||
scale: Checked,
|
||||
}
|
||||
|
||||
fn setup() -> (Harness<'static, EffectcraftApp>, Ids) {
|
||||
@@ -34,11 +56,20 @@ fn setup() -> (Harness<'static, EffectcraftApp>, Ids) {
|
||||
let uid = |path: &str| layer.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid;
|
||||
let g: &PropGroup = layer.props.find_group(fx).unwrap();
|
||||
let fuid = |m: &str| g.get(m).unwrap_or_else(|| panic!("no effect {m}")).uid;
|
||||
const LAYER: &[&str] = &[TIMELINE, PROPERTIES];
|
||||
const EFFECT: &[&str] = &[EFFECT_CONTROLS];
|
||||
let c = |uid, kind, panels| Checked { uid, kind, panels };
|
||||
let ids = Ids {
|
||||
layer: l,
|
||||
layer_props: [uid("transform/position"), uid("transform/rotation"), uid("transform/opacity")],
|
||||
scale: uid("transform/scale"),
|
||||
effect_props: [fuid("position"), fuid("rotation"), fuid("opacity")],
|
||||
driven: [
|
||||
c(uid("transform/position"), Kind::Vector, LAYER),
|
||||
c(uid("transform/rotation"), Kind::Angle, LAYER),
|
||||
c(uid("transform/opacity"), Kind::Scalar, LAYER),
|
||||
c(fuid("position"), Kind::Vector, EFFECT),
|
||||
c(fuid("rotation"), Kind::Angle, EFFECT),
|
||||
c(fuid("opacity"), Kind::Scalar, EFFECT),
|
||||
],
|
||||
scale: c(uid("transform/scale"), Kind::Vector, LAYER),
|
||||
};
|
||||
let transform = layer.props.sub("transform").unwrap().uid;
|
||||
s.state.selected_layers = vec![LayerId(l)];
|
||||
@@ -51,37 +82,42 @@ fn setup() -> (Harness<'static, EffectcraftApp>, Ids) {
|
||||
(h, ids)
|
||||
}
|
||||
|
||||
fn set_expression(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, prop: u64, p: serde_json::Value) {
|
||||
let mut p = p;
|
||||
fn set_expression(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, prop: u64, p: &serde_json::Value) {
|
||||
let mut p = p.clone();
|
||||
p["layer"] = json!(ids.layer);
|
||||
p["prop"] = json!(prop);
|
||||
h.state_mut().session.execute("prop.setExpression", p).unwrap();
|
||||
}
|
||||
|
||||
fn set_theme(h: &mut Harness<'_, EffectcraftApp>, theme: ThemeKind) {
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_theme(&ctx, theme);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// The value's area in `panel`: its value, revolutions and vector components together (so a
|
||||
/// vector's separator and an angle's sign are inside).
|
||||
fn value_rect(h: &Harness<'_, EffectcraftApp>, panel: &str, uid: u64) -> Option<Rect> {
|
||||
let ids = [
|
||||
format!("{panel}.{uid}.value"),
|
||||
format!("{panel}.{uid}.revolutions"),
|
||||
format!("{panel}.{uid}.value.0"),
|
||||
format!("{panel}.{uid}.value.1"),
|
||||
format!("{panel}.{uid}.value.2"),
|
||||
];
|
||||
ids.iter()
|
||||
.filter_map(|id| h.state().auto.find(id))
|
||||
.map(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])))
|
||||
.reduce(|a, b| a.union(b))
|
||||
fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}: the part isn't shown"));
|
||||
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
/// The colours of the text painted inside `area` in the last frame.
|
||||
fn text_colors(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec<Color32> {
|
||||
fn walk(s: &Shape, area: Rect, out: &mut Vec<Color32>) {
|
||||
/// The area of `c`'s value in `panel`, every expected part required: both components of a vector,
|
||||
/// the revolutions and degrees of an angle (the separator and sign lie between them).
|
||||
fn value_area(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked) -> Rect {
|
||||
let uid = c.uid;
|
||||
match c.kind {
|
||||
Kind::Vector => rect_of(h, &format!("{panel}.{uid}.value.0")).union(rect_of(h, &format!("{panel}.{uid}.value.1"))),
|
||||
Kind::Angle => rect_of(h, &format!("{panel}.{uid}.revolutions")).union(rect_of(h, &format!("{panel}.{uid}.value"))),
|
||||
Kind::Scalar => rect_of(h, &format!("{panel}.{uid}.value")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text painted inside `area` in the last frame, with its colour.
|
||||
fn texts(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec<(String, Color32)> {
|
||||
fn walk(s: &Shape, area: Rect, out: &mut Vec<(String, Color32)>) {
|
||||
match s {
|
||||
Shape::Vec(v) => v.iter().for_each(|s| walk(s, area, out)),
|
||||
Shape::Text(t) if area.expand(1.0).contains_rect(t.visual_bounding_rect()) && !t.galley.text().trim().is_empty() => {
|
||||
out.push(t.override_text_color.unwrap_or(t.fallback_color));
|
||||
out.push((t.galley.text().trim().to_string(), t.override_text_color.unwrap_or(t.fallback_color)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -93,71 +129,80 @@ fn text_colors(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec<Color32> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The colours `uid`'s value is painted with in every panel (each panel must show it).
|
||||
fn value_colors(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec<(&'static str, Vec<Color32>)> {
|
||||
PANELS
|
||||
.iter()
|
||||
.filter_map(|panel| {
|
||||
let area = value_rect(h, panel, uid)?;
|
||||
Some((*panel, text_colors(h, area)))
|
||||
})
|
||||
.collect()
|
||||
/// `c`'s value in `panel` shows every part, all in `want`: two numbers and (in the Timeline) the
|
||||
/// `,` of a vector, the revolutions, sign and degrees of an angle, the number of a scalar.
|
||||
fn assert_value(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked, want: Color32, what: &str) {
|
||||
let shown = texts(h, value_area(h, panel, c));
|
||||
let numbers = shown.iter().filter(|(s, _)| s.chars().any(|ch| ch.is_ascii_digit())).count();
|
||||
let has = |text: &str| shown.iter().any(|(s, _)| s == text);
|
||||
let ctx = format!("{what}: {:?} @{} in {panel}: {shown:?}", c.kind, c.uid);
|
||||
match c.kind {
|
||||
Kind::Vector => {
|
||||
assert!(numbers >= 2, "{ctx}: both components");
|
||||
if panel == TIMELINE {
|
||||
assert!(has(","), "{ctx}: the separator");
|
||||
}
|
||||
}
|
||||
Kind::Angle => {
|
||||
assert!(numbers >= 2, "{ctx}: revolutions and degrees");
|
||||
assert!(has("+") || has("-"), "{ctx}: the sign");
|
||||
}
|
||||
Kind::Scalar => assert!(numbers >= 1, "{ctx}: the number"),
|
||||
}
|
||||
assert!(shown.iter().all(|(_, col)| *col == want), "{ctx}: want {want:?}");
|
||||
}
|
||||
|
||||
fn assert_all(h: &Harness<'_, EffectcraftApp>, uids: &[u64], want: Color32, what: &str) {
|
||||
for &uid in uids {
|
||||
let shown = value_colors(h, uid);
|
||||
assert!(!shown.is_empty(), "{what}: @{uid} is shown in a panel");
|
||||
for (panel, colors) in shown {
|
||||
assert!(!colors.is_empty(), "{what}: @{uid} in {panel} paints its value");
|
||||
assert!(colors.iter().all(|c| *c == want), "{what}: @{uid} in {panel}: {colors:?}, want {want:?}");
|
||||
/// Every checked property in every panel it belongs to.
|
||||
fn assert_all(h: &Harness<'_, EffectcraftApp>, checked: &[Checked], want: Color32, what: &str) {
|
||||
for c in checked {
|
||||
for panel in c.panels {
|
||||
assert_value(h, panel, *c, want, what);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expression-driven values are red in every panel and both themes; other values stay blue.
|
||||
/// Expression-driven values are red in every panel, in both themes; Scale (none) stays blue.
|
||||
#[test]
|
||||
fn expression_driven_values_are_red_in_every_panel() {
|
||||
let (mut h, ids) = setup();
|
||||
for p in ids.layer_props.iter().chain(&ids.effect_props) {
|
||||
set_expression(&mut h, &ids, *p, json!({"expression": "value"}));
|
||||
}
|
||||
// Every value is shown somewhere: the layer's in the Timeline and Properties, the effect's in
|
||||
// Effect Controls.
|
||||
for p in ids.layer_props {
|
||||
assert!(value_rect(&h, "timeline.prop", p).is_some() && value_rect(&h, "properties.prop", p).is_some(), "@{p} in the Timeline and Properties");
|
||||
}
|
||||
for p in ids.effect_props {
|
||||
assert!(value_rect(&h, "effectControls.prop", p).is_some(), "@{p} in Effect Controls");
|
||||
for c in ids.driven {
|
||||
set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"}));
|
||||
}
|
||||
for theme in [ThemeKind::Dark, ThemeKind::Light] {
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_theme(&ctx, theme);
|
||||
h.run_steps(3);
|
||||
set_theme(&mut h, theme);
|
||||
let t = h.state().tokens;
|
||||
assert_ne!(t.expr_text, t.hot_text);
|
||||
let all: Vec<u64> = ids.layer_props.iter().chain(&ids.effect_props).copied().collect();
|
||||
assert_all(&h, &all, t.expr_text, &format!("{theme:?} expression"));
|
||||
assert_all(&h, &ids.driven, t.expr_text, &format!("{theme:?} expression"));
|
||||
assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} no expression"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Disabled, removed or empty: the ordinary colour; enabled again: red.
|
||||
/// Disabled, removed or whitespace-only: the ordinary colour; enabled again: red. Every part of
|
||||
/// every value is checked again after each change, in both themes.
|
||||
#[test]
|
||||
fn disabled_removed_and_empty_expressions_show_the_ordinary_colour() {
|
||||
fn expression_state_changes_switch_the_colour() {
|
||||
let (mut h, ids) = setup();
|
||||
let [position, rotation, opacity] = ids.layer_props;
|
||||
for p in ids.layer_props {
|
||||
set_expression(&mut h, &ids, p, json!({"expression": "value"}));
|
||||
let states = [
|
||||
(json!({"expression": "value"}), true, "enabled"),
|
||||
(json!({"enabled": false}), false, "disabled"),
|
||||
(json!({"enabled": true}), true, "enabled again"),
|
||||
(json!({"expression": ""}), false, "removed"),
|
||||
(json!({"expression": "value"}), true, "set again"),
|
||||
(json!({"expression": " "}), false, "whitespace only"),
|
||||
];
|
||||
for theme in [ThemeKind::Dark, ThemeKind::Light] {
|
||||
set_theme(&mut h, theme);
|
||||
let t = h.state().tokens;
|
||||
for (p, red, what) in &states {
|
||||
for c in ids.driven {
|
||||
set_expression(&mut h, &ids, c.uid, p);
|
||||
}
|
||||
h.run_steps(3);
|
||||
let want = if *red { t.expr_text } else { t.hot_text };
|
||||
assert_all(&h, &ids.driven, want, &format!("{theme:?} {what}"));
|
||||
assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} {what}: Scale"));
|
||||
}
|
||||
}
|
||||
let t = h.state().tokens;
|
||||
assert_all(&h, &ids.layer_props, t.expr_text, "enabled");
|
||||
set_expression(&mut h, &ids, position, json!({"enabled": false}));
|
||||
set_expression(&mut h, &ids, rotation, json!({"expression": ""}));
|
||||
set_expression(&mut h, &ids, opacity, json!({"expression": " "}));
|
||||
assert_all(&h, &ids.layer_props, t.hot_text, "disabled, removed, empty");
|
||||
set_expression(&mut h, &ids, position, json!({"enabled": true}));
|
||||
assert_all(&h, &[position], t.expr_text, "enabled again");
|
||||
}
|
||||
|
||||
fn hover(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) {
|
||||
@@ -165,23 +210,64 @@ fn hover(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) {
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Hovering a value (which turns an ordinary value's colour to the hover accent) keeps an
|
||||
/// expression-driven one red.
|
||||
/// Hovering a value (an ordinary one takes the hover accent) keeps an expression-driven one red,
|
||||
/// in every panel and both themes.
|
||||
#[test]
|
||||
fn a_hovered_expression_value_stays_red() {
|
||||
let (mut h, ids) = setup();
|
||||
let [_, _, opacity] = ids.layer_props;
|
||||
set_expression(&mut h, &ids, opacity, json!({"expression": "value"}));
|
||||
let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::<Vec<_>>();
|
||||
for c in &opacity {
|
||||
set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"}));
|
||||
}
|
||||
h.run_steps(3);
|
||||
for theme in [ThemeKind::Dark, ThemeKind::Light] {
|
||||
set_theme(&mut h, theme);
|
||||
let t = h.state().tokens;
|
||||
for c in &opacity {
|
||||
for panel in c.panels {
|
||||
let area = value_area(&h, panel, *c);
|
||||
hover(&mut h, area.center());
|
||||
let at = h.ctx.input(|i| i.pointer.hover_pos());
|
||||
assert!(at.is_some_and(|p| area.contains(p)), "{theme:?} {panel}: the pointer is over the value");
|
||||
assert_value(&h, panel, *c, t.expr_text, &format!("{theme:?} hovered"));
|
||||
}
|
||||
}
|
||||
// An ordinary value does take the hover accent.
|
||||
let area = value_area(&h, TIMELINE, ids.scale);
|
||||
let x = rect_of(&h, &format!("{TIMELINE}.{}.value.0", ids.scale.uid)).center();
|
||||
hover(&mut h, x);
|
||||
let shown = texts(&h, area);
|
||||
assert!(shown.iter().any(|(_, c)| *c == t.accent_hover), "{theme:?} an ordinary hovered value: {shown:?}");
|
||||
hover(&mut h, pos2(5.0, 5.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Dragging (scrubbing) an expression-driven value keeps it red while the drag is on, in the
|
||||
/// Timeline and Effect Controls.
|
||||
#[test]
|
||||
fn a_dragged_expression_value_stays_red() {
|
||||
let (mut h, ids) = setup();
|
||||
let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::<Vec<_>>();
|
||||
for c in &opacity {
|
||||
set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"}));
|
||||
}
|
||||
h.run_steps(3);
|
||||
let t = h.state().tokens;
|
||||
for panel in ["timeline.prop", "properties.prop"] {
|
||||
let area = value_rect(&h, panel, opacity).expect("the value");
|
||||
hover(&mut h, area.center());
|
||||
let colors = text_colors(&h, area);
|
||||
assert!(!colors.is_empty() && colors.iter().all(|c| *c == t.expr_text), "{panel} hovered: {colors:?}");
|
||||
for (c, panel) in [(opacity[0], TIMELINE), (opacity[1], EFFECT_CONTROLS)] {
|
||||
let at = value_area(&h, panel, c).center();
|
||||
h.input_mut().events.push(Event::PointerMoved(at));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=4 {
|
||||
h.input_mut().events.push(Event::PointerMoved(at + vec2(3.0 * k as f32, 0.0)));
|
||||
h.step();
|
||||
}
|
||||
assert!(h.ctx.dragged_id().is_some(), "{panel}: a drag is on");
|
||||
assert_value(&h, panel, c, t.expr_text, &format!("{panel} dragged"));
|
||||
let end = at + vec2(12.0, 0.0);
|
||||
h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
hover(&mut h, pos2(5.0, 5.0));
|
||||
}
|
||||
// An ordinary value does take the hover accent there.
|
||||
let area = value_rect(&h, "timeline.prop", ids.scale).expect("Scale");
|
||||
hover(&mut h, area.left_center() + vec2(6.0, 0.0));
|
||||
let colors = text_colors(&h, area);
|
||||
assert!(colors.contains(&t.accent_hover), "an ordinary hovered value: {colors:?}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user