use core::num::{NonZeroU16, NonZeroU32, NonZeroU64};
use std::sync::Arc;

use super::axis::{Axis, CrossAxisAlign, LayoutDirection, MainAxisJustify};
use super::dock::{
    DockNode, DockState, DockStateError, FloatingSurface, PanelId, SplitFraction, TabIndex,
};
use super::engine::{LayoutError, NodeKind, SolvedLayout, measure};
use super::geometry::{EdgeInsets, LayoutPos, LayoutPx, LayoutPxError, LayoutSize};
use super::paint::{PaintCommand, paint_plan};
use super::primitives::{DockPanel, GridChild, Layout};
use super::retained::{RetainedLayout, ScrollOffset};
use super::scroll::{ScrollAxes, clamp_scroll};
use super::splitter::{SplitterMove, SplitterStep, apply_keyboard_move, fraction_from_drag};
use super::track::{FlexWeight, GridLine, GridLineRef, GridSpan, GridTrack, TrackName, TrackSize};
use crate::theme::{Spacing, Step12, Theme};
use crate::widget_id::WidgetId;

fn wid(n: u64) -> WidgetId {
    let Some(nz) = NonZeroU64::new(n) else {
        panic!("test widget id must be non-zero");
    };
    WidgetId::from_raw(nz)
}

fn pid(n: u32) -> PanelId {
    let Some(nz) = NonZeroU32::new(n) else {
        panic!("test panel id must be non-zero");
    };
    PanelId::new(nz)
}

fn gl(n: u16) -> GridLine {
    let Some(nz) = NonZeroU16::new(n) else {
        panic!("test grid line must be non-zero");
    };
    GridLine::new(nz)
}

fn fw(n: u16) -> FlexWeight {
    let Some(nz) = NonZeroU16::new(n) else {
        panic!("test flex weight must be non-zero");
    };
    FlexWeight::new(nz)
}

fn size(w: f32, h: f32) -> LayoutSize {
    LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h))
}

fn sp(px: f32) -> Spacing {
    Spacing::px(px)
}

fn solve(layout: &Layout, available: LayoutSize, retained: &RetainedLayout) -> SolvedLayout {
    solve_with(layout, available, retained, LayoutDirection::Ltr)
}

fn solve_with(
    layout: &Layout,
    available: LayoutSize,
    retained: &RetainedLayout,
    direction: LayoutDirection,
) -> SolvedLayout {
    measure(layout, available, retained, direction)
        .unwrap_or_else(|e| panic!("measure failed: {e:?}"))
}

const PX_EPSILON: f32 = 1e-3;

fn approx_eq(a: f32, b: f32, tol: f32) -> bool {
    (a - b).abs() <= tol
}

#[test]
fn row_with_gap_distributes_children_along_main_axis() {
    let layout = Layout::Row {
        gap: sp(10.0),
        justify: MainAxisJustify::Start,
        cross: CrossAxisAlign::Stretch,
        children: vec![Layout::leaf(wid(1)), Layout::leaf(wid(2))],
    };
    let solved = solve(&layout, size(100.0, 50.0), &RetainedLayout::default());
    let root = solved.root_node();
    assert!(matches!(root.kind, NodeKind::Pass));
    assert!(approx_eq(root.rect.size.width.value(), 100.0, PX_EPSILON));
    let first = solved.node(root.children[0]);
    let second = solved.node(root.children[1]);
    let gap = second.rect.min_x().value() - first.rect.max_x().value();
    assert!((gap - 10.0).abs() < 1e-3, "gap {gap} should equal 10px");
    assert!(approx_eq(first.rect.min_y().value(), 0.0, PX_EPSILON));
    assert!(approx_eq(first.rect.size.height.value(), 50.0, PX_EPSILON));
}

#[test]
fn row_justify_center_centers_content() {
    let layout = Layout::Row {
        gap: sp(0.0),
        justify: MainAxisJustify::Center,
        cross: CrossAxisAlign::Center,
        children: vec![Layout::Gap { size: sp(40.0) }],
    };
    let solved = solve(&layout, size(100.0, 50.0), &RetainedLayout::default());
    let only = solved.node(solved.root_node().children[0]);
    let left = only.rect.min_x().value();
    let right = 100.0 - only.rect.max_x().value();
    assert!(
        (left - right).abs() < 1.0,
        "centered: left={left} right={right}"
    );
}

#[test]
fn column_stretches_cross_axis() {
    let layout = Layout::Column {
        gap: sp(0.0),
        justify: MainAxisJustify::Start,
        cross: CrossAxisAlign::Stretch,
        children: vec![Layout::leaf(wid(1)), Layout::leaf(wid(2))],
    };
    let solved = solve(&layout, size(80.0, 40.0), &RetainedLayout::default());
    let kids = &solved.root_node().children;
    assert_eq!(kids.len(), 2);
    assert!(kids.iter().all(|i| approx_eq(
        solved.node(*i).rect.size.width.value(),
        80.0,
        PX_EPSILON
    )));
    assert!(solved.node(kids[1]).rect.min_y().value() >= solved.node(kids[0]).rect.max_y().value());
}

#[test]
fn stack_fills_children_to_full_bounds() {
    let layout = Layout::Stack {
        children: vec![Layout::leaf(wid(1)), Layout::leaf(wid(2))],
    };
    let solved = solve(&layout, size(60.0, 40.0), &RetainedLayout::default());
    let kids = &solved.root_node().children;
    let a = solved.node(kids[0]);
    let b = solved.node(kids[1]);
    assert!(approx_eq(a.rect.size.width.value(), 60.0, PX_EPSILON));
    assert!(approx_eq(a.rect.size.height.value(), 40.0, PX_EPSILON));
    assert!(approx_eq(b.rect.size.width.value(), 60.0, PX_EPSILON));
    assert!(approx_eq(b.rect.size.height.value(), 40.0, PX_EPSILON));
    assert_eq!(a.rect.origin, b.rect.origin);
}

#[test]
fn grid_spans_two_columns() {
    let Some(span) = GridSpan::rect(gl(1), gl(3), gl(1), gl(2)) else {
        panic!("valid span");
    };
    let layout = Layout::Grid {
        columns: vec![
            GridTrack::unnamed(TrackSize::FLEX_1),
            GridTrack::unnamed(TrackSize::FLEX_1),
        ],
        rows: vec![GridTrack::unnamed(TrackSize::FLEX_1)],
        column_gap: sp(0.0),
        row_gap: sp(0.0),
        children: vec![GridChild {
            span,
            child: Layout::leaf(wid(1)),
        }],
    };
    let solved = solve(&layout, size(100.0, 30.0), &RetainedLayout::default());
    let cell = solved.node(solved.root_node().children[0]);
    assert!(
        (cell.rect.size.width.value() - 100.0).abs() < 1e-3,
        "spanning two columns must take full width: {}",
        cell.rect.size.width.value(),
    );
}

