//! Phase 4 exit criterion, end to end against the real AcroForm fixture. //! //! > Click a link in a real PDF → host receives `OpenUri`. Click a text field //! > → type text → field value updates → appearance regenerates → visual //! > change visible. //! //! These tests drive the same `InteractionState` the widget's `handle_event` //! delegates to, using a document parsed from bytes on disk rather than //! synthetic dictionaries. What the widget adds on top is Makepad event //! delivery, which needs a live `Cx`; the routing *order* it depends on is //! asserted here so the untested surface stays as small as possible. use nigig_pdf_document::appearance::regenerate_dirty; use nigig_pdf_document::form::FieldValue; 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; use nigig_pdf_makepad::interaction::{ render_fields, HoverTarget, InteractionState, KeyInput, PdfAction, Viewport, }; use nigig_pdf_makepad::page_view::{cursor_for, key_input_from_code}; /// The two-page AcroForm fixture added for Phase 3. Everything interactive /// is on page index 1, so any code defaulting to page 0 fails here. const FIXTURE: &[u8] = include_bytes!("../../pdf-document/tests/acroform.pdf"); const PAGE: usize = 1; fn viewport() -> Viewport { Viewport { origin_x: 0.0, origin_y: 0.0, zoom: 1.0, page_height: 792.0, } } fn state() -> InteractionState { InteractionState::new(PAGE, viewport()) } /// Screen position of a PDF-space point under the test viewport. fn screen(x: f64, y: f64) -> (f64, f64) { viewport().to_screen(x, y) } #[test] fn clicking_a_link_in_a_real_pdf_emits_open_uri_to_the_host() { let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses"); let annots = doc.page_annotations(PAGE).expect("annotations"); let mut state = state(); // The fixture's link occupies 100,500 to 200,520. let (sx, sy) = screen(150.0, 510.0); let action = state.click(sx, sy, &annots, None); assert_eq!( action, Some(PdfAction::OpenUri("https://example.org/".into())), "the viewer reports the intent; the host opens the URL" ); } #[test] fn the_full_edit_cycle_updates_the_value_and_regenerates_the_appearance() { let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses"); let annots = doc.page_annotations(PAGE).expect("annotations"); let mut form = doc.acroform().expect("acroform").expect("present"); let field_ref = form.find_field("personal.name").expect("field").obj_ref; let mut state = state(); // 1. Click the text field. Its widget is 100,600 to 300,620. let (sx, sy) = screen(200.0, 610.0); let action = state.click(sx, sy, &annots, Some(&mut form)); assert_eq!(action, None, "focusing a field is not itself a host action"); assert_eq!(state.focus.field, Some(field_ref)); assert_eq!(state.focus.buffer, "John", "the existing value is loaded"); // 2. Type: clear the field, then enter a new value. for _ in 0..4 { state.key(KeyInput::Backspace, &mut form); } for c in "Jane".chars() { state.key(KeyInput::Char(c), &mut form); } // 3. The document must not have changed yet. assert_eq!( form.field(field_ref).expect("field").value, FieldValue::Text("John".into()), "typing must not mutate the document before it is committed" ); // 4. Commit. let action = state.key(KeyInput::Enter, &mut form); assert_eq!( action, Some(PdfAction::FieldChanged { field: field_ref, invalidated_pages: vec![PAGE], }), "the host is told which pages to repaint" ); assert_eq!( form.field(field_ref).expect("field").value, FieldValue::Text("Jane".into()) ); // 5. The appearance regenerates with the new value. let (generated, errors) = regenerate_dirty(&form); assert!(errors.is_empty(), "unexpected errors: {errors:?}"); assert_eq!(generated.len(), 1); let content = generated[0].1.content_str(); assert!(content.contains("(Jane) Tj"), "got: {content}"); assert!(!content.contains("(John)"), "the stale value must be gone"); // 6. The visual change is observable through the draw list. let renders = render_fields(&form, PAGE, &viewport(), &state.focus); let drawn = renders .iter() .find(|r| r.text == "Jane") .expect("the new value must appear in the draw list"); assert!(drawn.rect[2] > 0.0 && drawn.rect[3] > 0.0); } #[test] fn an_abandoned_edit_leaves_the_real_document_untouched() { let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses"); let mut form = doc.acroform().expect("acroform").expect("present"); let field_ref = form.find_field("personal.name").expect("field").obj_ref; let mut state = state(); let (sx, sy) = screen(200.0, 610.0); state.click(sx, sy, &[], Some(&mut form)); for c in "XYZ".chars() { state.key(KeyInput::Char(c), &mut form); } state.key(KeyInput::Escape, &mut form); assert_eq!( form.field(field_ref).expect("field").value, FieldValue::Text("John".into()) ); assert!(!form.field(field_ref).expect("field").dirty); let (generated, _) = regenerate_dirty(&form); assert!( generated.is_empty(), "nothing is dirty, so nothing regenerates" ); } #[test] fn clicking_the_checkbox_uses_the_state_declared_in_the_file() { let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses"); let mut form = doc.acroform().expect("acroform").expect("present"); let field_ref = form.find_field("agree").expect("checkbox").obj_ref; let mut state = state(); // The fixture's checkbox is 100,560 to 114,574 with /On and /Off states. let (sx, sy) = screen(107.0, 567.0); let action = state.click(sx, sy, &[], Some(&mut form)); assert!(matches!(action, Some(PdfAction::FieldChanged { .. }))); assert_eq!( form.field(field_ref).expect("field").value, FieldValue::State("On".into()), "the on state comes from the widget's /AP, not an assumed /Yes" ); assert!(form.field(field_ref).expect("field").is_checked()); } #[test] fn hovering_reports_the_cursor_the_host_should_show() { let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses"); let annots = doc.page_annotations(PAGE).expect("annotations"); let form = doc.acroform().expect("acroform").expect("present"); let state = state(); let (fx, fy) = screen(200.0, 610.0); assert_eq!( state.hover_at(fx, fy, &annots, Some(&form)), HoverTarget::TextField ); let (lx, ly) = screen(150.0, 510.0); assert_eq!( state.hover_at(lx, ly, &annots, Some(&form)), HoverTarget::Link ); let (ex, ey) = screen(450.0, 300.0); assert_eq!( state.hover_at(ex, ey, &annots, Some(&form)), HoverTarget::None ); // And the widget layer turns those into real cursors. assert_ne!( cursor_for(HoverTarget::Link), cursor_for(HoverTarget::TextField) ); } /// The review's step 4.1 lists form-field events before annotation hit /// testing. The fixture has a field and a link on the same page, so the /// ordering is observable. #[test] fn form_fields_are_routed_before_annotations() { let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses"); let annots = doc.page_annotations(PAGE).expect("annotations"); let mut form = doc.acroform().expect("acroform").expect("present"); let mut state = state(); // Click the field: no navigation action, focus taken instead. let (fx, fy) = screen(200.0, 610.0); assert_eq!(state.click(fx, fy, &annots, Some(&mut form)), None); assert!(state.focus.is_focused()); // Click the link: focus is dropped and the action is emitted. let (lx, ly) = screen(150.0, 510.0); let action = state.click(lx, ly, &annots, Some(&mut form)); assert_eq!( action, Some(PdfAction::OpenUri("https://example.org/".into())) ); assert!(!state.focus.is_focused(), "clicking away drops focus"); } #[test] fn text_selection_reads_through_the_extracted_page_text() { let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses"); let page = doc.page(PAGE).expect("page"); let ops = parse_content_stream(&page.content_data).expect("content parses"); let commands = RecordingDevice::from_ops(&ops); let page_text = PageText::from_commands(&commands); assert!( page_text.plain_text().contains("Page two"), "extracted text was {:?}", page_text.plain_text() ); let mut state = state(); assert_eq!( state.selected_text(&page_text), "", "an empty selection yields no text" ); let (ax, ay) = screen(0.0, 0.0); state.begin_selection(ax, ay); assert!(state.selection.active); state.end_selection(); assert!(!state.selection.active); } #[test] fn every_key_the_widget_forwards_is_one_the_editor_handles() { // The widget only forwards keys that map to a KeyInput; anything else // must fall through to the host rather than being swallowed. use makepad_widgets::KeyCode; for code in [ KeyCode::Backspace, KeyCode::Delete, KeyCode::ArrowLeft, KeyCode::ArrowRight, KeyCode::Home, KeyCode::End, KeyCode::ReturnKey, KeyCode::Escape, KeyCode::Tab, ] { assert!( key_input_from_code(code).is_some(), "{code:?} should be handled" ); } for code in [KeyCode::F5, KeyCode::ArrowUp, KeyCode::KeyZ] { assert!( key_input_from_code(code).is_none(), "{code:?} must fall through to the host" ); } }