Skip to main content

native/guppy_nif/src/ir_tests.rs

use super::{
    BackgroundPatternSlash, BoxShadowSpec, CanvasCommand, CheckboxNode, DataTableSortDirection,
    DivNode, ImageObjectFit, ImageSource, IrNode, LinearGradientStop, MAX_IR_NODE_DEPTH, StyleAxis,
    StyleColor, StyleLength, StyleOp, decode_list_row_child_term,
    ensure_unique_list_row_control_ids, field_key, get_optional_integer_field,
    get_optional_usize_field, parse_positive_u32, parse_style_op,
};
use crate::ir_allowed::{allowed_node_event_fields, allowed_node_fields};
use eetf::{Atom, BigInteger, Binary, FixInteger, Float, List, Map, Term, Tuple};

fn atom(name: &str) -> Term {
    Term::Atom(Atom::from(name))
}

fn binary(value: &str) -> Term {
    Term::Binary(Binary {
        bytes: value.as_bytes().to_vec(),
    })
}

fn integer(value: i32) -> Term {
    Term::FixInteger(FixInteger { value })
}

fn bigint_i32(value: i32) -> Term {
    Term::BigInteger(BigInteger::from(value))
}

fn bigint_u32(value: u32) -> Term {
    Term::BigInteger(BigInteger::from(value))
}

fn bigint_u64(value: u64) -> Term {
    Term::BigInteger(BigInteger::from(value))
}

fn float(value: f64) -> Term {
    Term::Float(Float { value })
}

fn tuple(elements: Vec<Term>) -> Term {
    Term::Tuple(Tuple { elements })
}

fn list(elements: Vec<Term>) -> Term {
    Term::List(List { elements })
}

fn map(entries: Vec<(Term, Term)>) -> Term {
    Term::Map(Map {
        map: entries.into_iter().collect(),
    })
}

fn bool_atom(value: bool) -> Term {
    atom(if value { "true" } else { "false" })
}

fn events(entries: Vec<(&str, &str)>) -> Term {
    map(entries
        .into_iter()
        .map(|(key, value)| (atom(key), binary(value)))
        .collect())
}

fn deep_div(depth: usize) -> Term {
    let mut node = map(vec![
        (atom("kind"), atom("text")),
        (atom("content"), binary("leaf")),
    ]);

    for _ in 0..depth {
        node = map(vec![
            (atom("kind"), atom("div")),
            (atom("children"), list(vec![node])),
        ]);
    }

    node
}

fn validate_list_row_child(node: &IrNode) -> Result<(), String> {
    match node {
        IrNode::Text { .. } | IrNode::Spacer { .. } => Ok(()),
        IrNode::Div(node) => validate_static_list_row_div(node),
        other => Err(format!(
            "unsupported list row child kind: {}",
            ir_node_kind(other)
        )),
    }
}

fn validate_static_list_row_div(node: &DivNode) -> Result<(), String> {
    if !node.hover_style.is_empty()
        || !node.focus_style.is_empty()
        || !node.focus_visible_style.is_empty()
        || !node.in_focus_style.is_empty()
        || !node.active_style.is_empty()
        || !node.disabled_style.is_empty()
        || node.animation.is_some()
        || node.stack_priority.is_some()
        || node.occlude
        || node.focusable
        || node.tab_stop.is_some()
        || node.tab_index.is_some()
        || node.track_scroll
        || node.anchor_scroll
        || node.tooltip.is_some()
        || !node.shortcuts.is_empty()
        || node.hover.is_some()
        || node.focus.is_some()
        || node.blur.is_some()
        || node.key_down.is_some()
        || node.key_up.is_some()
        || node.context_menu.is_some()
        || node.drag_start.is_some()
        || node.drag_move.is_some()
        || node.drop.is_some()
        || node.mouse_down.is_some()
        || node.mouse_up.is_some()
        || node.mouse_move.is_some()
        || node.scroll_wheel.is_some()
    {
        return Err("unsupported list row div field".into());
    }

    for child in node.children.iter() {
        validate_list_row_child(child)?;
    }

    Ok(())
}

fn ir_node_kind(node: &IrNode) -> &'static str {
    match node {
        IrNode::Text { .. } => "text",
        IrNode::TextInput { .. } => "text_input",
        IrNode::Textarea { .. } => "textarea",
        IrNode::Scroll { .. } => "scroll",
        IrNode::Image { .. } => "image",
        IrNode::Icon { .. } => "icon",
        IrNode::Button(_) => "button",
        IrNode::Checkbox(_) => "checkbox",
        IrNode::Radio(_) => "radio",
        IrNode::UniformList { .. } => "uniform_list",
        IrNode::List { .. } => "list",
        IrNode::DataTable(_) => "data_table",
        IrNode::Tree(_) => "tree",
        IrNode::Canvas(_) => "canvas",
        IrNode::Select(_) => "select",
        IrNode::Popover { .. } => "popover",
        IrNode::Spacer { .. } => "spacer",
        IrNode::Div(_) => "div",
    }
}

const NODE_KINDS: &[&str] = &[
    "text",
    "div",
    "scroll",
    "popover",
    "uniform_list",
    "list",
    "data_table",
    "tree",
    "canvas",
    "select",
    "image",
    "icon",
    "spacer",
    "checkbox",
    "radio",
    "button",
    "text_input",
    "textarea",
];

#[test]
fn cached_ir_field_keys_cover_allowed_schema() {
    let mut missing = Vec::new();

    for kind in NODE_KINDS {
        for field in allowed_node_fields(kind).expect("known node kind") {
            if field_key(field).is_none() {
                missing.push(format!("{kind}.{field}"));
            }
        }

        if let Some((event_fields, _context)) = allowed_node_event_fields(kind) {
            for field in event_fields {
                if field_key(field).is_none() {
                    missing.push(format!("{kind}.events.{field}"));
                }
            }
        }
    }

    assert!(
        missing.is_empty(),
        "missing cached IR field keys: {}",
        missing.join(", ")
    );
}

