bevy_extended_ui 1.4.1

Create simply ui's with css and html for bevy.
Documentation
#[cfg(test)]
mod tests {
    use super::super::*;
    use crate::io::CssAsset;
    use crate::styles::components::UiStyle;
    use crate::styles::paint::Colored;
    use bevy::asset::AssetPlugin;
    use bevy::prelude::AppTypeRegistry;
    use bevy::prelude::*;
    use std::any::TypeId;
    use std::collections::{HashMap, HashSet};

    fn style_pair(selector: &str, origin: usize) -> StylePair {
        StylePair {
            selector: selector.to_string(),
            origin,
            ..default()
        }
    }

    #[test]
    fn css_source_helpers_create_and_extend_handles() {
        let mut app = App::new();
        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
        app.init_asset::<CssAsset>();
        let asset_server = app.world().resource::<AssetServer>().clone();

        let mut source = CssSource::from_path(&asset_server, "styles/a.css");
        assert_eq!(source.0.len(), 1);
        assert_eq!(
            source.0[0]
                .path()
                .expect("handle path missing")
                .path()
                .to_string_lossy(),
            "styles/a.css"
        );

        source.push_path(&asset_server, "styles/b.css");
        assert_eq!(source.0.len(), 2);
        assert_eq!(
            source.0[1]
                .path()
                .expect("handle path missing")
                .path()
                .to_string_lossy(),
            "styles/b.css"
        );

        let from_paths = CssSource::from_paths(&asset_server, vec!["c.css", "d.css"]);
        assert_eq!(from_paths.0.len(), 2);
    }

    #[test]
    fn radius_all_sets_all_corners() {
        let radius = Radius::all(Val::Px(8.0));
        assert_eq!(radius.top_left, Val::Px(8.0));
        assert_eq!(radius.top_right, Val::Px(8.0));
        assert_eq!(radius.bottom_left, Val::Px(8.0));
        assert_eq!(radius.bottom_right, Val::Px(8.0));
    }

    #[test]
    fn font_weight_parsers_and_number_roundtrip_work() {
        assert_eq!(
            super::super::FontWeight::from_name("semi-bold"),
            Some(super::super::FontWeight::SemiBold)
        );
        assert_eq!(
            super::super::FontWeight::from_name("HEAVY"),
            Some(super::super::FontWeight::Black)
        );
        assert_eq!(super::super::FontWeight::from_name("unknown"), None);

        assert_eq!(
            super::super::FontWeight::from_number(700),
            Some(super::super::FontWeight::Bold)
        );
        assert_eq!(
            super::super::FontWeight::from_number(999),
            Some(super::super::FontWeight::Normal)
        );
        assert_eq!(super::super::FontWeight::Bold.as_number(), 700);
    }

    #[test]
    fn font_val_get_resolves_px_and_rem() {
        assert_eq!(FontVal::Px(13.0).get(Some(16.0)), 13.0);
        assert_eq!(FontVal::Rem(2.0).get(Some(12.0)), 24.0);
        assert_eq!(FontVal::Rem(2.0).get(None), 2.0);
    }

    #[test]
    fn calc_expr_eval_length_handles_basic_math_and_units() {
        let ctx = CalcContext {
            base: 200.0,
            viewport: Vec2::new(1000.0, 500.0),
        };

        let expr = CalcExpr::Add(
            Box::new(CalcExpr::Value(CalcValue::new(50.0, CalcUnit::Percent))),
            Box::new(CalcExpr::Value(CalcValue::new(20.0, CalcUnit::Px))),
        );
        assert_eq!(expr.eval_length(ctx), Some(120.0));

        let mul = CalcExpr::Mul(
            Box::new(CalcExpr::Value(CalcValue::new(10.0, CalcUnit::Px))),
            Box::new(CalcExpr::Value(CalcValue::new(3.0, CalcUnit::None))),
        );
        assert_eq!(mul.eval_length(ctx), Some(30.0));

        let vmin = CalcExpr::Value(CalcValue::new(10.0, CalcUnit::VMin));
        assert_eq!(vmin.eval_length(ctx), Some(50.0));

        let vmax = CalcExpr::Value(CalcValue::new(10.0, CalcUnit::VMax));
        assert_eq!(vmax.eval_length(ctx), Some(100.0));
    }

    #[test]
    fn calc_expr_eval_unitless_and_division_edge_cases_work() {
        let unitless = CalcExpr::Div(
            Box::new(CalcExpr::Value(CalcValue::new(9.0, CalcUnit::None))),
            Box::new(CalcExpr::Value(CalcValue::new(3.0, CalcUnit::None))),
        );
        assert_eq!(unitless.eval_unitless(), Some(3.0));

        let div_zero = CalcExpr::Div(
            Box::new(CalcExpr::Value(CalcValue::new(9.0, CalcUnit::None))),
            Box::new(CalcExpr::Value(CalcValue::new(0.0, CalcUnit::None))),
        );
        assert_eq!(div_zero.eval_unitless(), None);
        assert_eq!(
            div_zero.eval_length(CalcContext {
                base: 1.0,
                viewport: Vec2::ONE
            }),
            None
        );

        let not_unitless = CalcExpr::Value(CalcValue::new(10.0, CalcUnit::Px));
        assert_eq!(not_unitless.eval_unitless(), None);
    }

