tests.rs raw

use guiduck_scene::geom::{Rect, Size};
use guiduck_scene::paint::color::palette::css;
use guiduck_scene::{DisplayItem, FragmentStore};
use taffy::prelude::{auto, length, percent};

use super::*;
use crate::text::TextContext;

fn tree() -> WidgetTree {
    // Layout tests must not depend on machine fonts.
    WidgetTree::with_text_context(TextContext::hermetic([sample_font()]))
}

fn sample_font() -> guiduck_scene::paint::Blob<u8> {
    static FONT: &[u8] = include_bytes!("../../../guiduck-samples/fonts/DejaVuSans.ttf");
    guiduck_scene::paint::Blob::new(std::sync::Arc::new(FONT))
}

fn row_style(gap: f32, padding: f32) -> taffy::Style {
    taffy::Style {
        size: taffy::Size {
            width: percent(1.0_f32),
            height: percent(1.0_f32),
        },
        flex_direction: taffy::FlexDirection::Row,
        gap: taffy::Size {
            width: length(gap),
            height: length(gap),
        },
        padding: taffy::Rect::length(padding),
        ..Default::default()
    }
}

#[test]
fn flex_row_with_gap_and_padding() {
    let mut tree = tree();
    let root = tree.insert(Container::new(), row_style(10.0, 8.0), None);
    let a = tree.insert(
        Container::new(),
        taffy::Style {
            size: taffy::Size {
                width: length(100.0_f32),
                height: length(50.0_f32),
            },
            ..Default::default()
        },
        Some(root),
    );
    let b = tree.insert(
        Container::new(),
        taffy::Style {
            size: taffy::Size {
                width: length(60.0_f32),
                height: auto(),
            },
            flex_grow: 0.0,
            align_self: Some(taffy::AlignItems::STRETCH),
            ..Default::default()
        },
        Some(root),
    );
    tree.compute_layout(Size::new(400.0, 300.0));

    assert_eq!(tree.layout(root), Rect::new(0.0, 0.0, 400.0, 300.0));
    assert_eq!(tree.layout(a), Rect::new(8.0, 8.0, 108.0, 58.0));
    // After a and the 10px gap; stretches to the padded height.
    assert_eq!(tree.layout(b), Rect::new(118.0, 8.0, 178.0, 292.0));
}

#[test]
fn text_measures_and_wraps() {
    let mut tree = tree();
    // The root is the viewport; what this needs from it is only that it not
    // stretch the text, so the height measured is the text's own.
    let root = tree.insert(
        Container::new(),
        taffy::Style {
            align_items: Some(taffy::AlignItems::FLEX_START),
            ..Default::default()
        },
        None,
    );
    let text = tree.insert(
        Text::new("one two three four five six seven eight nine ten")
            .font_size(16.0)
            .family(SAMPLE_FAMILY),
        taffy::Style::default(),
        Some(root),
    );

    tree.compute_layout(Size::new(500.0, 400.0));
    let wide = tree.layout(text);

    let mut narrow_tree = self::tree();
    let root2 = narrow_tree.insert(
        Container::new(),
        taffy::Style {
            align_items: Some(taffy::AlignItems::FLEX_START),
            ..Default::default()
        },
        None,
    );
    let text2 = narrow_tree.insert(
        Text::new("one two three four five six seven eight nine ten")
            .font_size(16.0)
            .family(SAMPLE_FAMILY),
        taffy::Style::default(),
        Some(root2),
    );
    narrow_tree.compute_layout(Size::new(120.0, 400.0));
    let narrow = narrow_tree.layout(text2);

    assert!(wide.height() > 0.0);
    assert!(
        narrow.height() > wide.height() * 1.5,
        "narrow viewport should wrap to more lines: wide {wide:?} narrow {narrow:?}"
    );
    assert!(narrow.width() <= 120.0 + f64::EPSILON);
}

const SAMPLE_FAMILY: &str = "DejaVu Sans";