#[test]
fn grid_resolves_named_tracks() {
    const SIDE: TrackName = TrackName::new("side");
    const MID: TrackName = TrackName::new("mid");
    const END: TrackName = TrackName::new("end");
    let span = GridSpan {
        column_start: GridLineRef::Name(MID),
        column_end: GridLineRef::Name(END),
        row_start: GridLineRef::Line(gl(1)),
        row_end: GridLineRef::Line(gl(2)),
    };
    let layout = Layout::Grid {
        columns: vec![
            GridTrack::named(SIDE, TrackSize::FLEX_1),
            GridTrack::named(MID, TrackSize::FLEX_1),
            GridTrack::named(END, TrackSize::FLEX_1),
        ],
        rows: vec![GridTrack::unnamed(TrackSize::FLEX_1)],
        column_gap: sp(0.0),
        row_gap: sp(0.0),
        children: vec![GridChild {
            span,
            child: Layout::leaf(wid(1)),
        }],
    };
    let solved = solve(&layout, size(120.0, 30.0), &RetainedLayout::default());
    let cell = solved.node(solved.root_node().children[0]);
    assert!(
        approx_eq(cell.rect.min_x().value(), 40.0, 1.0),
        "child should start at col 'mid' (40px): {}",
        cell.rect.min_x().value()
    );
    assert!(
        approx_eq(cell.rect.size.width.value(), 40.0, 1.0),
        "child width = one column (40px): {}",
        cell.rect.size.width.value()
    );
}

#[test]
fn grid_unknown_track_name_errors() {
    const KNOWN: TrackName = TrackName::new("known");
    const MISSING: TrackName = TrackName::new("missing");
    let span = GridSpan {
        column_start: GridLineRef::Name(MISSING),
        column_end: GridLineRef::Name(KNOWN),
        row_start: GridLineRef::Line(gl(1)),
        row_end: GridLineRef::Line(gl(2)),
    };
    let layout = Layout::Grid {
        columns: vec![GridTrack::named(KNOWN, TrackSize::FLEX_1)],
        rows: vec![GridTrack::unnamed(TrackSize::FLEX_1)],
        column_gap: sp(0.0),
        row_gap: sp(0.0),
        children: vec![GridChild {
            span,
            child: Layout::leaf(wid(1)),
        }],
    };
    let result = measure(
        &layout,
        size(100.0, 30.0),
        &RetainedLayout::default(),
        LayoutDirection::Ltr,
    );
    let Err(LayoutError::UnknownTrackName(name)) = result else {
        panic!("expected UnknownTrackName, got {result:?}");
    };
    assert_eq!(name, "missing");
}

#[test]
fn inset_subtracts_padding_from_child() {
    let layout = Layout::inset(EdgeInsets::all(sp(8.0)), Layout::leaf(wid(1)));
    let solved = solve(&layout, size(100.0, 100.0), &RetainedLayout::default());
    let child = solved.node(solved.root_node().children[0]);
    assert!(approx_eq(child.rect.min_x().value(), 8.0, PX_EPSILON));
    assert!(approx_eq(child.rect.min_y().value(), 8.0, PX_EPSILON));
    assert!((child.rect.size.width.value() - (100.0 - 16.0)).abs() < 1e-3);
}

#[test]
fn inset_start_lives_on_left_in_ltr() {
    let padding = EdgeInsets {
        start: sp(20.0),
        end: sp(4.0),
        top: sp(0.0),
        bottom: sp(0.0),
    };
    let layout = Layout::inset(padding, Layout::leaf(wid(1)));
    let solved = solve(&layout, size(100.0, 30.0), &RetainedLayout::default());
    let child = solved.node(solved.root_node().children[0]);
    assert!(approx_eq(child.rect.min_x().value(), 20.0, PX_EPSILON));
    assert!(approx_eq(child.rect.size.width.value(), 76.0, PX_EPSILON));
}

#[test]
fn inset_start_flips_to_right_in_rtl() {
    let padding = EdgeInsets {
        start: sp(20.0),
        end: sp(4.0),
        top: sp(0.0),
        bottom: sp(0.0),
    };
    let layout = Layout::inset(padding, Layout::leaf(wid(1)));
    let solved = solve_with(
        &layout,
        size(100.0, 30.0),
        &RetainedLayout::default(),
        LayoutDirection::Rtl,
    );
    let child = solved.node(solved.root_node().children[0]);
    assert!(approx_eq(child.rect.min_x().value(), 4.0, PX_EPSILON));
    assert!(approx_eq(child.rect.size.width.value(), 76.0, PX_EPSILON));
}

#[test]
fn row_in_rtl_places_first_child_on_the_right() {
    let layout = Layout::Row {
        gap: sp(0.0),
        justify: MainAxisJustify::Start,
        cross: CrossAxisAlign::Stretch,
        children: vec![
            Layout::Gap { size: sp(30.0) },
            Layout::Gap { size: sp(40.0) },
        ],
    };
    let ltr = solve(&layout, size(100.0, 10.0), &RetainedLayout::default());
    let rtl = solve_with(
        &layout,
        size(100.0, 10.0),
        &RetainedLayout::default(),
        LayoutDirection::Rtl,
    );
    let ltr_first = ltr.node(ltr.root_node().children[0]);
    let rtl_first = rtl.node(rtl.root_node().children[0]);
    assert!(approx_eq(ltr_first.rect.min_x().value(), 0.0, PX_EPSILON));
    assert!(approx_eq(rtl_first.rect.min_x().value(), 70.0, PX_EPSILON));
}

#[test]
fn grid_columns_flip_in_rtl() {
    let Some(span) = GridSpan::rect(gl(1), gl(2), gl(1), gl(2)) else {
        panic!("valid span");
    };
    let layout = Layout::Grid {
        columns: vec![
            GridTrack::unnamed(TrackSize::Fixed(sp(20.0))),
            GridTrack::unnamed(TrackSize::Fixed(sp(80.0))),
        ],
        rows: vec![GridTrack::unnamed(TrackSize::Fixed(sp(10.0)))],
        column_gap: sp(0.0),
        row_gap: sp(0.0),
        children: vec![GridChild {
            span,
            child: Layout::leaf(wid(1)),
        }],
    };
    let ltr = solve(&layout, size(100.0, 10.0), &RetainedLayout::default());
    let rtl = solve_with(
        &layout,
        size(100.0, 10.0),
        &RetainedLayout::default(),
        LayoutDirection::Rtl,
    );
    let ltr_cell = ltr.node(ltr.root_node().children[0]);
    let rtl_cell = rtl.node(rtl.root_node().children[0]);
    assert!(approx_eq(ltr_cell.rect.min_x().value(), 0.0, PX_EPSILON));
    assert!(approx_eq(rtl_cell.rect.min_x().value(), 80.0, PX_EPSILON));
    assert!(approx_eq(
        ltr_cell.rect.size.width.value(),
        20.0,
        PX_EPSILON
    ));
    assert!(approx_eq(
        rtl_cell.rect.size.width.value(),
        20.0,
        PX_EPSILON
    ));
}