    #[test]
    fn calc_expr_sin_supports_angle_units() {
        let expr = CalcExpr::Sin(Box::new(CalcExpr::Value(CalcValue::new(
            90.0,
            CalcUnit::Deg,
        ))));
        let out = expr.eval_unitless().expect("sin should resolve");
        assert!((out - 1.0).abs() < 0.0001);

        let expr_turn = CalcExpr::Sin(Box::new(CalcExpr::Value(CalcValue::new(
            0.5,
            CalcUnit::Turn,
        ))));
        let out_turn = expr_turn.eval_unitless().expect("sin should resolve");
        assert!(out_turn.abs() < 0.0001);
    }

    #[test]
    fn transition_timing_from_name_and_apply_work() {
        assert_eq!(
            TransitionTiming::from_name("ease-in-out"),
            Some(TransitionTiming::EaseInOut)
        );
        assert_eq!(TransitionTiming::from_name("invalid"), None);

        let t = 0.25;
        assert!((TransitionTiming::Linear.apply(t) - 0.25).abs() < 0.0001);
        assert!((TransitionTiming::EaseIn.apply(t) - 0.0625).abs() < 0.0001);
        assert!((TransitionTiming::EaseOut.apply(t) - 0.4375).abs() < 0.0001);
    }

    #[test]
    fn animation_direction_from_name_and_default_work() {
        assert_eq!(
            AnimationDirection::from_name("alternate-reverse"),
            Some(AnimationDirection::AlternateReverse)
        );
        assert_eq!(AnimationDirection::from_name("bad"), None);
        assert_eq!(AnimationDirection::default(), AnimationDirection::Normal);
    }

    #[test]
    fn media_query_condition_matches_and_cache_key_work() {
        let cond = MediaQueryCondition::And(vec![
            MediaQueryCondition::MinWidth(800.0),
            MediaQueryCondition::Not(Box::new(MediaQueryCondition::OrientationPortrait)),
        ]);
        assert!(cond.matches_viewport(Vec2::new(1280.0, 720.0)));
        assert!(!cond.matches_viewport(Vec2::new(700.0, 1200.0)));

        let or = MediaQueryCondition::Or(vec![
            MediaQueryCondition::Width(320.0),
            MediaQueryCondition::Height(200.0),
        ]);
        assert!(or.matches_viewport(Vec2::new(320.0, 10.0)));
        assert!(or.matches_viewport(Vec2::new(10.0, 200.0)));
        assert!(!or.matches_viewport(Vec2::new(640.0, 480.0)));

        let key = cond.cache_key();
        assert!(key.starts_with("and("));
        assert!(key.contains("minw:800.000"));
    }

    #[test]
    fn transform_style_is_empty_reports_state() {
        let mut transform = TransformStyle::default();
        assert!(transform.is_empty());
        transform.rotation = Some(1.0);
        assert!(!transform.is_empty());
    }

    #[test]
    fn style_merge_prefers_calc_over_value_and_can_switch_back_to_value() {
        let mut base = Style::default();
        base.width = Some(Val::Px(10.0));
        base.color = Some(Color::srgb(1.0, 1.0, 1.0));

        let mut other = Style::default();
        other.width_calc = Some(CalcExpr::Value(CalcValue::new(25.0, CalcUnit::Px)));
        other.color = Some(Color::srgb(0.0, 0.0, 0.0));
        other.transform.scale = Some(Vec2::splat(2.0));

        base.merge(&other);
        assert_eq!(base.width, None);
        assert_eq!(
            base.width_calc,
            Some(CalcExpr::Value(CalcValue::new(25.0, CalcUnit::Px)))
        );
        assert_eq!(base.color, Some(Color::srgb(0.0, 0.0, 0.0)));
        assert_eq!(base.transform.scale, Some(Vec2::splat(2.0)));

        let mut to_value = Style::default();
        to_value.width = Some(Val::Percent(40.0));
        base.merge(&to_value);
        assert_eq!(base.width, Some(Val::Percent(40.0)));
        assert_eq!(base.width_calc, None);
    }

