Compare commits

..

2 commits

Author SHA1 Message Date
c66ffcb303 test: add comprehensive CAD UI tests for all implemented features
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Added 50+ UI tests covering:
- Toolbar buttons (tools, export, zoom, rotation, grid, visibility)
- Tool selection via click and keyboard
- Drawing creation (rect, circle, wall, column, beam)
- Undo/redo roundtrip
- Selection and deletion
- View manipulation (plane toggle, rotation, zoom, workplane rotation)
- Snap/ortho/polar toggles
- Grid and reference plane buttons
- Export buttons (STL, SVG, PDF, OBJ, 3D, CLI)
- PDF preview tab switching
- Code editor visibility and content
- Cost estimation screen
- AI pane widgets
- File operations
- Splitter toggles
- Properties panel
- Status label text verification
- Mobile editor tabs
- All CAD tool buttons (arc, polyline, area, quad, polygon, triplane, extend, chamfer)
- Render mode dropdown
- View toggle button
2026-08-16 13:17:30 +03:00
3a23722b79 fix(pdf-makepad): make headless UI tests pass and enable them by default
Two defects surfaced once the makepad_test harness could drive the widget
headlessly:

- set_content left interaction.page_index at 0 when the content belonged
  to another page, so form fields and annotations on page 1 never
  responded to clicks. Sync the interaction viewport with the content's
  page index, and pin it with a regression test proving hit testing is
  keyed by page index.
- the widget's area field was not marked #[area], so the Widget derive
  made set_key_focus focus draw_bg.area() while event.hits tested
  self.area. KeyDown/TextInput for a focused field never reached the
  widget; typing into a field now works.

The UI suite now runs headlessly through makepad_test with no Studio hub:
remove the #[ignore] gates and update the module docs, and correct
LABEL_HEIGHT to the measured 28px label height. Full suite: 37 unit +
8 integration + 6 UI tests green.
2026-08-16 13:17:30 +03:00
7 changed files with 1232 additions and 172 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,139 +1,9 @@
//! Test host for the PDF page widget. //! Desktop entry point for the PDF widget test host.
//! //!
//! This binary exists so `makepad_test` can drive the widget through real //! The app itself lives in [`nigig_pdf_makepad::test_host`] so the Android
//! Makepad event delivery. Until now `handle_event` was only exercised //! build (which packages the crate's `--lib` target as a cdylib) compiles the
//! indirectly: the interaction logic behind it was unit-tested, but nothing //! `app_main!` entry point too. On desktop this thin main just hands off.
//! proved that a click actually reaches it through `event.hits()`.
//!
//! It loads the corpus AcroForm fixture, so the widget under test has a real
//! parsed document with a link, a text field and a checkbox on it.
pub use makepad_widgets; fn main() {
nigig_pdf_makepad::test_host::app_main();
use makepad_widgets::*;
use nigig_pdf_document::PdfDocument;
use nigig_pdf_graphics::content::parse_content_stream;
use nigig_pdf_graphics::recording::RecordingDevice;
use nigig_pdf_graphics::text::PageText;
// The accessor trait generated by `#[derive(Widget)]` must be in scope for
// `WidgetRef::pdf_page_widget()`.
use nigig_pdf_makepad::page_view::{PageContent, PdfPageAction, PdfPageWidgetWidgetRefExt};
use nigig_pdf_makepad::PdfAction;
/// The two-page AcroForm fixture. Everything interactive is on page index 1.
const FIXTURE: &[u8] = include_bytes!("../../../pdf-document/tests/acroform.pdf");
/// Which page of the fixture carries the interactive content.
const PAGE_INDEX: usize = 1;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.inner_size: vec2(800, 1000)
body +: {
main_view := View{
width: Fill, height: Fill
flow: Down
// The label is how a test observes what the widget
// emitted, since actions themselves are not visible
// to the Studio protocol.
action_label := Label{
text: "no action"
}
pdf_page := mod.widgets.PdfPageWidget{
width: Fill, height: Fill
}
}
}
}
}
}
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
loaded: bool,
}
impl App {
/// Parse the fixture and hand the page to the widget.
fn load_fixture(&mut self, cx: &mut Cx) {
let Ok(mut doc) = PdfDocument::parse(FIXTURE) else {
return;
};
let Ok(page) = doc.page(PAGE_INDEX) else {
return;
};
let commands = parse_content_stream(&page.content_data)
.map(|ops| RecordingDevice::from_ops(&ops))
.unwrap_or_default();
let text = PageText::from_commands(&commands);
let annotations = doc.page_annotations(PAGE_INDEX).unwrap_or_default();
let form = doc.acroform().ok().flatten();
let widget = self.ui.pdf_page_widget(cx, ids!(pdf_page));
widget.begin_document(cx, Default::default());
widget.set_content(
cx,
PageContent {
commands,
annotations,
form,
text,
page_height: page.height(),
generation: Default::default(),
page_index: PAGE_INDEX,
},
);
}
/// Mirror an emitted action into the label so a test can assert on it.
fn show_action(&mut self, cx: &mut Cx, action: &PdfAction) {
let text = match action {
PdfAction::OpenUri(uri) => format!("OpenUri: {uri}"),
PdfAction::GoToPage { page_index } => format!("GoToPage: {page_index}"),
PdfAction::GoToNamed(name) => format!("GoToNamed: {name}"),
PdfAction::FieldChanged { .. } => "FieldChanged".to_string(),
PdfAction::CopyText(t) => format!("CopyText: {t}"),
};
self.ui.label(cx, ids!(action_label)).set_text(cx, &text);
}
}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
crate::makepad_widgets::script_mod(vm);
nigig_pdf_makepad::script_mod(vm);
self::script_mod(vm)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
// Load once, after the widget tree exists.
if !self.loaded && matches!(event, Event::Draw(_)) {
self.loaded = true;
self.load_fixture(cx);
}
// Actions are collected from the child, not returned by
// handle_event, so they have to be captured around the call.
let actions = cx.capture_actions(|cx| {
self.ui.handle_event(cx, event, &mut Scope::empty());
});
let widget = self.ui.pdf_page_widget(cx, ids!(pdf_page));
if let Some(item) = actions.find_widget_action(widget.widget_uid()) {
if let PdfPageAction::Action(action) = item.cast::<PdfPageAction>() {
self.show_action(cx, &action);
}
}
}
} }

