openpencil/crates/op-editor-ui/tests/widgets_static.rs

247 lines
7.8 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 remaining OP inspector widgets (Tree /
//! PropertyRow) paint and emit accesskit nodes with the expected semantic roles.
use op_editor_ui::widgets::{
rect, LayoutBox, LayoutCx, PaintCx, PropertyRow, TreeWidget, Widget, WidgetId, ROOT_WIDGET_ID,
};
use op_editor_ui::{Color, 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: inspector widgets paint static content + expose semantic
// accesskit roles.
// ---------------------------------------------------------------------
#[test]
fn 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")),
];
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 (2); Tree adds one
// more for the selected row. Stroked rects: PropertyRow strokes its
// border. Text runs: PropertyRow has 2 (label+value); Tree has 3 items.
assert!(
backend.rects >= 3,
"fill_rect dispatch >= 3 (got {})",
backend.rects
);
assert!(
backend.strokes >= 1,
"stroke_rect dispatch >= 1 (got {})",
backend.strokes
);
assert!(
backend.text >= 5,
"draw_text dispatch >= 5 (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 layer_panel_scroll_view_model_keeps_offsets_in_scroll_state() {
let layer_panel = include_str!("../src/widgets/layer_panel.rs");
let walkers = include_str!("../src/widgets/layer_panel_walkers.rs");
for source in [layer_panel, walkers] {
for needle in [
"pages_scroll: f32",
"layers_scroll: f32",
"pages_h_scroll: f32",
"layers_h_scroll: f32",
"pages_max_scroll: f32",
"layers_max_scroll: f32",
"pages_max_h_scroll: f32",
"layers_max_h_scroll: f32",
] {
assert!(
!source.contains(needle),
"LayerPanel scroll state should not expose naked `{needle}` fields"
);
}
}
}