#[test]
fn horizontal_splitter_in_rtl_swaps_panel_sides() {
    let id = wid(7);
    let mut retained = RetainedLayout::default();
    retained.set_split(id, SplitFraction::clamped(0.25));
    let layout = Layout::Splitter {
        id,
        axis: Axis::Horizontal,
        default_fraction: SplitFraction::HALF,
        a: Box::new(Layout::leaf(wid(1))),
        b: Box::new(Layout::leaf(wid(2))),
    };
    let ltr = solve(&layout, size(200.0, 50.0), &retained);
    let rtl = solve_with(&layout, size(200.0, 50.0), &retained, LayoutDirection::Rtl);
    let ltr_a = ltr.node(ltr.root_node().children[0]);
    let rtl_a = rtl.node(rtl.root_node().children[0]);
    assert!(approx_eq(ltr_a.rect.min_x().value(), 0.0, PX_EPSILON));
    assert!(approx_eq(rtl_a.rect.min_x().value(), 150.0, PX_EPSILON));
    assert!(approx_eq(ltr_a.rect.size.width.value(), 50.0, 1.0));
    assert!(approx_eq(rtl_a.rect.size.width.value(), 50.0, 1.0));
}

#[test]
fn horizontal_dock_split_in_rtl_swaps_panel_sides() {
    let a = pid(10);
    let b = pid(11);
    let dock = DockState::new(DockNode::Split {
        axis: Axis::Horizontal,
        fraction: SplitFraction::clamped(0.25),
        a: Box::new(DockNode::tabs(vec![a])),
        b: Box::new(DockNode::tabs(vec![b])),
    });
    let panels = vec![
        DockPanel {
            id: a,
            child: Layout::leaf(wid(1)),
        },
        DockPanel {
            id: b,
            child: Layout::leaf(wid(2)),
        },
    ];
    let layout = Layout::DockHost {
        id: wid(50),
        state: Arc::new(dock),
        panels,
        tab_strip_height: sp(24.0),
    };
    let ltr = solve(&layout, size(200.0, 100.0), &RetainedLayout::default());
    let rtl = solve_with(
        &layout,
        size(200.0, 100.0),
        &RetainedLayout::default(),
        LayoutDirection::Rtl,
    );
    let leaf_x = |solved: &SolvedLayout, target: WidgetId| {
        solved
            .nodes
            .iter()
            .find_map(|n| match n.kind {
                NodeKind::Leaf(id) if id == target => Some(n.rect.min_x().value()),
                _ => None,
            })
            .unwrap_or_else(|| panic!("leaf {target:?} not found"))
    };
    assert!(approx_eq(leaf_x(&ltr, wid(1)), 0.0, PX_EPSILON));
    assert!(approx_eq(leaf_x(&rtl, wid(1)), 150.0, PX_EPSILON));
}

#[test]
fn column_cross_axis_start_flips_to_right_in_rtl() {
    let layout = Layout::Column {
        gap: sp(0.0),
        justify: MainAxisJustify::Start,
        cross: CrossAxisAlign::Start,
        children: vec![Layout::Row {
            gap: sp(0.0),
            justify: MainAxisJustify::Start,
            cross: CrossAxisAlign::Stretch,
            children: vec![Layout::Gap { size: sp(20.0) }],
        }],
    };
    let ltr = solve(&layout, size(100.0, 50.0), &RetainedLayout::default());
    let rtl = solve_with(
        &layout,
        size(100.0, 50.0),
        &RetainedLayout::default(),
        LayoutDirection::Rtl,
    );
    let ltr_row = ltr.node(ltr.root_node().children[0]);
    let rtl_row = rtl.node(rtl.root_node().children[0]);
    assert!(approx_eq(ltr_row.rect.min_x().value(), 0.0, PX_EPSILON));
    assert!(approx_eq(rtl_row.rect.min_x().value(), 80.0, PX_EPSILON));
}

#[test]
fn spacer_consumes_remaining_main_axis_space() {
    let layout = Layout::Row {
        gap: sp(0.0),
        justify: MainAxisJustify::Start,
        cross: CrossAxisAlign::Stretch,
        children: vec![
            Layout::Gap { size: sp(20.0) },
            Layout::spacer(),
            Layout::Gap { size: sp(20.0) },
        ],
    };
    let solved = solve(&layout, size(100.0, 10.0), &RetainedLayout::default());
    let middle = solved.node(solved.root_node().children[1]);
    assert!((middle.rect.size.width.value() - 60.0).abs() < 1.0);
}

#[test]
fn splitter_uses_retained_fraction() {
    let id = wid(7);
    let mut retained = RetainedLayout::default();
    retained.set_split(id, SplitFraction::clamped(0.25));
    let layout = Layout::Splitter {
        id,
        axis: Axis::Horizontal,
        default_fraction: SplitFraction::HALF,
        a: Box::new(Layout::leaf(wid(1))),
        b: Box::new(Layout::leaf(wid(2))),
    };
    let solved = solve(&layout, size(200.0, 50.0), &retained);
    let root = solved.root_node();
    let a = solved.node(root.children[0]);
    let b = solved.node(root.children[1]);
    assert!(
        (a.rect.size.width.value() - 50.0).abs() < 1.0,
        "left should take 25%: {}",
        a.rect.size.width.value()
    );
    assert!((b.rect.size.width.value() - 150.0).abs() < 1.0);
    let NodeKind::Splitter { fraction, .. } = root.kind else {
        panic!("expected splitter, got {:?}", root.kind);
    };
    assert!((fraction.value() - 0.25).abs() < 1e-6);
}

#[test]
fn splitter_default_fraction_when_unrecorded() {
    let id = wid(11);
    let layout = Layout::Splitter {
        id,
        axis: Axis::Vertical,
        default_fraction: SplitFraction::clamped(0.3),
        a: Box::new(Layout::leaf(wid(1))),
        b: Box::new(Layout::leaf(wid(2))),
    };
    let solved = solve(&layout, size(40.0, 100.0), &RetainedLayout::default());
    let a = solved.node(solved.root_node().children[0]);
    assert!((a.rect.size.height.value() - 30.0).abs() < 1.0);
}

#[test]
fn splitter_keyboard_move_clamps_to_unit_interval() {
    let f = SplitFraction::clamped(0.05);
    let next = apply_keyboard_move(
        f,
        SplitFraction::HALF,
        SplitterMove::DecreaseA(SplitterStep::Coarse),
    );
    assert_eq!(next, SplitFraction::clamped(0.0));
    let max = apply_keyboard_move(
        SplitFraction::clamped(0.99),
        SplitFraction::HALF,
        SplitterMove::Max,
    );
    assert!(approx_eq(max.value(), 1.0, 1e-6));
    let reset = apply_keyboard_move(
        SplitFraction::clamped(0.1),
        SplitFraction::clamped(0.4),
        SplitterMove::Reset,
    );
    assert!((reset.value() - 0.4).abs() < 1e-6);
}

