use super::*;
use crate::protocol::TreeNode;
use crate::shared_state::hash_str;
use iced::widget::canvas;
use iced::{Color, Point, alignment, event, keyboard};
use plushie_core::types::{CanvasShape, GroupShape};
use serde_json::Value;
use serde_json::json;
fn flat_shape_to_tree_node(val: &Value) -> TreeNode {
let obj = val.as_object().expect("expected JSON object");
let type_name = obj
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let id = obj
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("__auto__")
.to_string();
let mut props = serde_json::Map::new();
for (k, v) in obj {
if k == "type" || k == "id" || k == "children" {
continue;
}
props.insert(k.clone(), v.clone());
}
let children: Vec<TreeNode> = obj
.get("children")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(flat_shape_to_tree_node).collect())
.unwrap_or_default();
TreeNode {
id,
type_name,
props: plushie_core::protocol::Props::from_json(Value::Object(props)),
children,
}
}
fn group_from_json(val: &Value) -> GroupShape {
let node = flat_shape_to_tree_node(val);
GroupShape::from_node(&node)
}
fn shapes_from_json(shapes: &[Value]) -> Vec<CanvasShape> {
shapes
.iter()
.filter_map(|v| {
let node = flat_shape_to_tree_node(v);
CanvasShape::from_node(&node)
})
.collect()
}
fn collect_interactive_from_json(
shapes: &[Value],
layer_name: &str,
parent_transform: TransformMatrix,
parent_clip: Option<(f32, f32, f32, f32)>,
focusable_parent: Option<&str>,
id_prefix: &str,
out: &mut Vec<InteractiveElement>,
) {
let typed = shapes_from_json(shapes);
collect_interactive_elements(
&typed,
layer_name,
parent_transform,
parent_clip,
focusable_parent,
id_prefix,
out,
);
}
fn make_canvas_node(props: Value, children: Vec<TreeNode>) -> TreeNode {
TreeNode {
id: "test-canvas".to_string(),
type_name: "canvas".to_string(),
props: plushie_core::protocol::Props::from_json(props),
children,
}
}
fn make_layer_node(name: &str, shape_children: Vec<TreeNode>) -> TreeNode {
TreeNode {
id: format!("auto:layer:{name}"),
type_name: "__layer__".to_string(),
props: plushie_core::protocol::Props::from_json(json!({"name": name})),
children: shape_children,
}
}
fn make_shape_node(id: &str, type_name: &str, props: Value) -> TreeNode {
TreeNode {
id: id.to_string(),
type_name: type_name.to_string(),
props: plushie_core::protocol::Props::from_json(props),
children: vec![],
}
}
#[test]
fn canvas_layers_from_layer_children() {
let node = make_canvas_node(
json!({}),
vec![
make_layer_node(
"background",
vec![make_shape_node(
"auto:shape:bg:0",
"rect",
json!({"width": 100}),
)],
),
make_layer_node(
"foreground",
vec![make_shape_node(
"auto:shape:fg:0",
"circle",
json!({"radius": 50}),
)],
),
],
);
let result = canvas_layers_from_node(&node);
assert_eq!(result.len(), 2);
assert!(result.contains_key("background"));
assert!(result.contains_key("foreground"));
let bg = result.get("background").unwrap();
assert_eq!(bg.len(), 1);
assert!(matches!(&bg[0], plushie_core::types::CanvasShape::Rect(_)));
}
#[test]
fn canvas_flat_shape_children() {
let node = make_canvas_node(
json!({}),
vec![make_shape_node(
"auto:shape:0",
"line",
json!({"x1": 0, "y1": 0, "x2": 100, "y2": 100}),
)],
);
let result = canvas_layers_from_node(&node);
assert_eq!(result.len(), 1);
assert!(result.contains_key("default"));
}
#[test]
fn canvas_empty_children() {
let node = make_canvas_node(json!({}), vec![]);
let result = canvas_layers_from_node(&node);
assert!(result.is_empty());
}
#[test]
fn canvas_hash_changes() {
let hash_a = hash_str("[{\"type\":\"rect\"}]");
let hash_b = hash_str("[{\"type\":\"circle\"}]");
let hash_a2 = hash_str("[{\"type\":\"rect\"}]");
assert_eq!(hash_a, hash_a2);
assert_ne!(hash_a, hash_b);
}
#[test]
fn canvas_palette_background_resolves_from_current_theme() {
let light = iced::Theme::Light;
let dark = iced::Theme::Dark;
let color = json!("background");
let light_background = resolve_color(&color, &light);
let dark_background = resolve_color(&color, &dark);
assert_eq!(
light_background,
Some(light.palette().background.base.color)
);
assert_eq!(dark_background, Some(dark.palette().background.base.color));
assert_ne!(light_background, dark_background);
}
#[test]
fn canvas_layer_sort_order() {
let node = make_canvas_node(
json!({}),
vec![
make_layer_node(
"charlie",
vec![make_shape_node("auto:shape:c:0", "rect", json!({}))],
),
make_layer_node(
"alpha",
vec![make_shape_node("auto:shape:a:0", "circle", json!({}))],
),
make_layer_node(
"bravo",
vec![make_shape_node("auto:shape:b:0", "line", json!({}))],
),
],
);
let result = canvas_layers_from_node(&node);
let keys: Vec<&String> = result.keys().collect();
assert_eq!(keys, vec!["alpha", "bravo", "charlie"]);
}
#[test]
fn canvas_path_commands_basic() {
let shape = json!({
"type": "path",
"commands": [
["move_to", 10, 20],
["line_to", 30, 40],
"close"
]
});
assert_eq!(shape.get("type").and_then(|v| v.as_str()), Some("path"));
let commands = shape.get("commands").and_then(|v| v.as_array()).unwrap();
assert_eq!(commands.len(), 3);
let move_cmd = commands[0].as_array().unwrap();
assert_eq!(move_cmd[0].as_str(), Some("move_to"));
assert_eq!(move_cmd[1].as_f64(), Some(10.0));
assert_eq!(move_cmd[2].as_f64(), Some(20.0));
let line_cmd = commands[1].as_array().unwrap();
assert_eq!(line_cmd[0].as_str(), Some("line_to"));
assert_eq!(line_cmd[1].as_f64(), Some(30.0));
assert_eq!(line_cmd[2].as_f64(), Some(40.0));
assert_eq!(commands[2].as_str(), Some("close"));
}
#[test]
fn canvas_stroke_parse() {
let stroke_val = json!({
"color": "#ff0000",
"width": 3.0,
"cap": "round",
"join": "bevel"
});
let stroke = parse_canvas_stroke(&stroke_val);
assert_eq!(
stroke.style,
canvas::Style::Solid(Color::from_rgb8(255, 0, 0))
);
assert_eq!(stroke.width, 3.0);
assert_eq!(format!("{:?}", stroke.line_cap), "Round");
assert_eq!(format!("{:?}", stroke.line_join), "Bevel");
}
#[test]
fn dash_pattern_within_caps_is_applied() {
let stroke_val = json!({
"color": "#ff0000",
"width": 1.0,
"dash": {"segments": [4.0, 2.0], "offset": 0}
});
let stroke = parse_canvas_stroke(&stroke_val);
assert_eq!(stroke.line_dash.segments, &[4.0, 2.0]);
}
#[test]
fn dash_pattern_exceeding_segment_cap_is_dropped() {
let segments: Vec<f64> = (0..65).map(|i| (i + 1) as f64).collect();
let stroke_val = json!({
"color": "#ff0000",
"width": 1.0,
"dash": {"segments": segments, "offset": 0}
});
let stroke = parse_canvas_stroke(&stroke_val);
assert!(stroke.line_dash.segments.is_empty());
}
#[test]
fn canvas_gradient_parse() {
let fill_val = json!({
"type": "linear",
"start": [0.0, 0.0],
"end": [100.0, 0.0],
"stops": [
[0.0, "#ff0000"],
[1.0, "#0000ff"]
]
});
let shape = json!({"fill": fill_val.clone()});
let fill = parse_canvas_fill(&fill_val, &shape);
assert_eq!(fill.rule, canvas::fill::Rule::NonZero);
match &fill.style {
canvas::Style::Gradient(canvas::Gradient::Linear(_)) => {}
other => panic!("expected Gradient::Linear, got {other:?}"),
}
}
fn gradient_fill_with_alphas(alphas: &[f32]) -> canvas::Fill {
let mut linear = canvas::gradient::Linear::new(Point::new(0.0, 0.0), Point::new(100.0, 0.0));
for (index, alpha) in alphas.iter().enumerate() {
linear = linear.add_stop(
index as f32 / (alphas.len().saturating_sub(1).max(1) as f32),
Color::from_rgba(1.0, 0.0, 0.0, *alpha),
);
}
canvas::Fill {
style: canvas::Style::Gradient(canvas::Gradient::Linear(linear)),
rule: canvas::fill::Rule::NonZero,
}
}
fn gradient_stroke_with_alphas(alphas: &[f32]) -> canvas::Stroke<'static> {
canvas::Stroke {
style: gradient_fill_with_alphas(alphas).style,
..canvas::Stroke::default()
}
}
fn gradient_stop_alphas(style: canvas::Style) -> Vec<f32> {
match style {
canvas::Style::Gradient(canvas::Gradient::Linear(linear)) => linear
.stops
.iter()
.filter_map(|stop| stop.map(|stop| stop.color.a))
.collect(),
other => panic!("expected linear gradient, got {other:?}"),
}
}
fn assert_alphas(actual: Vec<f32>, expected: &[f32]) {
assert_eq!(actual.len(), expected.len());
for (actual, expected) in actual.iter().zip(expected) {
assert!(
(actual - expected).abs() < 0.001,
"expected alpha ~{expected}, got {actual}",
);
}
}
#[test]
fn canvas_fill_rule_defaults_to_non_zero() {
let fill_val = json!("#ff0000");
let shape = json!({"fill": "#ff0000"});
let fill = parse_canvas_fill(&fill_val, &shape);
assert_eq!(fill.rule, canvas::fill::Rule::NonZero);
}
#[test]
fn canvas_fill_rule_even_odd() {
let fill_val = json!("#00ff00");
let shape = json!({"fill": "#00ff00", "fill_rule": "even_odd"});
let fill = parse_canvas_fill(&fill_val, &shape);
assert_eq!(fill.rule, canvas::fill::Rule::EvenOdd);
}
#[test]
fn canvas_fill_rule_explicit_non_zero() {
let fill_val = json!("#0000ff");
let shape = json!({"fill": "#0000ff", "fill_rule": "non_zero"});
let fill = parse_canvas_fill(&fill_val, &shape);
assert_eq!(fill.rule, canvas::fill::Rule::NonZero);
}
#[test]
fn text_align_x_parses_left() {
let v = json!("left");
assert_eq!(
format!("{:?}", shapes::parse_canvas_text_align_x(Some(&v))),
"Left",
);
}
#[test]
fn text_align_x_parses_center() {
let v = json!("center");
assert_eq!(
format!("{:?}", shapes::parse_canvas_text_align_x(Some(&v))),
"Center",
);
}
#[test]
fn text_align_x_parses_right() {
let v = json!("right");
assert_eq!(
format!("{:?}", shapes::parse_canvas_text_align_x(Some(&v))),
"Right",
);
}
#[test]
fn text_align_x_defaults_to_default() {
assert_eq!(
format!("{:?}", shapes::parse_canvas_text_align_x(None)),
"Default",
);
}
#[test]
fn text_align_y_parses_center() {
let v = json!("center");
assert_eq!(
shapes::parse_canvas_text_align_y(Some(&v)),
alignment::Vertical::Center
);
}
#[test]
fn text_align_y_parses_bottom() {
let v = json!("bottom");
assert_eq!(
shapes::parse_canvas_text_align_y(Some(&v)),
alignment::Vertical::Bottom
);
}
#[test]
fn text_align_y_defaults_to_top() {
assert_eq!(
shapes::parse_canvas_text_align_y(None),
alignment::Vertical::Top,
);
}
#[test]
fn opacity_applied_to_fill() {
let fill =
shapes::apply_opacity_to_fill(Some(0.5), parse_canvas_fill(&json!("#ff0000"), &json!({})));
match fill.style {
canvas::Style::Solid(c) => {
assert!(
(c.a - 0.5).abs() < 0.001,
"expected alpha ~0.5, got {}",
c.a
);
}
_ => panic!("expected solid fill"),
}
}
#[test]
fn opacity_applied_to_gradient_fill() {
let fill = shapes::apply_opacity_to_fill(Some(0.5), gradient_fill_with_alphas(&[1.0, 0.4]));
assert_alphas(gradient_stop_alphas(fill.style), &[0.5, 0.2]);
}
#[test]
fn opacity_applied_to_stroke() {
let stroke_val = json!({"color": "#00ff00", "width": 2.0});
let stroke = shapes::apply_opacity_to_stroke(Some(0.25), parse_canvas_stroke(&stroke_val));
match stroke.style {
canvas::Style::Solid(c) => {
assert!(
(c.a - 0.25).abs() < 0.001,
"expected alpha ~0.25, got {}",
c.a
);
}
_ => panic!("expected solid stroke"),
}
}
#[test]
fn opacity_applied_to_gradient_stroke() {
let stroke =
shapes::apply_opacity_to_stroke(Some(0.25), gradient_stroke_with_alphas(&[1.0, 0.8]));
assert_alphas(gradient_stop_alphas(stroke.style), &[0.25, 0.2]);
}
#[test]
fn opacity_applied_to_color() {
let color = shapes::apply_opacity_to_color(Some(0.75), Color::WHITE);
assert!(
(color.a - 0.75).abs() < 0.001,
"expected alpha ~0.75, got {}",
color.a
);
}
#[test]
fn no_opacity_leaves_alpha_unchanged() {
let fill =
shapes::apply_opacity_to_fill(None, parse_canvas_fill(&json!("#ff0000"), &json!({})));
match fill.style {
canvas::Style::Solid(c) => {
assert!(
(c.a - 1.0).abs() < 0.001,
"expected alpha ~1.0, got {}",
c.a
);
}
_ => panic!("expected solid fill"),
}
}
#[test]
fn no_opacity_leaves_gradient_alpha_unchanged() {
let fill = shapes::apply_opacity_to_fill(None, gradient_fill_with_alphas(&[0.6, 0.3]));
assert_alphas(gradient_stop_alphas(fill.style), &[0.6, 0.3]);
}
#[test]
fn hit_test_rect_inside() {
let region = HitRegion::Rect {
x: 10.0,
y: 20.0,
w: 30.0,
h: 40.0,
};
assert!(interaction::hit_test(Point::new(25.0, 40.0), ®ion));
}
#[test]
fn hit_test_rect_outside() {
let region = HitRegion::Rect {
x: 10.0,
y: 20.0,
w: 30.0,
h: 40.0,
};
assert!(!interaction::hit_test(Point::new(5.0, 40.0), ®ion));
}
#[test]
fn hit_test_circle_inside() {
let region = HitRegion::Circle {
cx: 50.0,
cy: 50.0,
r: 20.0,
};
assert!(interaction::hit_test(Point::new(50.0, 50.0), ®ion));
assert!(interaction::hit_test(Point::new(60.0, 50.0), ®ion));
}
#[test]
fn hit_test_circle_outside() {
let region = HitRegion::Circle {
cx: 50.0,
cy: 50.0,
r: 20.0,
};
assert!(!interaction::hit_test(Point::new(80.0, 50.0), ®ion));
}
#[test]
fn hit_test_line_near() {
let region = HitRegion::Line {
x1: 0.0,
y1: 0.0,
x2: 100.0,
y2: 0.0,
half_width: 5.0,
};
assert!(interaction::hit_test(Point::new(50.0, 3.0), ®ion));
assert!(!interaction::hit_test(Point::new(50.0, 10.0), ®ion));
}
#[test]
fn hit_test_line_endpoint() {
let region = HitRegion::Line {
x1: 10.0,
y1: 10.0,
x2: 10.0,
y2: 10.0,
half_width: 5.0,
};
assert!(interaction::hit_test(Point::new(12.0, 10.0), ®ion));
assert!(!interaction::hit_test(Point::new(20.0, 10.0), ®ion));
}
#[test]
fn parse_interactive_group_basic() {
let shape = json!({
"type": "group",
"id": "bar-1",
"on_click": true,
"on_hover": true,
"cursor": "pointer",
"tooltip": "Bar 1: 200 units",
"children": [
{"type": "rect", "x": 10, "y": 20, "w": 30, "h": 40, "fill": "#ff0000"}
]
});
let result =
interaction::parse_interactive_element(&group_from_json(&shape), "default").unwrap();
assert_eq!(result.id, "bar-1");
assert!(result.on_click);
assert!(result.on_hover);
assert_eq!(result.cursor.as_deref(), Some("pointer"));
assert_eq!(result.tooltip.as_deref(), Some("Bar 1: 200 units"));
assert!(matches!(result.hit_region, HitRegion::Rect { .. }));
}
#[test]
fn parse_interactive_group_with_drag() {
let shape = json!({
"type": "group",
"id": "dot-1",
"on_click": true,
"draggable": true,
"drag_axis": "x",
"children": [
{"type": "circle", "x": 50, "y": 50, "r": 20}
]
});
let result =
interaction::parse_interactive_element(&group_from_json(&shape), "layer1").unwrap();
assert_eq!(result.id, "dot-1");
assert!(result.draggable);
assert_eq!(result.drag_axis, DragAxis::X);
assert!(matches!(result.hit_region, HitRegion::Rect { .. }));
}
#[test]
fn parse_interactive_group_with_hit_rect() {
let shape = json!({
"type": "group",
"id": "path-group",
"on_click": true,
"hit_rect": {"x": 0, "y": 0, "w": 100, "h": 100},
"children": [
{"type": "path", "commands": [["move_to", 0, 0], ["line_to", 100, 100]]}
]
});
let result =
interaction::parse_interactive_element(&group_from_json(&shape), "default").unwrap();
assert_eq!(result.id, "path-group");
assert!(matches!(result.hit_region, HitRegion::Rect { .. }));
}
#[test]
fn parse_interactive_group_stores_normalized_hit_rect() {
let shape = json!({
"type": "group",
"id": "bad-hit-rect",
"on_click": true,
"hit_rect": {"x": -5.0, "y": 1.0e39, "w": -20.0, "h": 1.0e39},
"children": [
{"type": "rect", "x": 0.0, "y": 0.0, "w": 10.0, "h": 10.0}
]
});
let result =
interaction::parse_interactive_element(&group_from_json(&shape), "default").unwrap();
match &result.hit_region {
HitRegion::Rect { x, y, w, h } => {
assert_eq!(*x, -5.0);
assert_eq!(*y, 0.0);
assert_eq!(*w, 0.0);
assert_eq!(*h, 0.0);
}
other => panic!("expected Rect, got {other:?}"),
}
let rect = interaction::hit_region_to_rect(&result.hit_region);
assert_eq!(rect.x, -5.0);
assert_eq!(rect.y, 0.0);
assert_eq!(rect.width, 0.0);
assert_eq!(rect.height, 0.0);
}
#[test]
fn parse_interactive_missing_id_returns_none() {
let shape = json!({
"type": "group",
"on_click": true,
"children": [{"type": "rect", "x": 0, "y": 0, "w": 10, "h": 10}]
});
assert!(interaction::parse_interactive_element(&group_from_json(&shape), "default").is_none());
}
#[test]
fn hit_region_to_rect_circle() {
let rect = interaction::hit_region_to_rect(&HitRegion::Circle {
cx: 50.0,
cy: 50.0,
r: 20.0,
});
assert!((rect.x - 30.0).abs() < 0.01);
assert!((rect.y - 30.0).abs() < 0.01);
assert!((rect.width - 40.0).abs() < 0.01);
assert!((rect.height - 40.0).abs() < 0.01);
}
#[test]
fn merge_shape_style_overrides_fill() {
let shape = json!({"type": "rect", "fill": "#ff0000", "stroke": {"color": "#000"}});
let overrides = json!({"fill": "#00ff00"});
let merged = program::merge_shape_style(&shape, &overrides);
assert_eq!(merged["fill"], "#00ff00");
assert_eq!(merged["stroke"]["color"], "#000");
}
#[test]
fn compute_hit_region_group_with_rect_children() {
let shape = json!({
"type": "group",
"id": "grp1", "on_click": true,
"children": [
{"type": "rect", "x": 0, "y": 0, "w": 100, "h": 40},
{"type": "rect", "x": 10, "y": 50, "w": 80, "h": 20}
]
});
let result =
interaction::parse_interactive_element(&group_from_json(&shape), "default").unwrap();
match result.hit_region {
HitRegion::Rect { x, y, w, h } => {
assert!((x - 0.0).abs() < 0.01);
assert!((y - 0.0).abs() < 0.01);
assert!((w - 100.0).abs() < 0.01);
assert!((h - 70.0).abs() < 0.01);
}
other => panic!("expected Rect, got {other:?}"),
}
}
#[test]
fn compute_hit_region_group_with_mixed_children() {
let shape = json!({
"type": "group",
"id": "grp2", "on_click": true,
"children": [
{"type": "rect", "x": 0, "y": 0, "w": 50, "h": 30},
{"type": "circle", "x": 80, "y": 15, "r": 10}
]
});
let result =
interaction::parse_interactive_element(&group_from_json(&shape), "default").unwrap();
match result.hit_region {
HitRegion::Rect { x, y, w, h } => {
assert!((x - 0.0).abs() < 0.01);
assert!((y - 0.0).abs() < 0.01);
assert!((w - 90.0).abs() < 0.01);
assert!((h - 30.0).abs() < 0.01);
}
other => panic!("expected Rect, got {other:?}"),
}
}
#[test]
fn compute_hit_region_includes_nested_group_transforms() {
let shape = json!({
"type": "group",
"id": "outer", "on_click": true,
"children": [{
"type": "group",
"transforms": [
{"type": "translate", "x": 10.0, "y": 20.0},
{"type": "scale", "x": 2.0, "y": 3.0}
],
"children": [
{"type": "rect", "x": -5.0, "y": 2.0, "w": 10.0, "h": 4.0}
]
}]
});
let result =
interaction::parse_interactive_element(&group_from_json(&shape), "default").unwrap();
match result.hit_region {
HitRegion::Rect { x, y, w, h } => {
assert!((x - 0.0).abs() < 0.01, "x={x}");
assert!((y - 26.0).abs() < 0.01, "y={y}");
assert!((w - 20.0).abs() < 0.01, "w={w}");
assert!((h - 12.0).abs() < 0.01, "h={h}");
}
other => panic!("expected Rect, got {other:?}"),
}
}
#[test]
fn compute_hit_region_includes_rotated_nested_group_aabb() {
let shape = json!({
"type": "group",
"id": "outer", "on_click": true,
"children": [{
"type": "group",
"transforms": [
{"type": "rotate", "angle": 90.0}
],
"children": [
{"type": "rect", "x": 2.0, "y": 3.0, "w": 4.0, "h": 6.0}
]
}]
});
let result =
interaction::parse_interactive_element(&group_from_json(&shape), "default").unwrap();
match result.hit_region {
HitRegion::Rect { x, y, w, h } => {
assert!((x + 9.0).abs() < 0.01, "x={x}");
assert!((y - 2.0).abs() < 0.01, "y={y}");
assert!((w - 6.0).abs() < 0.01, "w={w}");
assert!((h - 4.0).abs() < 0.01, "h={h}");
}
other => panic!("expected Rect, got {other:?}"),
}
}
#[test]
fn compute_hit_region_group_no_children() {
let shape = json!({
"type": "group",
"id": "empty", "on_click": true,
"children": []
});
assert!(interaction::parse_interactive_element(&group_from_json(&shape), "default").is_none());
}
#[test]
fn parse_interactive_group() {
let shape = json!({
"type": "group",
"id": "btn",
"on_click": true,
"on_hover": true,
"cursor": "pointer",
"a11y": {"role": "button", "label": "Save"},
"children": [
{"type": "rect", "x": 0, "y": 0, "w": 100, "h": 40, "fill": "#3498db"},
{"type": "text", "x": 30, "y": 25, "content": "Save", "fill": "#ccc"}
]
});
let result =
interaction::parse_interactive_element(&group_from_json(&shape), "default").unwrap();
assert_eq!(result.id, "btn");
assert!(result.on_click);
assert!(result.on_hover);
assert_eq!(result.cursor.as_deref(), Some("pointer"));
assert!(result.a11y.is_some());
assert!(result.show_focus_ring); assert!(!result.focusable); match result.hit_region {
HitRegion::Rect { x, y, w, h } => {
assert!((x - 0.0).abs() < 0.01, "x={x}");
assert!((y - 0.0).abs() < 0.01, "y={y}");
assert!((w - 100.0).abs() < 0.01, "w={w}");
assert!((h - 40.0).abs() < 0.01, "h={h}");
}
other => panic!("expected Rect, got {other:?}"),
}
}
#[test]
fn parse_interactive_element_skips_non_groups() {
let shape = json!({
"type": "rect", "x": 0, "y": 0, "w": 100, "h": 40,
"id": "rect-btn", "on_click": true
});
assert!(interaction::parse_interactive_element(&group_from_json(&shape), "default").is_none());
}
#[test]
fn parse_interactive_group_with_new_fields() {
let shape = json!({
"type": "group",
"id": "toggle",
"on_click": true,
"focus_style": {"stroke": {"color": "#3b82f6", "width": 2.0}},
"show_focus_ring": false,
"focusable": true,
"children": [
{"type": "rect", "x": 0, "y": 0, "w": 60, "h": 30}
]
});
let result =
interaction::parse_interactive_element(&group_from_json(&shape), "default").unwrap();
assert_eq!(result.id, "toggle");
assert!(result.has_focus_style);
assert!(!result.show_focus_ring);
assert!(result.focusable);
}
#[test]
fn structural_group_with_id_but_no_interactive_flags_is_skipped() {
let shape = json!({
"type": "group",
"id": "auto:ThemeToggle:89",
"transforms": [
{"type": "translate", "x": 32.0, "y": 16.0},
{"type": "rotate", "angle": 90.0}
],
"children": [
{"type": "circle", "x": -3.5, "y": -3.0, "r": 2.0}
]
});
assert!(
interaction::parse_interactive_element(&group_from_json(&shape), "default").is_none(),
"structural transform group must not be collected as interactive"
);
}
#[test]
fn structural_group_inside_interactive_group_not_collected() {
let shapes = vec![json!({
"type": "group",
"id": "switch",
"on_click": true,
"children": [
{"type": "rect", "x": 0, "y": 0, "w": 64, "h": 32, "fill": "#fff"},
{
"type": "group",
"id": "auto:ThemeToggle:89",
"transforms": [
{"type": "translate", "x": 32.0, "y": 16.0},
{"type": "rotate", "angle": 90.0}
],
"children": [
{"type": "circle", "x": -3.5, "y": -3.0, "r": 2.0},
{"type": "circle", "x": 3.5, "y": -3.0, "r": 2.0}
]
}
]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert_eq!(elements.len(), 1, "only the outer interactive group");
assert_eq!(elements[0].id, "switch");
}
#[test]
fn named_positioning_groups_not_collected_as_interactive() {
let shapes: Vec<Value> = (0..5_i32)
.map(|i| {
json!({
"type": "group",
"id": format!("star-{i}"),
"transforms": [{"type": "translate", "x": i * 32, "y": 15}],
"children": [
{
"type": "path",
"commands": [
["move_to", 0.0, -12.0],
["line_to", 11.4, -3.7],
["line_to", 7.0, 9.7],
["line_to", -7.0, 9.7],
["line_to", -11.4, -3.7],
"close"
],
"fill": "#ffd700"
}
]
})
})
.collect();
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert!(
elements.is_empty(),
"read-only positioning groups must not be collected"
);
}
#[test]
fn on_hover_only_group_is_collected() {
let shape = json!({
"type": "group",
"id": "tooltip-zone",
"on_hover": true,
"children": [{"type": "rect", "x": 0, "y": 0, "w": 50, "h": 50}]
});
assert!(interaction::parse_interactive_element(&group_from_json(&shape), "default").is_some());
}
#[test]
fn draggable_only_group_is_collected() {
let shape = json!({
"type": "group",
"id": "drag-handle",
"draggable": true,
"children": [{"type": "rect", "x": 0, "y": 0, "w": 20, "h": 20}]
});
assert!(interaction::parse_interactive_element(&group_from_json(&shape), "default").is_some());
}
#[test]
fn focusable_only_group_is_collected() {
let shape = json!({
"type": "group",
"id": "toolbar",
"focusable": true,
"children": [{"type": "rect", "x": 0, "y": 0, "w": 100, "h": 40}]
});
assert!(interaction::parse_interactive_element(&group_from_json(&shape), "default").is_some());
}
#[test]
fn group_translation_from_transforms() {
let shapes = vec![json!({
"type": "group",
"id": "g",
"on_click": true,
"transforms": [
{"type": "translate", "x": 50.0, "y": 30.0},
{"type": "rotate", "angle": 0.5},
{"type": "translate", "x": 10.0, "y": 0.0}
],
"children": [{"type": "rect", "x": 0, "y": 0, "w": 10, "h": 10}]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert_eq!(elements.len(), 1);
}
#[test]
fn group_translation_no_transforms() {
let shapes = vec![json!({
"type": "group",
"id": "notrans",
"on_click": true,
"children": [{"type": "rect", "x": 0, "y": 0, "w": 10, "h": 10}]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert_eq!(elements.len(), 1);
let (tx, ty) = elements[0].transform.transform_point(0.0, 0.0);
assert_eq!(tx, 0.0);
assert_eq!(ty, 0.0);
}
#[test]
fn collect_interactive_elements_recurses_into_groups() {
let shapes = vec![
json!({
"type": "rect", "x": 0, "y": 0, "w": 10, "h": 10,
"id": "top-rect", "on_click": true
}),
json!({
"type": "group",
"id": "grp", "on_click": true,
"children": [
{"type": "rect", "x": 0, "y": 0, "w": 50, "h": 50},
{
"type": "group",
"transforms": [{"type": "translate", "x": 10, "y": 10}],
"id": "nested-grp", "on_click": true,
"children": [
{"type": "circle", "x": 5, "y": 5, "r": 5}
]
}
]
}),
];
let mut result = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut result,
);
let ids: Vec<&str> = result.iter().map(|s| s.id.as_str()).collect();
assert!(!ids.contains(&"top-rect"));
assert!(ids.contains(&"grp"));
assert!(ids.contains(&"grp/nested-grp"));
}
#[test]
fn path_bounds_computes_from_commands() {
let shape = json!({
"type": "group",
"id": "star", "on_click": true,
"children": [{
"type": "path",
"commands": [
["move_to", 0.0, -12.0],
["line_to", 11.4, -3.7],
["line_to", 7.0, 9.7],
["line_to", -7.0, 9.7],
["line_to", -11.4, -3.7],
"close"
],
"fill": "#ff0000"
}]
});
let result = interaction::parse_interactive_element(&group_from_json(&shape), "default");
assert!(
result.is_some(),
"group with path child should have a hit region"
);
}
#[test]
fn interactive_group_with_path_child_gets_hit_region() {
let shape = json!({
"type": "group",
"id": "star", "on_click": true,
"children": [
{
"type": "path",
"commands": [
["move_to", 0.0, -12.0],
["line_to", 11.4, -3.7],
["line_to", 7.0, 9.7],
["line_to", -7.0, 9.7],
["line_to", -11.4, -3.7],
"close"
],
"fill": "#ff0000"
}
]
});
let result = interaction::parse_interactive_element(&group_from_json(&shape), "default");
assert!(
result.is_some(),
"group with path child should have a hit region"
);
}
#[test]
fn hit_rect_on_group_is_local_coordinates() {
let shape = json!({
"type": "group",
"id": "star", "on_click": true,
"hit_rect": {"x": -12.0, "y": -12.0, "w": 28.0, "h": 28.0},
"children": [
{"type": "rect", "x": 0, "y": 0, "w": 10, "h": 10}
]
});
let result =
interaction::parse_interactive_element(&group_from_json(&shape), "default").unwrap();
match result.hit_region {
HitRegion::Rect { x, y, w, h } => {
assert!((x - (-12.0)).abs() < 0.01, "x should be -12, got {x}");
assert!((y - (-12.0)).abs() < 0.01, "y should be -12, got {y}");
assert!((w - 28.0).abs() < 0.01);
assert!((h - 28.0).abs() < 0.01);
}
other => panic!("expected Rect, got {other:?}"),
}
}
#[test]
fn transform_identity() {
let m = TransformMatrix::identity();
let (x, y) = m.transform_point(10.0, 20.0);
assert!((x - 10.0).abs() < 0.001);
assert!((y - 20.0).abs() < 0.001);
}
#[test]
fn transform_translate() {
let m = TransformMatrix::identity().translate(50.0, 30.0);
let (x, y) = m.transform_point(10.0, 20.0);
assert!((x - 60.0).abs() < 0.001);
assert!((y - 50.0).abs() < 0.001);
}
#[test]
fn transform_rotate_90() {
let m = TransformMatrix::identity().rotate(std::f32::consts::FRAC_PI_2);
let (x, y) = m.transform_point(10.0, 0.0);
assert!(x.abs() < 0.001, "x should be ~0, got {x}");
assert!((y - 10.0).abs() < 0.001, "y should be ~10, got {y}");
}
#[test]
fn transform_scale() {
let m = TransformMatrix::identity().scale(2.0, 3.0);
let (x, y) = m.transform_point(10.0, 20.0);
assert!((x - 20.0).abs() < 0.001);
assert!((y - 60.0).abs() < 0.001);
}
#[test]
fn transform_inverse_roundtrip() {
let m = TransformMatrix::identity()
.translate(50.0, 30.0)
.rotate(0.5)
.scale(2.0, 1.5);
let inv = m.inverse().unwrap();
let (fx, fy) = m.transform_point(10.0, 20.0);
let (rx, ry) = inv.transform_point(fx, fy);
assert!(
(rx - 10.0).abs() < 0.01,
"roundtrip x: expected 10, got {rx}"
);
assert!(
(ry - 20.0).abs() < 0.01,
"roundtrip y: expected 20, got {ry}"
);
}
#[test]
fn transform_singular_has_no_inverse() {
let m = TransformMatrix::identity().scale(0.0, 1.0);
assert!(m.inverse().is_none());
}
#[test]
fn transform_from_json() {
let transforms = vec![
json!({"type": "translate", "x": 50.0, "y": 30.0}),
json!({"type": "rotate", "angle": 0.0}),
json!({"type": "scale", "factor": 2.0}),
];
let m = TransformMatrix::from_transforms(&transforms);
let (x, y) = m.transform_point(10.0, 20.0);
assert!((x - 70.0).abs() < 0.01, "x={x}");
assert!((y - 70.0).abs() < 0.01, "y={y}");
}
#[test]
fn find_hit_element_with_transform() {
let shapes = vec![json!({
"type": "group",
"id": "btn",
"on_click": true,
"transforms": [{"type": "translate", "x": 100, "y": 100}],
"children": [{"type": "rect", "x": 0, "y": 0, "w": 50, "h": 50}]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert_eq!(elements.len(), 1);
let hit = interaction::find_hit_element(Point::new(125.0, 125.0), &elements);
assert!(hit.is_some(), "should hit at (125, 125)");
let miss = interaction::find_hit_element(Point::new(50.0, 50.0), &elements);
assert!(miss.is_none(), "should miss at (50, 50)");
}
#[test]
fn find_hit_element_with_rotation() {
let shapes = vec![json!({
"type": "group",
"id": "rotated",
"on_click": true,
"transforms": [{"type": "rotate", "angle": 45.0}],
"children": [{"type": "rect", "x": 0, "y": 0, "w": 100, "h": 20}]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
let hit = interaction::find_hit_element(Point::new(35.0, 35.0), &elements);
assert!(hit.is_some(), "should hit along the rotated diagonal");
let miss = interaction::find_hit_element(Point::new(0.0, 80.0), &elements);
assert!(miss.is_none(), "should miss far from diagonal");
}
#[test]
fn find_hit_element_respects_clip() {
let shapes = vec![json!({
"type": "group",
"id": "clipped",
"on_click": true,
"clip": {"x": 0, "y": 0, "w": 50, "h": 50},
"children": [{"type": "rect", "x": 0, "y": 0, "w": 100, "h": 100}]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
let hit = interaction::find_hit_element(Point::new(25.0, 25.0), &elements);
assert!(hit.is_some(), "should hit inside clip");
let miss = interaction::find_hit_element(Point::new(75.0, 75.0), &elements);
assert!(
miss.is_none(),
"should miss outside clip despite being in hit region"
);
}
#[test]
fn intersect_rects_overlap() {
let r = interaction::intersect_rects((0.0, 0.0, 100.0, 100.0), (50.0, 50.0, 100.0, 100.0));
assert!((r.0 - 50.0).abs() < 0.01);
assert!((r.1 - 50.0).abs() < 0.01);
assert!((r.2 - 50.0).abs() < 0.01);
assert!((r.3 - 50.0).abs() < 0.01);
}
#[test]
fn intersect_rects_no_overlap() {
let r = interaction::intersect_rects((0.0, 0.0, 10.0, 10.0), (20.0, 20.0, 10.0, 10.0));
assert!(r.2 == 0.0 || r.3 == 0.0, "no-overlap should have zero area");
}
#[test]
fn collect_nested_transform_accumulates() {
let shapes = vec![json!({
"type": "group",
"transforms": [{"type": "translate", "x": 100, "y": 0}],
"children": [{
"type": "group",
"id": "inner",
"on_click": true,
"transforms": [{"type": "translate", "x": 0, "y": 50}],
"children": [{"type": "rect", "x": 0, "y": 0, "w": 10, "h": 10}]
}]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert_eq!(elements.len(), 1);
let e = &elements[0];
let (tx, ty) = e.transform.transform_point(0.0, 0.0);
assert!((tx - 100.0).abs() < 0.01, "tx={tx}");
assert!((ty - 50.0).abs() < 0.01, "ty={ty}");
}
#[test]
fn compose_identity_is_noop() {
let m = TransformMatrix::identity().translate(10.0, 20.0);
let id = TransformMatrix::identity();
let composed = m.compose(&id);
let (x, y) = composed.transform_point(0.0, 0.0);
assert!((x - 10.0).abs() < 0.001);
assert!((y - 20.0).abs() < 0.001);
}
#[test]
fn compose_translate_then_scale() {
let parent = TransformMatrix::identity().translate(100.0, 0.0);
let local = TransformMatrix::identity().scale(2.0, 2.0);
let composed = parent.compose(&local);
let (x, y) = composed.transform_point(5.0, 5.0);
assert!((x - 110.0).abs() < 0.01, "x={x}");
assert!((y - 10.0).abs() < 0.01, "y={y}");
}
#[test]
fn compose_matches_chained_transforms() {
let parent = TransformMatrix::identity().translate(50.0, 0.0);
let local = TransformMatrix::identity()
.translate(10.0, 0.0)
.rotate(std::f32::consts::FRAC_PI_2);
let composed = parent.compose(&local);
let chained = TransformMatrix::identity()
.translate(50.0, 0.0)
.translate(10.0, 0.0)
.rotate(std::f32::consts::FRAC_PI_2);
let (cx, cy) = composed.transform_point(1.0, 0.0);
let (sx, sy) = chained.transform_point(1.0, 0.0);
assert!((cx - sx).abs() < 0.01, "x: composed={cx}, chained={sx}");
assert!((cy - sy).abs() < 0.01, "y: composed={cy}, chained={sy}");
}
#[test]
fn hit_test_rect_boundary_with_epsilon() {
let region = HitRegion::Rect {
x: 0.0,
y: 0.0,
w: 100.0,
h: 50.0,
};
assert!(interaction::hit_test(Point::new(0.0, 0.0), ®ion));
assert!(interaction::hit_test(Point::new(100.0, 50.0), ®ion));
assert!(interaction::hit_test(Point::new(-0.3, -0.3), ®ion));
assert!(interaction::hit_test(Point::new(100.3, 50.3), ®ion));
assert!(!interaction::hit_test(Point::new(-1.0, 0.0), ®ion));
assert!(!interaction::hit_test(Point::new(0.0, -1.0), ®ion));
}
#[test]
fn nested_clip_is_intersected() {
let shapes = vec![json!({
"type": "group",
"clip": {"x": 0, "y": 0, "w": 100, "h": 100},
"children": [{
"type": "group",
"id": "inner",
"on_click": true,
"clip": {"x": 50, "y": 50, "w": 100, "h": 100},
"children": [{"type": "rect", "x": 0, "y": 0, "w": 200, "h": 200}]
}]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert_eq!(elements.len(), 1);
let clip = elements[0].clip_rect.unwrap();
assert!((clip.0 - 50.0).abs() < 0.01, "clip x={}", clip.0);
assert!((clip.1 - 50.0).abs() < 0.01, "clip y={}", clip.1);
assert!((clip.2 - 50.0).abs() < 0.01, "clip w={}", clip.2);
assert!((clip.3 - 50.0).abs() < 0.01, "clip h={}", clip.3);
}
#[test]
fn clip_from_parent_propagates_to_child() {
let shapes = vec![json!({
"type": "group",
"clip": {"x": 10, "y": 10, "w": 80, "h": 80},
"children": [{
"type": "group",
"id": "child",
"on_click": true,
"children": [{"type": "rect", "x": 0, "y": 0, "w": 100, "h": 100}]
}]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert_eq!(elements.len(), 1);
let clip = elements[0].clip_rect.unwrap();
assert!((clip.0 - 10.0).abs() < 0.01);
assert!((clip.1 - 10.0).abs() < 0.01);
assert!((clip.2 - 80.0).abs() < 0.01);
assert!((clip.3 - 80.0).abs() < 0.01);
}
#[test]
fn no_clip_means_no_clip_rect() {
let shapes = vec![json!({
"type": "group",
"id": "noclip",
"on_click": true,
"children": [{"type": "rect", "x": 0, "y": 0, "w": 50, "h": 50}]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert!(elements[0].clip_rect.is_none());
}
fn test_program(elements: &[InteractiveElement]) -> program::CanvasProgram<'_> {
static IMAGES: std::sync::LazyLock<crate::image_registry::ImageRegistry> =
std::sync::LazyLock::new(crate::image_registry::ImageRegistry::new);
program::CanvasProgram {
layers: vec![],
caches: None,
background: None,
window_id: "test-window".to_string(),
id: "test-canvas".to_string(),
on_press: false,
on_release: false,
on_move: false,
on_scroll: false,
images: &IMAGES,
interactive_elements: elements,
arrow_mode: ArrowMode::Wrap,
pending_focus: None,
}
}
fn test_program_with_arrow_mode(
elements: &[InteractiveElement],
arrow_mode: ArrowMode,
) -> program::CanvasProgram<'_> {
program::CanvasProgram {
arrow_mode,
..test_program(elements)
}
}
fn test_element(id: &str) -> InteractiveElement {
InteractiveElement {
id: id.to_string(),
layer: "default".to_string(),
hit_region: HitRegion::Rect {
x: 0.0,
y: 0.0,
w: 10.0,
h: 10.0,
},
transform: TransformMatrix::identity(),
inverse_transform: Some(TransformMatrix::identity()),
clip_rect: None,
on_click: true,
on_hover: false,
draggable: false,
drag_axis: DragAxis::Both,
drag_bounds: None,
cursor: None,
has_hover_style: false,
has_pressed_style: false,
has_focus_style: false,
show_focus_ring: true,
focus_ring_radius: None,
focusable: false,
parent_group: None,
tooltip: None,
a11y: None,
}
}
fn assert_captured_without_message(action: Option<iced::widget::Action<crate::message::Message>>) {
let (message, _, status) = action.expect("expected captured action").into_inner();
assert!(message.is_none());
assert_eq!(status, event::Status::Captured);
}
fn assert_published_and_captured(
action: Option<iced::widget::Action<crate::message::Message>>,
) -> crate::message::Message {
let (message, _, status) = action.expect("expected published action").into_inner();
assert_eq!(status, event::Status::Captured);
message.expect("expected message")
}
#[test]
fn tab_captures_when_canvas_has_no_focus_targets() {
let elements = Vec::new();
let program = test_program(&elements);
let mut state = CanvasState::default();
assert_captured_without_message(program.handle_keyboard(
&mut state,
&keyboard::Key::Named(keyboard::key::Named::Tab),
keyboard::Modifiers::empty(),
));
assert_captured_without_message(program.handle_keyboard(
&mut state,
&keyboard::Key::Named(keyboard::key::Named::Tab),
keyboard::Modifiers::SHIFT,
));
}
#[test]
fn tab_bubbles_at_top_level_boundaries() {
let elements = vec![test_element("a"), test_element("b")];
let program = test_program(&elements);
let mut state = CanvasState {
focused_id: Some("b".to_string()),
..Default::default()
};
assert!(
program
.handle_keyboard(
&mut state,
&keyboard::Key::Named(keyboard::key::Named::Tab),
keyboard::Modifiers::empty(),
)
.is_none()
);
assert_eq!(state.focused_id.as_deref(), Some("b"));
state.focused_id = Some("a".to_string());
assert!(
program
.handle_keyboard(
&mut state,
&keyboard::Key::Named(keyboard::key::Named::Tab),
keyboard::Modifiers::SHIFT,
)
.is_none()
);
assert_eq!(state.focused_id.as_deref(), Some("a"));
}
#[test]
fn linear_arrow_mode_captures_at_boundaries() {
let elements = vec![test_element("a"), test_element("b")];
let program = test_program_with_arrow_mode(&elements, ArrowMode::Linear);
let mut state = CanvasState {
focused_id: Some("b".to_string()),
..Default::default()
};
assert_captured_without_message(program.handle_keyboard(
&mut state,
&keyboard::Key::Named(keyboard::key::Named::ArrowRight),
keyboard::Modifiers::empty(),
));
assert_eq!(state.focused_id.as_deref(), Some("b"));
state.focused_id = Some("a".to_string());
assert_captured_without_message(program.handle_keyboard(
&mut state,
&keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
keyboard::Modifiers::empty(),
));
assert_eq!(state.focused_id.as_deref(), Some("a"));
}
#[test]
fn page_navigation_uses_bounded_local_step() {
let elements = vec![
test_element("a"),
test_element("b"),
test_element("c"),
test_element("d"),
test_element("e"),
test_element("f"),
test_element("g"),
];
let program = test_program(&elements);
let mut state = CanvasState {
focused_id: Some("a".to_string()),
..Default::default()
};
let message = assert_published_and_captured(program.handle_keyboard(
&mut state,
&keyboard::Key::Named(keyboard::key::Named::PageDown),
keyboard::Modifiers::empty(),
));
assert_eq!(state.focused_id.as_deref(), Some("f"));
match message {
crate::message::Message::CanvasElementFocusChanged { new_element_id, .. } => {
assert_eq!(new_element_id.as_deref(), Some("f"));
}
other => panic!("expected FocusChanged, got {other:?}"),
}
let message = assert_published_and_captured(program.handle_keyboard(
&mut state,
&keyboard::Key::Named(keyboard::key::Named::PageUp),
keyboard::Modifiers::empty(),
));
assert_eq!(state.focused_id.as_deref(), Some("a"));
match message {
crate::message::Message::CanvasElementFocusChanged { new_element_id, .. } => {
assert_eq!(new_element_id.as_deref(), Some("a"));
}
other => panic!("expected FocusChanged, got {other:?}"),
}
}
#[test]
fn arrow_mode_none_forwards_navigation_keys_to_focused_element() {
let elements = vec![test_element("a")];
let program = test_program_with_arrow_mode(&elements, ArrowMode::None);
let mut state = CanvasState {
focused_id: Some("a".to_string()),
..Default::default()
};
let message = assert_published_and_captured(program.handle_keyboard(
&mut state,
&keyboard::Key::Named(keyboard::key::Named::PageDown),
keyboard::Modifiers::empty(),
));
match message {
crate::message::Message::Event { id, family, .. } => {
assert_eq!(id, "a");
assert_eq!(family, "key_press");
}
other => panic!("expected key event, got {other:?}"),
}
assert_eq!(state.focused_id.as_deref(), Some("a"));
}
#[test]
fn resolve_focus_index_finds_element() {
let elements = vec![test_element("a"), test_element("b"), test_element("c")];
let program = test_program(&elements);
let state = CanvasState {
focused_id: Some("b".to_string()),
..Default::default()
};
assert_eq!(program.resolve_focus_index(&state), Some(1));
}
#[test]
fn resolve_focus_index_returns_none_for_missing() {
let elements = vec![test_element("a"), test_element("b")];
let program = test_program(&elements);
let state = CanvasState {
focused_id: Some("deleted".to_string()),
..Default::default()
};
assert_eq!(program.resolve_focus_index(&state), None);
}
#[test]
fn resolve_focus_index_returns_none_when_unfocused() {
let elements = vec![test_element("a")];
let program = test_program(&elements);
let state = CanvasState::default();
assert_eq!(program.resolve_focus_index(&state), None);
}
#[test]
fn set_focus_from_none_to_element() {
let elements = vec![test_element("a"), test_element("b")];
let program = test_program(&elements);
let mut state = CanvasState::default();
let msg = program.set_focus(&mut state, Some(1));
assert!(msg.is_some());
assert_eq!(state.focused_id, Some("b".to_string()));
match msg.unwrap() {
crate::message::Message::CanvasElementFocusChanged {
old_element_id,
new_element_id,
..
} => {
assert_eq!(old_element_id, None);
assert_eq!(new_element_id, Some("b".to_string()));
}
other => panic!("expected FocusChanged, got {other:?}"),
}
}
#[test]
fn set_focus_between_elements() {
let elements = vec![test_element("a"), test_element("b")];
let program = test_program(&elements);
let mut state = CanvasState {
focused_id: Some("a".to_string()),
..Default::default()
};
let msg = program.set_focus(&mut state, Some(1));
assert!(msg.is_some());
assert_eq!(state.focused_id, Some("b".to_string()));
match msg.unwrap() {
crate::message::Message::CanvasElementFocusChanged {
old_element_id,
new_element_id,
..
} => {
assert_eq!(old_element_id, Some("a".to_string()));
assert_eq!(new_element_id, Some("b".to_string()));
}
other => panic!("expected FocusChanged, got {other:?}"),
}
}
#[test]
fn set_focus_to_same_is_noop() {
let elements = vec![test_element("a"), test_element("b")];
let program = test_program(&elements);
let mut state = CanvasState {
focused_id: Some("a".to_string()),
..Default::default()
};
let msg = program.set_focus(&mut state, Some(0));
assert!(msg.is_none());
assert_eq!(state.focused_id, Some("a".to_string()));
}
#[test]
fn set_focus_clear() {
let elements = vec![test_element("a")];
let program = test_program(&elements);
let mut state = CanvasState {
focused_id: Some("a".to_string()),
..Default::default()
};
let msg = program.set_focus(&mut state, None);
assert!(msg.is_some());
assert_eq!(state.focused_id, None);
match msg.unwrap() {
crate::message::Message::CanvasElementFocusChanged {
old_element_id,
new_element_id,
..
} => {
assert_eq!(old_element_id, Some("a".to_string()));
assert_eq!(new_element_id, None);
}
other => panic!("expected FocusChanged, got {other:?}"),
}
}
#[test]
fn set_focus_clear_when_already_none() {
let elements = vec![test_element("a")];
let program = test_program(&elements);
let mut state = CanvasState::default();
let msg = program.set_focus(&mut state, None);
assert!(msg.is_none());
}
#[test]
fn set_focus_out_of_bounds_clears() {
let elements = vec![test_element("a")];
let program = test_program(&elements);
let mut state = CanvasState {
focused_id: Some("a".to_string()),
..Default::default()
};
let msg = program.set_focus(&mut state, Some(99));
assert!(msg.is_some());
assert_eq!(state.focused_id, None);
}
#[test]
fn layers_active_hover_style() {
let mut elements = vec![test_element("btn")];
elements[0].layer = "ui".to_string();
elements[0].has_hover_style = true;
let program = test_program(&elements);
let state = CanvasState {
hovered_element: Some("btn".to_string()),
..Default::default()
};
let layers = program.layers_with_active_interaction(&state);
assert_eq!(layers, vec!["ui"]);
}
#[test]
fn layers_active_focus_style() {
let mut elements = vec![test_element("btn")];
elements[0].layer = "ui".to_string();
elements[0].has_focus_style = true;
let program = test_program(&elements);
let state = CanvasState {
focused_id: Some("btn".to_string()),
canvas_focused: true,
focus_visible: true,
..Default::default()
};
let layers = program.layers_with_active_interaction(&state);
assert_eq!(layers, vec!["ui"]);
}
#[test]
fn layers_active_hover_and_focus_on_different_layers() {
let mut hover_elem = test_element("hover-btn");
hover_elem.layer = "layer-a".to_string();
hover_elem.has_hover_style = true;
let mut focus_elem = test_element("focus-btn");
focus_elem.layer = "layer-b".to_string();
focus_elem.has_focus_style = true;
let elements = vec![hover_elem, focus_elem];
let program = test_program(&elements);
let state = CanvasState {
hovered_element: Some("hover-btn".to_string()),
focused_id: Some("focus-btn".to_string()),
canvas_focused: true,
focus_visible: true,
..Default::default()
};
let layers = program.layers_with_active_interaction(&state);
assert_eq!(layers.len(), 2);
assert!(layers.contains(&"layer-a".to_string()));
assert!(layers.contains(&"layer-b".to_string()));
}
#[test]
fn layers_active_hover_and_focus_on_same_layer_no_dupe() {
let mut hover_elem = test_element("hover-btn");
hover_elem.layer = "ui".to_string();
hover_elem.has_hover_style = true;
let mut focus_elem = test_element("focus-btn");
focus_elem.layer = "ui".to_string();
focus_elem.has_focus_style = true;
let elements = vec![hover_elem, focus_elem];
let program = test_program(&elements);
let state = CanvasState {
hovered_element: Some("hover-btn".to_string()),
focused_id: Some("focus-btn".to_string()),
canvas_focused: true,
focus_visible: true,
..Default::default()
};
let layers = program.layers_with_active_interaction(&state);
assert_eq!(layers, vec!["ui"]);
}
#[test]
fn layers_active_no_style_returns_empty() {
let elements = vec![test_element("btn")]; let program = test_program(&elements);
let state = CanvasState {
hovered_element: Some("btn".to_string()),
..Default::default()
};
let layers = program.layers_with_active_interaction(&state);
assert!(layers.is_empty());
}
#[test]
fn find_hit_element_empty_list() {
assert!(interaction::find_hit_element(Point::new(0.0, 0.0), &[]).is_none());
}
#[test]
fn find_hit_element_singular_transform_not_hittable() {
let shapes = vec![json!({
"type": "group",
"id": "collapsed",
"on_click": true,
"transforms": [{"type": "scale", "x": 0, "y": 1}],
"children": [{"type": "rect", "x": 0, "y": 0, "w": 100, "h": 100}]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert_eq!(elements.len(), 1);
assert!(elements[0].inverse_transform.is_none());
let hit = interaction::find_hit_element(Point::new(50.0, 50.0), &elements);
assert!(hit.is_none());
}
#[test]
fn find_hit_element_topmost_wins() {
let mut a = test_element("bottom");
a.hit_region = HitRegion::Rect {
x: 0.0,
y: 0.0,
w: 100.0,
h: 100.0,
};
let mut b = test_element("top");
b.hit_region = HitRegion::Rect {
x: 0.0,
y: 0.0,
w: 100.0,
h: 100.0,
};
let elements = vec![a, b];
let hit = interaction::find_hit_element(Point::new(50.0, 50.0), &elements).unwrap();
assert_eq!(hit.id, "top");
}
#[test]
fn find_hit_element_skips_non_interactive() {
let mut elem = test_element("passive");
elem.on_click = false;
elem.hit_region = HitRegion::Rect {
x: 0.0,
y: 0.0,
w: 100.0,
h: 100.0,
};
let elements = vec![elem];
let hit = interaction::find_hit_element(Point::new(50.0, 50.0), &elements);
assert!(hit.is_none());
}
#[test]
fn clip_transformed_by_group_matrix() {
let shapes = vec![json!({
"type": "group",
"id": "shifted-clip",
"on_click": true,
"transforms": [{"type": "translate", "x": 100, "y": 100}],
"clip": {"x": 0, "y": 0, "w": 50, "h": 50},
"children": [{"type": "rect", "x": 0, "y": 0, "w": 100, "h": 100}]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
let clip = elements[0].clip_rect.unwrap();
assert!((clip.0 - 100.0).abs() < 0.01, "clip x={}", clip.0);
assert!((clip.1 - 100.0).abs() < 0.01, "clip y={}", clip.1);
assert!((clip.2 - 50.0).abs() < 0.01, "clip w={}", clip.2);
assert!((clip.3 - 50.0).abs() < 0.01, "clip h={}", clip.3);
let hit = interaction::find_hit_element(Point::new(125.0, 125.0), &elements);
assert!(hit.is_some());
let miss = interaction::find_hit_element(Point::new(175.0, 175.0), &elements);
assert!(miss.is_none());
}
#[test]
fn decompose_identity() {
let (tx, ty, angle, sx, sy) = TransformMatrix::identity().decompose();
assert!((tx).abs() < 0.001);
assert!((ty).abs() < 0.001);
assert!((angle).abs() < 0.001);
assert!((sx - 1.0).abs() < 0.001);
assert!((sy - 1.0).abs() < 0.001);
}
#[test]
fn decompose_translate_only() {
let m = TransformMatrix::identity().translate(42.0, -17.0);
let (tx, ty, angle, sx, sy) = m.decompose();
assert!((tx - 42.0).abs() < 0.001);
assert!((ty - (-17.0)).abs() < 0.001);
assert!((angle).abs() < 0.001);
assert!((sx - 1.0).abs() < 0.001);
assert!((sy - 1.0).abs() < 0.001);
}
#[test]
fn decompose_rotate_only() {
let angle_in = 0.7;
let m = TransformMatrix::identity().rotate(angle_in);
let (tx, ty, angle, sx, sy) = m.decompose();
assert!((tx).abs() < 0.001);
assert!((ty).abs() < 0.001);
assert!((angle - angle_in).abs() < 0.001, "angle={angle}");
assert!((sx - 1.0).abs() < 0.001);
assert!((sy - 1.0).abs() < 0.001);
}
#[test]
fn decompose_scale_only() {
let m = TransformMatrix::identity().scale(3.0, 0.5);
let (tx, ty, angle, sx, sy) = m.decompose();
assert!((tx).abs() < 0.001);
assert!((ty).abs() < 0.001);
assert!((angle).abs() < 0.001);
assert!((sx - 3.0).abs() < 0.001, "sx={sx}");
assert!((sy - 0.5).abs() < 0.001, "sy={sy}");
}
#[test]
fn decompose_zero_scale_returns_finite_nonzero_scales() {
let m = TransformMatrix::identity().scale(0.0, 0.0);
let (_, _, angle, sx, sy) = m.decompose();
assert!(angle.is_finite(), "angle={angle}");
assert!(sx.is_finite() && sx > 0.0, "sx={sx}");
assert!(sy.is_finite() && sy.abs() > 0.0, "sy={sy}");
}
#[test]
fn decompose_non_finite_matrix_returns_finite_values() {
let m = TransformMatrix {
a: f32::NAN,
b: f32::INFINITY,
c: f32::NEG_INFINITY,
d: 1.0,
tx: f32::NAN,
ty: f32::INFINITY,
};
let (tx, ty, angle, sx, sy) = m.decompose();
assert!(tx.is_finite(), "tx={tx}");
assert!(ty.is_finite(), "ty={ty}");
assert!(angle.is_finite(), "angle={angle}");
assert!(sx.is_finite() && sx > 0.0, "sx={sx}");
assert!(sy.is_finite() && sy.abs() > 0.0, "sy={sy}");
}
#[test]
fn decompose_roundtrip() {
let original = TransformMatrix::identity()
.translate(50.0, 30.0)
.rotate(0.5)
.scale(2.0, 1.5);
let (tx, ty, angle, sx, sy) = original.decompose();
let rebuilt = TransformMatrix::identity()
.translate(tx, ty)
.rotate(angle)
.scale(sx, sy);
for &(px, py) in &[(0.0, 0.0), (10.0, 20.0), (-5.0, 15.0)] {
let (ox, oy) = original.transform_point(px, py);
let (rx, ry) = rebuilt.transform_point(px, py);
assert!(
(ox - rx).abs() < 0.1 && (oy - ry).abs() < 0.1,
"point ({px},{py}): original=({ox},{oy}), rebuilt=({rx},{ry})"
);
}
}
#[test]
fn arrow_mode_from_str_known_values() {
assert_eq!(ArrowMode::from_str("wrap"), ArrowMode::Wrap);
assert_eq!(ArrowMode::from_str("clamp"), ArrowMode::Clamp);
assert_eq!(ArrowMode::from_str("linear"), ArrowMode::Linear);
assert_eq!(ArrowMode::from_str("none"), ArrowMode::None);
}
#[test]
fn arrow_mode_from_str_unknown_defaults_to_wrap() {
assert_eq!(ArrowMode::from_str("invalid"), ArrowMode::Wrap);
assert_eq!(ArrowMode::from_str(""), ArrowMode::Wrap);
}
#[test]
fn arrow_mode_default_is_wrap() {
assert_eq!(ArrowMode::default(), ArrowMode::Wrap);
}
#[test]
fn parent_group_set_for_children_of_focusable_group() {
let shapes = vec![json!({
"type": "group",
"id": "toolbar",
"focusable": true,
"on_click": true,
"children": [
{
"type": "group",
"id": "btn-a",
"on_click": true,
"children": [{"type": "rect", "x": 0, "y": 0, "w": 30, "h": 30}]
},
{
"type": "group",
"id": "btn-b",
"on_click": true,
"children": [{"type": "rect", "x": 40, "y": 0, "w": 30, "h": 30}]
}
]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert_eq!(elements.len(), 3);
assert_eq!(elements[0].id, "toolbar");
assert!(elements[0].focusable);
assert_eq!(elements[0].parent_group, None); assert_eq!(elements[1].id, "toolbar/btn-a");
assert_eq!(elements[1].parent_group, Some("toolbar".to_string()));
assert_eq!(elements[2].id, "toolbar/btn-b");
assert_eq!(elements[2].parent_group, Some("toolbar".to_string()));
}
#[test]
fn parent_group_none_without_focusable() {
let shapes = vec![json!({
"type": "group",
"id": "container",
"on_click": true,
"children": [
{
"type": "group",
"id": "child",
"on_click": true,
"children": [{"type": "rect", "x": 0, "y": 0, "w": 10, "h": 10}]
}
]
})];
let mut elements = Vec::new();
collect_interactive_from_json(
&shapes,
"default",
TransformMatrix::identity(),
None,
None,
"",
&mut elements,
);
assert_eq!(elements.len(), 2);
assert_eq!(elements[0].parent_group, None);
assert_eq!(elements[1].parent_group, None);
}
#[test]
fn top_level_indices_excludes_group_children() {
let mut toolbar = test_element("toolbar");
toolbar.focusable = true;
let mut btn_a = test_element("btn-a");
btn_a.parent_group = Some("toolbar".to_string());
let mut btn_b = test_element("btn-b");
btn_b.parent_group = Some("toolbar".to_string());
let standalone = test_element("standalone");
let elements = vec![toolbar, btn_a, btn_b, standalone];
let program = test_program(&elements);
let top = program.top_level_indices();
assert_eq!(top, vec![0, 3]);
}
#[test]
fn group_child_indices_returns_children() {
let mut toolbar = test_element("toolbar");
toolbar.focusable = true;
let mut btn_a = test_element("btn-a");
btn_a.parent_group = Some("toolbar".to_string());
let mut btn_b = test_element("btn-b");
btn_b.parent_group = Some("toolbar".to_string());
let standalone = test_element("standalone");
let elements = vec![toolbar, btn_a, btn_b, standalone];
let program = test_program(&elements);
let children = program.group_child_indices("toolbar");
assert_eq!(children, vec![1, 2]);
}
#[test]
fn group_child_indices_empty_for_nonexistent_group() {
let elements = vec![test_element("a"), test_element("b")];
let program = test_program(&elements);
assert!(program.group_child_indices("nonexistent").is_empty());
}