openpencil/crates/op-editor-ui/tests/widgets_static.rs
Kayshen-X 5f68ab3c1a refactor: extract op-editor-ui crate
Relocate the widget facade (widgets/, including the Widget trait,
render primitives, the editor-UI compositions, the CanvasViewport
center canvas, the lucide icon drawer, and editor_state_ext), the
theme tokens, the layout-resolved render scene (layout_scene +
layout_scene_hit), and the design-variable aggregation (scene_vars)
out of openpencil-shell-core into a dedicated op-editor-ui crate.

The canvas widgets (canvas_viewport*) stay inside op-editor-ui rather
than splitting into a separate op-canvas crate: they depend on the
widgets/ siblings editor_state_ext + icons and on the Widget trait, so
a clean mechanical split is not possible — per the task's explicit
allowance not to force a fragile split.

op-editor-ui's lib.rs mirrors the old shell-core crate-root re-exports
(render_backend facade types + jian gesture types + the i18n alias) so
every intra-module `crate::Color` / `crate::theme` / `crate::widgets`
path resolves unchanged — a pure relocation with no path rewrites
inside the moved modules. openpencil-shell-core becomes a thin
re-export shim (`pub use op_editor_ui::{widgets, theme, ...}`) so the
hosts keep resolving `openpencil_shell_core::*` until the Task 7.3
host rename dissolves the crate. The widgets_static integration test
moves to op-editor-ui/tests with its imports rewritten. The widget
boundary script's reverse-check path is updated to the new crate.
No behaviour change; all tests move with their code.
2026-05-16 23:33:47 +08:00

511 lines
16 KiB
Rust