#[test]
fn image_keeps_aspect_ratio() {
    let mut tree = tree();
    let image = guiduck_scene::paint::ImageBrush::from(guiduck_scene::paint::ImageData {
        data: guiduck_scene::paint::Blob::new(std::sync::Arc::new(vec![0u8; 40 * 20 * 4])),
        format: guiduck_scene::paint::ImageFormat::Rgba8,
        alpha_type: guiduck_scene::paint::ImageAlphaType::Alpha,
        width: 40,
        height: 20,
    });
    let root = tree.insert(
        Container::new(),
        taffy::Style {
            size: taffy::Size {
                width: percent(1.0_f32),
                height: percent(1.0_f32),
            },
            ..Default::default()
        },
        None,
    );
    let img = tree.insert(
        Image::new(crate::graphic::Graphic::Image(image)),
        taffy::Style {
            size: taffy::Size {
                width: length(80.0_f32),
                height: auto(),
            },
            // Without this, flexbox's default stretch overrides the measured
            // height — correct CSS behavior, but not what this test checks.
            align_self: Some(taffy::AlignItems::FLEX_START),
            ..Default::default()
        },
        Some(root),
    );
    tree.compute_layout(Size::new(200.0, 200.0));
    let rect = tree.layout(img);
    assert_eq!(rect.width(), 80.0);
    assert_eq!(rect.height(), 40.0, "2:1 aspect preserved");
}

#[test]
fn paint_composes_child_fragments_at_layout_positions() {
    let mut tree = tree();
    let root = tree.insert(
        Container::new().background(css::WHITE),
        row_style(0.0, 20.0),
        None,
    );
    tree.insert(
        Container::new().background(css::RED),
        taffy::Style {
            size: taffy::Size {
                width: length(50.0_f32),
                height: length(50.0_f32),
            },
            ..Default::default()
        },
        Some(root),
    );
    tree.compute_layout(Size::new(300.0, 100.0));

    let mut store = FragmentStore::new();
    let frame = tree.paint_fresh(&mut store).expect("tree has a root");
    // The frame fragment wraps the base root (and any overlays) as child
    // references; with no overlays it holds exactly one, at identity.
    let frame_fragment = store.get(frame).unwrap();
    let root_fragment = match frame_fragment.items.as_slice() {
        [
            DisplayItem::Child {
                transform,
                fragment,
            },
        ] => {
            assert_eq!(*transform, Affine::IDENTITY);
            *fragment
        }
        items => panic!("frame should hold one child reference, got {items:?}"),
    };
    let fragment = store.get(root_fragment).unwrap();
    assert!(fragment.is_balanced());

    // Background fill plus one child reference placed at the padded origin.
    let child = fragment
        .items
        .iter()
        .find_map(|item| match item {
            DisplayItem::Child {
                transform,
                fragment,
            } => Some((*transform, *fragment)),
            _ => None,
        })
        .expect("root has a child item");
    assert_eq!(
        child.0 * guiduck_scene::geom::Point::ORIGIN,
        guiduck_scene::geom::Point::new(20.0, 20.0)
    );
    let child_fragment = store.get(child.1).unwrap();
    assert!(
        child_fragment
            .items
            .iter()
            .any(|i| matches!(i, DisplayItem::Fill { .. }))
    );
}

#[test]
fn accessibility_tree_has_roles_and_absolute_bounds() {
    let mut tree = tree();
    let root = tree.insert(Container::new(), row_style(0.0, 10.0), None);
    let label = tree.insert(
        Text::new("hello").family(SAMPLE_FAMILY),
        taffy::Style::default(),
        Some(root),
    );
    tree.compute_layout(Size::new(200.0, 100.0));

    let update = tree.accessibility_tree();
    let get = |id: accesskit::NodeId| update.nodes.iter().find(|(n, _)| *n == id).map(|(_, n)| n);

    let window = get(accesskit::NodeId(u64::MAX)).expect("window root present");
    assert_eq!(window.role(), accesskit::Role::Window);

    let label_node = get(super::access_id(label)).expect("label node present");
    assert_eq!(label_node.role(), accesskit::Role::Label);
    let bounds = label_node.bounds().expect("label has bounds");
    // Absolute coordinates include the parent's 10px padding.
    assert_eq!(bounds.x0, 10.0);
    assert_eq!(bounds.y0, 10.0);
    assert_eq!(label_node.value(), Some("hello"));

    let root_node = get(super::access_id(root)).expect("root node present");
    assert_eq!(root_node.role(), accesskit::Role::GenericContainer);
    assert_eq!(root_node.children(), &[super::access_id(label)]);
}