#[test]
fn rejects_empty_node_ids() {
    let node = map(vec![
        (atom("kind"), atom("text")),
        (atom("content"), binary("hello")),
        (atom("id"), binary("")),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(
        err.contains("id must not be empty"),
        "unexpected error: {err}"
    );
}

#[test]
fn rejects_unknown_native_ir_fields() {
    let node = map(vec![
        (atom("kind"), atom("text")),
        (atom("content"), binary("hello")),
        (atom("typo"), bool_atom(true)),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(
        err.contains("unsupported text field"),
        "unexpected error: {err}"
    );

    let text_run = map(vec![
        (atom("text"), binary("hello")),
        (atom("style"), list(vec![])),
        (atom("typo"), bool_atom(true)),
    ]);
    let node = map(vec![
        (atom("kind"), atom("text")),
        (atom("content"), binary("hello")),
        (atom("runs"), list(vec![text_run])),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(
        err.contains("unsupported text run field"),
        "unexpected error: {err}"
    );

    let node = map(vec![
        (atom("kind"), atom("text")),
        (atom("content"), binary("hello")),
        (atom("events"), events(vec![("typo", "cb")])),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(
        err.contains("unsupported text events field"),
        "unexpected error: {err}"
    );

    let node = map(vec![
        (atom("kind"), atom("button")),
        (atom("label"), binary("Save")),
        (atom("tooltip"), binary("unsupported")),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(
        err.contains("unsupported button field"),
        "unexpected error: {err}"
    );

    let node = map(vec![
        (atom("kind"), atom("div")),
        (atom("children"), list(vec![])),
        (
            atom("animation"),
            map(vec![
                (atom("id"), binary("fade")),
                (atom("typo"), bool_atom(true)),
            ]),
        ),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(
        err.contains("unsupported animation field"),
        "unexpected error: {err}"
    );
}

#[test]
fn rejects_excessively_deep_ir_tree() {
    let node = deep_div(MAX_IR_NODE_DEPTH + 1);

    let err = std::thread::Builder::new()
        .stack_size(16 * 1024 * 1024)
        .spawn(move || IrNode::from_term(&node).unwrap_err())
        .unwrap()
        .join()
        .unwrap();

    assert_eq!(
        err,
        format!("ir node tree exceeds maximum depth of {MAX_IR_NODE_DEPTH}")
    );
}

#[test]
fn decodes_big_integer_fields() {
    let fields = match map(vec![
        (atom("tab_index"), bigint_i32(-2)),
        (atom("stack_priority"), bigint_u32(7)),
    ]) {
        Term::Map(map) => map.map,
        _ => unreachable!(),
    };

    assert_eq!(
        get_optional_integer_field(&fields, "tab_index").unwrap(),
        Some(-2)
    );
    assert_eq!(
        get_optional_usize_field(&fields, "stack_priority").unwrap(),
        Some(7)
    );
    assert_eq!(parse_positive_u32(&bigint_u32(4)).unwrap(), 4);
    assert!(parse_positive_u32(&bigint_i32(-1)).is_err());
}

#[test]
fn decodes_image_source_variants() {
    let auto = map(vec![
        (atom("kind"), atom("image")),
        (atom("source"), binary("logo.png")),
        (atom("object_fit"), atom("cover")),
        (atom("grayscale"), bool_atom(true)),
    ]);

    match IrNode::from_term(&auto).unwrap() {
        IrNode::Image {
            source,
            object_fit,
            grayscale,
            ..
        } => {
            assert_eq!(source, ImageSource::Auto("logo.png".into()));
            assert_eq!(object_fit, ImageObjectFit::Cover);
            assert!(grayscale);
        }
        other => panic!("expected image, got {other:?}"),
    }

    let uri = map(vec![
        (atom("kind"), atom("image")),
        (
            atom("source"),
            tuple(vec![atom("uri"), binary("https://example.com/logo.svg")]),
        ),
    ]);

    match IrNode::from_term(&uri).unwrap() {
        IrNode::Image {
            source,
            object_fit,
            grayscale,
            ..
        } => {
            assert_eq!(
                source,
                ImageSource::Uri("https://example.com/logo.svg".into())
            );
            assert_eq!(object_fit, ImageObjectFit::Contain);
            assert!(!grayscale);
        }
        other => panic!("expected image, got {other:?}"),
    }

    let icon = map(vec![
        (atom("kind"), atom("icon")),
        (
            atom("source"),
            tuple(vec![atom("path"), binary("/tmp/icon.png")]),
        ),
    ]);

    match IrNode::from_term(&icon).unwrap() {
        IrNode::Icon { source, .. } => {
            assert_eq!(source, ImageSource::Path("/tmp/icon.png".into()));
        }
        other => panic!("expected icon, got {other:?}"),
    }
}

#[test]
fn rejects_text_run_content_mismatches() {
    let node = map(vec![
        (atom("kind"), atom("text")),
        (atom("content"), binary("hello")),
        (
            atom("runs"),
            list(vec![map(vec![(atom("text"), binary("hell"))])]),
        ),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(err.contains("text runs content mismatch"));
    assert!(err.contains("expected \"hello\""));
    assert!(err.contains("got \"hell\""));
}

#[test]
fn decodes_text_input_context_menu_events() {
    let input = map(vec![
        (atom("kind"), atom("text_input")),
        (atom("value"), binary("Jason")),
        (
            atom("actions"),
            map(vec![(binary("submit"), binary("submit_name"))]),
        ),
        (
            atom("shortcuts"),
            list(vec![tuple(vec![binary("cmd-enter"), binary("submit")])]),
        ),
        (
            atom("events"),
            events(vec![("change", "changed"), ("context_menu", "contexted")]),
        ),
    ]);

    match IrNode::from_term(&input).unwrap() {
        IrNode::TextInput {
            change,
            context_menu,
            shortcuts,
            ..
        } => {
            assert_eq!(change.as_deref(), Some("changed"));
            assert_eq!(context_menu.as_deref(), Some("contexted"));
            assert_eq!(shortcuts.len(), 1);
            assert_eq!(shortcuts[0].callback, "submit_name");
        }
        other => panic!("expected text input, got {other:?}"),
    }

    let textarea = map(vec![
        (atom("kind"), atom("textarea")),
        (atom("value"), binary("Notes")),
        (
            atom("events"),
            events(vec![("context_menu", "notes_context")]),
        ),
    ]);

    match IrNode::from_term(&textarea).unwrap() {
        IrNode::Textarea { context_menu, .. } => {
            assert_eq!(context_menu.as_deref(), Some("notes_context"));
        }
        other => panic!("expected textarea, got {other:?}"),
    }
}

#[test]
fn decodes_list_context_menu_events() {
    let uniform = map(vec![
        (atom("kind"), atom("uniform_list")),
        (
            atom("items"),
            list(vec![map(vec![
                (atom("id"), binary("one")),
                (atom("label"), binary("One")),
            ])]),
        ),
        (
            atom("events"),
            events(vec![("click", "clicked"), ("context_menu", "contexted")]),
        ),
    ]);

    match IrNode::from_term(&uniform).unwrap() {
        IrNode::UniformList {
            click,
            context_menu,
            ..
        } => {
            assert_eq!(click.as_deref(), Some("clicked"));
            assert_eq!(context_menu.as_deref(), Some("contexted"));
        }
        other => panic!("expected uniform list, got {other:?}"),
    }

    let list_node = map(vec![
        (atom("kind"), atom("list")),
        (
            atom("items"),
            list(vec![map(vec![
                (atom("id"), binary("row_1")),
                (
                    atom("children"),
                    list(vec![map(vec![(atom("kind"), atom("spacer"))])]),
                ),
            ])]),
        ),
        (
            atom("events"),
            events(vec![
                ("click", "row_clicked"),
                ("context_menu", "row_context"),
            ]),
        ),
    ]);

    match IrNode::from_term(&list_node).unwrap() {
        IrNode::List {
            click,
            context_menu,
            ..
        } => {
            assert_eq!(click.as_deref(), Some("row_clicked"));
            assert_eq!(context_menu.as_deref(), Some("row_context"));
        }
        other => panic!("expected list, got {other:?}"),
    }
}

#[test]
fn decodes_data_table_node() {
    let node = map(vec![
        (atom("kind"), atom("data_table")),
        (atom("id"), binary("project_table")),
        (
            atom("columns"),
            list(vec![
                map(vec![
                    (atom("id"), binary("task")),
                    (atom("label"), binary("Task")),
                    (atom("width"), tuple(vec![atom("fr"), integer(1)])),
                    (atom("sortable"), bool_atom(true)),
                    (atom("pinned"), bool_atom(true)),
                ]),
                map(vec![
                    (atom("id"), binary("status")),
                    (atom("label"), binary("Status")),
                    (atom("width"), tuple(vec![atom("px"), integer(120)])),
                ]),
            ]),
        ),
        (
            atom("rows"),
            list(vec![map(vec![
                (atom("id"), binary("row_1")),
                (
                    atom("cells"),
                    list(vec![map(vec![
                        (atom("column_id"), binary("task")),
                        (
                            atom("children"),
                            list(vec![map(vec![
                                (atom("kind"), atom("text")),
                                (atom("content"), binary("Ship menus")),
                            ])]),
                        ),
                    ])]),
                ),
            ])]),
        ),
        (atom("selected_row_id"), binary("row_1")),
        (
            atom("selected_cell"),
            tuple(vec![binary("row_1"), binary("task")]),
        ),
        (
            atom("sort"),
            map(vec![
                (atom("column_id"), binary("task")),
                (atom("direction"), atom("asc")),
            ]),
        ),
        (
            atom("events"),
            events(vec![
                ("row_click", "select_row"),
                ("cell_click", "select_cell"),
                ("sort", "sort_table"),
                ("column_reorder", "reorder_column"),
                ("column_resize", "resize_column"),
                ("row_context_menu", "row_context"),
                ("cell_context_menu", "cell_context"),
            ]),
        ),
    ]);

    match IrNode::from_term(&node).unwrap() {
        IrNode::DataTable(table) => {
            assert_eq!(table.id.as_deref(), Some("project_table"));
            assert_eq!(table.columns.len(), 2);
            assert_eq!(table.columns[0].id, "task");
            assert!(table.columns[0].pinned);
            assert_eq!(table.rows[0].id, "row_1");
            assert_eq!(table.rows[0].cells[0].column_id, "task");
            assert_eq!(table.selected_row_id.as_deref(), Some("row_1"));
            assert_eq!(
                table
                    .selected_cell
                    .as_ref()
                    .map(|(row_id, column_id)| (row_id.as_str(), column_id.as_str())),
                Some(("row_1", "task"))
            );
            assert_eq!(
                table.sort.as_ref().unwrap().direction,
                DataTableSortDirection::Asc
            );
            assert_eq!(table.sort_callback.as_deref(), Some("sort_table"));
            assert_eq!(table.column_reorder.as_deref(), Some("reorder_column"));
            assert_eq!(table.column_resize.as_deref(), Some("resize_column"));
            assert_eq!(table.row_context_menu.as_deref(), Some("row_context"));
            assert_eq!(table.cell_context_menu.as_deref(), Some("cell_context"));
        }
        other => panic!("expected data table, got {other:?}"),
    }
}

#[test]
fn decodes_static_data_table_cell_div_children() {
    let node = map(vec![
        (atom("kind"), atom("data_table")),
        (
            atom("columns"),
            list(vec![map(vec![
                (atom("id"), binary("task")),
                (atom("label"), binary("Task")),
            ])]),
        ),
        (
            atom("rows"),
            list(vec![map(vec![
                (atom("id"), binary("row_1")),
                (
                    atom("cells"),
                    list(vec![map(vec![
                        (atom("column_id"), binary("task")),
                        (
                            atom("children"),
                            list(vec![map(vec![
                                (atom("kind"), atom("div")),
                                (atom("id"), binary("cell_layout")),
                                (
                                    atom("children"),
                                    list(vec![map(vec![
                                        (atom("kind"), atom("text")),
                                        (atom("content"), binary("Nested")),
                                    ])]),
                                ),
                            ])]),
                        ),
                    ])]),
                ),
            ])]),
        ),
    ]);

    match IrNode::from_term(&node).unwrap() {
        IrNode::DataTable(table) => match &table.rows[0].cells[0].children[0] {
            IrNode::Div(div) => {
                assert_eq!(div.id.as_deref(), Some("cell_layout"));
                assert!(
                    matches!(div.children.as_ref(), [IrNode::Text { content, .. }] if content == "Nested")
                );
            }
            other => panic!("expected data table cell div, got {other:?}"),
        },
        other => panic!("expected data table, got {other:?}"),
    }
}

#[test]
fn rejects_row_controls_inside_data_table_cell_divs() {
    let node = map(vec![
        (atom("kind"), atom("data_table")),
        (
            atom("columns"),
            list(vec![map(vec![
                (atom("id"), binary("task")),
                (atom("label"), binary("Task")),
            ])]),
        ),
        (
            atom("rows"),
            list(vec![map(vec![
                (atom("id"), binary("row_1")),
                (
                    atom("cells"),
                    list(vec![map(vec![
                        (atom("column_id"), binary("task")),
                        (
                            atom("children"),
                            list(vec![map(vec![
                                (atom("kind"), atom("div")),
                                (
                                    atom("children"),
                                    list(vec![map(vec![
                                        (atom("kind"), atom("button")),
                                        (atom("id"), binary("edit")),
                                        (atom("label"), binary("Edit")),
                                    ])]),
                                ),
                            ])]),
                        ),
                    ])]),
                ),
            ])]),
        ),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(err.contains("unsupported data_table cell child kind: button"));
}

#[test]
fn rejects_data_table_cell_unknown_columns() {
    let node = map(vec![
        (atom("kind"), atom("data_table")),
        (
            atom("columns"),
            list(vec![map(vec![
                (atom("id"), binary("task")),
                (atom("label"), binary("Task")),
            ])]),
        ),
        (
            atom("rows"),
            list(vec![map(vec![
                (atom("id"), binary("row_1")),
                (
                    atom("cells"),
                    list(vec![map(vec![
                        (atom("column_id"), binary("missing")),
                        (atom("children"), list(vec![])),
                    ])]),
                ),
            ])]),
        ),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(err.contains("unknown data_table cell column"));
}

#[test]
fn rejects_unsupported_data_table_cell_children() {
    let node = map(vec![
        (atom("kind"), atom("data_table")),
        (
            atom("columns"),
            list(vec![map(vec![
                (atom("id"), binary("task")),
                (atom("label"), binary("Task")),
            ])]),
        ),
        (
            atom("rows"),
            list(vec![map(vec![
                (atom("id"), binary("row_1")),
                (
                    atom("cells"),
                    list(vec![map(vec![
                        (atom("column_id"), binary("task")),
                        (
                            atom("children"),
                            list(vec![map(vec![
                                (atom("kind"), atom("button")),
                                (atom("label"), binary("Edit")),
                            ])]),
                        ),
                    ])]),
                ),
            ])]),
        ),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(err.contains("unsupported data_table cell child kind: button"));
}

#[test]
fn decodes_tree_node() {
    let node = map(vec![
        (atom("kind"), atom("tree")),
        (atom("id"), binary("project_tree")),
        (
            atom("nodes"),
            list(vec![map(vec![
                (atom("id"), binary("root")),
                (atom("label"), binary("Root")),
                (atom("expanded"), bool_atom(true)),
                (
                    atom("children"),
                    list(vec![map(vec![
                        (atom("id"), binary("child")),
                        (atom("label"), binary("Child")),
                    ])]),
                ),
            ])]),
        ),
        (atom("selected_id"), binary("child")),
        (
            atom("events"),
            events(vec![
                ("select", "select_node"),
                ("toggle", "toggle_node"),
                ("context_menu", "tree_context_menu"),
            ]),
        ),
    ]);

    match IrNode::from_term(&node).unwrap() {
        IrNode::Tree(tree) => {
            assert_eq!(tree.id.as_deref(), Some("project_tree"));
            assert_eq!(tree.nodes[0].id, "root");
            assert!(tree.nodes[0].expanded);
            assert_eq!(tree.nodes[0].children[0].id, "child");
            assert_eq!(tree.selected_id.as_deref(), Some("child"));
            assert_eq!(tree.select.as_deref(), Some("select_node"));
            assert_eq!(tree.toggle.as_deref(), Some("toggle_node"));
            assert_eq!(tree.context_menu.as_deref(), Some("tree_context_menu"));
        }
        other => panic!("expected tree, got {other:?}"),
    }
}

#[test]
fn rejects_tree_selected_id_that_is_not_in_decoded_nodes() {
    let node = map(vec![
        (atom("kind"), atom("tree")),
        (
            atom("nodes"),
            list(vec![map(vec![
                (atom("id"), binary("root")),
                (atom("label"), binary("Root")),
            ])]),
        ),
        (atom("selected_id"), binary("missing")),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(err.contains("unknown tree selected item: missing"));
}

#[test]
fn decodes_select_positioning_fields() {
    let node = map(vec![
        (atom("kind"), atom("select")),
        (atom("id"), binary("status")),
        (
            atom("options"),
            list(vec![map(vec![
                (atom("value"), binary("todo")),
                (atom("label"), binary("Todo")),
            ])]),
        ),
        (atom("anchor"), atom("bottom_left")),
        (atom("anchor_offset"), tuple(vec![integer(0), integer(10)])),
        (atom("anchor_fit"), atom("snap_to_window_with_margin")),
        (atom("snap_margin"), integer(10)),
    ]);

    match IrNode::from_term(&node).unwrap() {
        IrNode::Select(select) => {
            assert_eq!(select.anchor, super::PopoverAnchor::BottomLeft);
            assert_eq!(select.anchor_offset, Some((0.0, 10.0)));
            assert_eq!(
                select.anchor_fit,
                super::PopoverAnchorFit::SnapToWindowWithMargin
            );
            assert_eq!(select.snap_margin, 10.0);
        }
        other => panic!("expected select, got {other:?}"),
    }
}

#[test]
fn rejects_nested_overlay_nodes_inside_popover() {
    let nested_select = map(vec![
        (atom("kind"), atom("select")),
        (
            atom("options"),
            list(vec![map(vec![
                (atom("value"), binary("one")),
                (atom("label"), binary("One")),
            ])]),
        ),
    ]);

    let node = map(vec![
        (atom("kind"), atom("popover")),
        (atom("label"), binary("Menu")),
        (atom("open"), bool_atom(true)),
        (
            atom("children"),
            list(vec![map(vec![
                (atom("kind"), atom("div")),
                (atom("children"), list(vec![nested_select])),
            ])]),
        ),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(err.contains("nested overlay not supported: select"));

    let nested_popover = map(vec![
        (atom("kind"), atom("popover")),
        (atom("label"), binary("Nested")),
        (atom("open"), bool_atom(false)),
        (atom("children"), list(vec![])),
    ]);

    let node = map(vec![
        (atom("kind"), atom("popover")),
        (atom("label"), binary("Menu")),
        (atom("open"), bool_atom(true)),
        (atom("children"), list(vec![nested_popover])),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(err.contains("nested overlay not supported: popover"));
}

#[test]
fn decodes_canvas_commands() {
    let node = map(vec![
        (atom("kind"), atom("canvas")),
        (atom("id"), binary("summary_canvas")),
        (
            atom("commands"),
            list(vec![
                map(vec![
                    (atom("op"), atom("rect")),
                    (atom("x"), integer(0)),
                    (atom("y"), integer(0)),
                    (atom("width"), integer(120)),
                    (atom("height"), integer(80)),
                    (atom("fill"), binary("#0f172a")),
                ]),
                map(vec![
                    (atom("op"), atom("rounded_rect")),
                    (atom("x"), integer(12)),
                    (atom("y"), integer(12)),
                    (atom("width"), integer(96)),
                    (atom("height"), integer(24)),
                    (atom("radius"), integer(8)),
                    (atom("fill"), atom("blue")),
                ]),
                map(vec![
                    (atom("op"), atom("pattern_rect")),
                    (atom("x"), integer(12)),
                    (atom("y"), integer(48)),
                    (atom("width"), integer(96)),
                    (atom("height"), integer(20)),
                    (atom("color"), atom("yellow")),
                    (atom("line_width"), float(0.05)),
                    (atom("interval"), float(0.12)),
                ]),
            ]),
        ),
        (
            atom("events"),
            events(vec![
                ("click", "canvas_clicked"),
                ("context_menu", "canvas_context_menu"),
            ]),
        ),
    ]);

    match IrNode::from_term(&node).unwrap() {
        IrNode::Canvas(canvas) => {
            assert_eq!(canvas.id.as_deref(), Some("summary_canvas"));
            assert_eq!(canvas.commands.len(), 3);
            assert!(matches!(
                canvas.commands[2],
                CanvasCommand::PatternRect {
                    line_width,
                    interval,
                    ..
                } if line_width == 0.05 && interval == 0.12
            ));
            assert_eq!(canvas.click.as_deref(), Some("canvas_clicked"));
            assert_eq!(canvas.context_menu.as_deref(), Some("canvas_context_menu"));
        }
        other => panic!("expected canvas, got {other:?}"),
    }
}

#[test]
fn rejects_canvas_command_shape_mismatches() {
    let node = map(vec![
        (atom("kind"), atom("canvas")),
        (
            atom("commands"),
            list(vec![map(vec![(atom("op"), atom("path"))])]),
        ),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(err.contains("unsupported canvas command op: path"));

    let node = map(vec![
        (atom("kind"), atom("canvas")),
        (
            atom("commands"),
            list(vec![map(vec![
                (atom("op"), atom("pattern_rect")),
                (atom("x"), integer(0)),
                (atom("y"), integer(0)),
                (atom("width"), integer(20)),
                (atom("height"), integer(20)),
                (atom("fill"), atom("blue")),
                (atom("color"), atom("yellow")),
                (atom("line_width"), float(0.05)),
                (atom("interval"), float(0.12)),
            ])]),
        ),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(err.contains("unsupported canvas pattern_rect command field"));

    let node = map(vec![
        (atom("kind"), atom("canvas")),
        (
            atom("commands"),
            list(vec![map(vec![
                (atom("op"), atom("pattern_rect")),
                (atom("x"), integer(0)),
                (atom("y"), integer(0)),
                (atom("width"), integer(20)),
                (atom("height"), integer(20)),
                (atom("color"), atom("yellow")),
                (atom("line_width"), float(0.05)),
                (atom("interval"), float(1.5)),
            ])]),
        ),
    ]);

    let err = IrNode::from_term(&node).unwrap_err();
    assert!(err.contains("expected unit numeric field interval"));
}

#[test]
fn rejects_pathological_numeric_ir_values() {
    let cases = [
        map(vec![
            (atom("kind"), atom("canvas")),
            (
                atom("commands"),
                list(vec![map(vec![
                    (atom("op"), atom("rect")),
                    (atom("x"), bigint_u64(u64::MAX)),
                    (atom("y"), integer(0)),
                    (atom("width"), integer(20)),
                    (atom("height"), integer(20)),
                    (atom("fill"), atom("blue")),
                ])]),
            ),
        ]),
        map(vec![
            (atom("kind"), atom("canvas")),
            (
                atom("commands"),
                list(vec![map(vec![
                    (atom("op"), atom("rect")),
                    (atom("x"), float(f64::INFINITY)),
                    (atom("y"), integer(0)),
                    (atom("width"), integer(20)),
                    (atom("height"), integer(20)),
                    (atom("fill"), atom("blue")),
                ])]),
            ),
        ]),
        map(vec![
            (atom("kind"), atom("canvas")),
            (
                atom("commands"),
                list(vec![map(vec![
                    (atom("op"), atom("rect")),
                    (atom("x"), integer(0)),
                    (atom("y"), integer(0)),
                    (atom("width"), integer(20)),
                    (atom("height"), integer(20)),
                    (atom("radius"), float(f64::INFINITY)),
                    (atom("fill"), atom("blue")),
                ])]),
            ),
        ]),
        map(vec![
            (atom("kind"), atom("canvas")),
            (
                atom("commands"),
                list(vec![map(vec![
                    (atom("op"), atom("pattern_rect")),
                    (atom("x"), integer(0)),
                    (atom("y"), integer(0)),
                    (atom("width"), integer(20)),
                    (atom("height"), integer(20)),
                    (atom("color"), atom("yellow")),
                    (atom("line_width"), float(f64::INFINITY)),
                    (atom("interval"), float(0.12)),
                ])]),
            ),
        ]),
        map(vec![
            (atom("kind"), atom("popover")),
            (atom("label"), binary("Open")),
            (atom("open"), bool_atom(false)),
            (
                atom("anchor_position"),
                tuple(vec![float(f64::INFINITY), integer(0)]),
            ),
        ]),
        map(vec![
            (atom("kind"), atom("popover")),
            (atom("label"), binary("Open")),
            (atom("open"), bool_atom(false)),
            (atom("snap_margin"), float(f64::INFINITY)),
        ]),
        map(vec![
            (atom("kind"), atom("div")),
            (
                atom("animation"),
                map(vec![
                    (atom("id"), binary("fade")),
                    (atom("from"), float(f64::INFINITY)),
                ]),
            ),
        ]),
        map(vec![
            (atom("kind"), atom("data_table")),
            (
                atom("columns"),
                list(vec![map(vec![
                    (atom("id"), binary("task")),
                    (atom("label"), binary("Task")),
                    (atom("width"), tuple(vec![atom("px"), float(f64::INFINITY)])),
                ])]),
            ),
            (atom("rows"), list(vec![])),
        ]),
    ];

    for node in cases {
        let err = IrNode::from_term(&node).unwrap_err();
        assert!(
            err.contains("finite") || err.contains("numeric"),
            "unexpected error: {err}"
        );
    }
}

#[test]
fn list_row_decode_accepts_supported_controls_with_explicit_ids() {
    let button = map(vec![
        (atom("kind"), atom("button")),
        (atom("id"), binary("save")),
        (atom("label"), binary("Save")),
        (atom("events"), events(vec![("click", "save_row")])),
    ]);

    match decode_list_row_child_term(&button, 0).unwrap() {
        IrNode::Button(node) => {
            assert_eq!(node.id.as_deref(), Some("save"));
            assert_eq!(node.click.as_deref(), Some("save_row"));
            assert!(
                matches!(node.children.as_ref(), [IrNode::Text { content, .. }] if content == "Save")
            );
        }
        other => panic!("expected button row control, got {other:?}"),
    }

    let checkbox = map(vec![
        (atom("kind"), atom("checkbox")),
        (atom("id"), binary("done")),
        (atom("label"), binary("Done")),
        (atom("checked"), bool_atom(false)),
        (atom("events"), events(vec![("change", "toggle_done")])),
    ]);

    match decode_list_row_child_term(&checkbox, 0).unwrap() {
        IrNode::Checkbox(node) => {
            assert_eq!(node.id.as_deref(), Some("done"));
            assert_eq!(node.change.as_deref(), Some("toggle_done"));
        }
        other => panic!("expected checkbox row control, got {other:?}"),
    }

    let radio = map(vec![
        (atom("kind"), atom("radio")),
        (atom("id"), binary("priority_high")),
        (atom("label"), binary("High")),
        (atom("value"), binary("high")),
        (atom("checked"), bool_atom(true)),
        (atom("events"), events(vec![("change", "set_priority")])),
    ]);

    match decode_list_row_child_term(&radio, 0).unwrap() {
        IrNode::Radio(node) => {
            assert_eq!(node.id.as_deref(), Some("priority_high"));
            assert_eq!(node.value, "high");
            assert!(node.checked);
            assert_eq!(node.change.as_deref(), Some("set_priority"));
        }
        other => panic!("expected radio row control, got {other:?}"),
    }
}

#[test]
fn list_row_decode_rejects_supported_controls_without_ids() {
    let checkbox = map(vec![
        (atom("kind"), atom("checkbox")),
        (atom("label"), binary("Done")),
        (atom("checked"), bool_atom(false)),
    ]);

    let err = decode_list_row_child_term(&checkbox, 0).unwrap_err();
    assert!(err.contains("missing list row control id"));
}

#[test]
fn list_row_control_ids_are_unique_within_a_row() {
    let children = vec![
        IrNode::Checkbox(Box::new(CheckboxNode {
            id: Some("done".into()),
            label: "Done".into(),
            checked: false,
            style: Vec::new().into(),
            hover_style: Vec::new().into(),
            focus_style: Vec::new().into(),
            focus_visible_style: Vec::new().into(),
            in_focus_style: Vec::new().into(),
            active_style: Vec::new().into(),
            disabled_style: Vec::new().into(),
            disabled: false,
            tab_index: None,
            change: None,
            focus: None,
            blur: None,
        })),
        IrNode::Button(Box::new(DivNode {
            id: Some("done".into()),
            style: Vec::new().into(),
            hover_style: Vec::new().into(),
            focus_style: Vec::new().into(),
            focus_visible_style: Vec::new().into(),
            in_focus_style: Vec::new().into(),
            active_style: Vec::new().into(),
            disabled_style: Vec::new().into(),
            animation: None,
            disabled: false,
            stack_priority: None,
            occlude: false,
            focusable: true,
            tab_stop: Some(true),
            tab_index: None,
            track_scroll: false,
            anchor_scroll: false,
            scroll_to: false,
            tooltip: None,
            shortcuts: Vec::new().into(),
            children: vec![IrNode::text("Done")].into(),
            click: None,
            hover: None,
            focus: None,
            blur: None,
            key_down: None,
            key_up: None,
            context_menu: None,
            drag_start: None,
            drag_move: None,
            drop: None,
            mouse_down: None,
            mouse_up: None,
            mouse_move: None,
            scroll_wheel: None,
        })),
    ];

    let err = ensure_unique_list_row_control_ids(&children).unwrap_err();
    assert!(err.contains("duplicate list row control id: done"));
}

#[test]
fn parses_canonical_box_spacing_style_ops() {
    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("padding"),
            atom("y"),
            tuple(vec![atom("rem"), float(0.25)]),
        ]))
        .unwrap(),
        StyleOp::Padding {
            axis: StyleAxis::Y,
            length: StyleLength::Rem(0.25),
        }
    );

    assert_eq!(
        parse_style_op(&tuple(vec![atom("margin"), atom("x"), atom("auto")])).unwrap(),
        StyleOp::Margin {
            axis: StyleAxis::X,
            length: StyleLength::Auto,
        }
    );

    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("gap"),
            atom("all"),
            tuple(vec![atom("px"), integer(-1)]),
        ]))
        .unwrap(),
        StyleOp::Gap {
            axis: StyleAxis::All,
            length: StyleLength::Px(-1.0),
        }
    );

    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("width"),
            tuple(vec![atom("fraction"), float(1.0)]),
        ]))
        .unwrap(),
        StyleOp::Width(StyleLength::Fraction(1.0))
    );

    assert_eq!(
        parse_style_op(&tuple(vec![atom("height"), atom("auto")])).unwrap(),
        StyleOp::Height(StyleLength::Auto)
    );

    assert_eq!(
        parse_style_op(&tuple(vec![atom("aspect_ratio"), float(1.5)])).unwrap(),
        StyleOp::AspectRatio(1.5)
    );

    assert_eq!(
        parse_style_op(&tuple(vec![atom("position"), atom("relative")])).unwrap(),
        StyleOp::Position(super::PositionStyle::Relative)
    );

    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("inset"),
            atom("top"),
            tuple(vec![atom("rem"), float(-0.5)]),
        ]))
        .unwrap(),
        StyleOp::Inset {
            axis: StyleAxis::Top,
            length: StyleLength::Rem(-0.5),
        }
    );

    assert_eq!(
        parse_style_op(&tuple(vec![atom("display"), atom("flex")])).unwrap(),
        StyleOp::Display(super::DisplayStyle::Flex)
    );

    assert_eq!(
        parse_style_op(&tuple(vec![atom("visibility"), atom("hidden")])).unwrap(),
        StyleOp::Visibility(super::VisibilityStyle::Hidden)
    );

    assert_eq!(
        parse_style_op(&tuple(vec![atom("overflow"), atom("x"), atom("scroll")])).unwrap(),
        StyleOp::Overflow {
            axis: StyleAxis::X,
            behavior: super::OverflowStyle::Scroll,
        }
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("overflow"), atom("y"), atom("clip")])).unwrap(),
        StyleOp::Overflow {
            axis: StyleAxis::Y,
            behavior: super::OverflowStyle::Clip,
        }
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("overflow"), atom("all"), atom("visible")])).unwrap(),
        StyleOp::Overflow {
            axis: StyleAxis::All,
            behavior: super::OverflowStyle::Visible,
        }
    );
    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("allow_concurrent_scroll"),
            bool_atom(true)
        ]))
        .unwrap(),
        StyleOp::AllowConcurrentScroll(true)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("restrict_scroll_to_axis"),
            bool_atom(true)
        ]))
        .unwrap(),
        StyleOp::RestrictScrollToAxis(true)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("debug"), bool_atom(true)])).unwrap(),
        StyleOp::Debug(true)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("debug_below"), bool_atom(true)])).unwrap(),
        StyleOp::DebugBelow(true)
    );

    assert_eq!(
        parse_style_op(&tuple(vec![atom("cursor"), atom("not_allowed")])).unwrap(),
        StyleOp::Cursor(super::MouseCursorStyle::NotAllowed)
    );

    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("border_width"),
            atom("x"),
            tuple(vec![atom("px"), integer(4)]),
        ]))
        .unwrap(),
        StyleOp::BorderWidth {
            axis: StyleAxis::X,
            length: StyleLength::Px(4.0),
        }
    );

    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("border_radius"),
            atom("top_left"),
            tuple(vec![atom("rem"), float(0.25)]),
        ]))
        .unwrap(),
        StyleOp::BorderRadius {
            axis: super::BorderRadiusAxis::TopLeft,
            length: StyleLength::Rem(0.25),
        }
    );

    assert_eq!(
        parse_style_op(&tuple(vec![atom("border_style"), atom("dashed")])).unwrap(),
        StyleOp::BorderStyle(super::BorderLineStyle::Dashed)
    );

    assert_eq!(
        parse_style_op(&tuple(vec![atom("shadow"), atom("2xs")])).unwrap(),
        StyleOp::Shadow(super::ShadowStyle::TwoXs)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("box_shadow"),
            list(vec![list(vec![
                tuple(vec![atom("color"), atom("red")]),
                tuple(vec![atom("x"), integer(0)]),
                tuple(vec![atom("y"), integer(2)]),
                tuple(vec![atom("blur"), integer(4)]),
                tuple(vec![atom("spread"), integer(-1)]),
            ])]),
        ]))
        .unwrap(),
        StyleOp::BoxShadow(vec![BoxShadowSpec {
            color: StyleColor::Token(super::ColorToken::Red),
            x: 0.0,
            y: 2.0,
            blur: 4.0,
            spread: -1.0,
        }])
    );

    assert_eq!(
        parse_style_op(&tuple(vec![atom("flex_direction"), atom("row_reverse")])).unwrap(),
        StyleOp::FlexDirection(super::FlexDirectionStyle::RowReverse)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("flex_wrap"), atom("nowrap")])).unwrap(),
        StyleOp::FlexWrapValue(super::FlexWrapStyle::NoWrap)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("flex_item"), atom("shrink_0")])).unwrap(),
        StyleOp::FlexItem(super::FlexItemStyle::Shrink0)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("flex_basis"),
            tuple(vec![atom("fraction"), float(0.5)]),
        ]))
        .unwrap(),
        StyleOp::FlexBasis(StyleLength::Fraction(0.5))
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("flex_grow"), float(2.0)])).unwrap(),
        StyleOp::FlexGrowValue(2.0)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("flex_shrink"), float(0.5)])).unwrap(),
        StyleOp::FlexShrinkValue(0.5)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("align_items"), atom("baseline")])).unwrap(),
        StyleOp::AlignItems(super::AlignItemsStyle::Baseline)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("align_items"), atom("stretch")])).unwrap(),
        StyleOp::AlignItems(super::AlignItemsStyle::Stretch)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("align_self"), atom("stretch")])).unwrap(),
        StyleOp::AlignSelf(super::AlignItemsStyle::Stretch)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("justify_content"), atom("around")])).unwrap(),
        StyleOp::JustifyContent(super::JustifyContentStyle::Around)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("justify_content"), atom("evenly")])).unwrap(),
        StyleOp::JustifyContent(super::JustifyContentStyle::Evenly)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("align_content"), atom("evenly")])).unwrap(),
        StyleOp::AlignContent(super::AlignContentStyle::Evenly)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("text_align"), atom("center")])).unwrap(),
        StyleOp::TextAlign(super::TextAlignStyle::Center)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("white_space"), atom("nowrap")])).unwrap(),
        StyleOp::WhiteSpace(super::WhiteSpaceStyle::NoWrap)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("text_overflow"), atom("truncate")])).unwrap(),
        StyleOp::TextOverflow(super::TextOverflowStyle::Truncate)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("font_size"), atom("2xl")])).unwrap(),
        StyleOp::FontSize(super::FontSizeStyle::TwoXl)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("text_size"),
            tuple(vec![atom("px"), integer(14)]),
        ]))
        .unwrap(),
        StyleOp::TextSize(StyleLength::Px(14.0))
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("line_height"), atom("relaxed")])).unwrap(),
        StyleOp::LineHeight(super::LineHeightStyle::Relaxed)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("line_height_length"),
            tuple(vec![atom("fraction"), float(1.4)]),
        ]))
        .unwrap(),
        StyleOp::LineHeightLength(StyleLength::Fraction(1.4))
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("font_weight"), atom("bold")])).unwrap(),
        StyleOp::FontWeight(super::FontWeightStyle::Bold)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("font_weight_value"), integer(650)])).unwrap(),
        StyleOp::FontWeightValue(650.0)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("font_style"), atom("italic")])).unwrap(),
        StyleOp::FontStyle(super::FontStyleValue::Italic)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("font_family"), binary("Monaco")])).unwrap(),
        StyleOp::FontFamily("Monaco".into())
    );
    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("font_fallbacks"),
            list(vec![binary("Monaco"), binary("Menlo")]),
        ]))
        .unwrap(),
        StyleOp::FontFallbacks(vec!["Monaco".into(), "Menlo".into()])
    );
    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("font_features"),
            list(vec![
                tuple(vec![binary("calt"), integer(0)]),
                tuple(vec![binary("kern"), integer(1)]),
            ]),
        ]))
        .unwrap(),
        StyleOp::FontFeatures(vec![("calt".into(), 0), ("kern".into(), 1)])
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("text_decoration"), atom("none")])).unwrap(),
        StyleOp::TextDecoration(super::TextDecorationStyle::None)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("text_decoration_color"), atom("red")])).unwrap(),
        StyleOp::TextDecorationColor(super::ColorToken::Red)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("text_decoration_color_hex"),
            binary("abcdef"),
        ]))
        .unwrap(),
        StyleOp::TextDecorationColorHex(0xabcdef)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("text_decoration_style"), atom("wavy")])).unwrap(),
        StyleOp::TextDecorationLineStyle(super::TextDecorationLineStyle::Wavy)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("text_decoration_thickness"), integer(2)])).unwrap(),
        StyleOp::TextDecorationThickness(2.0)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("strikethrough_color"), atom("red")])).unwrap(),
        StyleOp::StrikethroughColor(super::ColorToken::Red)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![
            atom("strikethrough_color_hex"),
            binary("abcdef"),
        ]))
        .unwrap(),
        StyleOp::StrikethroughColorHex(0xabcdef)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("strikethrough_thickness"), integer(2)])).unwrap(),
        StyleOp::StrikethroughThickness(2.0)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("line_clamp"), integer(4)])).unwrap(),
        StyleOp::LineClamp(4)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("col_start"), integer(2)])).unwrap(),
        StyleOp::ColStart(2)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("col_end"), atom("auto")])).unwrap(),
        StyleOp::ColEndAuto
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("col_span"), atom("full")])).unwrap(),
        StyleOp::ColSpanFull
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("row_span"), atom("full")])).unwrap(),
        StyleOp::RowSpanFull
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("row_start"), integer(-1)])).unwrap(),
        StyleOp::RowStart(-1)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("row_end"), atom("auto")])).unwrap(),
        StyleOp::RowEndAuto
    );
}