//! Phase B1 + B2 widget facade smoke tests.
//!
//! B1 piece: proves the `Widget` trait + `PaintCx` / `LayoutCx` shape
//! compiles + that a recording backend plugs in via `&mut dyn
//! RenderBackend`. B2 piece: proves the four inspector widgets (Tree /
//! PropertyRow / Dropdown / TextInput) paint and emit accesskit nodes
//! with the expected semantic roles.
use op_editor_ui::widgets::{
rect, Dropdown, DropdownState, LayoutBox, LayoutCx, PaintCx, PropertyRow, TextInput,
TextInputState, TreeWidget, Widget, WidgetId, ROOT_WIDGET_ID,
};
use op_editor_ui::{
Color, ImeEvent, ImeKind, KeyCode, KeyEvent, KeyLocation, KeyState, KeyValue, Modifiers,
NamedKey, Point2D, Rect, RenderBackend, TextLayout,
};
#[derive(Default)]
struct RecordingBackend {
rects: usize,
strokes: usize,
text: usize,
saves: usize,
restores: usize,
clips: usize,
translates: usize,
}
impl RenderBackend for RecordingBackend {
fn begin_frame(&mut self) {}
fn end_frame(&mut self) {}
fn fill_rect(&mut self, _rect: Rect, _color: Color) {
self.rects += 1;
}
fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {
self.strokes += 1;
}
fn draw_text(&mut self, _layout: &TextLayout, _origin: Point2D) {
self.text += 1;
}
fn clip_rect(&mut self, _rect: Rect) {
self.clips += 1;
}
fn save(&mut self) {
self.saves += 1;
}
fn restore(&mut self) {
self.restores += 1;
}
fn translate(&mut self, _offset: Point2D) {
self.translates += 1;
}
fn stroke_line(&mut self, _from: Point2D, _to: Point2D, _color: Color, _width: f32) {
// Counted alongside strokes for the existing test asserts;
// a separate counter is unnecessary for Step 4 visual lift.
self.strokes += 1;
}
fn fill_round_rect(&mut self, _rect: Rect, _radius: f32, _color: Color) {
self.rects += 1;
}
fn stroke_round_rect(&mut self, _rect: Rect, _radius: f32, _color: Color, _width: f32) {
self.strokes += 1;
}
fn stroke_svg_path(
&mut self,
_d: &str,
_top_left: Point2D,
_size: f32,
_color: Color,
_width: f32,
) {
self.strokes += 1;
}
fn resize(&mut self, _width: u32, _height: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
}
}
#[test]
fn paint_cx_dispatches_through_dyn_backend() {
let mut backend = RecordingBackend::default();
{
let cx = PaintCx {
backend: &mut backend,
};
cx.backend.fill_rect(rect(0.0, 0.0, 10.0, 10.0), Color::RED);
cx.backend
.stroke_rect(rect(1.0, 2.0, 8.0, 8.0), Color::WHITE, 1.5);
cx.backend.save();
cx.backend.translate(Point2D::new(2.0, 3.0));
cx.backend.clip_rect(rect(0.0, 0.0, 4.0, 4.0));
cx.backend.restore();
}
assert_eq!(backend.rects, 1, "fill_rect dispatch");
assert_eq!(backend.strokes, 1, "stroke_rect dispatch");
assert_eq!(backend.saves, 1, "save dispatch");
assert_eq!(backend.restores, 1, "restore dispatch");
assert_eq!(backend.translates, 1, "translate dispatch");
assert_eq!(backend.clips, 1, "clip_rect dispatch");
}
/// A trivial widget impl proves the trait shape. Uses
/// `accesskit::Role::GenericContainer` (canonical "intentional placeholder",
/// ARIA `none`/`presentation`) so the test stays stable across unrelated
/// accesskit version bumps; B2 widgets use semantic roles.
struct StubWidget {
id: WidgetId,
box_rect: Rect,
}
impl Widget for StubWidget {
fn id(&self) -> WidgetId {
self.id
}
fn layout(&self, _cx: &LayoutCx) -> LayoutBox {
LayoutBox {
rect: self.box_rect,
}
}
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
cx.backend.fill_rect(rect, Color::WHITE);
}
fn access_node(&self) -> accesskit::Node {
// `GenericContainer` (ARIA `none` / `presentation`) is the canonical
// "intentional placeholder" role. Real B2 widgets use semantic
// roles (TreeItem / EditableText / etc); see codex B1 review NIT-5.
accesskit::Node::new(accesskit::Role::GenericContainer)
}
}
#[test]
fn widget_trait_dispatches_layout_and_paint() {
let widget = StubWidget {
id: WidgetId::new(7),
box_rect: rect(0.0, 0.0, 100.0, 24.0),
};
let layout_cx = LayoutCx {
available_width: 320.0,
dpi: 1.0,
};
let layout = widget.layout(&layout_cx);
assert_eq!(layout.rect.size.x, 100.0);
let mut backend = RecordingBackend::default();
{
let mut paint_cx = PaintCx {
backend: &mut backend,
};
widget.paint(&mut paint_cx, layout.rect);
}
assert_eq!(backend.rects, 1);
assert_eq!(widget.id(), WidgetId::new(7));
// Sanity check the root id constant survives codegen.
assert_eq!(ROOT_WIDGET_ID.0, 0);
// Trait surface check: access_node returns the placeholder
// `Role::GenericContainer` advertised by `StubWidget`. Real B2 widgets
// assert their semantic roles in the tests below.
let node = widget.access_node();
assert_eq!(node.role(), accesskit::Role::GenericContainer);
}
// ---------------------------------------------------------------------
// B2: four inspector widgets paint static content + expose semantic
// accesskit roles.
// ---------------------------------------------------------------------
#[test]
fn four_inspector_widgets_paint_static_content() {
let layout = LayoutCx {
available_width: 240.0,
dpi: 1.0,
};
let widgets: Vec<Box<dyn Widget>> = vec![
Box::new(TreeWidget::sample()),
Box::new(PropertyRow::new(200, "Width", "960")),
Box::new(Dropdown::sample()),
Box::new(TextInput::sample()),
];
let mut backend = RecordingBackend::default();
for widget in widgets {
let box_ = widget.layout(&layout);
let mut cx = PaintCx {
backend: &mut backend,
};
widget.paint(&mut cx, box_.rect);
}
// Each widget paints at least its background fill (4); Tree adds one
// more for the selected row → ≥ 5. Stroked rects: PropertyRow,
// Dropdown, TextInput each stroke their border (3). Text runs:
// PropertyRow has 2 (label+value); Tree has 3 items; Dropdown has 1;
// TextInput has 1 → ≥ 7 total.
assert!(
backend.rects >= 5,
"fill_rect dispatch ≥ 5 (got {})",
backend.rects
);
assert!(
backend.strokes >= 3,
"stroke_rect dispatch ≥ 3 (got {})",
backend.strokes
);
assert!(
backend.text >= 7,
"draw_text dispatch ≥ 7 (got {})",
backend.text
);
}
#[test]
fn tree_widget_advertises_tree_role_and_layers_label() {
let tree = TreeWidget::sample();
let node = tree.access_node();
assert_eq!(node.role(), accesskit::Role::Tree);
// accesskit::Node exposes label() returning Option<&str> in 0.24.
assert_eq!(node.label(), Some("Layers"));
// Sample tree has 3 items.
assert_eq!(tree.items.len(), 3);
assert!(tree.items.iter().any(|item| item.selected));
}
#[test]
fn property_row_advertises_label_and_value() {
let row = PropertyRow::new(201, "Width", "960");
let node = row.access_node();
// `Role::Group` (not GenericContainer) so the label survives ARIA
// filtering — see codex B2 R1 CONCERN + the fix in prop_row.rs.
assert_eq!(node.role(), accesskit::Role::Group);
assert_eq!(node.label(), Some("Width 960"));
}
#[test]
fn dropdown_advertises_combobox_role() {
let drop = Dropdown::sample();
let node = drop.access_node();
assert_eq!(node.role(), accesskit::Role::ComboBox);
assert_eq!(node.label(), Some("Blend"));
// Sample preserves the closed/first-selected state.
assert_eq!(drop.state.selected, 0);
assert!(!drop.state.open);
}
#[test]
fn text_input_advertises_text_input_role_and_value() {
let input = TextInput::sample();
let node = input.access_node();
assert_eq!(node.role(), accesskit::Role::TextInput);
assert_eq!(node.label(), Some("Name"));
assert_eq!(node.value(), Some("Frame 1"));
}
#[test]
fn text_input_paints_preedit_underline_when_composing() {
// The preedit-underline painting branch only fires when
// `state.preedit` is non-empty; verify it via the recording backend.
let mut input = TextInput::sample();
input.state.preedit = "你好".to_string();
let layout_cx = LayoutCx {
available_width: 240.0,
dpi: 1.0,
};
let layout = input.layout(&layout_cx);
let mut backend = RecordingBackend::default();
{
let mut cx = PaintCx {
backend: &mut backend,
};
input.paint(&mut cx, layout.rect);
}
// 1 fill (background) + 2 strokes (border + preedit underline) +
// 1 text run (preedit content).
assert_eq!(backend.rects, 1);
assert_eq!(backend.strokes, 2);
assert_eq!(backend.text, 1);
}
#[test]
fn dropdown_state_independent_state_struct() {
// DropdownState lives on its own so input handling can swap it
// without taking ownership of the surrounding Dropdown widget.
let s = DropdownState {
selected: 2,
open: true,
};
assert_eq!(s.selected, 2);
assert!(s.open);
}
#[test]
fn text_input_state_default_is_empty() {
let s = TextInputState::default();
assert_eq!(s.value, "");
assert_eq!(s.preedit, "");
}
// ---------------------------------------------------------------------
// C2 widget event handlers (apply_ime / apply_key)
// ---------------------------------------------------------------------
fn keydown(named: NamedKey) -> KeyEvent {
KeyEvent {
key: KeyValue::Named(named),
code: KeyCode::Unknown(String::new()),
location: KeyLocation::Standard,
modifiers: Modifiers::empty(),
state: KeyState::Pressed,
repeat: false,
is_composing: false,
}
}
fn keyup(named: NamedKey) -> KeyEvent {
KeyEvent {
state: KeyState::Released,
..keydown(named)
}
}
#[test]
fn text_input_apply_ime_start_clears_preedit() {
let mut state = TextInputState {
value: "Frame 1".into(),
preedit: "stale".into(),
};
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionStart,
text: String::new(),
});
assert_eq!(state.preedit, "");
// CompositionStart must NOT touch the committed value.
assert_eq!(state.value, "Frame 1");
}
#[test]
fn text_input_apply_ime_update_replaces_preedit() {
let mut state = TextInputState {
value: "Frame ".into(),
preedit: String::new(),
};
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionUpdate { selection: None },
text: "你".into(),
});
assert_eq!(state.preedit, "你");
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionUpdate { selection: None },
text: "你好".into(),
});
assert_eq!(state.preedit, "你好");
// Update path NEVER mutates value.
assert_eq!(state.value, "Frame ");
}
#[test]
fn text_input_apply_ime_end_appends_to_value_and_clears_preedit() {
let mut state = TextInputState {
value: "Frame ".into(),
preedit: "你好".into(),
};
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionEnd,
text: "你好".into(),
});
assert_eq!(state.value, "Frame 你好");
assert_eq!(state.preedit, "");
}
#[test]
fn text_input_apply_ime_double_start_clears_preedit_each_time() {
// Pathological host state machine: a CompositionStart followed by
// another CompositionStart without an intervening End. Each Start
// unconditionally resets preedit; the committed value never moves.
// (Codex C2.1 R1 CONCERN-1.)
let mut state = TextInputState {
value: "Frame ".into(),
preedit: "old".into(),
};
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionStart,
text: String::new(),
});
assert_eq!(state.preedit, "");
state.preedit = "leftover".into();
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionStart,
text: String::new(),
});
assert_eq!(state.preedit, "");
assert_eq!(state.value, "Frame ");
}
#[test]
fn dropdown_apply_key_arrow_down_advances_and_opens() {
let mut state = DropdownState {
selected: 0,
open: false,
};
state.apply_key(&keydown(NamedKey::ArrowDown), 3);
assert_eq!(state.selected, 1);
assert!(state.open);
state.apply_key(&keydown(NamedKey::ArrowDown), 3);
assert_eq!(state.selected, 2);
// Saturates at option_count - 1; does not wrap.
state.apply_key(&keydown(NamedKey::ArrowDown), 3);
assert_eq!(state.selected, 2);
}
#[test]
fn dropdown_apply_key_arrow_up_retreats_with_saturating_sub() {
let mut state = DropdownState {
selected: 1,
open: false,
};
state.apply_key(&keydown(NamedKey::ArrowUp), 3);
assert_eq!(state.selected, 0);
assert!(state.open);
// Already at 0 — saturating_sub holds at 0, no panic.
state.apply_key(&keydown(NamedKey::ArrowUp), 3);
assert_eq!(state.selected, 0);
}
#[test]
fn dropdown_apply_key_enter_and_escape_close() {
let mut state = DropdownState {
selected: 1,
open: true,
};
state.apply_key(&keydown(NamedKey::Enter), 3);
assert!(!state.open);
assert_eq!(state.selected, 1);
state.open = true;
state.apply_key(&keydown(NamedKey::Escape), 3);
assert!(!state.open);
// Escape closes the menu but does NOT mutate selection — confirm
// both halves of the close path leave selected stable (codex C2.1
// R1 CONCERN-2).
assert_eq!(state.selected, 1);
}
#[test]
fn dropdown_apply_key_ignores_keyup() {
let mut state = DropdownState {
selected: 1,
open: false,
};
state.apply_key(&keyup(NamedKey::ArrowDown), 3);
// Released event is a no-op.
assert_eq!(state.selected, 1);
assert!(!state.open);
}
#[test]
fn dropdown_apply_key_ignores_zero_options() {
let mut state = DropdownState {
selected: 0,
open: false,
};
state.apply_key(&keydown(NamedKey::ArrowDown), 0);
// No options → no-op rather than panic on `option_count - 1`.
assert_eq!(state.selected, 0);
assert!(!state.open);
}
#[test]
fn dropdown_apply_key_ignores_unrelated_keys() {
let mut state = DropdownState {
selected: 1,
open: true,
};
state.apply_key(&keydown(NamedKey::Tab), 3);
// Tab is not bound; state should be unchanged.
assert_eq!(state.selected, 1);
assert!(state.open);
}
#[test]
fn dropdown_apply_key_ignores_keys_during_ime_composition() {
// Spec §2.4 widget rule: skip key dispatch while is_composing is
// true. ArrowDown during a CJK composition belongs to the IME's
// candidate-picker UI, NOT to the dropdown selection cycle.
// Codex Phase C stop-hook (#2): "focused IME textarea lets
// composing keys mutate dropdown state."
let mut state = DropdownState {
selected: 0,
open: false,
};
let composing_arrow = KeyEvent {
is_composing: true,
..keydown(NamedKey::ArrowDown)
};
state.apply_key(&composing_arrow, 3);
assert_eq!(state.selected, 0, "composing ArrowDown must not advance");
assert!(!state.open, "composing ArrowDown must not open");
// Same protection for Enter / Escape — IME 'commit' shouldn't
// close the dropdown.
let mut state2 = DropdownState {
selected: 1,
open: true,
};
let composing_enter = KeyEvent {
is_composing: true,
..keydown(NamedKey::Enter)
};
state2.apply_key(&composing_enter, 3);
assert!(state2.open, "composing Enter must not close");
}