#[test]
fn splitter_drag_zero_total_falls_back_to_half() {
    assert_eq!(
        fraction_from_drag(LayoutPx::ZERO, LayoutPx::new(10.0)),
        SplitFraction::HALF
    );
}

#[test]
fn splitter_divider_paints_at_first_child_max_x() {
    let id = wid(12);
    let mut retained = RetainedLayout::default();
    retained.set_split(id, SplitFraction::clamped(0.4));
    let layout = Layout::Splitter {
        id,
        axis: Axis::Horizontal,
        default_fraction: SplitFraction::HALF,
        a: Box::new(Layout::leaf(wid(1))),
        b: Box::new(Layout::leaf(wid(2))),
    };
    let solved = solve(&layout, size(200.0, 50.0), &retained);
    let plan = paint_plan(&solved, &Theme::light(), None);
    let divider = plan.commands.iter().find_map(|c| match c {
        PaintCommand::Divider { rect, .. } => Some(*rect),
        _ => None,
    });
    let Some(divider) = divider else {
        panic!("divider command not emitted");
    };
    assert!(
        approx_eq(divider.min_x().value(), 80.0, 1.0),
        "divider should sit at A.max_x = 80px (40% of 200): got {}",
        divider.min_x().value()
    );
    assert!(approx_eq(divider.size.height.value(), 50.0, PX_EPSILON));
}

#[test]
fn splitter_divider_takes_accent_when_hovered() {
    let id = wid(12);
    let mut retained = RetainedLayout::default();
    retained.set_split(id, SplitFraction::clamped(0.4));
    let layout = Layout::Splitter {
        id,
        axis: Axis::Horizontal,
        default_fraction: SplitFraction::HALF,
        a: Box::new(Layout::leaf(wid(1))),
        b: Box::new(Layout::leaf(wid(2))),
    };
    let solved = solve(&layout, size(200.0, 50.0), &retained);
    let theme = Theme::light();
    let divider_color = |hot| {
        paint_plan(&solved, &theme, hot)
            .commands
            .iter()
            .find_map(|c| match c {
                PaintCommand::Divider { color, .. } => Some(*color),
                _ => None,
            })
    };
    assert_eq!(
        divider_color(None),
        Some(theme.colors.neutral.step(Step12::BORDER))
    );
    assert_eq!(
        divider_color(Some(id)),
        Some(theme.colors.accent.step(Step12::SOLID))
    );
    assert_eq!(
        divider_color(Some(wid(999))),
        Some(theme.colors.neutral.step(Step12::BORDER))
    );
}

#[test]
fn scroll_region_records_offset_and_content_size() {
    let id = wid(3);
    let mut retained = RetainedLayout::default();
    retained.set_scroll(id, ScrollOffset::new(LayoutPx::ZERO, LayoutPx::new(12.0)));
    let inner = Layout::Column {
        gap: sp(0.0),
        justify: MainAxisJustify::Start,
        cross: CrossAxisAlign::Stretch,
        children: (0..10)
            .map(|_| Layout::Gap { size: sp(15.0) })
            .chain(core::iter::once(Layout::leaf(wid(99))))
            .collect(),
    };
    let layout = Layout::ScrollRegion {
        id,
        axes: ScrollAxes::Vertical,
        child: Box::new(inner),
    };
    let solved = solve(&layout, size(80.0, 60.0), &retained);
    let NodeKind::ScrollRegion { offset, axes, .. } = &solved.root_node().kind else {
        panic!("expected ScrollRegion, got {:?}", solved.root_node().kind);
    };
    assert_eq!(
        *offset,
        ScrollOffset::new(LayoutPx::ZERO, LayoutPx::new(12.0))
    );
    assert!(matches!(axes, ScrollAxes::Vertical));
    assert!(solved.root_node().content_size.height.value() >= 60.0);
}

#[test]
fn clamp_scroll_caps_against_content_size() {
    let clamped = clamp_scroll(
        ScrollOffset::new(LayoutPx::ZERO, LayoutPx::new(500.0)),
        size(80.0, 100.0),
        size(80.0, 300.0),
        ScrollAxes::Vertical,
    );
    assert!(approx_eq(clamped.y.value(), 200.0, PX_EPSILON));
    assert!(approx_eq(clamped.x.value(), 0.0, PX_EPSILON));
}

#[test]
fn clamp_scroll_horizontal_axis_clamps_x() {
    let clamped = clamp_scroll(
        ScrollOffset::new(LayoutPx::new(800.0), LayoutPx::ZERO),
        size(200.0, 100.0),
        size(500.0, 100.0),
        ScrollAxes::Horizontal,
    );
    assert!(approx_eq(clamped.x.value(), 300.0, PX_EPSILON));
    assert!(approx_eq(clamped.y.value(), 0.0, PX_EPSILON));
}

#[test]
fn clamp_scroll_disallowed_axis_zeroes_request() {
    let clamped = clamp_scroll(
        ScrollOffset::new(LayoutPx::new(50.0), LayoutPx::new(999.0)),
        size(200.0, 100.0),
        size(500.0, 100.0),
        ScrollAxes::Horizontal,
    );
    assert!(approx_eq(clamped.x.value(), 50.0, PX_EPSILON));
    assert!(approx_eq(clamped.y.value(), 0.0, PX_EPSILON));
}

#[test]
fn clamp_scroll_both_axes_clamp_independently() {
    let clamped = clamp_scroll(
        ScrollOffset::new(LayoutPx::new(700.0), LayoutPx::new(700.0)),
        size(100.0, 100.0),
        size(400.0, 250.0),
        ScrollAxes::Both,
    );
    assert!(approx_eq(clamped.x.value(), 300.0, PX_EPSILON));
    assert!(approx_eq(clamped.y.value(), 150.0, PX_EPSILON));
}

#[test]
fn dock_classic_default_panel_set_is_complete() {
    let panels = (pid(1), pid(2), pid(3), pid(4), pid(5), pid(6));
    let Ok(dock) =
        DockState::classic_default(panels.0, panels.1, panels.2, panels.3, panels.4, panels.5)
    else {
        panic!("classic_default rejected distinct ids");
    };
    let mut ids = dock.main.panel_ids();
    ids.sort();
    let mut expected = vec![panels.0, panels.1, panels.2, panels.3, panels.4, panels.5];
    expected.sort();
    assert_eq!(ids, expected);
}

#[test]
fn dock_host_layout_resolves_panels() {
    let menu = pid(1);
    let feature = pid(2);
    let viewport = pid(3);
    let property = pid(4);
    let ribbon = pid(5);
    let status = pid(6);
    let Ok(dock) = DockState::classic_default(menu, feature, property, ribbon, viewport, status)
    else {
        panic!("classic_default rejected distinct ids");
    };
    let panels = vec![
        DockPanel {
            id: menu,
            child: Layout::leaf(wid(10)),
        },
        DockPanel {
            id: feature,
            child: Layout::leaf(wid(11)),
        },
        DockPanel {
            id: property,
            child: Layout::leaf(wid(12)),
        },
        DockPanel {
            id: ribbon,
            child: Layout::leaf(wid(13)),
        },
        DockPanel {
            id: viewport,
            child: Layout::leaf(wid(14)),
        },
        DockPanel {
            id: status,
            child: Layout::leaf(wid(15)),
        },
    ];
    let layout = Layout::DockHost {
        id: wid(99),
        state: Arc::new(dock),
        panels,
        tab_strip_height: sp(24.0),
    };
    let solved = solve(&layout, size(1280.0, 800.0), &RetainedLayout::default());
    assert!(matches!(solved.root_node().kind, NodeKind::DockHost { .. }));
}