#[test]
fn tree_without_focusables_has_no_focus() {
    let mut tree = tree();
    let root = tree.insert(Container::new(), row_style(0.0, 0.0), None);
    tree.insert(Container::new(), taffy::Style::default(), Some(root));
    tree.render_frame(Size::new(100.0, 100.0));
    assert_eq!(tree.focus(), None);
}

/// A focusable widget with a chosen cursor shape, for hover/cursor and
/// focus-restyle tests.
struct StateProbe {
    shape: CursorShape,
}

impl StateProbe {
    fn new(shape: CursorShape) -> Self {
        Self { shape }
    }
}

impl Widget for StateProbe {
    fn cursor(&self, _local: Point) -> CursorShape {
        self.shape
    }

    fn focusable(&self) -> bool {
        true
    }
}

fn probe_fixture() -> (WidgetTree, WidgetId) {
    let mut tree = tree();
    let root = tree.insert(Container::new(), row_style(0.0, 10.0), None);
    let probe = tree.insert(
        StateProbe::new(CursorShape::Pointer),
        taffy::Style {
            size: taffy::Size {
                width: length(40.0_f32),
                height: length(40.0_f32),
            },
            ..Default::default()
        },
        Some(root),
    );
    (tree, probe)
}

#[test]
fn focus_moves_mark_both_ends_style_dirty() {
    let mut tree = tree();
    let root = tree.insert(Container::new(), row_style(0.0, 0.0), None);
    let style = || taffy::Style {
        size: taffy::Size {
            width: length(40.0_f32),
            height: length(40.0_f32),
        },
        ..Default::default()
    };
    let a = tree.insert(StateProbe::new(CursorShape::Default), style(), Some(root));
    let b = tree.insert(StateProbe::new(CursorShape::Default), style(), Some(root));
    tree.render_frame(Size::new(100.0, 50.0));
    assert_eq!(tree.focus(), Some(a));

    // Moving focus must restyle both the old and new focus holders so a
    // theme's `:focus` rule can apply and unapply. The style pass drains
    // `style_dirty`, so it is empty here; set_focus repopulates it.
    assert!(tree.style_dirty.is_empty());
    tree.set_focus(Some(b));
    assert!(tree.style_dirty.contains(&a), "old focus holder restyled");
    assert!(tree.style_dirty.contains(&b), "new focus holder restyled");
}

#[test]
fn cursor_shape_comes_from_the_hovered_widget() {
    let (mut tree, probe) = probe_fixture();
    tree.render_frame(Size::new(200.0, 100.0));
    assert_eq!(tree.cursor_shape(), CursorShape::Default);

    tree.dispatch_pointer(
        crate::event::PointerInput::Moved(Point::new(30.0, 30.0)),
        &mut crate::clipboard::NoClipboard,
    );
    assert_eq!(tree.cursor_shape(), CursorShape::Pointer);

    // Disabling retargets hover to the enabled ancestor, so the disabled
    // control shows the default cursor, not its own.
    tree.set_enabled(probe, false);
    tree.dispatch_pointer(
        crate::event::PointerInput::Moved(Point::new(31.0, 30.0)),
        &mut crate::clipboard::NoClipboard,
    );
    assert_eq!(tree.cursor_shape(), CursorShape::Default);

    tree.dispatch_pointer(
        crate::event::PointerInput::Moved(Point::new(190.0, 90.0)),
        &mut crate::clipboard::NoClipboard,
    );
    assert_eq!(tree.cursor_shape(), CursorShape::Default);
}

/// A widget outside the framework's own vocabulary, standing in for one an
/// app registers: the case `bind_dyn` exists for is precisely a widget whose
/// type the code installing the binding cannot name. Its two setters
/// classify their dirt differently, so a test can tell which one ran and
/// with what class.
#[derive(Default)]
struct BindProbe {
    label: String,
    highlight: bool,
    dirt: Dirt,
}

