tests.rs raw

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

use super::*;
use crate::clipboard::NoClipboard;
use crate::event::{EventKind, PointerButton, PointerInput};
use crate::text::TextContext;
use crate::widget::{Text, TextInput};

const VIEWPORT: Size = Size::new(400.0, 300.0);

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 tree() -> WidgetTree {
    WidgetTree::with_text_context(TextContext::hermetic([sample_font()]))
}

fn full_size() -> taffy::Style {
    taffy::Style {
        size: taffy::Size {
            width: percent(1.0_f32),
            height: percent(1.0_f32),
        },
        ..Default::default()
    }
}

fn fixed(width: f32, height: f32) -> taffy::Style {
    taffy::Style {
        size: taffy::Size {
            width: length(width),
            height: length(height),
        },
        flex_shrink: 0.0,
        ..Default::default()
    }
}

/// A base tree with a white background and one 100×40 button-ish child at
/// the top-left (padding 10).
fn base_tree() -> (WidgetTree, WidgetId) {
    let mut tree = tree();
    let root = tree.insert(
        Container::new().background(css::WHITE),
        taffy::Style {
            padding: taffy::Rect::length(10.0_f32),
            ..full_size()
        },
        None,
    );
    let button = tree.insert(
        Container::new().background(css::STEEL_BLUE),
        fixed(100.0, 40.0),
        Some(root),
    );
    (tree, button)
}

fn press(tree: &mut WidgetTree, pos: Point) {
    tree.dispatch_pointer(
        PointerInput::Down {
            time: std::time::Instant::now(),
            pos,
            button: PointerButton::Left,
        },
        &mut NoClipboard,
    );
    tree.dispatch_pointer(
        PointerInput::Up {
            pos,
            button: PointerButton::Left,
        },
        &mut NoClipboard,
    );
}

#[test]
fn frame_composes_overlays_above_the_base_at_their_positions() {
    let (mut tree, _) = base_tree();
    let overlay = tree.open_overlay(fixed(80.0, 60.0), OverlayOptions::dialog());
    tree.insert(
        Container::new().background(css::MISTY_ROSE),
        full_size(),
        Some(overlay),
    );
    let frame = tree.render_frame(VIEWPORT).expect("frame");

    let items = &tree.fragments().get(frame).expect("frame exists").items;
    let children: Vec<_> = items
        .iter()
        .filter_map(|item| match item {
            DisplayItem::Child {
                transform,
                fragment,
            } => Some((*transform, *fragment)),
            _ => None,
        })
        .collect();
    assert_eq!(children.len(), 2, "base plus one overlay");
    assert_eq!(
        children[0].0,
        guiduck_scene::geom::Affine::IDENTITY,
        "base draws first, at identity"
    );
    // An 80×60 dialog centers in 400×300.
    assert_eq!(
        children[1].0,
        guiduck_scene::geom::Affine::translate((160.0, 120.0))
    );

    // The retained frame stays structurally identical to the oracle.
    let mut fresh = FragmentStore::new();
    let fresh_frame = tree.paint_fresh(&mut fresh).expect("root");
    assert!(tree.fragments().trees_equal(frame, &fresh, fresh_frame));
}

#[test]
fn hit_testing_prefers_the_topmost_overlay_and_modals_block() {
    let (mut tree, button) = base_tree();
    tree.render_frame(VIEWPORT);
    // The button is hittable before any overlay exists.
    assert_eq!(tree.hit_test(Point::new(30.0, 20.0)), Some(button));

    let overlay = tree.open_overlay(fixed(80.0, 60.0), OverlayOptions::dialog());
    let content = tree.insert(
        Container::new().background(css::MISTY_ROSE),
        full_size(),
        Some(overlay),
    );
    tree.render_frame(VIEWPORT);

    // Inside the centered dialog (160..240 × 120..180): the overlay wins.
    assert_eq!(tree.hit_test(Point::new(200.0, 150.0)), Some(content));
    // Outside it: the modal is a barrier, nothing beneath is hittable.
    assert_eq!(tree.hit_test(Point::new(30.0, 20.0)), None);

    tree.close_overlay(overlay);
    tree.render_frame(VIEWPORT);
    assert_eq!(tree.hit_test(Point::new(30.0, 20.0)), Some(button));
}

#[test]
fn outside_press_dismisses_a_popup_and_is_consumed() {
    let (mut tree, button) = base_tree();
    let clicked = std::rc::Rc::new(std::cell::Cell::new(0));
    {
        let clicked = clicked.clone();
        tree.on_event(button, EventKind::Click, move |_ctx, _ev| {
            clicked.set(clicked.get() + 1);
        });
    }
    let closed = std::rc::Rc::new(std::cell::Cell::new(false));
    let popup = {
        let closed = closed.clone();
        tree.open_overlay(
            fixed(80.0, 60.0),
            OverlayOptions::popup_at(Point::new(250.0, 100.0)).on_close(move || closed.set(true)),
        )
    };
    tree.render_frame(VIEWPORT);

    // Press on the button, outside the popup: the popup closes and the
    // press is consumed — the button must NOT click.
    press(&mut tree, Point::new(30.0, 20.0));
    assert!(closed.get(), "popup dismissed by the outside press");
    assert!(!tree.is_overlay(popup));
    assert_eq!(clicked.get(), 0, "the dismissing press activates nothing");

    // The next press, with the popup gone, clicks normally.
    tree.render_frame(VIEWPORT);
    press(&mut tree, Point::new(30.0, 20.0));
    assert_eq!(clicked.get(), 1);
}