#[test]
fn dock_node_tabs_picks_active_panel() {
    let a = pid(10);
    let b = pid(11);
    let mut tabs = DockNode::tabs(vec![a, b]);
    if let DockNode::Tabs { active, .. } = &mut tabs {
        *active = TabIndex::new(1);
    }
    let layout = Layout::DockHost {
        id: wid(50),
        state: Arc::new(DockState::new(tabs)),
        panels: vec![
            DockPanel {
                id: a,
                child: Layout::leaf(wid(1)),
            },
            DockPanel {
                id: b,
                child: Layout::leaf(wid(2)),
            },
        ],
        tab_strip_height: sp(24.0),
    };
    let solved = solve(&layout, size(120.0, 80.0), &RetainedLayout::default());
    let leaf = solved.nodes.iter().find_map(|n| match &n.kind {
        NodeKind::Leaf(id) => Some(*id),
        _ => None,
    });
    let Some(leaf) = leaf else {
        panic!("expected a leaf node in the solved layout");
    };
    assert_eq!(leaf, wid(2));
}

#[test]
fn dock_multi_tab_emits_tab_strip_with_active() {
    let a = pid(30);
    let b = pid(31);
    let c = pid(32);
    let mut tabs = DockNode::tabs(vec![a, b, c]);
    if let DockNode::Tabs { active, .. } = &mut tabs {
        *active = TabIndex::new(1);
    }
    let layout = Layout::DockHost {
        id: wid(80),
        state: Arc::new(DockState::new(tabs)),
        panels: vec![
            DockPanel {
                id: a,
                child: Layout::leaf(wid(1)),
            },
            DockPanel {
                id: b,
                child: Layout::leaf(wid(2)),
            },
            DockPanel {
                id: c,
                child: Layout::leaf(wid(3)),
            },
        ],
        tab_strip_height: sp(24.0),
    };
    let solved = solve(&layout, size(300.0, 200.0), &RetainedLayout::default());
    let plan = paint_plan(&solved, &Theme::light(), None);
    let strip = plan.commands.iter().find_map(|c| match c {
        PaintCommand::TabStrip { active, tabs, .. } => Some((*active, tabs.clone())),
        _ => None,
    });
    let Some((active, tabs)) = strip else {
        panic!("multi-tab dock did not emit TabStrip");
    };
    assert_eq!(active, b);
    assert_eq!(tabs, vec![a, b, c]);
}

#[test]
fn dock_tab_index_out_of_range_falls_back_to_first() {
    let a = pid(40);
    let b = pid(41);
    let mut tabs = DockNode::tabs(vec![a, b]);
    if let DockNode::Tabs { active, .. } = &mut tabs {
        *active = TabIndex::new(99);
    }
    let layout = Layout::DockHost {
        id: wid(81),
        state: Arc::new(DockState::new(tabs)),
        panels: vec![
            DockPanel {
                id: a,
                child: Layout::leaf(wid(1)),
            },
            DockPanel {
                id: b,
                child: Layout::leaf(wid(2)),
            },
        ],
        tab_strip_height: sp(24.0),
    };
    let solved = solve(&layout, size(120.0, 80.0), &RetainedLayout::default());
    let leaf = solved.nodes.iter().find_map(|n| match &n.kind {
        NodeKind::Leaf(id) => Some(*id),
        _ => None,
    });
    let Some(leaf) = leaf else {
        panic!("expected a leaf");
    };
    assert_eq!(leaf, wid(1));
}

#[test]
fn paint_plan_emits_clip_for_scroll_region() {
    let id = wid(4);
    let layout = Layout::ScrollRegion {
        id,
        axes: ScrollAxes::Both,
        child: Box::new(Layout::leaf(wid(5))),
    };
    let solved = solve(&layout, size(40.0, 40.0), &RetainedLayout::default());
    let plan = paint_plan(&solved, &Theme::light(), None);
    let has_clip = plan
        .commands
        .iter()
        .any(|c| matches!(c, PaintCommand::PushClip { .. }));
    let has_pop = plan
        .commands
        .iter()
        .any(|c| matches!(c, PaintCommand::PopClip));
    assert!(has_clip && has_pop);
}

#[test]
fn paint_plan_emits_divider_for_splitter() {
    let layout = Layout::Splitter {
        id: wid(8),
        axis: Axis::Horizontal,
        default_fraction: SplitFraction::HALF,
        a: Box::new(Layout::leaf(wid(1))),
        b: Box::new(Layout::leaf(wid(2))),
    };
    let solved = solve(&layout, size(200.0, 50.0), &RetainedLayout::default());
    let plan = paint_plan(&solved, &Theme::light(), None);
    let dividers = plan
        .commands
        .iter()
        .filter(|c| matches!(c, PaintCommand::Divider { .. }))
        .count();
    assert_eq!(dividers, 1);
}

#[test]
fn split_fraction_rejects_non_unit_input() {
    assert!(SplitFraction::new(-0.1).is_err());
    assert!(SplitFraction::new(1.1).is_err());
    assert!(SplitFraction::new(f32::NAN).is_err());
}

#[test]
fn grid_span_rect_rejects_non_increasing_lines() {
    assert!(GridSpan::rect(gl(2), gl(1), gl(1), gl(2)).is_none());
    assert!(GridSpan::rect(gl(1), gl(1), gl(1), gl(2)).is_none());
    assert!(GridSpan::rect(gl(1), gl(2), gl(2), gl(2)).is_none());
    assert!(GridSpan::rect(gl(1), gl(2), gl(1), gl(2)).is_some());
}

#[test]
fn layout_px_saturating_replaces_non_finite_with_zero() {
    assert!(approx_eq(
        LayoutPx::saturating(f32::NAN).value(),
        0.0,
        PX_EPSILON
    ));
    assert!(approx_eq(
        LayoutPx::saturating(f32::INFINITY).value(),
        0.0,
        PX_EPSILON
    ));
    assert!(approx_eq(
        LayoutPx::saturating(-3.0).value(),
        -3.0,
        PX_EPSILON
    ));
    assert!(approx_eq(
        LayoutPx::saturating_nonneg(-3.0).value(),
        0.0,
        PX_EPSILON
    ));
    assert!(approx_eq(
        LayoutPx::saturating_nonneg(f32::NEG_INFINITY).value(),
        0.0,
        PX_EPSILON,
    ));
    assert!(approx_eq(
        LayoutPx::saturating_nonneg(7.0).value(),
        7.0,
        PX_EPSILON
    ));
}