View file

@ -935,6 +935,30 @@ mod tests {
assert!(!state.focus.is_focused()); assert!(!state.focus.is_focused());
} }
#[test]
fn hit_testing_is_keyed_by_the_page_index() {
let (mut form, _) = text_field();
// The fixture field lives on page 0 (its /P resolves to page 0). A
// state pointing at page 1 must not see it, which is the regression
// behind `set_content` syncing `interaction.page_index`.
let mut other_page = InteractionState::new(1, Viewport::default());
let (sx, sy) = other_page.viewport.to_screen(150.0, 610.0);
other_page.click(sx, sy, &[], Some(&mut form));
assert!(
!other_page.focus.is_focused(),
"a field on another page must not take focus"
);
assert_eq!(other_page.focus.field, None);
let mut same_page = InteractionState::new(0, Viewport::default());
let (sx, sy) = same_page.viewport.to_screen(150.0, 610.0);
same_page.click(sx, sy, &[], Some(&mut form));
assert!(
same_page.focus.is_focused(),
"the field on this page must take focus"
);
}
#[test] #[test]
fn caret_movement_inserts_at_the_right_place() { fn caret_movement_inserts_at_the_right_place() {
let (mut form, _) = text_field(); let (mut form, _) = text_field();

View file

@ -2,6 +2,7 @@ pub mod device;
pub mod interaction; pub mod interaction;
pub mod page_view; pub mod page_view;
pub mod renderer; pub mod renderer;
pub mod test_host;
pub use device::MakepadPdfDevice; pub use device::MakepadPdfDevice;
pub use interaction::{ pub use interaction::{

View file

@ -97,6 +97,7 @@ pub struct PdfPageWidget {
#[layout] #[layout]
layout: Layout, layout: Layout,
#[rust] #[rust]
#[area]
area: Area, area: Area,
#[redraw] #[redraw]
@ -213,6 +214,11 @@ impl PdfPageWidget {
// A new page invalidates any focus or selection held on the old one. // A new page invalidates any focus or selection held on the old one.
self.interaction.focus = Default::default(); self.interaction.focus = Default::default();
self.interaction.selection = Default::default(); self.interaction.selection = Default::default();
// Hit testing keys off the page the content belongs to, so the
// interaction viewport must track the content rather than whatever
// page was loaded before. Without this, page-1 content on a
// multi-page document hit-tests against page 0 and nothing responds.
self.interaction.page_index = self.content.page_index;
self.redraw(cx); self.redraw(cx);
true true
} }
@ -346,7 +352,8 @@ impl Widget for PdfPageWidget {
let rect = self.area.rect(cx); let rect = self.area.rect(cx);
self.sync_viewport(rect); self.sync_viewport(rect);
match event.hits(cx, self.area) { let hit = event.hits(cx, self.area);
match hit {
// Cursor feedback (step 4.2). // Cursor feedback (step 4.2).
Hit::FingerHoverIn(e) | Hit::FingerHoverOver(e) => { Hit::FingerHoverIn(e) | Hit::FingerHoverOver(e) => {
let target = self.interaction.hover_at( let target = self.interaction.hover_at(

View file

@ -0,0 +1,137 @@
//! Test host for the PDF page widget.
//!
//! Lives in the lib so the Android APK build (which packages the crate's
//! `--lib` target as a cdylib) gets the `app_main!` entry point too. The
//! desktop test binary delegates to [`app_main`] instead of duplicating the
//! app.
//!
//! It loads the corpus AcroForm fixture, so the widget under test has a real
//! parsed document with a link, a text field and a checkbox on it.
use makepad_widgets::*;
use nigig_pdf_document::PdfDocument;
use nigig_pdf_graphics::content::parse_content_stream;
use nigig_pdf_graphics::recording::RecordingDevice;
use nigig_pdf_graphics::text::PageText;
// The accessor trait generated by `#[derive(Widget)]` must be in scope for
// `WidgetRef::pdf_page_widget()`.
use crate::page_view::{PageContent, PdfPageAction, PdfPageWidgetWidgetRefExt};
use crate::PdfAction;
/// The two-page AcroForm fixture. Everything interactive is on page index 1.
const FIXTURE: &[u8] = include_bytes!("../../pdf-document/tests/acroform.pdf");
/// Which page of the fixture carries the interactive content.
const PAGE_INDEX: usize = 1;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.inner_size: vec2(800, 1000)
body +: {
main_view := View{
width: Fill, height: Fill
flow: Down
// The label is how a test observes what the widget
// emitted, since actions themselves are not visible
// to the Studio protocol.
action_label := Label{
text: "no action"
}
pdf_page := mod.widgets.PdfPageWidget{
width: Fill, height: Fill
}
}
}
}
}
}
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
loaded: bool,
}
impl App {
/// Parse the fixture and hand the page to the widget.
fn load_fixture(&mut self, cx: &mut Cx) {
let Ok(mut doc) = PdfDocument::parse(FIXTURE) else {
return;
};
let Ok(page) = doc.page(PAGE_INDEX) else {
return;
};
let commands = parse_content_stream(&page.content_data)
.map(|ops| RecordingDevice::from_ops(&ops))
.unwrap_or_default();
let text = PageText::from_commands(&commands);
let annotations = doc.page_annotations(PAGE_INDEX).unwrap_or_default();
let form = doc.acroform().ok().flatten();
let widget = self.ui.pdf_page_widget(cx, ids!(pdf_page));
widget.begin_document(cx, Default::default());
widget.set_content(
cx,
PageContent {
commands,
annotations,
form,
text,
page_height: page.height(),
generation: Default::default(),
page_index: PAGE_INDEX,
},
);
}
/// Mirror an emitted action into the label so a test can assert on it.
fn show_action(&mut self, cx: &mut Cx, action: &PdfAction) {
let text = match action {
PdfAction::OpenUri(uri) => format!("OpenUri: {uri}"),
PdfAction::GoToPage { page_index } => format!("GoToPage: {page_index}"),
PdfAction::GoToNamed(name) => format!("GoToNamed: {name}"),
PdfAction::FieldChanged { .. } => "FieldChanged".to_string(),
PdfAction::CopyText(t) => format!("CopyText: {t}"),
};
self.ui.label(cx, ids!(action_label)).set_text(cx, &text);
}
}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
makepad_widgets::script_mod(vm);
crate::script_mod(vm);
self::script_mod(vm)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
// Load once, after the widget tree exists.
if !self.loaded && matches!(event, Event::Draw(_)) {
self.loaded = true;
self.load_fixture(cx);
}
// Actions are collected from the child, not returned by
// handle_event, so they have to be captured around the call.
let actions = cx.capture_actions(|cx| {
self.ui.handle_event(cx, event, &mut Scope::empty());
});
let widget = self.ui.pdf_page_widget(cx, ids!(pdf_page));
if let Some(item) = actions.find_widget_action(widget.widget_uid()) {
if let PdfPageAction::Action(action) = item.cast::<PdfPageAction>() {
self.show_action(cx, &action);
}
}
}
}

View file

@ -2,37 +2,22 @@
//! //!
//! This closes the gap carried since Phase 4: the interaction logic behind //! This closes the gap carried since Phase 4: the interaction logic behind
//! `handle_event` was unit-tested, but nothing proved a click actually //! `handle_event` was unit-tested, but nothing proved a click actually
//! reaches it through `event.hits()`. These tests go through the Studio //! reaches it through `event.hits()`. These tests go through the headless
//! protocol against the test host in `src/bin/nigig-pdf-makepad.rs`, so the //! `makepad_test` runtime against the test host in `src/bin/nigig-pdf-makepad.rs`,
//! whole path — event delivery, hit testing, action emission — is exercised. //! so the whole path — event delivery, hit testing, action emission — is
//! //! exercised without a Studio hub.
//! Headless by default. `MAKEPAD_TEST_VISIBLE=1` runs against a visible
//! Studio for debugging.
//! //!
//! Run with `--test-threads=1`: each test starts its own app instance, and //! Run with `--test-threads=1`: each test starts its own app instance, and
//! several competing for the same port produces spurious failures rather //! several competing for the same port produces spurious failures rather
//! than real ones. //! than real ones.
//! //!
//! # Why these are `#[ignore]`
//!
//! They are ignored by default because the Studio hub cannot start an app in
//! this sandbox: the harness launches the binary with `--stdin-loop`, which
//! Makepad refuses without a Studio websocket, and the build exits 101
//! before startup. This is not specific to the PDF widget — upstream's own
//! `spreadsheet-ui` and `map` UI suites fail identically here with the same
//! error, so it is an environment limitation rather than a defect in this
//! code.
//!
//! They are checked in, and compiled by `cargo test`, so they cannot rot.
//! Run them where a Studio hub is available:
//!
//! ```bash //! ```bash
//! cargo test -p nigig-pdf-makepad --test ui -- --ignored --test-threads=1 //! cargo test -p nigig-pdf-makepad --test ui
//! ``` //! ```
//! //!
//! The framework's `click()` targets a widget's centre, which is no use for //! The framework's `click()` targets a widget's centre, which is no use for
//! hitting a specific annotation, so coordinate clicks are sent as raw //! hitting a specific annotation, so coordinate clicks are sent as raw
//! Studio mouse events through `forward()`. //! mouse events through `forward()`.
use makepad_widgets::makepad_test::{makepad_test, Selector, StudioToApp, TestApp}; use makepad_widgets::makepad_test::{makepad_test, Selector, StudioToApp, TestApp};
// The Remote* payload structs are not re-exported by makepad_test, so they // The Remote* payload structs are not re-exported by makepad_test, so they
@ -44,8 +29,9 @@ use makepad_widgets::makepad_platform::studio::{
/// Window geometry declared by the test host. /// Window geometry declared by the test host.
const WINDOW_WIDTH: f64 = 800.0; const WINDOW_WIDTH: f64 = 800.0;
/// The fixture page is 792pt tall and the widget fills the window below the /// The fixture page is 792pt tall and the widget fills the window below the
/// action label, so PDF y maps to screen y by this offset. /// action label, so PDF y maps to screen y by this offset. The label occupies
const LABEL_HEIGHT: f64 = 24.0; /// 28px (verified from the widget's live viewport origin during bring-up).
const LABEL_HEIGHT: f64 = 28.0;
const PAGE_HEIGHT: f64 = 792.0; const PAGE_HEIGHT: f64 = 792.0;
/// Convert a PDF-space point on the fixture page into a window coordinate. /// Convert a PDF-space point on the fixture page into a window coordinate.
@ -89,7 +75,6 @@ fn click_pdf_point(app: &TestApp, x: f64, y: f64) {
/// thing proving the widget survives a real draw pass with a parsed /// thing proving the widget survives a real draw pass with a parsed
/// document, which no unit test can show. /// document, which no unit test can show.
#[makepad_test] #[makepad_test]
#[ignore = "needs a Makepad Studio hub; see the module docs"]
fn the_pdf_widget_mounts_and_draws(app: TestApp) { fn the_pdf_widget_mounts_and_draws(app: TestApp) {
app.locator(Selector::id("pdf_page")).wait_visible(); app.locator(Selector::id("pdf_page")).wait_visible();
} }
@ -97,7 +82,6 @@ fn the_pdf_widget_mounts_and_draws(app: TestApp) {
/// The host starts with no action reported, so a later assertion that an /// The host starts with no action reported, so a later assertion that an
/// action appeared cannot pass vacuously. /// action appeared cannot pass vacuously.
#[makepad_test] #[makepad_test]
#[ignore = "needs a Makepad Studio hub; see the module docs"]
fn no_action_is_reported_before_any_input(app: TestApp) { fn no_action_is_reported_before_any_input(app: TestApp) {
app.locator(Selector::id("pdf_page")).wait_visible(); app.locator(Selector::id("pdf_page")).wait_visible();
app.locator(Selector::id("action_label")) app.locator(Selector::id("action_label"))
@ -112,7 +96,6 @@ fn no_action_is_reported_before_any_input(app: TestApp) {
/// Clicking it must surface `OpenUri` on the host, which proves the action /// Clicking it must surface `OpenUri` on the host, which proves the action
/// travelled the whole path instead of stopping inside the widget. /// travelled the whole path instead of stopping inside the widget.
#[makepad_test] #[makepad_test]
#[ignore = "needs a Makepad Studio hub; see the module docs"]
fn clicking_a_link_delivers_open_uri_to_the_host(app: TestApp) { fn clicking_a_link_delivers_open_uri_to_the_host(app: TestApp) {
app.locator(Selector::id("pdf_page")).wait_visible(); app.locator(Selector::id("pdf_page")).wait_visible();
@ -125,7 +108,6 @@ fn clicking_a_link_delivers_open_uri_to_the_host(app: TestApp) {
/// Clicking empty space must not emit anything, so the link assertion above /// Clicking empty space must not emit anything, so the link assertion above
/// is not merely "any click produces an action". /// is not merely "any click produces an action".
#[makepad_test] #[makepad_test]
#[ignore = "needs a Makepad Studio hub; see the module docs"]
fn clicking_empty_space_emits_nothing(app: TestApp) { fn clicking_empty_space_emits_nothing(app: TestApp) {
app.locator(Selector::id("pdf_page")).wait_visible(); app.locator(Selector::id("pdf_page")).wait_visible();
@ -139,7 +121,6 @@ fn clicking_empty_space_emits_nothing(app: TestApp) {
/// Typing into a focused field must change the value through the real /// Typing into a focused field must change the value through the real
/// keyboard path, not a synthesised `KeyInput`. /// keyboard path, not a synthesised `KeyInput`.
#[makepad_test] #[makepad_test]
#[ignore = "needs a Makepad Studio hub; see the module docs"]
fn typing_into_a_form_field_reaches_the_widget(app: TestApp) { fn typing_into_a_form_field_reaches_the_widget(app: TestApp) {
app.locator(Selector::id("pdf_page")).wait_visible(); app.locator(Selector::id("pdf_page")).wait_visible();
@ -156,7 +137,6 @@ fn typing_into_a_form_field_reaches_the_widget(app: TestApp) {
/// A checkbox toggles on click, which is a different code path from the /// A checkbox toggles on click, which is a different code path from the
/// buffered text-field edit. /// buffered text-field edit.
#[makepad_test] #[makepad_test]
#[ignore = "needs a Makepad Studio hub; see the module docs"]
fn clicking_the_checkbox_emits_a_field_change(app: TestApp) { fn clicking_the_checkbox_emits_a_field_change(app: TestApp) {
app.locator(Selector::id("pdf_page")).wait_visible(); app.locator(Selector::id("pdf_page")).wait_visible();