#[test]
fn escape_dismisses_the_topmost_dismissable_overlay() {
    let (mut tree, _) = base_tree();
    let dialog = tree.open_overlay(fixed(80.0, 60.0), OverlayOptions::dialog());
    tree.render_frame(VIEWPORT);

    let consumed = tree.dispatch_key(
        &crate::event::KeyInput {
            key: crate::event::Key::Escape,
            modifiers: Default::default(),
            pressed: true,
        },
        &mut NoClipboard,
    );
    assert!(consumed);
    assert!(!tree.is_overlay(dialog));
}

#[test]
fn a_modal_traps_focus_and_restores_it_on_close() {
    let mut tree = tree();
    let root = tree.insert(Container::new(), full_size(), None);
    let base_input = tree.insert(
        TextInput::new(13.0).family("DejaVu Sans"),
        fixed(120.0, 24.0),
        Some(root),
    );
    tree.render_frame(VIEWPORT);
    assert_eq!(tree.focus(), Some(base_input), "focus invariant");

    let dialog = tree.open_overlay(fixed(200.0, 100.0), OverlayOptions::dialog());
    let dialog_input = tree.insert(
        TextInput::new(13.0).family("DejaVu Sans"),
        fixed(120.0, 24.0),
        Some(dialog),
    );
    tree.render_frame(VIEWPORT);
    assert_eq!(
        tree.focus(),
        Some(dialog_input),
        "opening a modal moves focus into it"
    );

    // Tab cycles within the modal (one focusable: back to itself), never
    // out to the base input.
    tree.focus_step(false);
    assert_eq!(tree.focus(), Some(dialog_input));

    tree.close_overlay(dialog);
    tree.render_frame(VIEWPORT);
    assert_eq!(
        tree.focus(),
        Some(base_input),
        "closing the modal restores the prior focus"
    );
}

#[test]
fn a_modal_without_focusables_parks_focus() {
    let mut tree = tree();
    let root = tree.insert(Container::new(), full_size(), None);
    let base_input = tree.insert(
        TextInput::new(13.0).family("DejaVu Sans"),
        fixed(120.0, 24.0),
        Some(root),
    );
    tree.render_frame(VIEWPORT);
    assert_eq!(tree.focus(), Some(base_input));

    let dialog = tree.open_overlay(fixed(200.0, 100.0), OverlayOptions::dialog());
    tree.insert(
        Text::new("sure?").family("DejaVu Sans"),
        taffy::Style::default(),
        Some(dialog),
    );
    tree.render_frame(VIEWPORT);
    assert_eq!(
        tree.focus(),
        None,
        "keys must not leak beneath the modal while it is open"
    );

    tree.close_overlay(dialog);
    tree.render_frame(VIEWPORT);
    assert_eq!(tree.focus(), Some(base_input));
}

#[test]
fn anchored_placement_flips_at_the_viewport_edge() {
    let (mut tree, _button) = base_tree();
    // An anchor near the bottom of the viewport.
    let root = tree.root().expect("root");
    let anchor = tree.insert(
        Container::new(),
        taffy::Style {
            margin: taffy::Rect {
                top: length(220.0_f32),
                ..taffy::Rect::length(0.0_f32)
            },
            ..fixed(100.0, 40.0)
        },
        Some(root),
    );
    tree.render_frame(VIEWPORT);
    let anchor_rect =
        Rect::from_origin_size(tree.absolute_origin(anchor), tree.layout(anchor).size());

    let popup = tree.open_overlay(
        fixed(80.0, 60.0),
        OverlayOptions::popup(anchor, AnchorSide::Below),
    );
    tree.render_frame(VIEWPORT);
    let position = tree
        .overlays
        .iter()
        .find(|o| o.root == popup)
        .expect("open")
        .position;
    // Below would end at y ≈ anchor.y1 + 60 > 300, so it flips above.
    assert_eq!(
        position,
        Point::new(anchor_rect.x0, anchor_rect.y0 - 60.0),
        "flipped above the anchor (anchor at {anchor_rect:?})"
    );
}

#[test]
fn overlay_widgets_report_layered_absolute_origins() {
    let (mut tree, _) = base_tree();
    let overlay = tree.open_overlay(
        fixed(80.0, 60.0),
        OverlayOptions::popup_at(Point::new(250.0, 100.0)),
    );
    let content = tree.insert(
        Container::new(),
        taffy::Style {
            margin: taffy::Rect {
                left: length(5.0_f32),
                top: length(7.0_f32),
                ..taffy::Rect::length(0.0_f32)
            },
            ..fixed(20.0, 20.0)
        },
        Some(overlay),
    );
    tree.render_frame(VIEWPORT);
    assert_eq!(
        tree.absolute_origin(content),
        Point::new(255.0, 107.0),
        "overlay position plus local layout"
    );
    // And the hit test agrees with the reported origin.
    assert_eq!(tree.hit_test(Point::new(256.0, 108.0)), Some(content));
}