#[test]
fn layout_rect_max_x_saturates_on_overflow() {
    use super::geometry::{LayoutPos, LayoutRect};
    let big = LayoutPx::saturating(f32::MAX);
    let r = LayoutRect::new(
        LayoutPos::new(big, LayoutPx::ZERO),
        LayoutSize::new(big, LayoutPx::ZERO),
    );
    assert_eq!(r.max_x(), LayoutPx::ZERO);
}

#[test]
fn row_space_between_pushes_extremes_to_edges() {
    let layout = Layout::Row {
        gap: sp(0.0),
        justify: MainAxisJustify::SpaceBetween,
        cross: CrossAxisAlign::Center,
        children: vec![
            Layout::Gap { size: sp(20.0) },
            Layout::Gap { size: sp(20.0) },
            Layout::Gap { size: sp(20.0) },
        ],
    };
    let solved = solve(&layout, size(120.0, 10.0), &RetainedLayout::default());
    let kids = &solved.root_node().children;
    let first = solved.node(kids[0]);
    let last = solved.node(kids[2]);
    assert!(approx_eq(first.rect.min_x().value(), 0.0, 1.0));
    assert!(approx_eq(last.rect.max_x().value(), 120.0, 1.0));
}

#[test]
fn row_space_around_distributes_equal_padding() {
    let layout = Layout::Row {
        gap: sp(0.0),
        justify: MainAxisJustify::SpaceAround,
        cross: CrossAxisAlign::Center,
        children: vec![
            Layout::Gap { size: sp(20.0) },
            Layout::Gap { size: sp(20.0) },
        ],
    };
    let solved = solve(&layout, size(120.0, 10.0), &RetainedLayout::default());
    let kids = &solved.root_node().children;
    let first = solved.node(kids[0]);
    let second = solved.node(kids[1]);
    let lead = first.rect.min_x().value();
    let trail = 120.0 - second.rect.max_x().value();
    assert!(approx_eq(lead, trail, 1.0));
    let mid_gap = second.rect.min_x().value() - first.rect.max_x().value();
    assert!(approx_eq(mid_gap, 2.0 * lead, 1.0));
}

#[test]
fn retained_layout_round_trips_through_ron() {
    let mut original = RetainedLayout::default();
    original.set_scroll(
        wid(1),
        ScrollOffset::new(LayoutPx::new(4.0), LayoutPx::new(8.0)),
    );
    original.set_split(wid(2), SplitFraction::clamped(0.42));
    let Ok(serialized) = ron::ser::to_string(&original) else {
        panic!("retained layout serialize failed");
    };
    let Ok(parsed) = ron::de::from_str::<RetainedLayout>(&serialized) else {
        panic!("retained layout deserialize failed");
    };
    assert_eq!(original, parsed);
}

#[test]
fn retained_layout_serializes_deterministically_regardless_of_insertion_order() {
    let entries = (1u8..=10)
        .map(|n| {
            (
                wid(u64::from(n)),
                ScrollOffset::new(LayoutPx::new(f32::from(n)), LayoutPx::ZERO),
            )
        })
        .collect::<Vec<_>>();
    let mut forward = RetainedLayout::default();
    entries
        .iter()
        .fold((), |(), (id, off)| forward.set_scroll(*id, *off));
    let mut backward = RetainedLayout::default();
    entries
        .iter()
        .rev()
        .fold((), |(), (id, off)| backward.set_scroll(*id, *off));
    let Ok(forward_ron) = ron::ser::to_string(&forward) else {
        panic!("forward serialize failed");
    };
    let Ok(reverse_ron) = ron::ser::to_string(&backward) else {
        panic!("backward serialize failed");
    };
    assert_eq!(forward_ron, reverse_ron);
}

#[test]
fn dock_host_with_empty_tabs_returns_empty_dock_tabs_error() {
    let layout = Layout::DockHost {
        id: wid(60),
        state: Arc::new(DockState::new(DockNode::tabs(Vec::new()))),
        panels: Vec::new(),
        tab_strip_height: sp(24.0),
    };
    let result = measure(
        &layout,
        size(100.0, 100.0),
        &RetainedLayout::default(),
        LayoutDirection::Ltr,
    );
    assert!(matches!(result, Err(LayoutError::EmptyDockTabs)));
}

#[test]
fn dock_host_paint_emits_surface_for_each_panel() {
    let a = pid(20);
    let b = pid(21);
    let dock = DockState::new(DockNode::split(
        Axis::Horizontal,
        SplitFraction::HALF,
        DockNode::tabs(vec![a]),
        DockNode::tabs(vec![b]),
    ));
    let layout = Layout::DockHost {
        id: wid(70),
        state: Arc::new(dock),
        panels: vec![
            DockPanel {
                id: a,
                child: Layout::leaf(wid(1)),
            },
            DockPanel {
                id: b,
                child: Layout::leaf(wid(2)),
            },
        ],
        tab_strip_height: sp(24.0),
    };
    let solved = solve(&layout, size(400.0, 300.0), &RetainedLayout::default());
    let plan = paint_plan(&solved, &Theme::light(), None);
    let panel_slots = plan
        .commands
        .iter()
        .filter(|c| matches!(c, PaintCommand::PanelSlot { .. }))
        .count();
    let dividers = plan
        .commands
        .iter()
        .filter(|c| matches!(c, PaintCommand::Divider { .. }))
        .count();
    assert_eq!(panel_slots, 2);
    assert_eq!(dividers, 1);
}

#[test]
fn paint_plan_balances_translate_pairs_for_scroll_region() {
    let layout = Layout::scroll_region(wid(101), ScrollAxes::Both, Layout::leaf(wid(102)));
    let solved = solve(&layout, size(40.0, 40.0), &RetainedLayout::default());
    let plan = paint_plan(&solved, &Theme::light(), None);
    let translates = plan
        .commands
        .iter()
        .filter(|c| matches!(c, PaintCommand::Translate { .. }))
        .count();
    let untranslates = plan
        .commands
        .iter()
        .filter(|c| matches!(c, PaintCommand::Untranslate))
        .count();
    let pushes = plan
        .commands
        .iter()
        .filter(|c| matches!(c, PaintCommand::PushClip { .. }))
        .count();
    let pops = plan
        .commands
        .iter()
        .filter(|c| matches!(c, PaintCommand::PopClip))
        .count();
    assert_eq!(translates, 1);
    assert_eq!(untranslates, 1);
    assert_eq!(pushes, 1);
    assert_eq!(pops, 1);
}