#[test]
fn rejects_invalid_canonical_length_style_ops() {
    for term in [
        tuple(vec![
            atom("padding"),
            atom("inline"),
            tuple(vec![atom("rem"), float(0.25)]),
        ]),
        tuple(vec![
            atom("padding"),
            atom("all"),
            tuple(vec![atom("px"), integer(-1)]),
        ]),
        tuple(vec![
            atom("gap"),
            atom("top"),
            tuple(vec![atom("px"), integer(1)]),
        ]),
        tuple(vec![atom("width"), tuple(vec![atom("bad"), integer(1)])]),
        tuple(vec![atom("aspect_ratio"), integer(0)]),
        tuple(vec![atom("position"), atom("fixed")]),
        tuple(vec![
            atom("inset"),
            atom("center"),
            tuple(vec![atom("px"), integer(1)]),
        ]),
        tuple(vec![atom("display"), atom("inline")]),
        tuple(vec![atom("visibility"), atom("collapsed")]),
        tuple(vec![atom("overflow"), atom("x"), atom("auto")]),
        tuple(vec![atom("allow_concurrent_scroll"), atom("yes")]),
        tuple(vec![atom("restrict_scroll_to_axis"), atom("yes")]),
        tuple(vec![atom("debug"), atom("yes")]),
        tuple(vec![atom("debug_below"), atom("yes")]),
        tuple(vec![atom("cursor"), atom("bad")]),
        tuple(vec![
            atom("border_width"),
            atom("center"),
            tuple(vec![atom("px"), integer(1)]),
        ]),
        tuple(vec![
            atom("border_width"),
            atom("all"),
            tuple(vec![atom("fraction"), integer(1)]),
        ]),
        tuple(vec![
            atom("border_radius"),
            atom("x"),
            tuple(vec![atom("px"), integer(1)]),
        ]),
        tuple(vec![atom("border_style"), atom("double")]),
        tuple(vec![atom("shadow"), atom("huge")]),
        tuple(vec![
            atom("box_shadow"),
            list(vec![list(vec![
                tuple(vec![atom("color"), atom("purple")]),
                tuple(vec![atom("x"), integer(0)]),
                tuple(vec![atom("y"), integer(2)]),
                tuple(vec![atom("blur"), integer(4)]),
                tuple(vec![atom("spread"), integer(-1)]),
            ])]),
        ]),
        tuple(vec![
            atom("box_shadow"),
            list(vec![list(vec![
                tuple(vec![atom("color"), atom("red")]),
                tuple(vec![atom("x"), integer(0)]),
                tuple(vec![atom("y"), integer(2)]),
                tuple(vec![atom("blur"), integer(-1)]),
                tuple(vec![atom("spread"), integer(-1)]),
            ])]),
        ]),
        tuple(vec![atom("flex_direction"), atom("sideways")]),
        tuple(vec![atom("flex_wrap"), atom("maybe")]),
        tuple(vec![atom("flex_item"), atom("bad")]),
        tuple(vec![atom("flex_grow"), integer(-1)]),
        tuple(vec![atom("flex_shrink"), integer(-1)]),
        tuple(vec![atom("align_items"), atom("left")]),
        tuple(vec![atom("align_self"), atom("auto")]),
        tuple(vec![atom("justify_content"), atom("auto")]),
        tuple(vec![atom("align_content"), atom("bad")]),
        tuple(vec![atom("text_align"), atom("justify")]),
        tuple(vec![atom("white_space"), atom("pre")]),
        tuple(vec![atom("text_overflow"), atom("clip")]),
        tuple(vec![atom("font_size"), atom("huge")]),
        tuple(vec![
            atom("text_size"),
            tuple(vec![atom("fraction"), integer(1)]),
        ]),
        tuple(vec![atom("line_height"), atom("bad")]),
        tuple(vec![atom("line_height_length"), atom("auto")]),
        tuple(vec![
            atom("line_height_length"),
            tuple(vec![atom("px"), integer(-1)]),
        ]),
        tuple(vec![atom("font_weight"), atom("heavy")]),
        tuple(vec![atom("font_weight_value"), integer(50)]),
        tuple(vec![atom("font_style"), atom("oblique")]),
        tuple(vec![atom("font_family"), binary("")]),
        tuple(vec![atom("font_fallbacks"), list(vec![])]),
        tuple(vec![atom("font_fallbacks"), list(vec![binary("")])]),
        tuple(vec![atom("font_features"), list(vec![])]),
        tuple(vec![
            atom("font_features"),
            list(vec![tuple(vec![binary("bad"), integer(1)])]),
        ]),
        tuple(vec![
            atom("font_features"),
            list(vec![tuple(vec![binary("calt"), integer(-1)])]),
        ]),
        tuple(vec![atom("text_decoration"), atom("blink")]),
        tuple(vec![atom("text_decoration_color"), atom("purple")]),
        tuple(vec![atom("text_decoration_style"), atom("double")]),
        tuple(vec![atom("text_decoration_thickness"), integer(-1)]),
        tuple(vec![atom("strikethrough_color"), atom("purple")]),
        tuple(vec![atom("strikethrough_color_hex"), binary("bad")]),
        tuple(vec![atom("strikethrough_thickness"), integer(-1)]),
    ] {
        let err = parse_style_op(&term).unwrap_err();
        assert!(
            err.contains("padding")
                || err.contains("gap")
                || err.contains("position")
                || err.contains("inset")
                || err.contains("display")
                || err.contains("visibility")
                || err.contains("overflow")
                || err.contains("cursor")
                || err.contains("border")
                || err.contains("shadow")
                || err.contains("flex")
                || err.contains("align")
                || err.contains("justify")
                || err.contains("text")
                || err.contains("white")
                || err.contains("color")
                || err.contains("font")
                || err.contains("line")
                || err.contains("length")
                || err.contains("aspect")
                || err.contains("numeric")
                || err.contains("boolean"),
            "unexpected error: {err}"
        );
    }
}

