Files
effectcraft/crates/expr/src/tests/property_group.rs
T
Gert Pellin d347a0efca Expressions: propertyGroup() and a group's propertyIndex (#1619)
The After Effects stagger idiom
  id = thisProperty.propertyGroup(2).propertyIndex;
failed with "not a callable function": the expression object model had
no propertyGroup() and property groups had no propertyIndex.

- Property.propertyGroup(countUp = 1) and PropertyGroup.propertyGroup()
  return the group countUp levels up; from a top-level group they reach
  the layer. A count below 1, not a number, or above the layer is an
  error naming propertyGroup, never a crash.
- PropertyGroup.propertyIndex: the group's 1-based index among its
  siblings (Child requests now carry it).
- One new host request (Ancestor) walks up from the node's full uid
  path, so it also works for the property being evaluated.
- Tests for the stagger expression, effect parameters, reaching the
  layer, other layers' properties and groups and out-of-range counts;
  the fuzzer seeds and tokens include the new members.
2026-10-16 05:31:49 +02:00

125 lines
6.2 KiB
Rust
Raw 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.
//! `propertyGroup(countUp)` on properties and property groups, and a group's `propertyIndex`
//! (#1619): name-independent references up the property tree, as in After Effects.
use super::*;
const SCALE_1: &str = "contents/#1/transform/scale";
const SCALE_2: &str = "contents/#2/transform/scale";
/// The test comp with a second shape group "Rectangle 2" on layer "Shape", and "Rectangle 1"'s
/// Transform ▸ Scale keyed 0 → 100 over 0–1 s.
fn staggered() -> Fx {
let mut f = fx();
let g = group_push(&mut f.p, |ids| {
let rect = build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0);
build::shape_group(ids, "Rectangle 2", vec![rect])
});
let shape = f.layer_mut(f.shape);
shape.props.sub_mut("contents").unwrap().children.push(g.into());
shape.props.prop_mut(SCALE_1).unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec2([0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec2([100.0, 100.0]))];
f
}
impl Fx {
fn shape_num(&self, path: &str, text: &str) -> f64 {
match self.ev(self.shape, path, &format!("var r = ({text}); [r, r]"), 0.0) {
Ok(v) => v.as_vec2()[0],
Err(e) => panic!("{text}: {e}"),
}
}
fn shape_err(&self, path: &str, text: &str) -> String {
match self.ev(self.shape, path, text, 0.0) {
Ok(v) => panic!("{text}: expected an error, got {v:?}"),
Err(e) => e,
}
}
}
#[test]
fn shape_group_stagger_from_the_adobe_community_answer() {
let f = staggered();
// thisProperty = Scale, propertyGroup(1) = Transform, propertyGroup(2) = the shape group.
let text = "offset = .1;\nid = thisProperty.propertyGroup(2).propertyIndex;\ncontent(\"Rectangle 1\").transform.scale.valueAtTime(time-id*offset)";
let v = f.ev(f.shape, SCALE_2, text, 0.5).unwrap().as_vec2();
// "Rectangle 2" is the second group in Contents: Rectangle 1's scale 0.2 s earlier.
assert_close!(v[0], 30.0);
assert_close!(v[1], 30.0);
}
#[test]
fn property_group_walks_up_from_the_evaluated_property() {
let f = staggered();
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).name == 'Transform' ? 1 : 0"), 1.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup().name == 'Transform' ? 1 : 0"), 1.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).name == 'Rectangle 2' ? 1 : 0"), 1.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(3).name == 'Contents' ? 1 : 0"), 1.0);
assert_close!(f.shape_num(SCALE_1, "thisProperty.propertyGroup(2).propertyIndex"), 1.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).propertyIndex"), 2.0);
// A shape group holds Blend Mode, Contents and Transform, in After Effects' order.
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyIndex"), 3.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).numProperties"), 3.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(3).numProperties"), 2.0);
// Groups walk on: Transform → group → Contents.
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyGroup(2).name == 'Contents' ? 1 : 0"), 1.0);
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyGroup(1).propertyIndex"), 2.0);
}
#[test]
fn property_group_reaches_the_layer() {
let f = fx();
assert_close!(f.num("thisProperty.propertyGroup(1).name == 'Transform' ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0);
assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0);
assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0);
let s = staggered();
assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0);
}
#[test]
fn property_group_of_an_effect_parameter_is_the_effect() {
let f = fx();
let path = "effects/#1/slider";
let ev = |text: &str| f.ev(f.a, path, text, 0.0).unwrap().as_f64();
assert_close!(ev("thisProperty.propertyGroup(1).name == 'Slider Control' ? 1 : 0"), 1.0);
assert_close!(ev("thisProperty.propertyGroup(2).name == 'Effects' ? 1 : 0"), 1.0);
// The name-independent rig: read the effect this parameter belongs to by its own name.
assert_close!(ev("effect(thisProperty.propertyGroup(1).name)('Slider') + 1"), 43.0);
assert_close!(ev("thisProperty.propertyGroup(1).propertyIndex"), 1.0);
}
#[test]
fn property_group_on_other_layers_properties_and_groups() {
let f = staggered();
// From layer B: another layer's property and group, reached by name.
let ev = |text: &str| f.ev(f.b, ROT, text, 0.0).unwrap().as_f64();
assert_close!(ev("thisComp.layer('A').effect(1)('Slider').propertyGroup(1).name == 'Slider Control' ? 1 : 0"), 1.0);
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 2').propertyIndex"), 2.0);
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 2').transform.scale.propertyGroup(2).propertyIndex"), 2.0);
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 1').content('Fill 1').propertyGroup(2).name == 'Rectangle 1' ? 1 : 0"), 1.0);
assert_close!(ev("thisComp.layer('Shape').content('Rectangle 1').propertyGroup(2).name == 'Shape' ? 1 : 0"), 1.0);
assert_close!(ev("thisComp.layer('A').effect('Slider Control').propertyIndex"), 1.0);
assert_close!(ev("thisComp.layer('A').mask('Mask 1').propertyGroup(1).numProperties"), 1.0);
}
#[test]
fn property_group_out_of_range_is_an_error_not_a_crash() {
let f = staggered();
for text in [
"thisProperty.propertyGroup(0)",
"thisProperty.propertyGroup(-1)",
"thisProperty.propertyGroup(5)",
"thisProperty.propertyGroup(1e300)",
"thisProperty.propertyGroup(NaN)",
"thisProperty.propertyGroup('x')",
"thisProperty.propertyGroup(1).propertyGroup(4)",
] {
let e = f.shape_err(SCALE_2, text);
assert!(e.contains("propertyGroup"), "{text}: {e}");
}
let e = f.err("thisProperty.propertyGroup(3)");
assert!(e.contains("propertyGroup"), "{e}");
// A fractional count rounds down, as an index does.
assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2.7).propertyIndex"), 2.0);
}