#[test]
fn paint_plan_nested_scroll_regions_emit_balanced_paired_commands() {
    let inner = Layout::scroll_region(wid(201), ScrollAxes::Vertical, Layout::leaf(wid(202)));
    let outer = Layout::scroll_region(wid(200), ScrollAxes::Both, inner);
    let solved = solve(&outer, size(80.0, 80.0), &RetainedLayout::default());
    let plan = paint_plan(&solved, &Theme::light(), None);
    let pushes = plan
        .commands
        .iter()
        .filter(|c| matches!(c, PaintCommand::PushClip { .. }))
        .count();
    let pops = plan
        .commands
        .iter()
        .filter(|c| matches!(c, PaintCommand::PopClip))
        .count();
    let translates = plan
        .commands
        .iter()
        .filter(|c| matches!(c, PaintCommand::Translate { .. }))
        .count();
    let untranslates = plan
        .commands
        .iter()
        .filter(|c| matches!(c, PaintCommand::Untranslate))
        .count();
    assert_eq!(pushes, 2);
    assert_eq!(pops, 2);
    assert_eq!(translates, 2);
    assert_eq!(untranslates, 2);
    let depth = plan.commands.iter().try_fold(0i32, |d, c| match c {
        PaintCommand::PushClip { .. } | PaintCommand::Translate { .. } => Some(d + 1),
        PaintCommand::PopClip | PaintCommand::Untranslate => {
            if d <= 0 {
                None
            } else {
                Some(d - 1)
            }
        }
        _ => Some(d),
    });
    assert_eq!(depth, Some(0), "push/pop pairs must nest correctly");
}

#[test]
fn dock_state_round_trip_preserves_floating_surfaces() {
    let main = DockNode::tabs(vec![pid(1)]);
    let floating_root = DockNode::split(
        Axis::Horizontal,
        SplitFraction::clamped(0.4),
        DockNode::tabs(vec![pid(2)]),
        DockNode::tabs(vec![pid(3)]),
    );
    let original = DockState {
        main,
        floating: vec![FloatingSurface {
            root: floating_root,
            origin: LayoutPos::new(LayoutPx::new(120.0), LayoutPx::new(80.0)),
            size: LayoutSize::new(LayoutPx::new(640.0), LayoutPx::new(480.0)),
        }],
    };
    let Ok(serialized) = ron::ser::to_string(&original) else {
        panic!("dock state serialize failed");
    };
    let Ok(parsed) = ron::de::from_str::<DockState>(&serialized) else {
        panic!("dock state deserialize failed");
    };
    assert_eq!(parsed, original);
    assert_eq!(parsed.floating.len(), 1);
    assert_eq!(parsed.floating[0].root.panel_ids(), vec![pid(2), pid(3)]);
}

#[test]
fn grid_duplicate_track_name_errors() {
    const SHARED: TrackName = TrackName::new("dup");
    let span = GridSpan {
        column_start: GridLineRef::Name(SHARED),
        column_end: GridLineRef::Line(gl(3)),
        row_start: GridLineRef::Line(gl(1)),
        row_end: GridLineRef::Line(gl(2)),
    };
    let layout = Layout::Grid {
        columns: vec![
            GridTrack::named(SHARED, TrackSize::FLEX_1),
            GridTrack::named(SHARED, TrackSize::FLEX_1),
        ],
        rows: vec![GridTrack::unnamed(TrackSize::FLEX_1)],
        column_gap: sp(0.0),
        row_gap: sp(0.0),
        children: vec![GridChild {
            span,
            child: Layout::leaf(wid(1)),
        }],
    };
    let result = measure(
        &layout,
        size(100.0, 30.0),
        &RetainedLayout::default(),
        LayoutDirection::Ltr,
    );
    let Err(LayoutError::DuplicateTrackName(name)) = result else {
        panic!("expected DuplicateTrackName, got {result:?}");
    };
    assert_eq!(name, "dup");
}

#[test]
fn flex_weight_non_unit_doubles_spacer_share() {
    let layout = Layout::Row {
        gap: sp(0.0),
        justify: MainAxisJustify::Start,
        cross: CrossAxisAlign::Stretch,
        children: vec![
            Layout::weighted_spacer(fw(1)),
            Layout::weighted_spacer(fw(2)),
        ],
    };
    let solved = solve(&layout, size(150.0, 10.0), &RetainedLayout::default());
    let kids = &solved.root_node().children;
    let one = solved.node(kids[0]);
    let two = solved.node(kids[1]);
    assert!(approx_eq(one.rect.size.width.value(), 50.0, 1.0));
    assert!(approx_eq(two.rect.size.width.value(), 100.0, 1.0));
}

#[test]
fn grid_named_span_inverted_resolved_order_errors() {
    const SIDE: TrackName = TrackName::new("side");
    const END: TrackName = TrackName::new("end");
    let span = GridSpan {
        column_start: GridLineRef::Name(END),
        column_end: GridLineRef::Name(SIDE),
        row_start: GridLineRef::Line(gl(1)),
        row_end: GridLineRef::Line(gl(2)),
    };
    let layout = Layout::Grid {
        columns: vec![
            GridTrack::named(SIDE, TrackSize::FLEX_1),
            GridTrack::named(END, TrackSize::FLEX_1),
        ],
        rows: vec![GridTrack::unnamed(TrackSize::FLEX_1)],
        column_gap: sp(0.0),
        row_gap: sp(0.0),
        children: vec![GridChild {
            span,
            child: Layout::leaf(wid(1)),
        }],
    };
    let result = measure(
        &layout,
        size(100.0, 30.0),
        &RetainedLayout::default(),
        LayoutDirection::Ltr,
    );
    assert!(matches!(
        result,
        Err(LayoutError::GridColumnSpanNotIncreasing)
    ));
}

#[test]
fn grid_named_row_span_inverted_errors() {
    const TOP: TrackName = TrackName::new("top");
    const BOTTOM: TrackName = TrackName::new("bottom");
    let span = GridSpan {
        column_start: GridLineRef::Line(gl(1)),
        column_end: GridLineRef::Line(gl(2)),
        row_start: GridLineRef::Name(BOTTOM),
        row_end: GridLineRef::Name(TOP),
    };
    let layout = Layout::Grid {
        columns: vec![GridTrack::unnamed(TrackSize::FLEX_1)],
        rows: vec![
            GridTrack::named(TOP, TrackSize::FLEX_1),
            GridTrack::named(BOTTOM, TrackSize::FLEX_1),
        ],
        column_gap: sp(0.0),
        row_gap: sp(0.0),
        children: vec![GridChild {
            span,
            child: Layout::leaf(wid(1)),
        }],
    };
    let result = measure(
        &layout,
        size(60.0, 60.0),
        &RetainedLayout::default(),
        LayoutDirection::Ltr,
    );
    assert!(matches!(result, Err(LayoutError::GridRowSpanNotIncreasing)));
}

#[test]
fn inset_inside_row_distributes_main_axis_evenly() {
    let layout = Layout::Row {
        gap: sp(0.0),
        justify: MainAxisJustify::Start,
        cross: CrossAxisAlign::Stretch,
        children: vec![
            Layout::inset(EdgeInsets::all(sp(8.0)), Layout::leaf(wid(1))),
            Layout::inset(EdgeInsets::all(sp(8.0)), Layout::leaf(wid(2))),
        ],
    };
    let solved = solve(&layout, size(200.0, 50.0), &RetainedLayout::default());
    let kids = &solved.root_node().children;
    let a = solved.node(kids[0]);
    let b = solved.node(kids[1]);
    assert!(
        approx_eq(a.rect.size.width.value(), 100.0, 1.0),
        "first inset: {}",
        a.rect.size.width.value()
    );
    assert!(
        approx_eq(b.rect.size.width.value(), 100.0, 1.0),
        "second inset: {}",
        b.rect.size.width.value()
    );
    assert!(approx_eq(b.rect.min_x().value(), 100.0, 1.0));
}