#[test]
fn native_style_catalog_loads() {
    let catalog: serde_json::Value =
        serde_json::from_str(include_str!("../../../data/gpui_style_catalog.json")).unwrap();

    assert_eq!(catalog["gpui_version"], "0.2.2");
    let operations = catalog["operations"].as_array().unwrap();
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "padding")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "margin")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "width")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "aspect_ratio")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "position")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "display")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "cursor")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "allow_concurrent_scroll")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "restrict_scroll_to_axis")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "debug")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "debug_below")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "border_width")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "border_radius")
    );
    assert!(operations.iter().any(|operation| operation["name"] == "bg"));
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "text_color")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "bg_hex")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "bg_linear_gradient")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "bg_pattern_slash")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "opacity")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "scrollbar_width")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "shadow")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "box_shadow")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "flex_direction")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "flex_basis")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "flex_grow")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "flex_shrink")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "align_self")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "align_content")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "font_weight")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "font_weight_value")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "text_size")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "line_height_length")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "font_family")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "font_fallbacks")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "font_features")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "text_bg")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "text_decoration")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "text_decoration_color")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "text_decoration_thickness")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "strikethrough_color")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "strikethrough_thickness")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "line_clamp")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "grid_cols")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "col_start")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "object_fit")
    );
    assert!(
        operations
            .iter()
            .any(|operation| operation["name"] == "grayscale")
    );
}