    #[test]
    fn style_merge_updates_transform_fields_incrementally() {
        let mut base = Style::default();
        base.transform.scale_x = Some(1.0);
        base.transform.rotation = Some(0.1);

        let mut other = Style::default();
        other.transform.scale_y = Some(3.0);
        other.transform.translation_x = Some(Val::Px(4.0));

        base.merge(&other);
        assert_eq!(base.transform.scale_x, Some(1.0));
        assert_eq!(base.transform.scale_y, Some(3.0));
        assert_eq!(base.transform.translation_x, Some(Val::Px(4.0)));
        assert_eq!(base.transform.rotation, Some(0.1));
    }

    #[test]
    fn extended_styling_plugin_registers_reflected_types() {
        let mut app = App::new();
        app.add_plugins(MinimalPlugins);
        app.add_plugins(ExtendedStylingPlugin);

        let registry = app.world().resource::<AppTypeRegistry>().read();
        assert!(registry.get(TypeId::of::<UiStyle>()).is_some());
        assert!(registry.get(TypeId::of::<CssClass>()).is_some());
        assert!(registry.get(TypeId::of::<CssID>()).is_some());
    }

    #[test]
    fn ui_style_from_asset_handle_returns_empty_for_missing_asset() {
        let mut app = App::new();
        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
        app.init_asset::<CssAsset>();

        // Use the invalid UUID handle so this test is independent from filesystem
        // paths and global CSS cache state.
        let handle = Handle::<CssAsset>::from(AssetId::<CssAsset>::INVALID_UUID);
        let css_assets = app.world().resource::<Assets<CssAsset>>();

        let ui = UiStyle::from_asset_handle(handle, &css_assets);
        assert!(ui.styles.is_empty());
        assert!(ui.keyframes.is_empty());
        assert_eq!(ui.active_style, None);
    }

    #[test]
    fn ui_style_from_asset_handle_parses_css_and_reload_updates_content() {
        let mut app = App::new();
        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
        app.init_asset::<CssAsset>();

        let handle = {
            let mut assets = app.world_mut().resource_mut::<Assets<CssAsset>>();
            assets.add(CssAsset {
                text: "div { color: red; }".to_string(),
            })
        };

        let mut ui = {
            let assets = app.world().resource::<Assets<CssAsset>>();
            UiStyle::from_asset_handle(handle.clone(), &assets)
        };
        assert!(ui.styles.values().any(|pair| pair.selector == "div"));

        app.world_mut()
            .resource_mut::<Assets<CssAsset>>()
            .insert(
                handle.id(),
                CssAsset {
                    text: ".next { width: 12px; }".to_string(),
                },
            )
            .expect("failed to replace css asset");

        {
            let assets = app.world().resource::<Assets<CssAsset>>();
            ui.reload_from_assets(&assets);
        }

        let selectors: HashSet<String> = ui
            .styles
            .values()
            .map(|pair| pair.selector.clone())
            .collect();
        assert!(selectors.contains(".next"));
    }

    #[test]
    fn ui_style_filtered_clone_keeps_relevant_selectors() {
        let mut styles = HashMap::new();
        styles.insert("s1".to_string(), style_pair("*", 0));
        styles.insert("s2".to_string(), style_pair(".btn", 1));
        styles.insert("s3".to_string(), style_pair(".btn:focus", 1));
        styles.insert("s4".to_string(), style_pair("#main", 2));
        styles.insert("s5".to_string(), style_pair("div", 3));
        styles.insert("s6".to_string(), style_pair("p", 4));

        let ui = UiStyle {
            css: Handle::default(),
            styles,
            keyframes: HashMap::new(),
            active_style: None,
        };

        let id = CssID("main".to_string());
        let classes = CssClass(vec!["btn".to_string()]);
        let tag = TagName("div".to_string());

        let filtered = ui.filtered_clone(Some(&id), Some(&classes), Some(&tag));
        let selectors: HashSet<String> = filtered
            .styles
            .values()
            .map(|pair| pair.selector.clone())
            .collect();

        assert!(selectors.contains("*"));
        assert!(selectors.contains(".btn"));
        assert!(selectors.contains(".btn:focus"));
        assert!(selectors.contains("#main"));
        assert!(selectors.contains("div"));
        assert!(!selectors.contains("p"));
    }

    #[test]
    fn colored_hex_and_named_parsing_work() {
        let short_hex = Colored::hex_to_color("#0f0");
        let short = short_hex.to_srgba();
        assert!((short.red - 0.0).abs() < 0.001);
        assert!((short.green - 1.0).abs() < 0.001);
        assert!((short.blue - 0.0).abs() < 0.001);
        assert!((short.alpha - 1.0).abs() < 0.001);

        let alpha_hex = Colored::hex_to_color("#ff000080");
        let alpha = alpha_hex.to_srgba();
        assert!((alpha.alpha - (128.0 / 255.0)).abs() < 0.001);

        assert_eq!(Colored::named("darkgrey"), Some(Colored::DARK_GRAY));
        assert_eq!(Colored::named("missing-color"), None);
    }
}