use super::channels::{Channel, ProgressChannel};
use super::*;
use crate::animations::{AnimatedBindings, Driver, Easing, Lerp};
use crate::protocol::{animatable::AnimatableField, units::Time as WireTime};
use std::time::Duration;
pub(super) fn timing(duration: f32, easing: Easing) -> ChannelTransition {
ChannelTransition {
duration: Some(WireTime::from_secs(duration)),
easing,
delay: WireTime::from_secs(0.0),
stiffness: None,
damping: None,
mass: 1.0,
}
}
fn parse<T: serde::de::DeserializeOwned>(json: serde_json::Value) -> T {
serde_json::from_value(json).expect("valid json")
}
#[test]
fn progress_channel_ease_settles_exactly_and_snaps_without_spec() {
let mut ch = ProgressChannel::<Length>::default();
ch.init(Length::Px(10.0));
let spec = timing(0.2, Easing::EaseInOut);
let v = ch.drive(Length::Px(20.0), Some(&spec), 0.07);
assert!(
matches!(v, Length::Px(x) if x > 10.0 && x < 20.0),
"mid-ease reading: {v:?}"
);
let mid = ch.drive(Length::Px(20.0), Some(&spec), 0.07);
assert!(
matches!(mid, Length::Px(x) if x > 10.0 && x < 20.0),
"still easing: {mid:?}"
);
let v = ch.drive(Length::Px(0.0), Some(&spec), 0.0);
assert_eq!(v, mid, "retarget frame holds the current reading");
let v = ch.drive(Length::Px(0.0), Some(&spec), 10.0);
assert_eq!(v, Length::Px(0.0), "completion lands bit-exact on target");
assert_eq!(
ch.drive(Length::Px(0.0), Some(&spec), 0.016),
Length::Px(0.0)
);
assert_eq!(ch.drive(Length::Px(5.0), None, 0.0), Length::Px(5.0));
let v = ch.drive(Length::Percent(50.0), Some(&spec), 0.01);
assert_eq!(v, Length::Percent(50.0), "cross-unit lerp snaps");
}
#[test]
fn shared_element_channel_is_explicit_only() {
let t: Transition = parse(serde_json::json!({
"sharedElement": { "duration": 400, "easing": "easeOut" },
}));
let spec = t.shared_element.as_ref().expect("sharedElement spec");
assert_eq!(spec.duration.map(WireTime::seconds), Some(0.4));
assert_eq!(spec.easing, Easing::EaseOut);
assert!(t.layout.is_none(), "no fallback into other channels");
let none: Transition = parse(serde_json::json!({ "layout": { "duration": 100 } }));
assert!(none.shared_element.is_none());
}
#[test]
fn channel_resolution_is_explicit_only() {
let t: Transition = parse(serde_json::json!({
"transform": { "duration": 100 },
"backgroundColor": { "duration": 100 },
"opacity": { "duration": 200 },
}));
let secs = |c: &ChannelTransition| c.duration.map(WireTime::seconds);
assert!(t.opacity.is_some());
assert_eq!(secs(t.opacity.as_ref().unwrap()), Some(0.2));
assert_eq!(secs(t.transform.as_ref().unwrap()), Some(0.1));
assert_eq!(secs(t.background_color.as_ref().unwrap()), Some(0.1));
let t: Transition = parse(serde_json::json!({ "opacity": { "duration": 50 } }));
assert!(t.transform.is_none());
assert!(t.opacity.is_some());
assert!(t.border_radius.is_none());
let t: Transition = parse(serde_json::json!({ "borderRadius": { "duration": 50 } }));
assert!(t.border_radius.is_some());
assert!(
t.size.is_none(),
"borderRadius is its own channel, not size"
);
}
#[test]
fn filter_channel_resolves_explicit_only() {
let secs = |c: &ChannelTransition| c.duration.map(WireTime::seconds);
let t: Transition = parse(serde_json::json!({
"opacity": { "duration": 100 },
"filter": { "duration": 400 },
}));
assert_eq!(secs(t.filter.as_ref().unwrap()), Some(0.4));
let t: Transition = parse(serde_json::json!({ "filter": { "duration": 100 } }));
assert_eq!(secs(t.filter.as_ref().unwrap()), Some(0.1));
let t: Transition = parse(serde_json::json!({ "opacity": { "duration": 50 } }));
assert!(t.filter.is_none());
}
#[test]
fn layout_channel_resolves_explicit_only() {
let secs = |c: &ChannelTransition| c.duration.map(WireTime::seconds);
let t: Transition = parse(serde_json::json!({
"layout": { "duration": 250, "easing": "easeOut" },
}));
assert_eq!(secs(t.layout.as_ref().unwrap()), Some(0.25));
assert_eq!(t.layout.as_ref().unwrap().easing, Easing::EaseOut);
let t: Transition = parse(serde_json::json!({ "size": { "duration": 50 } }));
assert!(t.layout.is_none(), "size is not layout");
}
#[test]
fn to_driver_selects_spring_or_timing() {
let spring = ChannelTransition {
duration: None,
easing: Easing::Linear,
delay: WireTime::from_secs(0.0),
stiffness: Some(120.0),
damping: Some(14.0),
mass: 1.0,
};
assert!(matches!(spring.to_driver(1.0), Driver::Spring { .. }));
assert!(matches!(
timing(0.3, Easing::Linear).to_driver(1.0),
Driver::Timing { .. }
));
let delayed = ChannelTransition {
delay: WireTime::from_secs(0.2),
..timing(0.3, Easing::Linear)
};
assert!(matches!(delayed.to_driver(1.0), Driver::Delay { .. }));
}
#[test]
fn channel_snaps_without_spec_and_eases_with_one() {
let mut ch = Channel::default();
ch.init(1.0);
assert_eq!(ch.drive(0.5, None, 0.016), 0.5);
let mut ch = Channel::default();
ch.init(1.0);
let spec = timing(1.0, Easing::Linear);
ch.drive(0.0, Some(&spec), 0.0); let v = ch.drive(0.0, Some(&spec), 0.5); assert!((v - 0.5).abs() < 1e-3, "halfway expected ~0.5, got {v}");
let v = ch.drive(0.0, Some(&spec), 0.5);
assert!((v - 0.0).abs() < 1e-3, "end expected 0, got {v}");
assert!(ch.runner.is_none(), "runner dropped once finished");
}
#[test]
fn color_channel_lerps_to_target() {
let mut c = ProgressChannel::<[f32; 4]>::default();
c.init([0.0, 0.0, 0.0, 1.0]);
let spec = timing(1.0, Easing::Linear);
c.drive([1.0, 0.5, 0.0, 1.0], Some(&spec), 0.0); let mid = c.drive([1.0, 0.5, 0.0, 1.0], Some(&spec), 0.5);
assert!((mid[0] - 0.5).abs() < 1e-3);
assert!((mid[1] - 0.25).abs() < 1e-3);
assert!((mid[2] - 0.0).abs() < 1e-3);
}
fn drive_world() -> (World, Schedule) {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
world.insert_resource(Time::<()>::default());
let mut schedule = Schedule::default();
schedule.add_systems(drive_transitions);
(world, schedule)
}
fn advance(world: &mut World, secs: f32) {
world
.resource_mut::<Time>()
.advance_by(Duration::from_secs_f32(secs));
}
#[test]
fn system_eases_scale_on_press_then_release() {
let (mut world, mut schedule) = drive_world();
let spec = Transition {
transform: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let e = world
.spawn((
TransitionInput {
spec: spec.clone(),
scale: Some(1.0),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
))
.id();
schedule.run(&mut world);
assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 1.0);
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.scale = Some(0.95);
advance(&mut world, 0.5);
schedule.run(&mut world);
let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
assert!(
(sx - 0.975).abs() < 1e-2,
"mid-press expected ~0.975, got {sx}"
);
advance(&mut world, 0.5);
schedule.run(&mut world);
let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
assert!((sx - 0.95).abs() < 1e-3, "pressed expected 0.95, got {sx}");
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.scale = Some(1.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
assert!(
(sx - 0.975).abs() < 1e-2,
"mid-release expected ~0.975, got {sx}"
);
}
#[test]
fn system_eases_transform3d() {
use crate::layer::transform3d::LayerTransform3d;
use crate::protocol::transform::Transform3d;
let (mut world, mut schedule) = drive_world();
let spec = Transition {
transform3d: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let base = Transform3d::default();
let e = world
.spawn((
TransitionInput {
spec: spec.clone(),
transform3d: Some(base.clone()),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
LayerTransform3d(base.clone()),
))
.id();
schedule.run(&mut world);
let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
assert!(t.is_identity());
let target = Transform3d {
rotate_y: Some(crate::protocol::animatable::Animatable::Static(
crate::protocol::units::Angle::from_radians(std::f32::consts::FRAC_PI_2),
)),
perspective: Some(crate::protocol::animatable::Animatable::Static(800.0)),
..Default::default()
};
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.transform3d = Some(target.clone());
advance(&mut world, 0.5);
schedule.run(&mut world);
let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
let ry = t.rotate_y.static_val().unwrap().radians();
assert!(
(ry - std::f32::consts::FRAC_PI_4).abs() < 0.05,
"mid-ease expected ~45°, got {}°",
ry.to_degrees()
);
assert_eq!(
t.perspective.static_val(),
Some(800.0),
"ortho→perspective snaps"
);
advance(&mut world, 0.6);
schedule.run(&mut world);
let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
assert!(
(t.rotate_y.static_val().unwrap().radians() - std::f32::consts::FRAC_PI_2).abs() < 1e-3
);
let e2 = world
.spawn((
TransitionInput {
spec: Transition {
opacity: Some(timing(1.0, Easing::Linear)),
..Default::default()
},
transform3d: Some(target.clone()),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
LayerTransform3d(base),
))
.id();
schedule.run(&mut world);
let t2 = world
.entity(e2)
.get::<LayerTransform3d>()
.unwrap()
.0
.clone();
assert!(t2.is_identity());
let e3 = world
.spawn((
TransitionInput {
spec,
transform3d: Some(target.clone()),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
))
.id();
advance(&mut world, 0.1);
schedule.run(&mut world);
assert!(world.entity(e3).get::<LayerTransform3d>().is_none());
}
#[test]
fn system_eases_percent_translate() {
let (mut world, mut schedule) = drive_world();
let spec = Transition {
transform: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let e = world
.spawn((
TransitionInput {
spec,
translate_x: Some(Length::Percent(0.0)),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
))
.id();
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<UiTransform>().unwrap().translation.x,
Val::Percent(0.0)
);
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.translate_x = Some(Length::Percent(100.0));
advance(&mut world, 0.5);
schedule.run(&mut world);
let tx = world.entity(e).get::<UiTransform>().unwrap().translation.x;
assert!(
matches!(tx, Val::Percent(v) if (v - 50.0).abs() < 1.0),
"mid expected ~50%, got {tx:?}"
);
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<UiTransform>().unwrap().translation.x,
Val::Percent(100.0)
);
}
#[test]
fn animated_style_channel_wins_over_transition() {
let (mut world, mut schedule) = drive_world();
let spec = Transition {
transform: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let bindings = AnimatedBindings(
[(
crate::animations::AnimatableProperty::Scale,
crate::animations::protocol::Binding::Shared { id: 1 },
)]
.into(),
);
let e = world
.spawn((
TransitionInput {
spec,
scale: Some(1.0),
..Default::default()
},
TransitionState::default(),
UiTransform::from_scale(Vec2::splat(2.0)), AnimatedNode(bindings),
))
.id();
schedule.run(&mut world);
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.scale = Some(0.95);
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 2.0);
}
#[test]
fn filter_param_binding_gates_filter_transition() {
use crate::animations::ValueKind;
use std::sync::Arc;
let (mut world, mut schedule) = drive_world();
let spec = Transition {
filter: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let pass = |amount: f32| crate::filters::ResolvedFilterPass {
shader: Handle::default(),
params: vec![Vec4::new(amount, 0.0, 0.0, 0.0)],
layout: Arc::from(vec![crate::filters::ParamSlot {
name: "amount",
kind: ValueKind::Scalar,
vec: 0,
comp: 0,
len: 1,
}]),
wire_index: 0,
};
let wire = |amount: f32| -> crate::filters::FilterChain {
serde_json::from_value(serde_json::json!(
{ "name": "grayscale", "params": { "amount": amount } }
))
.unwrap()
};
let chain = |amount: f32| crate::filters::ResolvedFilterChain {
passes: vec![pass(amount)],
outset_px: 0,
always_dirty: false,
bucketable: true,
version: 1,
scale: 1.0,
};
let bindings = AnimatedBindings(
[(
crate::animations::AnimatableProperty::FilterParam {
index: 0,
name: "amount".into(),
},
crate::animations::protocol::Binding::Shared { id: 1 },
)]
.into(),
);
let spawn = |world: &mut World, gated: bool| {
let mut e = world.spawn((
TransitionInput {
spec: spec.clone(),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
crate::filters::FilterInput(wire(0.0)),
chain(0.0),
));
if gated {
e.insert(AnimatedNode(bindings.clone()));
}
e.id()
};
let gated = spawn(&mut world, true);
let control = spawn(&mut world, false);
schedule.run(&mut world);
for e in [gated, control] {
*world
.entity_mut(e)
.get_mut::<crate::filters::FilterInput>()
.unwrap() = crate::filters::FilterInput(wire(1.0));
let mut em = world.entity_mut(e);
let mut c = em.get_mut::<crate::filters::ResolvedFilterChain>().unwrap();
c.passes = vec![pass(1.0)];
c.version = 2;
}
advance(&mut world, 0.1);
schedule.run(&mut world);
let c = world
.entity(control)
.get::<crate::filters::ResolvedFilterChain>()
.unwrap();
let w = c.passes[0].params[0].x;
assert!(
w > 0.0 && w < 1.0,
"control: transition eased over the snap, got {w}"
);
assert_eq!(c.version, 3, "control: transition bumped the version");
let c = world
.entity(gated)
.get::<crate::filters::ResolvedFilterChain>()
.unwrap();
assert_eq!(
c.passes[0].params[0].x, 1.0,
"gated: the transition must not touch the chain"
);
assert_eq!(c.version, 2, "gated: version stays the resolver's");
}
#[test]
fn settled_transition_does_not_dirty_components() {
#[derive(Resource, Default)]
struct Dirty(usize);
let (mut world, mut schedule) = drive_world();
world.init_resource::<Dirty>();
let spec = Transition {
transform: Some(timing(0.2, Easing::Linear)),
background_color: Some(timing(0.2, Easing::Linear)),
opacity: Some(timing(0.2, Easing::Linear)),
..Default::default()
};
let e = world
.spawn((
TransitionInput {
spec,
scale: Some(1.0),
opacity: Some(0.5),
background_color: Some([1.0, 0.0, 0.0, 1.0]),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
BackgroundColor(Color::WHITE),
))
.id();
type AnyTargetChanged = Or<(Changed<UiTransform>, Changed<BackgroundColor>)>;
let mut detect = Schedule::default();
detect.add_systems(|q: Query<(), AnyTargetChanged>, mut dirty: ResMut<Dirty>| {
dirty.0 = q.iter().count();
});
schedule.run(&mut world);
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.scale = Some(0.9);
advance(&mut world, 0.5);
schedule.run(&mut world);
detect.run(&mut world);
advance(&mut world, 0.5);
schedule.run(&mut world);
detect.run(&mut world);
assert_eq!(
world.resource::<Dirty>().0,
0,
"a settled transition must not dirty anything"
);
}
#[test]
fn lerp_length_same_unit_else_snaps() {
assert_eq!(Length::Px(0.0).lerp(Length::Px(10.0), 0.5), Length::Px(5.0));
assert_eq!(
Length::Percent(0.0).lerp(Length::Percent(100.0), 0.25),
Length::Percent(25.0)
);
assert_eq!(Length::Auto.lerp(Length::Px(10.0), 0.5), Length::Px(10.0));
assert_eq!(
Length::Px(0.0).lerp(Length::Percent(10.0), 0.5),
Length::Percent(10.0)
);
}
fn px(l: Length) -> f32 {
match l {
Length::Px(v) => v,
other => panic!("expected Px, got {other:?}"),
}
}
#[test]
fn length_channel_eases_then_idles() {
let mut ch = ProgressChannel::<Length>::default();
ch.init(Length::Px(0.0));
let spec = timing(1.0, Easing::Linear);
assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.0)) - 0.0).abs() < 1e-3);
assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 50.0).abs() < 1e-3);
assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 100.0).abs() < 1e-3);
assert!(ch.runner.is_none(), "runner dropped once settled");
assert_eq!(
ch.drive(Length::Px(100.0), Some(&spec), 0.5),
Length::Px(100.0)
);
}
#[test]
fn system_eases_max_height_layout() {
let (mut world, mut schedule) = drive_world();
let spec = Transition {
size: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let e = world
.spawn((
TransitionInput {
spec,
max_height: Some(Length::Px(120.0)),
..Default::default()
},
TransitionState::default(),
Node::default(),
UiTransform::default(),
))
.id();
schedule.run(&mut world);
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.max_height = Some(Length::Px(0.0));
advance(&mut world, 0.5);
schedule.run(&mut world);
let mh = world.entity(e).get::<Node>().unwrap().max_height;
assert!(
matches!(mh, Val::Px(v) if (v - 60.0).abs() < 1.0),
"mid expected ~60px, got {mh:?}"
);
advance(&mut world, 0.5);
schedule.run(&mut world);
let mh = world.entity(e).get::<Node>().unwrap().max_height;
assert!(
matches!(mh, Val::Px(v) if v.abs() < 1e-3),
"settled expected 0px, got {mh:?}"
);
}
#[test]
fn system_eases_scroll_toward_target() {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
world.insert_resource(Time::<()>::default());
let mut schedule = Schedule::default();
schedule.add_systems(drive_scroll_transition);
let e = world
.spawn((
ScrollTransitionInput(timing(1.0, Easing::Linear)),
ScrollTransitionState::default(),
ScrollPosition::default(),
))
.id();
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<ScrollPosition>().unwrap().0,
Vec2::ZERO
);
world
.entity_mut(e)
.get_mut::<ScrollTransitionState>()
.unwrap()
.target = Vec2::new(0.0, 100.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
let y = world.entity(e).get::<ScrollPosition>().unwrap().0.y;
assert!((y - 50.0).abs() < 1.0, "mid-ease expected ~50, got {y}");
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 100.0)
);
}
#[test]
fn scroll_direct_write_snaps_the_ease() {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
world.insert_resource(Time::<()>::default());
let mut schedule = Schedule::default();
schedule.add_systems(drive_scroll_transition);
let e = world
.spawn((
ScrollTransitionInput(timing(1.0, Easing::Linear)),
ScrollTransitionState::default(),
ScrollPosition::default(),
))
.id();
schedule.run(&mut world);
world
.entity_mut(e)
.get_mut::<ScrollTransitionState>()
.unwrap()
.target = Vec2::new(0.0, 100.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
let y = world.entity(e).get::<ScrollPosition>().unwrap().0.y;
assert!(y > 0.0 && y < 100.0, "mid-ease expected, got {y}");
world.entity_mut(e).get_mut::<ScrollPosition>().unwrap().0 = Vec2::new(0.0, 42.0);
advance(&mut world, 0.25);
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 42.0)
);
advance(&mut world, 0.25);
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 42.0)
);
}
#[test]
fn scroll_snap_to_parks_a_mid_flight_ease() {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
world.insert_resource(Time::<()>::default());
let mut schedule = Schedule::default();
schedule.add_systems(drive_scroll_transition);
let e = world
.spawn((
ScrollTransitionInput(timing(1.0, Easing::Linear)),
ScrollTransitionState::default(),
ScrollPosition::default(),
))
.id();
schedule.run(&mut world);
world
.entity_mut(e)
.get_mut::<ScrollTransitionState>()
.unwrap()
.target = Vec2::new(0.0, 100.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
let live = world.entity(e).get::<ScrollPosition>().unwrap().0;
assert!(
live.y > 0.0 && live.y < 100.0,
"mid-ease expected, got {live:?}"
);
world
.entity_mut(e)
.get_mut::<ScrollTransitionState>()
.unwrap()
.snap_to(live);
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(world.entity(e).get::<ScrollPosition>().unwrap().0, live);
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(world.entity(e).get::<ScrollPosition>().unwrap().0, live);
}
#[test]
fn lerp_rect_eases_per_corner_and_snaps_mixed_units() {
use crate::protocol::units::Rect as WireRect;
use Length::*;
let a = WireRect {
top: Px(0.0),
right: Px(10.0),
bottom: Percent(0.0),
left: Px(4.0),
};
let b = WireRect {
top: Px(10.0),
right: Px(20.0),
bottom: Px(50.0),
left: Percent(50.0),
};
let m = a.lerp(b, 0.5);
assert_eq!(m.top, Px(5.0));
assert_eq!(m.right, Px(15.0));
assert_eq!(m.bottom, Px(50.0), "unit change snaps that corner");
assert_eq!(m.left, Percent(50.0), "unit change snaps that corner");
}
#[test]
fn system_eases_border_radius_and_dirties_content() {
use crate::layer::LayerContentDirt;
use crate::protocol::units::Rect as WireRect;
let uniform = |px: f32| WireRect {
top: Length::Px(px),
right: Length::Px(px),
bottom: Length::Px(px),
left: Length::Px(px),
};
let (mut world, mut schedule) = drive_world();
let spec = Transition {
border_radius: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let e = world
.spawn((
TransitionInput {
spec,
border_radius: Some(uniform(8.0)),
..Default::default()
},
TransitionState::default(),
Node {
border_radius: BorderRadius::all(Val::Px(8.0)),
..Default::default()
},
UiTransform::default(),
))
.id();
schedule.run(&mut world);
let dirt = std::mem::take(&mut world.resource_mut::<LayerContentDirt>().nodes);
assert!(!dirt.contains(&e), "seed frame writes nothing");
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.border_radius = Some(uniform(100.0));
advance(&mut world, 0.5);
schedule.run(&mut world);
let r = world.entity(e).get::<Node>().unwrap().border_radius;
assert!(
matches!(r.top_left, Val::Px(v) if (v - 54.0).abs() < 1.0),
"mid expected ~54px, got {r:?}"
);
assert_eq!(r.bottom_right, r.top_left, "every corner eases");
let dirt = std::mem::take(&mut world.resource_mut::<LayerContentDirt>().nodes);
assert!(dirt.contains(&e), "an easing frame pushes content dirt");
advance(&mut world, 0.5);
schedule.run(&mut world);
let r = world.entity(e).get::<Node>().unwrap().border_radius;
assert_eq!(r, BorderRadius::all(Val::Px(100.0)), "exact settle");
world.resource_mut::<LayerContentDirt>().nodes.clear();
advance(&mut world, 0.1);
schedule.run(&mut world);
let dirt = std::mem::take(&mut world.resource_mut::<LayerContentDirt>().nodes);
assert!(!dirt.contains(&e), "a settled radius pushes no dirt");
}
#[test]
fn a_spec_less_commit_keeps_the_transform_reading_current() {
use crate::protocol::op::Op;
use crate::reconcile::test_util::op_app_manual_time;
let (mut app, tx) = op_app_manual_time();
app.init_resource::<crate::layer::LayerContentDirt>();
app.add_systems(
Update,
crate::transition::drive_transitions.after(crate::reconcile::apply_js_ops),
);
let update = |style: serde_json::Value| Op::Update {
id: 1,
props: serde_json::from_value(serde_json::json!({ "style": style })).unwrap(),
unset: vec![],
style_unset: vec![],
};
let slide = serde_json::json!({ "transform": { "duration": 250, "easing": "linear" } });
tx.send(vec![
Op::Create {
id: 1,
kind: "node".into(),
props: serde_json::from_value(serde_json::json!({ "style": {
"transform": { "translateX": 0 },
"transition": slide,
}}))
.unwrap(),
text: None,
},
Op::Append {
parent: 0,
child: 1,
},
])
.unwrap();
app.update();
let read = |app: &App| {
app.world()
.entity(crate::reconcile::test_util::ent(app, 1))
.get::<UiTransform>()
.unwrap()
.translation
.x
};
let step = |app: &mut App, ms: u64| {
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_millis(ms));
app.update();
};
assert_eq!(read(&app), Val::Px(0.0), "mount seeds the resting value");
tx.send(vec![update(serde_json::json!({
"transform": { "translateX": -260 },
"transition": {},
}))])
.unwrap();
step(&mut app, 16);
assert_eq!(read(&app), Val::Px(-260.0), "a spec-less change snaps");
step(&mut app, 16);
assert_eq!(read(&app), Val::Px(-260.0), "and stays put");
tx.send(vec![update(serde_json::json!({
"transform": { "translateX": 0 },
"transition": slide,
}))])
.unwrap();
step(&mut app, 125);
let Val::Px(x) = read(&app) else {
panic!("expected px, got {:?}", read(&app))
};
assert!(
(x + 130.0).abs() < 1.0,
"halfway through the 250ms slide expected ~-130, got {x}"
);
step(&mut app, 125);
assert_eq!(read(&app), Val::Px(0.0), "and lands on the target");
}
#[test]
fn a_spec_less_commit_keeps_the_size_reading_current() {
use crate::protocol::op::Op;
use crate::reconcile::test_util::op_app_manual_time;
let (mut app, tx) = op_app_manual_time();
app.init_resource::<crate::layer::LayerContentDirt>();
app.add_systems(
Update,
crate::transition::drive_transitions.after(crate::reconcile::apply_js_ops),
);
let grow = serde_json::json!({ "size": { "duration": 200, "easing": "linear" } });
tx.send(vec![
Op::Create {
id: 1,
kind: "node".into(),
props: serde_json::from_value(
serde_json::json!({ "style": { "width": 100, "transition": grow } }),
)
.unwrap(),
text: None,
},
Op::Append {
parent: 0,
child: 1,
},
])
.unwrap();
app.update();
let width = |app: &App| {
app.world()
.entity(crate::reconcile::test_util::ent(app, 1))
.get::<Node>()
.unwrap()
.width
};
let send = |style: serde_json::Value| Op::Update {
id: 1,
props: serde_json::from_value(serde_json::json!({ "style": style })).unwrap(),
unset: vec![],
style_unset: vec![],
};
tx.send(vec![send(
serde_json::json!({ "width": 300, "transition": {} }),
)])
.unwrap();
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_millis(16));
app.update();
assert_eq!(width(&app), Val::Px(300.0), "a spec-less resize snaps");
tx.send(vec![send(
serde_json::json!({ "width": 100, "transition": grow }),
)])
.unwrap();
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_millis(100));
app.update();
let Val::Px(w) = width(&app) else {
panic!("expected px, got {:?}", width(&app))
};
assert!(
(w - 200.0).abs() < 2.0,
"halfway through the 200ms ease expected ~200, got {w}"
);
}