#[test]
fn parses_named_text_background_style_op() {
    assert_eq!(
        parse_style_op(&tuple(vec![atom("text_bg"), atom("blue")])).unwrap(),
        StyleOp::TextBg(super::ColorToken::Blue)
    );
}

#[test]
fn parses_bg_linear_gradient_style_op() {
    let term = tuple(vec![
        atom("bg_linear_gradient"),
        list(vec![
            tuple(vec![atom("angle"), integer(90)]),
            tuple(vec![
                atom("from"),
                tuple(vec![binary("#0f172a"), float(0.0)]),
            ]),
            tuple(vec![atom("to"), tuple(vec![atom("blue"), float(1.0)])]),
        ]),
    ]);

    assert_eq!(
        parse_style_op(&term).unwrap(),
        StyleOp::BgLinearGradient {
            angle: 90.0,
            from: LinearGradientStop {
                color: StyleColor::Hex(0x0f172a),
                percentage: 0.0,
            },
            to: LinearGradientStop {
                color: StyleColor::Token(super::ColorToken::Blue),
                percentage: 1.0,
            },
        }
    );

    let pattern = tuple(vec![
        atom("bg_pattern_slash"),
        list(vec![
            tuple(vec![atom("color"), atom("red")]),
            tuple(vec![atom("width"), float(1.5)]),
            tuple(vec![atom("interval"), integer(4)]),
        ]),
    ]);

    assert_eq!(
        parse_style_op(&pattern).unwrap(),
        StyleOp::BgPatternSlash(BackgroundPatternSlash {
            color: StyleColor::Token(super::ColorToken::Red),
            width: 1.5,
            interval: 4.0,
        })
    );
}