#[test]
fn nested_row_inside_row_claims_proportional_main_axis() {
    let inner = Layout::Row {
        gap: sp(0.0),
        justify: MainAxisJustify::Start,
        cross: CrossAxisAlign::Stretch,
        children: vec![Layout::leaf(wid(1)), Layout::leaf(wid(2))],
    };
    let layout = Layout::Row {
        gap: sp(0.0),
        justify: MainAxisJustify::Start,
        cross: CrossAxisAlign::Stretch,
        children: vec![inner, Layout::leaf(wid(3))],
    };
    let solved = solve(&layout, size(200.0, 50.0), &RetainedLayout::default());
    let kids = &solved.root_node().children;
    let inner_solved = solved.node(kids[0]);
    let outer_leaf = solved.node(kids[1]);
    assert!(
        approx_eq(inner_solved.rect.size.width.value(), 100.0, 1.0),
        "inner row: {}",
        inner_solved.rect.size.width.value()
    );
    assert!(approx_eq(outer_leaf.rect.size.width.value(), 100.0, 1.0));
}

#[test]
fn scroll_region_clamps_retained_offset_against_content() {
    let id = wid(50);
    let mut retained = RetainedLayout::default();
    retained.set_scroll(
        id,
        ScrollOffset::new(LayoutPx::ZERO, LayoutPx::new(10_000.0)),
    );
    let inner = Layout::Column {
        gap: sp(0.0),
        justify: MainAxisJustify::Start,
        cross: CrossAxisAlign::Stretch,
        children: (0..5).map(|_| Layout::Gap { size: sp(20.0) }).collect(),
    };
    let layout = Layout::ScrollRegion {
        id,
        axes: ScrollAxes::Vertical,
        child: Box::new(inner),
    };
    let solved = solve(&layout, size(80.0, 40.0), &retained);
    let root = solved.root_node();
    let NodeKind::ScrollRegion { offset, .. } = &root.kind else {
        panic!("expected ScrollRegion");
    };
    let max_y = (root.content_size.height.value() - root.rect.size.height.value()).max(0.0);
    assert!(
        approx_eq(offset.y.value(), max_y, PX_EPSILON),
        "y must clamp to content_size - viewport ({max_y}); got {}",
        offset.y.value()
    );
    assert!(offset.y.value() < 10_000.0);
}

#[test]
fn classic_default_rejects_duplicate_panel_ids() {
    let result = DockState::classic_default(pid(1), pid(2), pid(1), pid(4), pid(5), pid(6));
    let Err(DockStateError::DuplicatePanelId(p)) = result else {
        panic!("expected DuplicatePanelId, got {result:?}");
    };
    assert_eq!(p, pid(1));
}

#[test]
fn dock_host_with_floating_surfaces_returns_unsupported_error() {
    let main = DockNode::tabs(vec![pid(1)]);
    let floating = FloatingSurface {
        root: DockNode::tabs(vec![pid(2)]),
        origin: LayoutPos::new(LayoutPx::new(50.0), LayoutPx::new(50.0)),
        size: LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(150.0)),
    };
    let state = DockState {
        main,
        floating: vec![floating],
    };
    let layout = Layout::DockHost {
        id: wid(60),
        state: Arc::new(state),
        panels: vec![DockPanel {
            id: pid(1),
            child: Layout::leaf(wid(1)),
        }],
        tab_strip_height: sp(24.0),
    };
    let result = measure(
        &layout,
        size(200.0, 200.0),
        &RetainedLayout::default(),
        LayoutDirection::Ltr,
    );
    assert!(matches!(
        result,
        Err(LayoutError::UnsupportedFloatingSurfaces)
    ));
}

#[test]
fn row_main_axis_end_pushes_children_to_right_edge() {
    let layout = Layout::Row {
        gap: sp(0.0),
        justify: MainAxisJustify::End,
        cross: CrossAxisAlign::Center,
        children: vec![
            Layout::Gap { size: sp(20.0) },
            Layout::Gap { size: sp(20.0) },
        ],
    };
    let solved = solve(&layout, size(120.0, 10.0), &RetainedLayout::default());
    let kids = &solved.root_node().children;
    let last = solved.node(kids[1]);
    assert!(
        approx_eq(last.rect.max_x().value(), 120.0, 1.0),
        "last child should hug right edge: {}",
        last.rect.max_x().value()
    );
    let first = solved.node(kids[0]);
    assert!(approx_eq(first.rect.min_x().value(), 80.0, 1.0));
}

#[test]
fn layout_px_try_from_rejects_non_finite() {
    assert_eq!(LayoutPx::try_from(f32::NAN), Err(LayoutPxError::NotFinite));
    assert_eq!(
        LayoutPx::try_from(f32::INFINITY),
        Err(LayoutPxError::NotFinite)
    );
    assert_eq!(
        LayoutPx::try_from(f32::NEG_INFINITY),
        Err(LayoutPxError::NotFinite)
    );
    let Ok(px) = LayoutPx::try_from(7.5) else {
        panic!("finite must round-trip");
    };
    assert!(approx_eq(px.value(), 7.5, PX_EPSILON));
}

#[test]
fn layout_px_serde_rejects_non_finite() {
    assert!(ron::de::from_str::<LayoutPx>("NaN").is_err());
    assert!(ron::de::from_str::<LayoutPx>("inf").is_err());
    assert!(ron::de::from_str::<LayoutPx>("-inf").is_err());
    let Ok(parsed) = ron::de::from_str::<LayoutPx>("12.5") else {
        panic!("finite must deserialize");
    };
    assert!(approx_eq(parsed.value(), 12.5, PX_EPSILON));
}

#[test]
fn retained_layout_rejects_non_finite_scroll_offset_at_deserialize() {
    let poisoned = "(scroll: {(1): (x: NaN, y: 0.0)}, split: {})";
    assert!(ron::de::from_str::<RetainedLayout>(poisoned).is_err());
}

#[test]
fn split_fraction_clamped_is_const_safe_for_constants() {
    const A: SplitFraction = SplitFraction::clamped(0.22);
    const B: SplitFraction = SplitFraction::clamped(-1.0);
    const C: SplitFraction = SplitFraction::clamped(2.0);
    const D: SplitFraction = SplitFraction::clamped(f32::NAN);
    assert!(approx_eq(A.value(), 0.22, 1e-6));
    assert!(approx_eq(B.value(), 0.0, 1e-6));
    assert!(approx_eq(C.value(), 1.0, 1e-6));
    assert!(approx_eq(D.value(), 0.5, 1e-6));
}
