openpencil/crates/op-host-native/src/widget_host/input_tests.rs

2493 lines
86 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `#[cfg(test)]` companion to the input modules — extracted here so
//! the input module stays under the 800-line ceiling.
//!
//! `EditorState` is the host's source of truth, so the fixtures seed
//! `host.editor_state` from canonical-schema JSON and assert against
//! `editor_state` + the derived `LayoutScene` render scene.
use super::{CursorHint, WidgetHostNative};
use op_editor_core::ui_draft::PropertyFocus;
use op_editor_core::PenNodeExt;
use op_editor_core::{own_bounds, NodeId};
use op_editor_ui::widgets::{PropertyPanel, TopBar, TopBarHit, TOP_BAR_HEIGHT};
/// Seed a host's `editor_state` from a canonical `.op` JSON snippet.
fn seed(host: &mut WidgetHostNative, json: &str) {
let doc = jian_ops_schema::load_str(json)
.expect("fixture JSON parses")
.value;
*host.editor_state_mut() = op_editor_core::EditorState::from_document(doc);
host.mark_paint_dirty_for_test();
}
fn point_inside_property_panel_without_target(host: &WidgetHostNative) -> op_editor_ui::Point2D {
let panel = PropertyPanel::for_selection(host.editor_state()).expect("property panel");
let rect = host.property_rect(host.last_viewport_w, host.last_viewport_h);
let mut y = rect.origin.y + rect.size.y - 12.0;
while y > rect.origin.y {
let mut x = rect.origin.x + 12.0;
while x < rect.origin.x + rect.size.x - 12.0 {
let point = op_editor_ui::Point2D::new(x, y);
let no_action = panel.hit_test_action(rect, point).is_none();
let no_input = panel.hit_test(rect, point).is_none();
let no_tab = panel.tab_hover_at(rect, point).is_none();
let no_fill_type = panel.fill_type_picker_row_at(rect, point).is_none();
if no_action && no_input && no_tab && no_fill_type {
return point;
}
x += 8.0;
}
y -= 8.0;
}
panic!("no empty property-panel point found");
}
#[test]
fn layer_hover_does_not_refresh_stale_canvas_layout_scene() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{"type":"rectangle","id":"n1","name":"n1","x":0,"y":0,"width":100,"height":50}]}"#,
);
host.editor_state_mut().editor_ui.hovered_layer_id = Some(NodeId::new("n1"));
host.mark_paint_dirty_for_test();
let panel = op_editor_ui::widgets::LayerPanel::from_editor(host.editor_state());
let rect = op_editor_ui::Rect {
origin: op_editor_ui::Point2D::new(0.0, op_editor_ui::widgets::TOP_BAR_HEIGHT),
size: op_editor_ui::Point2D::new(
host.editor_state().editor_ui.layer_panel_width,
800.0 - op_editor_ui::widgets::TOP_BAR_HEIGHT,
),
};
let regions = panel.regions(rect);
let x = 48.0;
let y = regions.layers_rows_top + 8.0;
assert!(!host.update_layer_hover(x, y, 1200.0, 800.0));
assert!(
host.editor_state_dirty,
"layer hover should not rebuild or clear the stale canvas layout scene"
);
}
#[test]
fn file_menu_cursor_move_clears_stale_layer_hover() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{"type":"rectangle","id":"n1","name":"n1","x":0,"y":0,"width":100,"height":50}]}"#,
);
host.last_viewport_w = 1200.0;
host.last_viewport_h = 800.0;
host.editor_state_mut().editor_ui.file_menu_open = true;
host.editor_state_mut().editor_ui.hovered_layer_id = Some(NodeId::new("n1"));
let menu_rect = host
.file_menu_rect(host.last_viewport_w)
.expect("file menu rect");
let menu = op_editor_ui::widgets::file_menu::FileMenu::from_editor_ui(
&host.editor_state().editor_ui,
0,
);
let x = menu_rect.origin.x + 80.0;
let mut y = menu_rect.origin.y + 2.0;
let mut point = None;
while y < menu_rect.origin.y + menu_rect.size.y {
let p = op_editor_ui::Point2D::new(x, y);
if menu.hovered_at(menu_rect, p).is_some() {
point = Some(p);
break;
}
y += 2.0;
}
let point = point.expect("file menu row point");
assert!(host.over_dropdown_overlay(
point.x,
point.y,
host.last_viewport_w,
host.last_viewport_h
));
assert!(host.apply_cursor_move(point.x, point.y));
assert_eq!(host.editor_state().editor_ui.hovered_layer_id, None);
assert_eq!(host.editor_state().editor_ui.hovered_page_index, None);
assert!(
host.editor_state().editor_ui.file_menu.hover.is_some(),
"file-menu hover itself should still be active"
);
}
#[test]
fn property_panel_blank_hover_consumes_and_clears_lower_hover() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"group","id":"shape_group","name":"Shape Group",
"children":[
{"type":"rectangle","id":"box","name":"Box","width":80,"height":40}
]}
]}"##,
);
host.last_viewport_w = 1200.0;
host.last_viewport_h = 800.0;
host.editor_state_mut()
.set_single_selection(NodeId::new("shape_group"));
host.editor_state_mut().editor_ui.canvas_hover_node = Some(NodeId::new("box"));
let point = point_inside_property_panel_without_target(&host);
assert!(
host.apply_cursor_move(point.x, point.y),
"right inspector should own cursor movement inside its bounds"
);
assert_eq!(host.editor_state().editor_ui.canvas_hover_node, None);
}
#[test]
fn opening_file_menu_clears_stale_layer_hover() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{"type":"rectangle","id":"n1","name":"n1","x":0,"y":0,"width":100,"height":50}]}"#,
);
host.last_viewport_w = 1200.0;
host.last_viewport_h = 800.0;
host.editor_state_mut().editor_ui.hovered_layer_id = Some(NodeId::new("n1"));
host.editor_state_mut().editor_ui.hovered_page_index = Some(0);
let top_bar_rect = op_editor_ui::Rect {
origin: op_editor_ui::Point2D::new(0.0, 0.0),
size: op_editor_ui::Point2D::new(host.last_viewport_w, TOP_BAR_HEIGHT),
};
let top_bar = TopBar::for_editor_ui(&host.editor_state().editor_ui);
let mut file_button = None;
let mut x = top_bar_rect.origin.x;
while x < top_bar_rect.origin.x + top_bar_rect.size.x {
let p = op_editor_ui::Point2D::new(x, TOP_BAR_HEIGHT / 2.0);
if top_bar.hit_test(top_bar_rect, p) == Some(TopBarHit::ToggleFileMenu) {
file_button = Some(p);
break;
}
x += 1.0;
}
let point = file_button.expect("top-bar file button point");
assert!(host.apply_press(point.x, point.y, host.last_viewport_w, host.last_viewport_h));
assert!(host.editor_state().editor_ui.file_menu_open);
assert_eq!(host.editor_state().editor_ui.hovered_layer_id, None);
assert_eq!(host.editor_state().editor_ui.hovered_page_index, None);
}
#[test]
fn layer_row_selection_does_not_dirty_canvas_layout_scene() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{"type":"rectangle","id":"n1","name":"n1","x":0,"y":0,"width":100,"height":50},{"type":"rectangle","id":"n2","name":"n2","x":120,"y":0,"width":100,"height":50}]}"#,
);
let _ = host.layout_scene();
assert!(!host.editor_state_dirty);
let panel = op_editor_ui::widgets::LayerPanel::from_editor(host.editor_state());
let rect = op_editor_ui::Rect {
origin: op_editor_ui::Point2D::new(0.0, op_editor_ui::widgets::TOP_BAR_HEIGHT),
size: op_editor_ui::Point2D::new(
host.editor_state().editor_ui.layer_panel_width,
800.0 - op_editor_ui::widgets::TOP_BAR_HEIGHT,
),
};
let regions = panel.regions(rect);
let x = 48.0;
let y = regions.layers_rows_top + 8.0;
assert!(host.apply_click(x, y, 1200.0, 800.0));
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n1"));
assert!(
!host.editor_state_dirty,
"selecting a layer row should not invalidate canvas layout"
);
}
#[test]
fn layer_right_press_does_not_dirty_canvas_layout_scene() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{"type":"rectangle","id":"n1","name":"n1","x":0,"y":0,"width":100,"height":50},{"type":"rectangle","id":"n2","name":"n2","x":120,"y":0,"width":100,"height":50}]}"#,
);
let _ = host.layout_scene();
assert!(!host.editor_state_dirty);
let panel = op_editor_ui::widgets::LayerPanel::from_editor(host.editor_state());
let rect = op_editor_ui::Rect {
origin: op_editor_ui::Point2D::new(0.0, op_editor_ui::widgets::TOP_BAR_HEIGHT),
size: op_editor_ui::Point2D::new(
host.editor_state().editor_ui.layer_panel_width,
800.0 - op_editor_ui::widgets::TOP_BAR_HEIGHT,
),
};
let regions = panel.regions(rect);
let x = 48.0;
let y = regions.layers_rows_top + 8.0;
assert!(host.apply_right_press(x, y, 1200.0, 800.0));
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n1"));
assert!(host.editor_state().editor_ui.layer_context_menu.is_some());
assert!(
!host.editor_state_dirty,
"right-clicking a layer row should not invalidate canvas layout"
);
}
#[test]
fn layer_right_press_does_not_refresh_stale_canvas_layout_scene() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{"type":"rectangle","id":"n1","name":"n1","x":0,"y":0,"width":100,"height":50}]}"#,
);
host.mark_paint_dirty_for_test();
let panel = op_editor_ui::widgets::LayerPanel::from_editor(host.editor_state());
let rect = op_editor_ui::Rect {
origin: op_editor_ui::Point2D::new(0.0, op_editor_ui::widgets::TOP_BAR_HEIGHT),
size: op_editor_ui::Point2D::new(
host.editor_state().editor_ui.layer_panel_width,
800.0 - op_editor_ui::widgets::TOP_BAR_HEIGHT,
),
};
let regions = panel.regions(rect);
let x = 48.0;
let y = regions.layers_rows_top + 8.0;
assert!(host.apply_right_press(x, y, 1200.0, 800.0));
assert!(
host.editor_state_dirty,
"right-clicking a layer row should not rebuild or clear a stale canvas layout scene"
);
}
#[test]
fn canvas_node_selection_does_not_dirty_canvas_layout_scene() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
&three_rects(
[
(360.0, 180.0, 80.0, 40.0),
(480.0, 180.0, 80.0, 40.0),
(600.0, 180.0, 80.0, 40.0),
],
["n10", "n11", "n12"],
),
);
let viewport_w = 1440.0;
let viewport_h = 900.0;
let (cx0, cy0, _cw, _ch) = host.canvas_region(viewport_w, viewport_h);
let _ = host.layout_scene();
assert!(!host.editor_state_dirty);
assert!(host.apply_press(
cx0 + 360.0 + 20.0,
cy0 + 180.0 + 20.0,
viewport_w,
viewport_h
));
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n10"));
assert!(
!host.editor_state_dirty,
"selecting a canvas node should not invalidate canvas layout"
);
}
#[test]
fn node_drag_smart_guides_do_not_refresh_layout_scene_on_each_move() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
&three_rects(
[
(360.0, 180.0, 80.0, 40.0),
(480.0, 180.0, 80.0, 40.0),
(600.0, 180.0, 80.0, 40.0),
],
["n10", "n11", "n12"],
),
);
let viewport_w = 1440.0;
let viewport_h = 900.0;
let (cx0, cy0, _cw, _ch) = host.canvas_region(viewport_w, viewport_h);
let _ = host.layout_scene();
assert!(!host.editor_state_dirty);
let scene_x_before = host
.layout_scene
.active_page()
.and_then(|p| p.find("n10"))
.expect("scene node")
.bounds
.origin
.x;
assert!(host.apply_press(
cx0 + 360.0 + 20.0,
cy0 + 180.0 + 20.0,
viewport_w,
viewport_h
));
assert!(host.node_drag.is_some(), "press should start a node drag");
assert!(host.apply_cursor_move(cx0 + 360.0 + 30.0, cy0 + 180.0 + 20.0));
let moved = op_editor_core::walkers::find_node(
host.editor_state().active_children(),
&NodeId::new("n10"),
)
.expect("moved node");
assert_eq!(own_bounds(moved).x, 370.0);
let scene_x_after = host
.layout_scene
.active_page()
.and_then(|p| p.find("n10"))
.expect("patched scene node")
.bounds
.origin
.x;
assert_eq!(
scene_x_after,
scene_x_before + 10.0,
"node drag should patch the current layout scene without a rebuild"
);
assert!(
!host.editor_state_dirty,
"node drag should not force a full layout-scene rebuild on every move"
);
assert!(host.apply_release_with_viewport(viewport_w, viewport_h));
assert!(
host.editor_state_dirty,
"node drag release should schedule one full layout-scene reconciliation"
);
}
/// Three top-level rect nodes at the given `(x, y, w, h)` boxes.
fn three_rects(boxes: [(f64, f64, f64, f64); 3], ids: [&str; 3]) -> String {
let node = |id: &str, b: (f64, f64, f64, f64)| {
format!(
r#"{{"type":"rectangle","id":"{id}","name":"{id}",
"x":{},"y":{},"width":{},"height":{}}}"#,
b.0, b.1, b.2, b.3
)
};
format!(
r#"{{"version":"0.8.0","children":[{},{},{}]}}"#,
node(ids[0], boxes[0]),
node(ids[1], boxes[1]),
node(ids[2], boxes[2]),
)
}
#[test]
fn ai_chat_maximize_click_expands_panel_geometry() {
let mut host = WidgetHostNative::new();
let before = host
.ai_chat_rect(1200.0, 800.0)
.expect("chat panel visible");
let x = before.origin.x + before.size.x - 16.0 - 50.0 + 9.0;
let y = before.origin.y + 17.0;
assert!(host.apply_click(x, y, 1200.0, 800.0));
let after = host
.ai_chat_rect(1200.0, 800.0)
.expect("maximized chat panel visible");
assert!(host.editor_state().chat.maximized);
assert!(after.size.x > before.size.x);
assert!(after.size.y > before.size.y);
}
#[test]
fn ai_chat_new_chat_click_clears_transcript_and_queues_abort() {
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.chat
.messages
.push(op_editor_core::ChatMessage::assistant("old"));
host.editor_state_mut().chat.set_input_text("draft");
let rect = host
.ai_chat_rect(1200.0, 800.0)
.expect("chat panel visible");
let x = rect.origin.x + rect.size.x - 16.0 - 22.0 + 9.0;
let y = rect.origin.y + 17.0;
assert!(host.apply_click(x, y, 1200.0, 800.0));
assert!(host.editor_state().chat.messages.is_empty());
assert!(host.editor_state().chat.input.text().is_empty());
assert!(host.editor_state().chat.pending_new_chat);
}
#[test]
fn dragging_user_transcript_text_selects_and_copy_queues_text() {
let prompt = "生成一个设计精良的美食应用移动端首页";
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.chat
.messages
.push(op_editor_core::ChatMessage::user(prompt));
let viewport_w = 1200.0;
let viewport_h = 800.0;
let rect = host
.ai_chat_rect(viewport_w, viewport_h)
.expect("chat panel visible");
// At the 360px default panel width this 18-char CJK prompt wraps to two
// bubble lines (line 0 = first 17 chars, line 1 = the final "页"), so a
// whole-prompt drag has to run from line 0 down into line 1. A horizontal
// drag on line 0 alone can only reach the end of line 0, dropping "页".
let start_x = rect.origin.x + 96.0;
let start_y = rect.origin.y + 74.0; // line 0
let end_x = rect.origin.x + 338.0;
let end_y = rect.origin.y + 88.0; // line 1 (the wrapped final glyph)
assert!(host.apply_press(start_x, start_y, viewport_w, viewport_h));
assert!(host.apply_cursor_move(end_x, end_y));
assert!(host.apply_release_with_viewport(viewport_w, viewport_h));
assert_eq!(
host.editor_state().chat.selected_transcript_text(),
Some(prompt)
);
assert!(host.apply_copy());
assert_eq!(
host.editor_state().chat.pending_copy_text.as_deref(),
Some(prompt)
);
}
#[test]
fn user_transcript_text_uses_text_cursor() {
let prompt = "生成一个设计精良的美食应用移动端首页";
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.chat
.messages
.push(op_editor_core::ChatMessage::user(prompt));
let viewport_w = 1200.0;
let viewport_h = 800.0;
let rect = host
.ai_chat_rect(viewport_w, viewport_h)
.expect("chat panel visible");
let x = rect.origin.x + 96.0;
let y = rect.origin.y + 74.0;
assert_eq!(
host.cursor_hint(x, y, viewport_w, viewport_h),
CursorHint::Text
);
}
#[test]
fn chat_input_text_uses_text_cursor() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().chat.set_input_text("abcdef");
let viewport_w = 1200.0;
let viewport_h = 800.0;
let rect = host
.ai_chat_rect(viewport_w, viewport_h)
.expect("chat panel visible");
let x = rect.origin.x + 32.0;
let y = rect.origin.y + textarea_center_y_for_test();
assert_eq!(
host.cursor_hint(x, y, viewport_w, viewport_h),
CursorHint::Text
);
}
#[test]
fn ai_chat_east_edge_shows_resize_cursor() {
let host = WidgetHostNative::new();
let rect = host
.ai_chat_rect(1200.0, 800.0)
.expect("chat panel visible");
let x = rect.origin.x + rect.size.x - 2.0;
let y = rect.origin.y + rect.size.y / 2.0;
assert_eq!(host.cursor_hint(x, y, 1200.0, 800.0), CursorHint::ResizeEw);
}
#[test]
fn ai_chat_east_edge_drag_resizes_panel_width() {
let mut host = WidgetHostNative::new();
let before = host
.ai_chat_rect(1200.0, 800.0)
.expect("chat panel visible");
let x = before.origin.x + before.size.x - 2.0;
let y = before.origin.y + before.size.y / 2.0;
assert!(host.apply_press(x, y, 1200.0, 800.0));
assert!(host.apply_cursor_move(x + 72.0, y));
let after = host
.ai_chat_rect(1200.0, 800.0)
.expect("chat panel visible after resize");
assert!(
after.size.x > before.size.x + 60.0,
"dragging the east edge should grow chat width; before={before:?}, after={after:?}"
);
assert_eq!(after.origin.x, before.origin.x);
}
#[test]
fn ai_chat_stop_click_keeps_transcript_and_queues_abort() {
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.chat
.messages
.push(op_editor_core::ChatMessage::user("make a dashboard"));
host.editor_state_mut()
.chat
.messages
.push(op_editor_core::ChatMessage::assistant_streaming());
let rect = host
.ai_chat_rect(1200.0, 800.0)
.expect("chat panel visible");
let x = rect.origin.x + rect.size.x - 16.0 - 20.0;
let y = rect.origin.y + toolbar_center_y_for_test();
assert!(host.apply_click(x, y, 1200.0, 800.0));
assert_eq!(host.editor_state().chat.messages.len(), 2);
assert!(!host.editor_state().chat.messages[1].streaming);
assert!(host.editor_state().chat.pending_stop_chat);
}
#[test]
fn ai_chat_agent_team_click_sets_team_size_via_parallel_agents_picker() {
// #32: the ⚡ footer chip no longer cycles the team size on click — it
// opens the Parallel Agents picker, and clicking a row (1x–6x) sets
// `agent_team_size`. Drive that two-step flow through the real hit-test
// routing (probe with `hit_test`, then click the resolved points).
use op_editor_ui::widgets::AIChatHit;
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.chat
.available_models
.push(op_editor_core::chat::ModelEntry::new(
op_editor_core::chat::AgentProvider::CodexCli,
"gpt-5",
"GPT-5",
));
let viewport_w = 1200.0;
let viewport_h = 800.0;
host.last_viewport_w = viewport_w;
host.last_viewport_h = viewport_h;
let rect = host.ai_chat_rect(viewport_w, viewport_h).unwrap();
// Locate the ⚡ speed chip along the footer toolbar row and click it to
// open the picker.
let panel = op_editor_ui::widgets::AIChatPlaceholder::from_editor(host.editor_state());
let toolbar_y = rect.origin.y + toolbar_center_y_for_test();
let mut speed_point = None;
let mut sx = rect.origin.x;
while sx < rect.origin.x + rect.size.x {
let p = op_editor_ui::Point2D::new(sx, toolbar_y);
if panel.hit_test(rect, p) == Some(AIChatHit::ToggleParallelAgentsPicker) {
speed_point = Some(p);
break;
}
sx += 1.0;
}
let speed_point = speed_point.expect("footer speed chip should be hittable");
assert!(host.apply_click(speed_point.x, speed_point.y, viewport_w, viewport_h));
assert!(host.editor_state().editor_ui.parallel_agents_picker_open);
// With the picker open, find and click the "2x" row.
let panel = op_editor_ui::widgets::AIChatPlaceholder::from_editor(host.editor_state());
let mut row_point = None;
let mut ry = rect.origin.y;
'scan: while ry < rect.origin.y + rect.size.y {
let mut rx = rect.origin.x;
while rx < rect.origin.x + rect.size.x {
let p = op_editor_ui::Point2D::new(rx, ry);
if panel.hit_test(rect, p) == Some(AIChatHit::SetParallelAgents(2)) {
row_point = Some(p);
break 'scan;
}
rx += 2.0;
}
ry += 2.0;
}
let row_point = row_point.expect("parallel agents picker row 2 should be hittable");
assert!(host.apply_click(row_point.x, row_point.y, viewport_w, viewport_h));
assert_eq!(host.editor_state().chat.agent_team_size, 2);
assert!(!host.editor_state().editor_ui.parallel_agents_picker_open);
}
#[test]
fn ai_chat_streaming_textarea_click_does_not_focus_disabled_input() {
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.chat
.messages
.push(op_editor_core::ChatMessage::assistant_streaming());
let rect = host
.ai_chat_rect(1200.0, 800.0)
.expect("chat panel visible");
let x = rect.origin.x + 120.0;
let y = rect.origin.y + textarea_center_y_for_test();
assert!(host.apply_click(x, y, 1200.0, 800.0));
assert!(!host.editor_state().chat.focused);
}
#[test]
fn ai_chat_streaming_attachment_click_does_not_open_picker() {
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.chat
.messages
.push(op_editor_core::ChatMessage::assistant_streaming());
let rect = host
.ai_chat_rect(1200.0, 800.0)
.expect("chat panel visible");
let x = rect.origin.x + op_editor_ui::widgets::AI_CHAT_WIDTH - 16.0 - 52.0;
let y = rect.origin.y + toolbar_center_y_for_test();
assert!(host.apply_click(x, y, 1200.0, 800.0));
assert!(!host.editor_state().chat.pending_attachment_pick);
}
fn toolbar_action_point_for_test(
host: &WidgetHostNative,
action: op_editor_ui::widgets::ToolbarAction,
viewport_w: f32,
viewport_h: f32,
) -> (f32, f32) {
let toolbar = op_editor_ui::widgets::Toolbar::for_editor(host.editor_state());
let rect = host.toolbar_rect(viewport_w, viewport_h);
let min_x = rect.origin.x.floor() as i32;
let max_x = (rect.origin.x + rect.size.x).ceil() as i32;
let min_y = rect.origin.y.floor() as i32;
let max_y = (rect.origin.y + rect.size.y).ceil() as i32;
let mut hits = Vec::new();
for y in min_y..=max_y {
for x in min_x..=max_x {
if toolbar.hit_test(rect, op_editor_ui::Point2D::new(x as f32, y as f32))
== Some(op_editor_ui::widgets::ToolbarHit::Action(action))
{
hits.push((x as f32, y as f32));
}
}
}
let (sum_x, sum_y) = hits
.iter()
.fold((0.0, 0.0), |(sx, sy), (x, y)| (sx + *x, sy + *y));
let count = hits.len().max(1) as f32;
assert!(
!hits.is_empty(),
"toolbar action {action:?} should expose a hit target"
);
(sum_x / count, sum_y / count)
}
#[test]
fn toolbar_panel_actions_open_variables_and_design_panels() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{"type":"rectangle","id":"n1","name":"n1","x":0,"y":0,"width":100,"height":50}]}"#,
);
host.editor_state_mut().editor_ui.property_tab = op_editor_core::PropertyTab::Design;
host.editor_state_mut().chat.collapsed = true;
let viewport_w = 1200.0;
let viewport_h = 800.0;
let (variables_x, variables_y) = toolbar_action_point_for_test(
&host,
op_editor_ui::widgets::ToolbarAction::ToggleVariablesPanel,
viewport_w,
viewport_h,
);
let toolbar = op_editor_ui::widgets::Toolbar::for_editor(host.editor_state());
assert_eq!(
toolbar.hit_test(
host.toolbar_rect(viewport_w, viewport_h),
op_editor_ui::Point2D::new(variables_x, variables_y)
),
Some(op_editor_ui::widgets::ToolbarHit::Action(
op_editor_ui::widgets::ToolbarAction::ToggleVariablesPanel
))
);
// The VariablesPanel is a floating canvas overlay (TS
// `variables-panel.tsx` absolute-positioned panel), NOT a
// right-rail tab — toggling it must not affect the rail.
// Mirrors `right_rail_visible_true_on_selection_only` in
// op-editor-core::tests_mutators.
assert!(!host.editor_state().right_rail_visible());
assert!(host.apply_press(variables_x, variables_y, viewport_w, viewport_h));
assert!(host.editor_state().editor_ui.variables_panel_open);
assert!(!host.editor_state().right_rail_visible());
assert_eq!(
host.editor_state().editor_ui.property_tab,
op_editor_core::PropertyTab::Design
);
assert!(host.apply_press(variables_x, variables_y, viewport_w, viewport_h));
assert!(!host.editor_state().editor_ui.variables_panel_open);
assert!(!host.editor_state().right_rail_visible());
let (design_x, design_y) = toolbar_action_point_for_test(
&host,
op_editor_ui::widgets::ToolbarAction::ToggleDesignPanel,
viewport_w,
viewport_h,
);
assert!(host.apply_press(design_x, design_y, viewport_w, viewport_h));
assert!(host.editor_state().editor_ui.design_md_panel_open);
}
#[test]
fn explicit_variables_toolbar_opens_floating_variables_panel() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{"version":"0.8.0","children":[{"type":"frame","id":"frame-1","name":"Frame","x":0,"y":0,"width":100,"height":50}]}"##,
);
host.editor_state_mut().selection.anchor = NodeId::new("frame-1");
host.editor_state_mut().selection.set = vec![NodeId::new("frame-1")];
host.editor_state_mut().create_variable(
"spacing-md",
jian_ops_schema::variable::VariableKind::Number,
jian_ops_schema::variable::VariableScalar::Num(16.0),
);
host.editor_state_mut().editor_ui.variables_panel_open = true;
let viewport_w = 1200.0;
let viewport_h = 800.0;
let vars_rect = host.variables_panel_rect(viewport_w, viewport_h).unwrap();
assert!(
host.editor_state().right_rail_visible(),
"selected frame should still own the right rail"
);
assert!(host.apply_press(
vars_rect.origin.x + 16.0,
vars_rect.origin.y + 44.0 + 36.0 + 18.0,
viewport_w,
viewport_h
));
assert!(host.editor_state().editor_ui.variables_panel_open);
assert!(host.editor_state().editor_ui.variable_row_focus.is_some());
}
fn toolbar_center_y_for_test() -> f32 {
op_editor_ui::widgets::AI_CHAT_HEIGHT - 19.0
}
fn textarea_center_y_for_test() -> f32 {
const INPUT_AREA_HEIGHT: f32 = 56.0;
const INPUT_TOOLBAR_HEIGHT: f32 = 40.0;
op_editor_ui::widgets::AI_CHAT_HEIGHT - (INPUT_AREA_HEIGHT + INPUT_TOOLBAR_HEIGHT)
+ 1.0
+ INPUT_AREA_HEIGHT / 2.0
}
#[test]
fn chat_input_click_clears_select_all_without_erasing_text() {
let mut host = WidgetHostNative::new();
let viewport_w = 1200.0;
let viewport_h = 800.0;
let rect = host.ai_chat_rect(viewport_w, viewport_h).unwrap();
host.editor_state_mut()
.chat
.set_input_text("设计一个现代的移动端登录页面");
host.editor_state_mut().chat.focused = true;
host.editor_state_mut().chat.select_all_input(0);
assert!(host.apply_press(
rect.origin.x + 80.0,
rect.origin.y + textarea_center_y_for_test(),
viewport_w,
viewport_h
));
assert_eq!(
host.editor_state().chat.input.text(),
"设计一个现代的移动端登录页面"
);
assert!(host.editor_state().chat.focused);
assert!(host.editor_state().chat.input.highlight_range().is_none());
}
#[test]
fn chat_input_drag_selects_partial_text_and_replaces_it() {
let mut host = WidgetHostNative::new();
let viewport_w = 1200.0;
let viewport_h = 800.0;
let rect = host.ai_chat_rect(viewport_w, viewport_h).unwrap();
host.editor_state_mut().chat.set_input_text("abcdef");
host.editor_state_mut().chat.focused = true;
let text_x = rect.origin.x + 24.0;
let text_y = rect.origin.y + textarea_center_y_for_test();
assert!(host.apply_press(text_x + 6.6, text_y, viewport_w, viewport_h));
assert_eq!(
host.editor_state().chat.input.selection(),
jian_core::text_input::Selection {
anchor: 1,
focus: 1
}
);
assert!(host.apply_cursor_move(text_x + 19.8, text_y));
assert_eq!(
host.editor_state().chat.input.selection(),
jian_core::text_input::Selection {
anchor: 1,
focus: 3
}
);
assert!(host.apply_release());
assert!(host.apply_text('X'));
assert_eq!(host.editor_state().chat.input.text(), "aXdef");
}
#[test]
fn escape_closes_one_overlay_per_press_in_priority_order() {
// Codex CONCERN-2 regression: Escape used to clear all
// three pickers in a single press. TS parity is one-at-a-
// time, in the order property-focus → locale → shape →
// fill-type → chat → selection.
let mut host = WidgetHostNative::new();
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::PositionX);
host.editor_state_mut().ui.property_input.set_text("12");
host.editor_state_mut().editor_ui.locale_picker.open = true;
host.editor_state_mut().editor_ui.shape_picker.open = true;
host.editor_state_mut().editor_ui.fill_type_picker.open = true;
host.editor_state_mut().chat.focused = true;
host.editor_state_mut()
.set_single_selection(NodeId::new("n10"));
// 1. Property focus clears first.
assert!(host.apply_escape());
assert!(host.editor_state().ui.property_focus.is_none());
assert!(host.editor_state().ui.property_input.text().is_empty());
assert!(host.editor_state().editor_ui.locale_picker.open);
// 2. Locale picker next.
assert!(host.apply_escape());
assert!(!host.editor_state().editor_ui.locale_picker.open);
assert!(host.editor_state().editor_ui.shape_picker.open);
// 3. Shape picker.
assert!(host.apply_escape());
assert!(!host.editor_state().editor_ui.shape_picker.open);
assert!(host.editor_state().editor_ui.fill_type_picker.open);
// 4. Fill-type picker.
assert!(host.apply_escape());
assert!(!host.editor_state().editor_ui.fill_type_picker.open);
assert!(host.editor_state().chat.focused);
// 5. Chat focus.
assert!(host.apply_escape());
assert!(!host.editor_state().chat.focused);
assert!(!host.editor_state().selection.is_empty());
// 6. Selection.
assert!(host.apply_escape());
assert!(host.editor_state().selection.is_empty());
// 7. Nothing left — returns false.
assert!(!host.apply_escape());
}
#[test]
fn rename_caret_arrows_move_caret_then_fall_through() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
&three_rects(
[
(0.0, 0.0, 10.0, 10.0),
(20.0, 0.0, 10.0, 10.0),
(40.0, 0.0, 10.0, 10.0),
],
["ab", "b", "c"],
),
);
assert!(host
.editor_state_mut()
.start_rename_layer(NodeId::new("ab")));
// Draft "ab" seeds caret at the end (2).
assert_eq!(
host.editor_state()
.ui
.layer_rename
.as_ref()
.unwrap()
.input
.caret(),
2
);
// Left arrow during rename is consumed and moves the caret.
assert!(host.apply_rename_caret(false));
assert_eq!(
host.editor_state()
.ui
.layer_rename
.as_ref()
.unwrap()
.input
.caret(),
1
);
assert!(host.apply_rename_caret(true));
assert_eq!(
host.editor_state()
.ui
.layer_rename
.as_ref()
.unwrap()
.input
.caret(),
2
);
// With no rename active the arrow falls through (not consumed).
host.editor_state_mut().rename_cancel();
assert!(!host.apply_rename_caret(false));
}
#[test]
fn status_bar_search_click_frames_content_in_viewport() {
// Three rects spread across doc space (union ≈ x[100,400] y[100,300]).
let mut host = WidgetHostNative::new();
seed(
&mut host,
&three_rects(
[
(100.0, 100.0, 100.0, 100.0),
(300.0, 200.0, 100.0, 100.0),
(150.0, 150.0, 50.0, 50.0),
],
["a", "b", "c"],
),
);
// Pan + zoom far away so the design is off-screen.
host.editor_state_mut().viewport.pan_x = -5000.0;
host.editor_state_mut().viewport.pan_y = -5000.0;
host.editor_state_mut().viewport.zoom = 0.2;
let (vw, vh) = (1200.0, 800.0);
let r = host
.status_bar_rect(vw, vh)
.expect("status bar visible at this size");
// Click the search icon (left section of the pill).
let consumed = host.apply_press(r.origin.x + 5.0, r.origin.y + r.size.y / 2.0, vw, vh);
assert!(consumed, "search-icon click must be consumed");
let v = host.editor_state().viewport;
assert!((v.zoom - 1.0).abs() < 1e-3, "zoom {}", v.zoom);
assert!((v.pan_x - 230.0).abs() < 1e-2, "pan_x {}", v.pan_x);
assert!((v.pan_y - 180.0).abs() < 1e-2, "pan_y {}", v.pan_y);
}
#[test]
fn status_bar_press_sets_and_release_clears_pressed_button() {
let mut host = WidgetHostNative::new();
let (vw, vh) = (1200.0, 800.0);
let r = host
.status_bar_rect(vw, vh)
.expect("status bar visible at this size");
let x = r.origin.x + 5.0;
let y = r.origin.y + r.size.y / 2.0;
assert!(host.apply_press(x, y, vw, vh));
assert_eq!(
host.editor_state().editor_ui.pressed_button,
Some(op_editor_core::ButtonPressTarget::StatusBar(
op_editor_core::StatusBarButton::Search
))
);
assert!(host.apply_release_with_viewport(vw, vh));
assert_eq!(host.editor_state().editor_ui.pressed_button, None);
}
#[test]
fn export_dialog_press_sets_and_release_clears_pressed_button() {
let mut host = WidgetHostNative::new();
let (vw, vh) = (1200.0, 800.0);
host.editor_state_mut().editor_ui.export_dialog_open = true;
let dlg = op_editor_ui::widgets::ExportDialog::centered(vw, vh);
let mut point = None;
let r = dlg.rect();
let mut y = r.origin.y;
while y <= r.origin.y + r.size.y && point.is_none() {
let mut x = r.origin.x;
while x <= r.origin.x + r.size.x {
let p = op_editor_ui::Point2D::new(x, y);
if dlg.hit_test(p)
== Some(op_editor_ui::widgets::export_dialog::ExportDialogHit::Scale(1))
{
point = Some(p);
break;
}
x += 4.0;
}
y += 4.0;
}
let point = point.expect("scale 1 pill is hittable");
assert!(host.apply_press(point.x, point.y, vw, vh));
assert_eq!(
host.editor_state().editor_ui.pressed_button,
Some(op_editor_core::ButtonPressTarget::ExportDialog(
op_editor_core::ExportDialogButton::Scale(1)
))
);
assert!(host.apply_release_with_viewport(vw, vh));
assert_eq!(host.editor_state().editor_ui.pressed_button, None);
}
#[test]
fn figma_import_press_sets_and_release_clears_pressed_button() {
let mut host = WidgetHostNative::new();
let (vw, vh) = (1200.0, 800.0);
host.editor_state_mut().editor_ui.figma_import_open = true;
let modal =
op_editor_ui::widgets::figma_import::FigmaImportModal::for_editor(host.editor_state());
let panel = modal.rect(vw, vh);
let mut point = None;
let mut y = panel.origin.y;
while y <= panel.origin.y + panel.size.y && point.is_none() {
let mut x = panel.origin.x;
while x <= panel.origin.x + panel.size.x {
let p = op_editor_ui::Point2D::new(x, y);
if modal.hit_test(panel, p)
== op_editor_ui::widgets::figma_import::FigmaImportHit::DropZone
{
point = Some(p);
break;
}
x += 4.0;
}
y += 4.0;
}
let point = point.expect("drop zone is hittable");
assert!(host.apply_press(point.x, point.y, vw, vh));
assert_eq!(
host.editor_state().editor_ui.pressed_button,
Some(op_editor_core::ButtonPressTarget::FigmaImport(
op_editor_core::FigmaImportButton::DropZone
))
);
assert!(host.apply_release_with_viewport(vw, vh));
assert_eq!(host.editor_state().editor_ui.pressed_button, None);
}
#[test]
fn pick_fill_image_keeps_image_popover_open_for_mode_selection() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().editor_ui.image_fill_popover_open = true;
host.apply_property_action(op_editor_ui::widgets::PropertyPanelAction::PickFillImage);
assert_eq!(
host.editor_state().editor_ui.pending_file_action,
Some(op_editor_core::editor_ui_state::FileAction::PickFillImage),
);
assert!(
host.editor_state().editor_ui.image_fill_popover_open,
"the image popover must stay open so Fill/Fit/Crop/Tile remain selectable",
);
}
#[test]
fn image_adjustment_drag_updates_live_after_press() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"rectangle","id":"n60","name":"Photo fill",
"x":40,"y":40,"width":180,"height":120,
"fill":[{"type":"image","url":"","mode":"fill",
"exposure":0,"contrast":0,"saturation":0,
"temperature":0,"tint":0,"highlights":0,"shadows":0}]}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("n60"));
host.editor_state_mut().editor_ui.image_fill_popover_open = true;
host.image_adjustment_drag = Some(op_editor_core::ImageAdjustmentField::Exposure);
host.last_viewport_w = 900.0;
host.last_viewport_h = 760.0;
assert!(host.apply_cursor_move(0.0, 0.0));
let node = host
.editor_state()
.selected_node()
.expect("selected image-fill node");
match op_editor_core::fills::node_fills(node)
.unwrap()
.first()
.unwrap()
{
jian_ops_schema::style::PenFill::Image(body) => {
assert_eq!(body.exposure, Some(-100.0));
}
other => panic!("expected image fill, got {other:?}"),
}
}
#[test]
fn image_fill_actions_refresh_the_render_scene() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"rectangle","id":"n61","name":"Photo fill",
"x":40,"y":40,"width":180,"height":120,
"fill":[{"type":"image","url":"data:image/png;base64,AA==","mode":"fill",
"exposure":0,"contrast":0,"saturation":0,
"temperature":0,"tint":0,"highlights":0,"shadows":0}]}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("n61"));
host.mark_paint_dirty_for_test();
let initial_fit = host
.layout_scene()
.active_page()
.unwrap()
.find("n61")
.unwrap()
.image_fit;
assert_eq!(initial_fit, op_editor_ui::layout_scene::SceneImageFit::Fill);
host.apply_property_action(
op_editor_ui::widgets::PropertyPanelAction::SetImageFillMode(
op_editor_core::ImageFillMode::Fit,
),
);
host.apply_property_action(
op_editor_ui::widgets::PropertyPanelAction::SetImageAdjustment {
field: op_editor_core::ImageAdjustmentField::Exposure,
value: 64.0,
},
);
let rendered = host
.layout_scene()
.active_page()
.unwrap()
.find("n61")
.unwrap();
assert_eq!(
rendered.image_fit,
op_editor_ui::layout_scene::SceneImageFit::Fit
);
assert_eq!(rendered.image_adjustments.exposure, 64.0);
}
#[test]
fn corner_radius_property_focus_updates_selected_rectangle() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"rectangle","id":"n62","name":"Rounded",
"x":40,"y":40,"width":180,"height":120,
"fill":[{"type":"solid","color":"#BDC7D9"}]}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("n62"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::PositionR);
host.editor_state_mut().ui.property_input.set_text("24");
host.commit_property_focus_if_any();
let node = host.editor_state().selected_node().unwrap();
match node {
jian_ops_schema::node::PenNode::Rectangle(rect) => {
assert_eq!(
rect.container.corner_radius,
Some(jian_ops_schema::node::container::CornerRadius::Uniform(
24.0
)),
);
}
other => panic!("expected rectangle, got {other:?}"),
}
let rendered = host
.layout_scene()
.active_page()
.unwrap()
.find("n62")
.unwrap();
assert_eq!(rendered.corner_radius, 24.0);
}
#[test]
fn property_focus_commit_reads_text_input_state() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"rectangle","id":"n62","name":"Wide",
"x":40,"y":40,"width":180,"height":120,
"fill":[{"type":"solid","color":"#BDC7D9"}]}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("n62"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::SizeW);
host.editor_state_mut().ui.property_input.set_text("321");
host.commit_property_focus_if_any();
let bounds = own_bounds(host.editor_state().selected_node().unwrap());
assert_eq!(bounds.w, 321.0);
assert!(host.editor_state().ui.property_input.text().is_empty());
}
#[test]
fn widget_leading_icon_focus_commits_onto_text_input() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"text_input","id":"email","name":"Email",
"x":24,"y":32,"width":220,"height":40,"placeholder":"Email"}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("email"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::WidgetLeadingIcon);
// Type the glyph name char-by-char to exercise the free-text gate
// (letters must NOT be rejected as non-numeric).
for c in "mail".chars() {
assert!(host.apply_text(c), "letter '{c}' must be accepted");
}
host.commit_property_focus_if_any();
match host.editor_state().selected_node().unwrap() {
jian_ops_schema::node::PenNode::TextInput(t) => {
assert_eq!(t.leading_icon.as_deref(), Some("mail"));
}
other => panic!("expected text_input, got {other:?}"),
}
}
#[test]
fn widget_bind_key_focus_writes_state_binding() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"text_input","id":"email","name":"Email",
"x":24,"y":32,"width":220,"height":40,"placeholder":"Email"}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("email"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::WidgetBindKey);
host.editor_state_mut().ui.property_input.set_text("email");
host.commit_property_focus_if_any();
match host.editor_state().selected_node().unwrap() {
jian_ops_schema::node::PenNode::TextInput(t) => {
let bindings = t.bindings.as_ref().expect("bindings written");
assert_eq!(
bindings.get("bind:value").map(|e| e.0.as_str()),
Some("$state.email"),
);
}
other => panic!("expected text_input, got {other:?}"),
}
}
#[test]
fn property_focus_commit_is_undoable() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"rectangle","id":"n62","name":"Wide",
"x":40,"y":40,"width":180,"height":120,
"fill":[{"type":"solid","color":"#BDC7D9"}]}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("n62"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::SizeW);
host.editor_state_mut().ui.property_input.set_text("321");
host.commit_property_focus_if_any();
let bounds = own_bounds(host.editor_state().selected_node().unwrap());
assert_eq!(bounds.w, 321.0);
assert!(host.editor_state().history.can_undo());
assert!(host.editor_state_mut().undo());
let bounds = own_bounds(host.editor_state().selected_node().unwrap());
assert_eq!(bounds.w, 180.0);
}
#[test]
fn property_press_seeds_text_input_state() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"rectangle","id":"n62","name":"Wide",
"x":40,"y":40,"width":180,"height":120,
"fill":[{"type":"solid","color":"#BDC7D9"}]}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("n62"));
let viewport_w = 1200.0;
let viewport_h = 800.0;
let property_rect = host.property_rect(viewport_w, viewport_h);
let panel = op_editor_ui::widgets::PropertyPanel::for_selection(host.editor_state())
.expect("selection should show the property panel");
let mut point = None;
'outer: for y in 0..property_rect.size.y as i32 {
for x in 0..property_rect.size.x as i32 {
let candidate = op_editor_ui::Point2D::new(
property_rect.origin.x + x as f32 + 0.5,
property_rect.origin.y + y as f32 + 0.5,
);
if panel.hit_test(property_rect, candidate) == Some(PropertyFocus::SizeW) {
point = Some(candidate);
break 'outer;
}
}
}
let point = point.expect("width input should be hit-testable");
assert!(host.apply_press(point.x, point.y, viewport_w, viewport_h));
assert_eq!(
host.editor_state().ui.property_focus,
Some(PropertyFocus::SizeW)
);
assert_eq!(host.editor_state().ui.property_input.text(), "180");
assert_eq!(host.editor_state().ui.property_input.caret(), 3);
}
#[test]
fn effect_param_focus_seeds_text_input_state() {
let mut host = WidgetHostNative::new();
host.apply_property_action(
op_editor_ui::widgets::PropertyPanelAction::FocusEffectParam {
effect: 0,
field: op_editor_core::EffectField::OffsetX,
value: 12.5,
},
);
assert_eq!(
host.editor_state().editor_ui.effect_param_focus,
Some(op_editor_core::editor_ui_state::EffectParamFocus {
effect: 0,
field: op_editor_core::EffectField::OffsetX,
})
);
assert_eq!(host.editor_state().ui.property_input.text(), "12.5");
assert_eq!(host.editor_state().ui.property_input.caret(), 4);
}
#[test]
fn effect_param_input_uses_text_input_state_for_editing() {
let mut host = WidgetHostNative::new();
{
let editor = host.editor_state_mut();
editor.editor_ui.effect_param_focus =
Some(op_editor_core::editor_ui_state::EffectParamFocus {
effect: 0,
field: op_editor_core::EffectField::OffsetX,
});
editor.ui.property_input.set_text("1234");
}
assert!(host.apply_property_caret(false));
assert!(host.apply_property_caret(false));
assert_eq!(host.editor_state().ui.property_input.caret(), 2);
assert!(host.apply_text('9'));
assert_eq!(host.editor_state().ui.property_input.text(), "12934");
assert_eq!(host.editor_state().ui.property_input.caret(), 3);
}
#[test]
fn property_delete_uses_text_input_state() {
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.set_single_selection(NodeId::new("n10"));
{
let ui = &mut host.editor_state_mut().ui;
ui.property_focus = Some(PropertyFocus::PositionX);
ui.property_input.set_text("123");
}
assert!(host.apply_property_caret(false));
assert!(host.apply_delete());
assert_eq!(host.editor_state().ui.property_input.text(), "12");
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n10"));
}
#[test]
fn property_step_reads_and_updates_text_input_state() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"rectangle","id":"n62","name":"Wide",
"x":40,"y":40,"width":180,"height":120,
"fill":[{"type":"solid","color":"#BDC7D9"}]}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("n62"));
{
let ui = &mut host.editor_state_mut().ui;
ui.property_focus = Some(PropertyFocus::SizeW);
ui.property_input.set_text("180");
}
assert!(host.apply_property_step(5.0));
let bounds = own_bounds(host.editor_state().selected_node().unwrap());
assert_eq!(bounds.w, 185.0);
assert_eq!(host.editor_state().ui.property_input.text(), "185");
assert_eq!(host.editor_state().ui.property_input.caret(), 3);
}
#[test]
fn property_escape_clears_text_input_state() {
let mut host = WidgetHostNative::new();
{
let ui = &mut host.editor_state_mut().ui;
ui.property_focus = Some(PropertyFocus::PositionX);
ui.property_input.set_text("123");
}
assert!(host.apply_escape());
assert!(host.editor_state().ui.property_focus.is_none());
assert!(host.editor_state().ui.property_input.text().is_empty());
}
#[test]
fn polygon_sides_property_focus_updates_selected_polygon() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"polygon","id":"poly","name":"Polygon",
"x":40,"y":40,"width":120,"height":120,
"polygonCount":3}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("poly"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::PolygonSides);
host.editor_state_mut().ui.property_input.set_text("7");
host.commit_property_focus_if_any();
let node = host.editor_state().selected_node().unwrap();
match node {
jian_ops_schema::node::PenNode::Polygon(poly) => {
assert_eq!(poly.polygon_count, 7);
}
other => panic!("expected polygon, got {other:?}"),
}
let rendered = host
.layout_scene()
.active_page()
.unwrap()
.find("poly")
.unwrap();
assert_eq!(rendered.polygon_sides, 7);
}
#[test]
fn ellipse_arc_property_focus_updates_selected_ellipse() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"ellipse","id":"ell","name":"Ellipse",
"x":40,"y":40,"width":120,"height":100}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("ell"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::EllipseStart);
host.editor_state_mut().ui.property_input.set_text("45");
host.commit_property_focus_if_any();
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::EllipseSweep);
host.editor_state_mut().ui.property_input.set_text("180");
host.commit_property_focus_if_any();
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::EllipseInnerRadius);
host.editor_state_mut().ui.property_input.set_text("25");
host.commit_property_focus_if_any();
let node = host.editor_state().selected_node().unwrap();
match node {
jian_ops_schema::node::PenNode::Ellipse(ell) => {
assert_eq!(ell.start_angle, Some(45.0));
assert_eq!(ell.sweep_angle, Some(180.0));
assert_eq!(ell.inner_radius, Some(0.25));
}
other => panic!("expected ellipse, got {other:?}"),
}
let rendered = host
.layout_scene()
.active_page()
.unwrap()
.find("ell")
.unwrap();
assert_eq!(rendered.arc_start_angle, Some(45.0));
assert_eq!(rendered.arc_sweep_angle, Some(180.0));
assert_eq!(rendered.arc_inner_radius, Some(0.25));
}
#[test]
fn backspace_with_property_input_does_not_delete_selected() {
// With a non-empty property input, Backspace must pop a char
// from the input, not delete the selected node.
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.set_single_selection(NodeId::new("n10"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::PositionX);
host.editor_state_mut().ui.property_input.set_text("123");
// Caret at the input's end, as a real focus seeds it — Backspace
// deletes the char *before* the caret.
assert!(host.apply_backspace());
assert_eq!(host.editor_state().ui.property_input.text(), "12");
assert_eq!(host.editor_state().ui.property_input.caret(), 2);
// Selection must be untouched.
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n10"));
}
#[test]
fn backspace_without_focus_deletes_selected() {
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.set_single_selection(NodeId::new("n10"));
host.editor_state_mut().ui.property_focus = None;
host.editor_state_mut().chat.focused = false;
assert!(host.apply_backspace());
assert!(host.editor_state().selection.is_empty());
}
#[test]
fn delete_with_model_picker_open_does_not_delete_selected() {
// The chat model-picker search owns the keyboard while open, so
// Delete must be swallowed instead of dropping the canvas node
// behind the dropdown.
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.set_single_selection(NodeId::new("n10"));
host.editor_state_mut().editor_ui.chat_model_picker.open = true;
assert!(!host.apply_delete());
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n10"));
}
#[test]
fn backspace_with_model_picker_open_edits_search_not_selection() {
// Backspace pops from the model-picker search query, never the
// selected node.
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.set_single_selection(NodeId::new("n10"));
host.editor_state_mut().editor_ui.chat_model_picker.open = true;
host.editor_state_mut()
.editor_ui
.chat_model_picker_input
.set_text("gp");
assert!(host.apply_backspace());
assert_eq!(
host.editor_state().editor_ui.chat_model_picker_input.text(),
"g"
);
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n10"));
}
#[test]
fn shortcuts_gated_while_model_picker_open() {
// Nudge / duplicate / reorder all route through `input_active`,
// which must report the model picker as owning the keyboard.
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.set_single_selection(NodeId::new("n10"));
host.editor_state_mut().editor_ui.chat_model_picker.open = true;
assert!(!host.apply_nudge(1.0, 0.0));
assert!(!host.apply_duplicate());
assert!(!host.apply_reorder(op_editor_core::ReorderDirection::Up));
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n10"));
}
#[test]
fn marquee_drag_replaces_selection_with_intersecting_nodes() {
let mut host = WidgetHostNative::new();
// 3 rects: two close together near origin, one far away.
seed(
&mut host,
&three_rects(
[
(50.0, 10.0, 20.0, 20.0),
(90.0, 10.0, 20.0, 20.0),
(200.0, 200.0, 20.0, 20.0),
],
["n50", "n51", "n52"],
),
);
host.editor_state_mut().clear_selection();
let viewport_w = 1440.0;
let viewport_h = 900.0;
let (cx0, cy0, _cw, _ch) = host.canvas_region(viewport_w, viewport_h);
let press_x = cx0 + 5.0;
let press_y = cy0 + 5.0;
host.apply_press(press_x, press_y, viewport_w, viewport_h);
assert!(
host.marquee_drag.is_some(),
"empty-canvas press should start a marquee"
);
host.apply_cursor_move(cx0 + 130.0, cy0 + 50.0);
assert!(host.apply_release_with_viewport(viewport_w, viewport_h));
assert!(host.marquee_drag.is_none(), "marquee consumed on release");
let mut hits: Vec<String> = host
.editor_state()
.selection
.set
.iter()
.map(|i| i.as_str().to_string())
.collect();
hits.sort();
assert_eq!(hits, vec!["n50", "n51"]);
}
#[test]
fn marquee_drag_with_shift_preserves_already_selected_hit() {
// Codex CONCERN-Q2 regression: shift-marquee must be ADD-only.
let mut host = WidgetHostNative::new();
seed(
&mut host,
&three_rects(
[
(50.0, 50.0, 20.0, 20.0),
(300.0, 300.0, 20.0, 20.0),
(900.0, 900.0, 20.0, 20.0),
],
["n70", "n71", "n72"],
),
);
host.editor_state_mut()
.set_single_selection(NodeId::new("n70"));
host.set_modifier_shift(true);
let viewport_w = 1440.0;
let viewport_h = 900.0;
let (cx0, cy0, _cw, _ch) = host.canvas_region(viewport_w, viewport_h);
host.apply_press(cx0 + 5.0, cy0 + 5.0, viewport_w, viewport_h);
host.apply_cursor_move(cx0 + 90.0, cy0 + 90.0);
host.apply_release_with_viewport(viewport_w, viewport_h);
// "n70" stays in the set (shift-marquee is ADD-only).
assert!(host.editor_state().is_selected(&NodeId::new("n70")));
assert_eq!(host.editor_state().selection.set.len(), 1);
}
#[test]
fn marquee_drag_below_screen_threshold_is_a_no_op() {
// Codex CONCERN-Q5 regression: threshold is screen-px.
let mut host = WidgetHostNative::new();
seed(
&mut host,
&three_rects(
[
(0.0, 0.0, 100.0, 100.0),
(5000.0, 5000.0, 10.0, 10.0),
(6000.0, 6000.0, 10.0, 10.0),
],
["n80", "n81", "n82"],
),
);
host.editor_state_mut().viewport.zoom = 0.1;
host.editor_state_mut().clear_selection();
let viewport_w = 1440.0;
let viewport_h = 900.0;
let (cx0, cy0, _cw, _ch) = host.canvas_region(viewport_w, viewport_h);
host.apply_press(cx0 + 100.0, cy0 + 50.0, viewport_w, viewport_h);
assert!(host.marquee_drag.is_some());
// Tiny drag: 1 screen-px — below the 2-px threshold.
host.apply_cursor_move(cx0 + 101.0, cy0 + 50.0);
host.apply_release_with_viewport(viewport_w, viewport_h);
assert!(host.editor_state().selection.set.is_empty());
}
#[test]
fn marquee_drag_with_shift_extends_existing_selection() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
&three_rects(
[
(10.0, 10.0, 20.0, 20.0),
(50.0, 10.0, 20.0, 20.0),
(300.0, 300.0, 20.0, 20.0),
],
["n60", "n61", "n62"],
),
);
host.editor_state_mut()
.set_single_selection(NodeId::new("n62"));
host.set_modifier_shift(true);
let viewport_w = 1440.0;
let viewport_h = 900.0;
let (cx0, cy0, _cw, _ch) = host.canvas_region(viewport_w, viewport_h);
host.apply_press(cx0 + 5.0, cy0 + 5.0, viewport_w, viewport_h);
assert!(host.marquee_drag.is_some());
host.apply_cursor_move(cx0 + 130.0, cy0 + 50.0);
host.apply_release_with_viewport(viewport_w, viewport_h);
let mut ids: Vec<String> = host
.editor_state()
.selection
.set
.iter()
.map(|i| i.as_str().to_string())
.collect();
ids.sort();
assert_eq!(ids, vec!["n60", "n61", "n62"]);
}
#[test]
fn layer_drag_to_reorder_commits_on_release_with_threshold_move() {
use op_editor_ui::widgets::TOP_BAR_HEIGHT;
let mut host = WidgetHostNative::new();
// Three top-level nodes painted as flat layer rows.
seed(
&mut host,
&three_rects(
[
(0.0, 0.0, 10.0, 10.0),
(0.0, 0.0, 10.0, 10.0),
(0.0, 0.0, 10.0, 10.0),
],
["n70", "n71", "n72"],
),
);
host.editor_state_mut().clear_selection();
let row_h = 28.0; // LAYER_ROW_HEIGHT
let page_row_h = 32.0; // PAGE_ROW_HEIGHT
let section_header_h = 28.0;
let section_gap = 8.0;
let viewport_w = 1440.0;
let viewport_h = 900.0;
let layers_top =
TOP_BAR_HEIGHT + 8.0 + section_header_h + page_row_h + section_gap + section_header_h;
let row_y = |i: usize| layers_top + (i as f32) * row_h + row_h / 2.0;
let row_x = host.editor_state().editor_ui.layer_panel_width / 2.0;
host.apply_press(row_x, row_y(0), viewport_w, viewport_h);
assert!(host.layer_drag.is_some());
assert!(!host.layer_drag.as_ref().unwrap().active);
host.apply_cursor_move(row_x, row_y(2) + row_h / 2.0 - 4.0);
assert!(host.layer_drag.as_ref().unwrap().active);
host.apply_release_with_viewport(viewport_w, viewport_h);
assert!(host.layer_drag.is_none(), "drag must be cleared on release");
// A moved after C → final order [B, C, A].
let order: Vec<String> = host
.editor_state()
.doc
.children
.iter()
.map(|n| n.base().id.clone())
.collect();
assert_eq!(order, vec!["n71", "n72", "n70"]);
}
#[test]
fn layer_drag_below_activation_threshold_is_a_click_not_a_reorder() {
use op_editor_ui::widgets::TOP_BAR_HEIGHT;
let mut host = WidgetHostNative::new();
seed(
&mut host,
&three_rects(
[
(0.0, 0.0, 10.0, 10.0),
(0.0, 0.0, 10.0, 10.0),
(5000.0, 5000.0, 10.0, 10.0),
],
["n80", "n81", "n82"],
),
);
host.editor_state_mut().clear_selection();
let row_y_first = TOP_BAR_HEIGHT + 8.0 + 28.0 + 32.0 + 8.0 + 28.0 + 14.0;
let row_x = host.editor_state().editor_ui.layer_panel_width / 2.0;
let viewport_w = 1440.0;
let viewport_h = 900.0;
host.apply_press(row_x, row_y_first, viewport_w, viewport_h);
host.apply_cursor_move(row_x, row_y_first + 2.0);
assert!(
host.layer_drag.is_some() && !host.layer_drag.as_ref().unwrap().active,
"sub-threshold move must not activate"
);
host.apply_release_with_viewport(viewport_w, viewport_h);
let order: Vec<String> = host
.editor_state()
.doc
.children
.iter()
.map(|n| n.base().id.clone())
.collect();
assert_eq!(order, vec!["n80", "n81", "n82"]);
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n80"));
}
#[test]
fn layer_context_create_component_click_promotes_frame() {
use op_editor_core::editor_ui_state::LayerContextMenuState;
use op_editor_core::ui_draft::LayerContextTarget;
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[
{"type":"frame","id":"n10","name":"Card","x":0,"y":0,"width":100,"height":80,
"children":[]}
]}"#,
);
host.editor_state_mut().editor_ui.layer_context_menu = Some(LayerContextMenuState {
target: LayerContextTarget::Layer(NodeId::new("n10")),
anchor_x: 100.0,
anchor_y: 100.0,
menu: Default::default(),
});
let create_row_y = 100.0 + 6.0 + 32.0 * 2.0 + 16.0;
assert!(host.apply_press(120.0, create_row_y, 1440.0, 900.0));
assert!(host
.editor_state()
.components
.find_by_id(&NodeId::new("n10"))
.is_some());
match &host.editor_state().doc.children[0] {
jian_ops_schema::node::PenNode::Frame(f) => assert_eq!(f.reusable, Some(true)),
_ => panic!("expected frame"),
}
}
#[test]
fn property_panel_create_component_click_promotes_selected_frame() {
use op_editor_ui::widgets::TOP_BAR_HEIGHT;
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[
{"type":"frame","id":"n20","name":"Hero","x":0,"y":0,"width":120,"height":90,
"children":[]}
]}"#,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("n20"));
let viewport_w = 1440.0;
let viewport_h = 900.0;
let panel_left = viewport_w - host.editor_state().editor_ui.property_panel_width;
let button_x = panel_left + 24.0;
let button_y = TOP_BAR_HEIGHT + 36.0 + 30.0 + 8.0 + 18.0;
assert!(host.apply_press(button_x, button_y, viewport_w, viewport_h));
assert!(host
.editor_state()
.components
.find_by_id(&NodeId::new("n20"))
.is_some());
}
#[test]
fn layer_context_group_preserves_multi_selection_and_groups() {
use op_editor_ui::widgets::TOP_BAR_HEIGHT;
let mut host = WidgetHostNative::new();
seed(
&mut host,
&three_rects(
[
(0.0, 0.0, 20.0, 20.0),
(40.0, 0.0, 20.0, 20.0),
(120.0, 0.0, 20.0, 20.0),
],
["n30", "n31", "n32"],
),
);
host.editor_state_mut().selection.set = vec![NodeId::new("n30"), NodeId::new("n31")];
host.editor_state_mut().selection.anchor = NodeId::new("n30");
let viewport_w = 1440.0;
let viewport_h = 900.0;
let row_x = host.editor_state().editor_ui.layer_panel_width / 2.0;
let first_row_y = TOP_BAR_HEIGHT + 8.0 + 28.0 + 32.0 + 8.0 + 28.0 + 14.0;
assert!(host.apply_right_press(row_x, first_row_y, viewport_w, viewport_h));
assert_eq!(
host.editor_state().selection.set.len(),
2,
"right-clicking an already-selected layer must keep the multi-selection"
);
let group_row_y = first_row_y + 6.0 + 32.0 * 2.0 + 16.0;
assert!(host.apply_press(row_x + 20.0, group_row_y, viewport_w, viewport_h));
assert!(matches!(
host.editor_state().doc.children.first(),
Some(jian_ops_schema::node::PenNode::Group(_))
));
}
#[test]
fn component_browser_open_owns_keyboard_search() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().editor_ui.component_browser_open = true;
assert!(host.input_active_pub());
assert!(host.apply_text('b'));
assert!(host.apply_text('a'));
assert_eq!(host.editor_state().editor_ui.component_browser_search, "ba");
assert!(host.apply_backspace());
assert_eq!(host.editor_state().editor_ui.component_browser_search, "b");
assert!(host.apply_escape());
assert!(!host.editor_state().editor_ui.component_browser_open);
}
#[test]
fn component_browser_header_buttons_queue_kit_io_requests() {
// Press → dispatch → `component_browser_kit_request` seam: the
// desktop runner drains the queued request into the rfd dialogs
// (`op-host-desktop/src/kit_io.rs`).
let mut host = WidgetHostNative::new();
host.editor_state_mut().editor_ui.component_browser_open = true;
let (vw, vh) = (1440.0, 900.0);
let rect = host
.component_browser_panel_rect(vw, vh)
.expect("open panel rect");
let right = rect.origin.x + rect.size.x;
let y = rect.origin.y + 20.0; // header-button row centre (HEADER_H / 2)
// Header buttons right-aligned: ✕ centre at right-26, Upload
// (import) at right-54, Download (export) at right-82 — from
// PAD 14 + 24-px buttons + 4-px gaps.
assert!(host.apply_press(right - 54.0, y, vw, vh));
assert_eq!(
host.editor_state().editor_ui.component_browser_kit_request,
Some(op_editor_core::KitIoRequest::Import)
);
host.editor_state_mut()
.editor_ui
.component_browser_kit_request = None;
assert!(host.apply_press(right - 82.0, y, vw, vh));
assert_eq!(
host.editor_state().editor_ui.component_browser_kit_request,
Some(op_editor_core::KitIoRequest::Export)
);
}
#[test]
fn component_browser_header_press_sets_and_release_clears_pressed_button() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().editor_ui.component_browser_open = true;
let (vw, vh) = (1440.0, 900.0);
let rect = host
.component_browser_panel_rect(vw, vh)
.expect("open panel rect");
let x = rect.origin.x + rect.size.x - 54.0;
let y = rect.origin.y + 20.0;
assert!(host.apply_press(x, y, vw, vh));
assert_eq!(
host.editor_state().editor_ui.pressed_button,
Some(op_editor_core::ButtonPressTarget::ComponentBrowser(
op_editor_core::ComponentBrowserButton::ImportKit
))
);
assert!(host.apply_release_with_viewport(vw, vh));
assert_eq!(host.editor_state().editor_ui.pressed_button, None);
}
#[test]
fn chat_model_picker_open_owns_keyboard_search() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().editor_ui.chat_model_picker.open = true;
host.editor_state_mut().chat.focused = true;
assert!(host.input_active_pub());
assert!(host.apply_text('g'));
assert!(host.apply_text('p'));
assert_eq!(
host.editor_state().editor_ui.chat_model_picker_input.text(),
"gp"
);
assert!(host.editor_state().chat.input.text().is_empty());
assert!(host.apply_backspace());
assert_eq!(
host.editor_state().editor_ui.chat_model_picker_input.text(),
"g"
);
assert!(host.apply_escape());
assert!(!host.editor_state().editor_ui.chat_model_picker.open);
}
#[test]
fn select_all_in_chat_input_replaces_next_typed_text() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().chat.focused = true;
host.editor_state_mut().chat.set_input_text("abcdef");
assert!(host.apply_select_all());
assert_eq!(host.editor_state().chat.input.text(), "abcdef");
assert!(host.apply_text('X'));
assert_eq!(host.editor_state().chat.input.text(), "X");
}
#[test]
fn select_all_in_settings_input_replaces_next_typed_text() {
let mut host = WidgetHostNative::new();
{
let ui = &mut host.editor_state_mut().editor_ui;
ui.agent_settings.focus = Some(
op_editor_core::agent_settings::SettingsFocus::BuiltinAgentDraft(
op_editor_core::agent_settings::BuiltinAgentField::BaseUrl,
),
);
ui.settings_input.set_text("https://example.invalid");
}
assert!(host.apply_select_all());
assert!(host.apply_text('x'));
assert_eq!(host.editor_state().editor_ui.settings_input.text(), "x");
assert_eq!(host.editor_state().editor_ui.settings_input.caret(), 1);
}
#[test]
fn select_all_in_property_input_replaces_next_typed_text() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::PositionX);
host.editor_state_mut().ui.property_input.set_text("123");
assert!(host.apply_select_all());
assert!(host.apply_text('4'));
assert_eq!(host.editor_state().ui.property_input.text(), "4");
assert_eq!(host.editor_state().ui.property_input.caret(), 1);
}
#[test]
fn property_input_uses_text_input_state_for_editing() {
let mut host = WidgetHostNative::new();
{
let ui = &mut host.editor_state_mut().ui;
ui.property_focus = Some(PropertyFocus::PositionX);
ui.property_input.set_text("1234");
}
assert!(host.apply_property_caret(false));
assert!(host.apply_property_caret(false));
assert_eq!(host.editor_state().ui.property_input.caret(), 2);
assert!(host.apply_text('9'));
assert_eq!(host.editor_state().ui.property_input.text(), "12934");
assert_eq!(host.editor_state().ui.property_input.caret(), 3);
assert!(host.apply_backspace());
assert_eq!(host.editor_state().ui.property_input.text(), "1234");
assert_eq!(host.editor_state().ui.property_input.caret(), 2);
assert!(host.apply_select_all());
assert!(host.apply_text('5'));
assert_eq!(host.editor_state().ui.property_input.text(), "5");
assert_eq!(host.editor_state().ui.property_input.caret(), 1);
}
#[test]
fn select_all_in_chat_model_picker_replaces_next_typed_text() {
let mut host = WidgetHostNative::new();
{
let ui = &mut host.editor_state_mut().editor_ui;
ui.chat_model_picker.open = true;
ui.chat_model_picker_input.set_text("gpt");
}
assert!(host.apply_select_all());
assert!(host.apply_text('x'));
assert_eq!(
host.editor_state().editor_ui.chat_model_picker_input.text(),
"x"
);
assert_eq!(
host.editor_state()
.editor_ui
.chat_model_picker_input
.caret(),
1
);
}
#[test]
fn shape_picker_icon_row_opens_icon_picker() {
let mut host = WidgetHostNative::new();
let viewport_w = 1440.0;
let viewport_h = 900.0;
host.editor_state_mut().editor_ui.shape_picker.open = true;
let panel = host.shape_picker_rect(viewport_w, viewport_h);
let picker = op_editor_ui::widgets::ShapePicker::for_editor_ui(&host.editor_state().editor_ui);
let x = panel.origin.x + 24.0;
let mut probe = panel.origin.y + 2.0;
let mut icon_row_y = None;
while probe < panel.origin.y + panel.size.y {
if matches!(
picker.hit_test(panel, op_editor_ui::Point2D::new(x, probe)),
Some(op_editor_ui::widgets::ShapeChoice::OpenIconPicker)
) {
icon_row_y = Some(probe);
break;
}
probe += 2.0;
}
let icon_row_y = icon_row_y.expect("icon row present in the shape picker");
assert!(host.apply_press(x, icon_row_y, viewport_w, viewport_h));
assert!(!host.editor_state().editor_ui.shape_picker.open);
assert!(host.editor_state().editor_ui.icon_picker.open);
}
#[test]
fn icon_picker_open_owns_keyboard_search() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().editor_ui.icon_picker.open = true;
assert!(host.input_active_pub());
assert!(host.apply_text('h'));
assert!(host.apply_text('o'));
assert_eq!(host.editor_state().editor_ui.icon_picker_search, "ho");
assert!(host.apply_backspace());
assert_eq!(host.editor_state().editor_ui.icon_picker_search, "h");
assert!(host.apply_escape());
assert!(!host.editor_state().editor_ui.icon_picker.open);
}
#[test]
fn icon_picker_click_inserts_icon_font_node() {
let mut host = WidgetHostNative::new();
let viewport_w = 1440.0;
let viewport_h = 900.0;
host.editor_state_mut().editor_ui.icon_picker.open = true;
host.editor_state_mut().editor_ui.icon_picker_search = "home".to_string();
let panel = host
.icon_picker_panel_rect(viewport_w, viewport_h)
.expect("icon picker rect");
let row_y = panel.origin.y + 40.0 + 42.0 + 20.0;
assert!(host.apply_press(panel.origin.x + 40.0, row_y, viewport_w, viewport_h));
assert!(!host.editor_state().editor_ui.icon_picker.open);
let icon = host
.editor_state()
.doc
.children
.iter()
.find_map(|node| match node {
jian_ops_schema::node::PenNode::IconFont(icon) => Some(icon),
_ => None,
})
.expect("inserted icon_font node");
assert_eq!(icon.icon_font_name, "home");
assert_eq!(icon.icon_font_family.as_deref(), Some("lucide"));
assert_eq!(host.editor_state().selection.anchor.as_str(), icon.base.id);
}
#[test]
fn icon_picker_header_drag_moves_the_panel() {
let mut host = WidgetHostNative::new();
let viewport_w = 1440.0;
let viewport_h = 900.0;
host.editor_state_mut().editor_ui.icon_picker.open = true;
let start = host
.icon_picker_panel_rect(viewport_w, viewport_h)
.expect("icon picker rect");
let press_x = start.origin.x + 72.0;
let press_y = start.origin.y + 20.0;
assert!(host.apply_press(press_x, press_y, viewport_w, viewport_h));
assert!(host.apply_cursor_move(press_x + 96.0, press_y + 44.0));
let moved = host
.icon_picker_panel_rect(viewport_w, viewport_h)
.expect("icon picker rect after drag");
assert_eq!(moved.origin.x, start.origin.x + 96.0);
assert_eq!(moved.origin.y, start.origin.y + 44.0);
assert!(host.apply_release_with_viewport(viewport_w, viewport_h));
}
#[test]
fn codegen_select_framework_updates_state() {
use op_editor_core::codegen::Framework;
use op_editor_ui::widgets::property_panel_action::CodegenAction;
use op_editor_ui::widgets::PropertyPanelAction;
let mut host = WidgetHostNative::new();
host.apply_property_action(PropertyPanelAction::Codegen(
CodegenAction::SelectFramework(Framework::Vue),
));
assert_eq!(host.editor_state().codegen.framework, Framework::Vue);
}
#[test]
fn codegen_generate_raises_pending_and_generating_phase() {
use op_editor_core::codegen::CodegenPhase;
use op_editor_ui::widgets::property_panel_action::CodegenAction;
use op_editor_ui::widgets::PropertyPanelAction;
let mut host = WidgetHostNative::new();
host.apply_property_action(PropertyPanelAction::Codegen(CodegenAction::Generate));
assert!(host.editor_state().codegen.pending_generate);
assert_eq!(host.editor_state().codegen.phase, CodegenPhase::Generating);
}
#[test]
fn codegen_copy_queues_code_to_system_clipboard() {
use op_editor_ui::widgets::property_panel_action::CodegenAction;
use op_editor_ui::widgets::PropertyPanelAction;
let mut host = WidgetHostNative::new();
host.set_now_ms(4242);
host.editor_state_mut().codegen.code = "export const App = () => null;".to_string();
host.apply_property_action(PropertyPanelAction::Codegen(CodegenAction::Copy));
assert_eq!(host.editor_state().codegen.copied_at, Some(4242));
assert_eq!(
host.editor_state().chat.pending_copy_text.as_deref(),
Some("export const App = () => null;"),
);
}
#[test]
fn codegen_copy_button_queues_full_code_even_with_selection() {
use op_editor_core::codegen::CodeSelection;
use op_editor_ui::widgets::property_panel_action::CodegenAction;
use op_editor_ui::widgets::PropertyPanelAction;
let mut host = WidgetHostNative::new();
host.set_now_ms(4242);
host.editor_state_mut().codegen.code = "export const App = () => null;".to_string();
host.editor_state_mut().codegen.code_selection = Some(CodeSelection {
anchor: 7,
focus: 12,
});
host.apply_property_action(PropertyPanelAction::Codegen(CodegenAction::Copy));
assert_eq!(host.editor_state().codegen.copied_at, Some(4242));
assert_eq!(
host.editor_state().chat.pending_copy_text.as_deref(),
Some("export const App = () => null;"),
);
}
#[test]
fn shortcut_copy_prefers_selected_code_text() {
use op_editor_core::codegen::CodeSelection;
let mut host = WidgetHostNative::new();
host.editor_state_mut().codegen.code = "export const App = () => null;".to_string();
host.editor_state_mut().codegen.code_selection = Some(CodeSelection {
anchor: 7,
focus: 12,
});
assert!(host.apply_copy());
assert_eq!(
host.editor_state().chat.pending_copy_text.as_deref(),
Some("const"),
);
}
#[test]
fn codegen_hover_tracks_idle_generate_button() {
use op_editor_core::codegen::CodegenHover;
use op_editor_core::PropertyTab;
use op_editor_ui::widgets::property_panel_action::CodegenAction;
use op_editor_ui::widgets::property_panel_inputs::TAB_HEIGHT;
use op_editor_ui::widgets::{property_panel_code, TOP_BAR_HEIGHT};
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{"type":"rectangle","id":"n-code","name":"n-code","x":0,"y":0,"width":100,"height":50}]}"#,
);
host.last_viewport_w = 1200.0;
host.last_viewport_h = 800.0;
host.editor_state_mut()
.set_single_selection(NodeId::new("n-code"));
host.editor_state_mut().editor_ui.property_tab = PropertyTab::Code;
let pw = host.editor_state().editor_ui.property_panel_width;
let panel_x = host.last_viewport_w - pw;
let (_, generate_rect) = property_panel_code::code_action_rects(
panel_x,
TOP_BAR_HEIGHT + TAB_HEIGHT,
pw,
&host.editor_state().codegen,
)
.into_iter()
.find(|(action, _)| matches!(action, CodegenAction::Generate))
.expect("Generate rect present");
let point = op_editor_ui::Point2D::new(
generate_rect.origin.x + generate_rect.size.x / 2.0,
generate_rect.origin.y + generate_rect.size.y / 2.0,
);
assert!(host.apply_cursor_move(point.x, point.y));
assert_eq!(
host.editor_state().codegen.action_hover,
Some(CodegenHover::Generate)
);
assert!(host.apply_cursor_move(panel_x - 12.0, TOP_BAR_HEIGHT + TAB_HEIGHT));
assert_eq!(host.editor_state().codegen.action_hover, None);
}
#[test]
fn codegen_preview_drag_selects_code_text() {
use op_editor_core::codegen::{CodeSelection, CodegenPhase};
use op_editor_core::PropertyTab;
use op_editor_ui::widgets::TOP_BAR_HEIGHT;
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{"type":"rectangle","id":"n-code","name":"n-code","x":0,"y":0,"width":100,"height":50}]}"#,
);
let viewport_w = 1200.0;
let viewport_h = 800.0;
host.editor_state_mut()
.set_single_selection(NodeId::new("n-code"));
host.editor_state_mut().editor_ui.property_tab = PropertyTab::Code;
host.editor_state_mut().codegen.phase = CodegenPhase::Complete;
host.editor_state_mut().codegen.code = "import React\nconst n = 1".into();
let panel_x = viewport_w - host.editor_state().editor_ui.property_panel_width;
let char_w = 11.0 * 0.55;
let start = op_editor_ui::Point2D::new(panel_x + 66.0, TOP_BAR_HEIGHT + 112.0);
let end = op_editor_ui::Point2D::new(panel_x + 66.0 + char_w * 6.0, TOP_BAR_HEIGHT + 112.0);
assert!(host.apply_press(start.x, start.y, viewport_w, viewport_h));
assert!(host.apply_cursor_move(end.x, end.y));
assert_eq!(
host.editor_state().codegen.code_selection,
Some(CodeSelection {
anchor: 0,
focus: 6
})
);
assert!(host.apply_release_with_viewport(viewport_w, viewport_h));
}
#[test]
fn codegen_preview_wheel_scrolls_code_not_property_panel() {
use op_editor_core::codegen::CodegenPhase;
use op_editor_core::PropertyTab;
use op_editor_ui::widgets::TOP_BAR_HEIGHT;
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{"type":"rectangle","id":"n-code","name":"n-code","x":0,"y":0,"width":100,"height":50}]}"#,
);
let viewport_w = 1200.0;
let viewport_h = 800.0;
host.editor_state_mut()
.set_single_selection(NodeId::new("n-code"));
host.editor_state_mut().editor_ui.property_tab = PropertyTab::Code;
host.editor_state_mut().codegen.phase = CodegenPhase::Complete;
host.editor_state_mut().codegen.code = (0..100)
.map(|i| format!("line-{i:02}"))
.collect::<Vec<_>>()
.join("\n");
let panel_x = viewport_w - host.editor_state().editor_ui.property_panel_width;
assert!(host.apply_wheel(
panel_x + 80.0,
TOP_BAR_HEIGHT + 112.0,
-160.0,
viewport_w,
viewport_h
));
assert!(host.editor_state().codegen.code_scroll.offset > 0.0);
assert_eq!(
host.editor_state().editor_ui.property_panel_scroll.offset,
0.0
);
}
#[test]
fn property_tab_hover_tracks_inactive_design_tab() {
use op_editor_core::PropertyTab;
use op_editor_ui::widgets::TOP_BAR_HEIGHT;
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{"type":"rectangle","id":"n-tabs","name":"n-tabs","x":0,"y":0,"width":100,"height":50}]}"#,
);
host.last_viewport_w = 1200.0;
host.last_viewport_h = 800.0;
host.editor_state_mut()
.set_single_selection(NodeId::new("n-tabs"));
host.editor_state_mut().editor_ui.property_tab = PropertyTab::Code;
let panel_x = host.last_viewport_w - host.editor_state().editor_ui.property_panel_width;
assert!(host.apply_cursor_move(panel_x + 40.0, TOP_BAR_HEIGHT + 18.0));
assert_eq!(
host.editor_state().editor_ui.property_tab_hover,
Some(PropertyTab::Design)
);
assert!(host.apply_cursor_move(panel_x - 12.0, TOP_BAR_HEIGHT + 18.0));
assert_eq!(host.editor_state().editor_ui.property_tab_hover, None);
}
#[test]
fn codegen_cancel_resets_phase_by_code_presence() {
use op_editor_core::codegen::CodegenPhase;
use op_editor_ui::widgets::property_panel_action::CodegenAction;
use op_editor_ui::widgets::PropertyPanelAction;
let mut host = WidgetHostNative::new();
// No code yet → Cancel falls back to Idle.
host.apply_property_action(PropertyPanelAction::Codegen(CodegenAction::Generate));
host.apply_property_action(PropertyPanelAction::Codegen(CodegenAction::Cancel));
assert!(!host.editor_state().codegen.pending_generate);
assert_eq!(host.editor_state().codegen.phase, CodegenPhase::Idle);
// With code present → Cancel returns to Complete.
host.editor_state_mut().codegen.code = "rendered".to_string();
host.apply_property_action(PropertyPanelAction::Codegen(CodegenAction::Generate));
host.apply_property_action(PropertyPanelAction::Codegen(CodegenAction::Cancel));
assert_eq!(host.editor_state().codegen.phase, CodegenPhase::Complete);
}