#[test]
fn rejects_invalid_bg_linear_gradient_style_op() {
    let term = tuple(vec![
        atom("bg_linear_gradient"),
        list(vec![
            tuple(vec![atom("angle"), integer(90)]),
            tuple(vec![
                atom("from"),
                tuple(vec![binary("0f172a"), float(0.0)]),
            ]),
            tuple(vec![atom("to"), tuple(vec![binary("#2563eb"), float(1.5)])]),
        ]),
    ]);

    let err = parse_style_op(&term).unwrap_err();
    assert!(err.contains("gradient"));

    let term = tuple(vec![
        atom("bg_pattern_slash"),
        list(vec![
            tuple(vec![atom("color"), atom("purple")]),
            tuple(vec![atom("width"), integer(1)]),
            tuple(vec![atom("interval"), integer(4)]),
        ]),
    ]);

    let err = parse_style_op(&term).unwrap_err();
    assert!(err.contains("background pattern"));
}

#[test]
fn parses_style_hex_color_ops_with_optional_hash() {
    assert_eq!(
        parse_style_op(&tuple(vec![atom("bg_hex"), binary("#0f172a")])).unwrap(),
        StyleOp::BgHex(0x0f172a)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("text_color_hex"), binary("445566")])).unwrap(),
        StyleOp::TextColorHex(0x445566)
    );
    assert_eq!(
        parse_style_op(&tuple(vec![atom("text_bg_hex"), binary("778899")])).unwrap(),
        StyleOp::TextBgHex(0x778899)
    );
}