impl BindProbe {
    /// Layout-classed, as text content is.
    fn set_label(&mut self, label: String) {
        if self.label != label {
            self.label = label;
            self.dirt.mark_layout();
        }
    }

    /// Paint-classed: appearance within the same geometry.
    fn set_highlight(&mut self, highlight: bool) {
        if self.highlight != highlight {
            self.highlight = highlight;
            self.dirt.mark_paint();
        }
    }
}

impl Widget for BindProbe {
    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }
}

/// The thunks a generated setter table would hold for [`BindProbe`]: the
/// widget's type and the value's are both recovered inside the closure.
fn label_thunk() -> SetterThunk {
    Rc::new(|widget, value| {
        if let (Some(widget), Ok(label)) = (
            widget.downcast_mut::<BindProbe>(),
            value.downcast::<String>(),
        ) {
            widget.set_label(*label);
        }
    })
}

fn highlight_thunk() -> SetterThunk {
    Rc::new(|widget, value| {
        if let (Some(widget), Ok(highlight)) =
            (widget.downcast_mut::<BindProbe>(), value.downcast::<bool>())
        {
            widget.set_highlight(*highlight);
        }
    })
}

/// Run the reactive and command halves of a frame — the two stages a binding
/// travels — and return the dirt they raised, leaving the tree clean for the
/// next observation. Stopping short of `render_frame` is what lets a test
/// see the dirt itself rather than its consequences.
fn settle(tree: &mut WidgetTree) -> Vec<(WidgetId, Dirt)> {
    guiduck_signals::flush_effects();
    tree.drain_commands();
    std::mem::take(&mut tree.dirt)
}

fn bind_probe_fixture() -> (WidgetTree, WidgetId) {
    let mut tree = tree();
    let root = tree.insert(Container::new(), row_style(0.0, 0.0), None);
    let probe = tree.insert(BindProbe::default(), taffy::Style::default(), Some(root));
    // Insertion's own structural dirt is not what these tests are about.
    settle(&mut tree);
    (tree, probe)
}

#[test]
fn bind_dyn_applies_value_and_raises_the_setters_dirt() {
    let (mut tree, probe) = bind_probe_fixture();
    let label = guiduck_signals::Signal::new(String::from("first"));
    tree.bind_dyn(
        probe,
        move || Box::new(label.get()) as Box<dyn Any>,
        label_thunk(),
    );

    assert_eq!(settle(&mut tree), [(probe, Dirt::LAYOUT)]);
    assert_eq!(tree.widget::<BindProbe>(probe).unwrap().label, "first");

    label.set(String::from("second"));
    assert_eq!(settle(&mut tree), [(probe, Dirt::LAYOUT)]);
    assert_eq!(tree.widget::<BindProbe>(probe).unwrap().label, "second");

    // The setter compares and sets, so a mutation that changes nothing marks
    // nothing — the erased borrow must not manufacture dirt of its own.
    tree.widget_dyn_mut(probe)
        .expect("probe exists")
        .downcast_mut::<BindProbe>()
        .expect("probe is a BindProbe")
        .set_label(String::from("second"));
    assert_eq!(settle(&mut tree), []);
}

#[test]
fn bind_dyn_carries_the_paint_class_too() {
    let (mut tree, probe) = bind_probe_fixture();
    let highlight = guiduck_signals::Signal::new(false);
    tree.bind_dyn(
        probe,
        move || Box::new(highlight.get()) as Box<dyn Any>,
        highlight_thunk(),
    );

    // The first run applies `false` over the default `false`: the setter
    // marks nothing, so the binding raises nothing.
    assert_eq!(settle(&mut tree), []);

    highlight.set(true);
    assert_eq!(settle(&mut tree), [(probe, Dirt::PAINT)]);
    assert!(tree.widget::<BindProbe>(probe).unwrap().highlight);
}

