use fenestra_core::{Element, Fonts, FrameState, Theme, Transition, build_frame, by, col, div};
const SIZE: (f32, f32) = (200.0, 400.0);
fn flip_view(top_h: f32) -> Element<()> {
col::<()>().w(200.0).h(400.0).children([
div::<()>().id("top").w(100.0).h(top_h).shrink0(),
div::<()>()
.id("k")
.w(100.0)
.h(20.0)
.shrink0()
.animate_layout(),
])
}
#[test]
fn flip_snaps_under_reduced_motion() {
let theme = Theme::light();
let mut fonts = Fonts::embedded();
let mut state = FrameState::new();
state.reduced_motion = true;
let f1 = build_frame(&flip_view(10.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
let k = f1.get(&by::id("k")).id;
let f2 = build_frame(&flip_view(60.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert!(
!state.has_anim(k),
"reduced motion must not inject a FLIP spring"
);
assert!(!f2.animating, "reduced motion snaps; nothing animates");
assert!(
(f2.rect_of(k).unwrap().y0 - 60.0).abs() < 1.0,
"layout still moved to the new row"
);
}
#[test]
fn flip_injects_a_spring_on_a_real_move() {
let theme = Theme::light();
let mut fonts = Fonts::embedded();
let mut state = FrameState::new();
let f1 = build_frame(&flip_view(10.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
let k = f1.get(&by::id("k")).id;
assert!(!f1.animating, "first mount with no movement is settled");
assert!(
(state.prev_rect(k).unwrap().y0 - 10.0).abs() < 1.0,
"the first frame's rect is recorded for next-frame FLIP"
);
let f2 = build_frame(&flip_view(60.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert!(
state.has_anim(k),
"a real layout move retargets a retained spring"
);
assert!(f2.animating, "the FLIP slide keeps the runner scheduling");
assert!((f2.rect_of(k).unwrap().y0 - 60.0).abs() < 1.0);
assert!(
(state.prev_rect(k).unwrap().y0 - 60.0).abs() < 1.0,
"prev_rects advanced to this frame's measurement"
);
}
#[test]
fn flip_slide_settles_over_time() {
let theme = Theme::light();
let mut fonts = Fonts::embedded();
let mut state = FrameState::new();
let f1 = build_frame(&flip_view(10.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
let k = f1.get(&by::id("k")).id;
let f2 = build_frame(&flip_view(60.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert!(f2.animating, "the slide is in flight right after the move");
state.tick(5.0);
let f3 = build_frame(&flip_view(60.0), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert!(
!f3.animating,
"the FLIP spring settles and releases the runner"
);
assert!(
state.has_anim(k),
"the settled spring is retained while mounted"
);
}
fn list_view(order: [&str; 2], keyed: bool) -> Element<()> {
let item = |name: &str| {
let d = div::<()>().w(100.0).h(20.0).shrink0().animate_layout();
if keyed { d.id(name) } else { d }
};
col::<()>()
.w(200.0)
.h(400.0)
.children([item(order[0]), item(order[1])])
}
#[test]
fn flip_needs_a_stable_key() {
let theme = Theme::light();
let mut fonts = Fonts::embedded();
let mut state = FrameState::new();
build_frame(
&list_view(["a", "b"], false),
&theme,
&mut fonts,
&mut state,
SIZE,
1.0,
);
let f = build_frame(
&list_view(["b", "a"], false),
&theme,
&mut fonts,
&mut state,
SIZE,
1.0,
);
assert!(
!f.animating,
"index-keyed reorder cannot FLIP: identity follows position"
);
let mut state = FrameState::new();
let f1 = build_frame(
&list_view(["a", "b"], true),
&theme,
&mut fonts,
&mut state,
SIZE,
1.0,
);
let a = f1.get(&by::id("a")).id;
let f2 = build_frame(
&list_view(["b", "a"], true),
&theme,
&mut fonts,
&mut state,
SIZE,
1.0,
);
assert!(f2.animating, "keyed reorder slides the moved row");
assert!(state.has_anim(a), "the moved row holds a retargeted spring");
}
fn exit_view(present: bool) -> Element<()> {
let mut kids: Vec<Element<()>> = vec![div::<()>().id("keep").w(100.0).h(20.0).shrink0()];
if present {
kids.push(
div::<()>()
.id("gone")
.w(100.0)
.h(20.0)
.shrink0()
.exit(Transition::all()),
);
}
col::<()>().w(200.0).h(400.0).children(kids)
}
#[test]
fn exit_ghost_created_on_departure() {
let theme = Theme::light();
let mut fonts = Fonts::embedded();
let mut state = FrameState::new();
let f1 = build_frame(&exit_view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
let gone = f1.get(&by::id("gone")).id;
assert!(!state.has_exiting(gone), "a present element is not exiting");
let f2 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert!(
state.has_exiting(gone),
"removing an exit-tagged element starts an exit"
);
assert_eq!(
state.exiting_settled(gone),
Some(false),
"the exit is mid-flight, not settled"
);
assert!(
f2.animating,
"an in-flight exit keeps the runner scheduling"
);
let _ = f2.paint(&mut fonts, &mut state);
assert!(
state.has_exiting(gone),
"still exiting immediately after one paint at t0"
);
}
#[test]
fn exit_settles_instantly_under_reduced_motion_and_is_gced() {
let theme = Theme::light();
let mut fonts = Fonts::embedded();
let mut state = FrameState::new();
state.reduced_motion = true;
let f1 = build_frame(&exit_view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
let gone = f1.get(&by::id("gone")).id;
let f2 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert_eq!(state.exiting_settled(gone), Some(true));
assert!(
!f2.animating,
"a settled exit does not animate (headless stays inert)"
);
let _ = f2.paint(&mut fonts, &mut state);
let _f3 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert!(
!state.has_exiting(gone),
"a settled exit is garbage-collected on the next build"
);
}
#[test]
fn exit_cancels_when_the_element_reappears() {
let theme = Theme::light();
let mut fonts = Fonts::embedded();
let mut state = FrameState::new();
let f1 = build_frame(&exit_view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
let gone = f1.get(&by::id("gone")).id;
let _f2 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert!(state.has_exiting(gone), "the exit started");
let f3 = build_frame(&exit_view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert!(
!state.has_exiting(gone),
"the element reappearing cancels its exit"
);
assert!(
f3.query(&by::id("gone")).is_some(),
"and it is live again in the tree"
);
}
#[test]
fn exit_completes_after_its_duration_then_is_collected() {
let theme = Theme::light();
let mut fonts = Fonts::embedded();
let mut state = FrameState::new();
let f1 = build_frame(&exit_view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
let gone = f1.get(&by::id("gone")).id;
let f2 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert_eq!(state.exiting_settled(gone), Some(false));
state.tick(5.0);
let _ = f2.paint(&mut fonts, &mut state);
assert_eq!(
state.exiting_settled(gone),
Some(true),
"the exit settles once its duration has elapsed"
);
let _f3 = build_frame(&exit_view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert!(
!state.has_exiting(gone),
"the completed exit is collected next build"
);
}
#[test]
fn exit_to_targets_scale_and_translate() {
let theme = Theme::light();
let mut fonts = Fonts::embedded();
let mut state = FrameState::new();
let view = |present: bool| -> Element<()> {
let mut kids: Vec<Element<()>> = vec![div::<()>().id("keep").w(100.0).h(20.0).shrink0()];
if present {
kids.push(
div::<()>()
.id("toast")
.w(100.0)
.h(20.0)
.shrink0()
.exit_to(0.0, 0.96, 0.0, 8.0),
);
}
col::<()>().w(200.0).h(400.0).children(kids)
};
let f1 = build_frame(&view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
let toast = f1.get(&by::id("toast")).id;
let f2 = build_frame(&view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert_eq!(state.exiting_settled(toast), Some(false));
assert!(f2.animating);
state.tick(0.05);
let _ = f2.paint(&mut fonts, &mut state);
assert!(state.has_exiting(toast));
}
#[test]
fn exit_transform_pivots_about_the_elements_own_transform_origin() {
let theme = Theme::light();
let mut fonts = Fonts::embedded();
let mut state = FrameState::new();
let view = |present: bool| -> Element<()> {
let mut kids: Vec<Element<()>> = vec![div::<()>().id("keep").w(100.0).h(20.0).shrink0()];
if present {
kids.push(
div::<()>()
.id("corner")
.w(100.0)
.h(20.0)
.shrink0()
.transform_origin(0.0, 0.0)
.exit_to(0.0, 0.5, 0.0, 0.0),
);
}
col::<()>().w(200.0).h(400.0).children(kids)
};
let f1 = build_frame(&view(true), &theme, &mut fonts, &mut state, SIZE, 1.0);
let corner = f1.get(&by::id("corner")).id;
let rect = f1.rect_of(corner).expect("corner rect");
let _f2 = build_frame(&view(false), &theme, &mut fonts, &mut state, SIZE, 1.0);
let pivot = state
.exiting_ghost_pivot(corner)
.expect("an in-flight exit reports its pivot");
assert!(
(f64::from(pivot.0) - rect.x0).abs() < 1e-3 && (f64::from(pivot.1) - rect.y0).abs() < 1e-3,
"the exit pivots at the element's top-left transform_origin {:?}, not its rect center {:?}",
pivot,
rect.center()
);
}
#[test]
fn exit_ghost_inherits_a_mid_flip_slide() {
let theme = Theme::light();
let mut fonts = Fonts::embedded();
let mut state = FrameState::new();
let view = |top_h: f32, present: bool| -> Element<()> {
let mut kids: Vec<Element<()>> = vec![div::<()>().id("top").w(100.0).h(top_h).shrink0()];
if present {
kids.push(
div::<()>()
.id("k")
.w(100.0)
.h(20.0)
.shrink0()
.animate_layout()
.exit(Transition::all()),
);
}
col::<()>().w(200.0).h(400.0).children(kids)
};
let f1 = build_frame(&view(10.0, true), &theme, &mut fonts, &mut state, SIZE, 1.0);
let k = f1.get(&by::id("k")).id;
let _f2 = build_frame(&view(60.0, true), &theme, &mut fonts, &mut state, SIZE, 1.0);
assert!(state.has_anim(k), "the move injected a FLIP spring");
let f3 = build_frame(
&view(60.0, false),
&theme,
&mut fonts,
&mut state,
SIZE,
1.0,
);
assert!(
state.has_exiting(k),
"removing the sliding element starts an exit"
);
let t = state
.exiting_ghost_translate(k)
.expect("a removed exit-tagged element is exiting");
assert!(
t.1 < -40.0,
"the exit ghost inherits the mid-FLIP slide (translate.y ~= -50), got {t:?}"
);
let _ = f3.paint(&mut fonts, &mut state);
}