#[test]
fn rejects_invalid_hex_style_color_ops() {
    let term = tuple(vec![atom("bg_hex"), binary("#12")]);

    let err = parse_style_op(&term).unwrap_err();
    assert!(err.contains("invalid style hex color"));
}

#[test]
fn rejects_invalid_numeric_style_ops() {
    for term in [
        tuple(vec![atom("opacity"), float(1.5)]),
        tuple(vec![atom("line_clamp"), integer(0)]),
        tuple(vec![atom("col_start"), integer(40_000)]),
        tuple(vec![atom("col_span"), atom("auto")]),
        tuple(vec![
            atom("flex_basis"),
            tuple(vec![atom("px"), integer(-1)]),
        ]),
        tuple(vec![atom("w_px"), integer(-1)]),
        tuple(vec![atom("w_frac"), float(1.2)]),
        tuple(vec![atom("scrollbar_width_px"), integer(-1)]),
    ] {
        let err = parse_style_op(&term).unwrap_err();
        assert!(err.contains("invalid"), "unexpected error: {err}");
    }
}

#[test]
fn list_row_validation_rejects_stateful_controls() {
    let err = validate_list_row_child(&IrNode::TextInput {
        id: Some("row_input".into()),
        value: "nope".into(),
        placeholder: String::new(),
        style: Vec::new().into(),
        disabled: false,
        tab_index: None,
        shortcuts: Vec::new().into(),
        change: None,
        focus: None,
        blur: None,
        context_menu: None,
    })
    .unwrap_err();

    assert!(err.contains("text_input"));
}