#[test]
fn bind_dyn_matches_typed_bind_in_state_and_dirt() {
    let mut tree = tree();
    let root = tree.insert(Container::new(), row_style(0.0, 0.0), None);
    let typed = tree.insert(BindProbe::default(), taffy::Style::default(), Some(root));
    let erased = tree.insert(BindProbe::default(), taffy::Style::default(), Some(root));
    settle(&mut tree);

    let label = guiduck_signals::Signal::new(String::from("first"));
    let highlight = guiduck_signals::Signal::new(false);
    tree.bind::<BindProbe, _>(typed, move || label.get(), |w, v| w.set_label(v));
    tree.bind_dyn(
        erased,
        move || Box::new(label.get()) as Box<dyn Any>,
        label_thunk(),
    );
    tree.bind::<BindProbe, _>(typed, move || highlight.get(), |w, v| w.set_highlight(v));
    tree.bind_dyn(
        erased,
        move || Box::new(highlight.get()) as Box<dyn Any>,
        highlight_thunk(),
    );

    // Each write drives both widgets through their respective path; the two
    // must agree on the value that lands and on the dirt it raises.
    for (label_value, highlight_value, expected) in [
        // A new label alone: layout-classed.
        ("first", false, Dirt::LAYOUT),
        // A new label and a new highlight in one settle: their classes
        // union, and layout subsumes paint.
        ("second", true, Dirt::LAYOUT),
        // The label is unchanged (so its effect never re-runs); only the
        // highlight moves, and its class is paint.
        ("second", false, Dirt::PAINT),
        ("third", false, Dirt::LAYOUT),
    ] {
        label.set(String::from(label_value));
        highlight.set(highlight_value);

        let dirt = settle(&mut tree);
        let dirt_for = |id| {
            dirt.iter()
                .filter(|(dirty, _)| *dirty == id)
                .fold(Dirt::CLEAN, |acc, (_, d)| acc.union(*d))
        };
        assert_eq!(
            dirt_for(typed),
            dirt_for(erased),
            "same mutation, same dirt ({label_value}, {highlight_value})"
        );
        assert_eq!(
            dirt_for(erased),
            expected,
            "the class the setter marked reaches the tree ({label_value}, {highlight_value})"
        );
        let typed_probe = tree.widget::<BindProbe>(typed).unwrap();
        let erased_probe = tree.widget::<BindProbe>(erased).unwrap();
        assert_eq!(typed_probe.label, erased_probe.label);
        assert_eq!(typed_probe.highlight, erased_probe.highlight);
        assert_eq!(typed_probe.label, label_value);
        assert_eq!(typed_probe.highlight, highlight_value);
    }
}

#[test]
fn bind_dyn_is_inert_when_the_thunk_does_not_recognize_its_target() {
    let (mut tree, probe) = bind_probe_fixture();
    let root = tree.parent(probe).expect("probe has a parent");
    let value = guiduck_signals::Signal::new(String::from("first"));

    // A thunk aimed at a widget of another type, and one handed a value of
    // another type: both apply nothing, exactly as a `mutate::<T>` whose
    // downcast fails does, and neither raises dirt.
    tree.bind_dyn(
        root,
        move || Box::new(value.get()) as Box<dyn Any>,
        label_thunk(),
    );
    tree.bind_dyn(
        probe,
        move || Box::new(value.get().len()) as Box<dyn Any>,
        label_thunk(),
    );

    assert_eq!(settle(&mut tree), []);
    assert_eq!(tree.widget::<BindProbe>(probe).unwrap().label, "");

    value.set(String::from("second"));
    assert_eq!(settle(&mut tree), []);
    assert_eq!(tree.widget::<BindProbe>(probe).unwrap().label, "");
}

#[test]
fn bind_dyn_on_a_removed_widget_is_a_no_op() {
    let (mut tree, probe) = bind_probe_fixture();
    let value = guiduck_signals::Signal::new(String::from("first"));
    tree.bind_dyn(
        probe,
        move || Box::new(value.get()) as Box<dyn Any>,
        label_thunk(),
    );
    settle(&mut tree);

    tree.remove(probe);
    settle(&mut tree);
    value.set(String::from("second"));
    // The command finds no node; as with `mutate`, a vanished widget makes
    // the update evaporate rather than panic.
    settle(&mut tree);
    assert!(tree.widget::<BindProbe>(probe).is_none());
}