use crate::filters::test_util::{create, drain_dirt, ease_app, entity_of, tick, update};
use crate::layer::LayerContentDirt;
use bevy::ui::{BackgroundGradient, BorderGradient, Gradient};
use serde_json::json;
fn gradient_json(color: &str) -> serde_json::Value {
json!({
"type": "linear",
"stops": [
{ "color": color, "position": 0 },
{ "color": color, "position": "100%" },
],
})
}
fn expect_gradients(json: serde_json::Value, opacity: Option<f32>) -> Vec<Gradient> {
let list: crate::protocol::visual::GradientList =
serde_json::from_value(json).expect("valid gradient json");
crate::ui_map::build_gradients(&list, opacity)
}
fn linear_stop_rgba(gradients: &[Gradient]) -> Vec<[f32; 4]> {
let Gradient::Linear(l) = &gradients[0] else {
panic!("expected a linear gradient, got {:?}", gradients[0]);
};
l.stops
.iter()
.map(|s| {
let c = s.color.to_srgba();
[c.red, c.green, c.blue, c.alpha]
})
.collect()
}
#[test]
fn background_gradient_transition_eases_matched_stops() {
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"backgroundGradient": gradient_json("#ff0000"),
"transition": { "backgroundGradient": { "duration": 1000, "easing": "linear" } },
} }),
)])
.unwrap();
app.update(); let e = entity_of(&app, 1);
drain_dirt(&mut app);
ops_tx
.send(vec![update(
1,
json!({ "style": { "backgroundGradient": gradient_json("#0000ff") } }),
&[],
)])
.unwrap();
tick(&mut app, 0.5);
for rgba in linear_stop_rgba(&app.world().get::<BackgroundGradient>(e).unwrap().0) {
assert!(
rgba[0] > 0.0 && rgba[0] < 1.0 && rgba[2] > 0.0 && rgba[2] < 1.0,
"mid-ease stop strictly between red and blue: {rgba:?}"
);
}
let dirt = app.world().resource::<LayerContentDirt>();
assert!(dirt.nodes.contains(&e), "easing frame is content dirt");
tick(&mut app, 0.6);
let expected = expect_gradients(gradient_json("#0000ff"), None);
assert_eq!(
app.world().get::<BackgroundGradient>(e).unwrap().0,
expected,
"settles on the resolver's own build"
);
drain_dirt(&mut app);
tick(&mut app, 0.25);
tick(&mut app, 0.25);
let dirt = app.world().resource::<LayerContentDirt>();
assert!(!dirt.nodes.contains(&e), "settled: no dirt");
assert!(!dirt.composite_only.contains(&e), "settled: no dirt");
assert_eq!(
app.world().get::<BackgroundGradient>(e).unwrap().0,
expected
);
}
#[test]
fn gradient_transition_mid_ease_retarget_starts_from_current() {
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"backgroundGradient": gradient_json("#ff0000"),
"transition": { "backgroundGradient": { "duration": 1000, "easing": "linear" } },
} }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
ops_tx
.send(vec![update(
1,
json!({ "style": { "backgroundGradient": gradient_json("#0000ff") } }),
&[],
)])
.unwrap();
tick(&mut app, 0.5);
let mid = linear_stop_rgba(&app.world().get::<BackgroundGradient>(e).unwrap().0)[0];
assert!(
(mid[0] - 0.5).abs() < 1e-3 && (mid[2] - 0.5).abs() < 1e-3,
"midpoint expected ~(0.5, 0, 0.5), got {mid:?}"
);
ops_tx
.send(vec![update(
1,
json!({ "style": { "backgroundGradient": gradient_json("#00ff00") } }),
&[],
)])
.unwrap();
tick(&mut app, 0.1);
let c = linear_stop_rgba(&app.world().get::<BackgroundGradient>(e).unwrap().0)[0];
assert!(
(c[0] - 0.45).abs() < 1e-3 && (c[1] - 0.1).abs() < 1e-3 && (c[2] - 0.45).abs() < 1e-3,
"new ease starts from current: expected ~(0.45, 0.1, 0.45), got {c:?}"
);
tick(&mut app, 1.0);
let expected = expect_gradients(gradient_json("#00ff00"), None);
assert_eq!(
app.world().get::<BackgroundGradient>(e).unwrap().0,
expected
);
drain_dirt(&mut app);
tick(&mut app, 0.25);
tick(&mut app, 0.25);
let dirt = app.world().resource::<LayerContentDirt>();
assert!(!dirt.nodes.contains(&e), "settled: no dirt");
assert_eq!(
app.world().get::<BackgroundGradient>(e).unwrap().0,
expected
);
}
#[test]
fn gradient_transition_structural_mismatch_snaps_silently() {
let _guard = crate::diag::test_lock();
crate::diag::arm_runtime();
let _ = crate::diag::take_runtime_warnings();
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"backgroundGradient": gradient_json("#ff0000"),
"transition": { "backgroundGradient": { "duration": 1000, "easing": "linear" } },
} }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
let three_stops = json!({
"type": "linear",
"stops": [
{ "color": "#0000ff", "position": 0 },
{ "color": "#00ff00", "position": "50%" },
{ "color": "#ff0000", "position": "100%" },
],
});
ops_tx
.send(vec![update(
1,
json!({ "style": { "backgroundGradient": three_stops } }),
&[],
)])
.unwrap();
tick(&mut app, 0.1);
assert_eq!(
app.world().get::<BackgroundGradient>(e).unwrap().0,
expect_gradients(three_stops.clone(), None),
"structural mismatch snaps the same frame, no intermediate"
);
#[cfg(all(feature = "devtools", debug_assertions))]
{
let warnings = crate::diag::take_runtime_warnings();
assert!(
!warnings.iter().any(|w| w.kind == "gradientTransition"),
"a structural mismatch must snap without warning, got {warnings:?}"
);
}
}
#[test]
fn gradient_appear_and_unset_snap_silently() {
let _guard = crate::diag::test_lock();
crate::diag::arm_runtime();
let _ = crate::diag::take_runtime_warnings();
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"transition": { "backgroundGradient": { "duration": 1000, "easing": "linear" } },
} }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
assert!(app.world().get::<BackgroundGradient>(e).is_none());
let red = expect_gradients(gradient_json("#ff0000"), None);
ops_tx
.send(vec![update(
1,
json!({ "style": { "backgroundGradient": gradient_json("#ff0000") } }),
&[],
)])
.unwrap();
tick(&mut app, 0.25);
assert_eq!(
app.world().get::<BackgroundGradient>(e).unwrap().0,
red,
"appear snaps to the target immediately"
);
tick(&mut app, 0.25);
assert_eq!(
app.world().get::<BackgroundGradient>(e).unwrap().0,
red,
"no ease is running after an appear"
);
ops_tx
.send(vec![update(
1,
json!({ "style": {} }),
&["backgroundGradient"],
)])
.unwrap();
tick(&mut app, 0.25);
assert!(
app.world().get::<BackgroundGradient>(e).is_none(),
"unset removes the component"
);
let blue = expect_gradients(gradient_json("#0000ff"), None);
ops_tx
.send(vec![update(
1,
json!({ "style": { "backgroundGradient": gradient_json("#0000ff") } }),
&[],
)])
.unwrap();
tick(&mut app, 0.25);
assert_eq!(
app.world().get::<BackgroundGradient>(e).unwrap().0,
blue,
"re-add snaps to the target"
);
#[cfg(all(feature = "devtools", debug_assertions))]
{
let warnings = crate::diag::take_runtime_warnings();
assert!(
!warnings.iter().any(|w| w.kind == "gradientTransition"),
"appear/unset must not warn, got {warnings:?}"
);
}
}
#[test]
fn border_gradient_channel_is_independent() {
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"backgroundGradient": gradient_json("#ff0000"),
"borderGradient": gradient_json("#ff0000"),
"transition": { "borderGradient": { "duration": 1000, "easing": "linear" } },
} }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
ops_tx
.send(vec![update(
1,
json!({ "style": {
"backgroundGradient": gradient_json("#0000ff"),
"borderGradient": gradient_json("#0000ff"),
} }),
&[],
)])
.unwrap();
tick(&mut app, 0.5);
let border = linear_stop_rgba(&app.world().get::<BorderGradient>(e).unwrap().0)[0];
assert!(
border[0] > 0.0 && border[0] < 1.0 && border[2] > 0.0 && border[2] < 1.0,
"border mid-ease strictly between: {border:?}"
);
assert_eq!(
app.world().get::<BackgroundGradient>(e).unwrap().0,
expect_gradients(gradient_json("#0000ff"), None),
"background without a spec snaps"
);
tick(&mut app, 0.6);
assert_eq!(
app.world().get::<BorderGradient>(e).unwrap().0,
expect_gradients(gradient_json("#0000ff"), None)
);
}
#[test]
fn gradient_transition_folds_static_opacity() {
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"opacity": 0.5,
"backgroundGradient": gradient_json("#ff0000"),
"transition": { "backgroundGradient": { "duration": 1000, "easing": "linear" } },
} }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
assert_eq!(
app.world().get::<BackgroundGradient>(e).unwrap().0,
expect_gradients(gradient_json("#ff0000"), Some(0.5))
);
ops_tx
.send(vec![update(
1,
json!({ "style": { "backgroundGradient": gradient_json("#0000ff") } }),
&[],
)])
.unwrap();
tick(&mut app, 0.5);
let mid = linear_stop_rgba(&app.world().get::<BackgroundGradient>(e).unwrap().0)[0];
assert!(
(mid[3] - 0.5).abs() < 1e-3,
"mid-ease alpha = lerped alpha × 0.5: {mid:?}"
);
assert!(
(mid[0] - 0.5).abs() < 1e-3 && (mid[2] - 0.5).abs() < 1e-3,
"mid-ease rgb: {mid:?}"
);
tick(&mut app, 0.6);
let unfolded = expect_gradients(gradient_json("#0000ff"), None);
let settled = &app.world().get::<BackgroundGradient>(e).unwrap().0;
assert_eq!(
*settled,
crate::ui_map::fold_gradients(&unfolded, Some(0.5)),
"settle equals the write-time fold"
);
assert_eq!(
*settled,
expect_gradients(gradient_json("#0000ff"), Some(0.5)),
"…which is apply_style's own folded build"
);
}
#[test]
fn gradient_fold_tracks_eased_opacity() {
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"opacity": 1.0,
"backgroundGradient": gradient_json("#ff0000"),
"transition": { "opacity": { "duration": 1000, "easing": "linear" } },
} }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
ops_tx
.send(vec![update(1, json!({ "style": { "opacity": 0.2 } }), &[])])
.unwrap();
tick(&mut app, 0.5);
let mid = linear_stop_rgba(&app.world().get::<BackgroundGradient>(e).unwrap().0)[0];
assert!(
(mid[3] - 0.6).abs() < 1e-3,
"mid-ease fold alpha ~0.6, got {mid:?}"
);
assert!(
(mid[0] - 1.0).abs() < 1e-6,
"colors untouched (no gradient retarget): {mid:?}"
);
tick(&mut app, 0.6);
let done = linear_stop_rgba(&app.world().get::<BackgroundGradient>(e).unwrap().0)[0];
assert!(
(done[3] - 0.2).abs() < 1e-3,
"settled fold alpha ~0.2, got {done:?}"
);
drain_dirt(&mut app);
tick(&mut app, 0.25);
tick(&mut app, 0.25);
let dirt = app.world().resource::<LayerContentDirt>();
assert!(!dirt.nodes.contains(&e), "settled: no dirt");
}
#[test]
fn hover_restyle_eases_background_gradient() {
use bevy::ui::Interaction;
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![create(
1,
json!({
"style": {
"backgroundGradient": gradient_json("#ff0000"),
"transition": { "backgroundGradient": { "duration": 1000, "easing": "linear" } },
},
"hoverStyle": { "backgroundGradient": gradient_json("#0000ff") },
}),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
drain_dirt(&mut app);
app.world_mut().entity_mut(e).insert(Interaction::Hovered);
tick(&mut app, 0.5);
for rgba in linear_stop_rgba(&app.world().get::<BackgroundGradient>(e).unwrap().0) {
assert!(
rgba[0] > 0.0 && rgba[0] < 1.0 && rgba[2] > 0.0 && rgba[2] < 1.0,
"mid-ease stop strictly between red and blue: {rgba:?}"
);
}
let expected = expect_gradients(gradient_json("#0000ff"), None);
tick(&mut app, 0.6);
assert_eq!(
app.world().get::<BackgroundGradient>(e).unwrap().0,
expected,
"settles on the hover gradient"
);
}