//! Filling in a form in the real shell (egui_kittest): typing, Tab, check boxes, radios, choices. use egui_kittest::Harness; use pdfcraft_ui_egui::PdfCraftApp; fn harness() -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); app.set_option("left", "closed").unwrap(); // The whole 300×400 pt page on screen. app.set_option("zoom", "150").unwrap(); app }); for _ in 0..60 { h.run_steps(2); if !h.state().render_pending() { break; } std::thread::sleep(std::time::Duration::from_millis(5)); } h } fn value(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec { let s = h.state(); s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() } /// Click the centre of a field's widget. fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str, widget: usize) { let p = { let s = h.state(); let doc = s.session.get(s.views[0].id).unwrap(); let f = doc.form.iter().find(|f| f.name == name).unwrap(); pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, widget).expect("on screen").center() }; h.hover_at(p); h.run_steps(1); h.drag_at(p); h.run_steps(1); h.drop_at(p); h.run_steps(3); } #[test] fn typing_into_a_text_field_and_tabbing_on() { let mut h = harness(); click_field(&mut h, "name", 0); assert!(h.state().views[0].forms.focus.is_some(), "the editor opened"); h.event(egui::Event::Text("Ada Lovelace".into())); h.run_steps(2); h.key_press(egui::Key::Tab); h.run_steps(4); assert_eq!(value(&h, "name"), ["Ada Lovelace"]); assert_eq!( h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("country"), "Tab moves to the next field that takes typing or a choice" ); // The country list is open; Escape closes it. Escape also abandons a draft. h.key_press(egui::Key::Escape); h.run_steps(2); click_field(&mut h, "city", 0); assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); h.event(egui::Event::Text("Paris".into())); h.run_steps(1); h.key_press(egui::Key::Escape); h.run_steps(3); assert!(value(&h, "city").is_empty()); assert!(h.state().views[0].forms.focus.is_none()); } #[test] fn check_boxes_radios_and_choices() { let mut h = harness(); click_field(&mut h, "agree", 0); assert_eq!(value(&h, "agree"), ["Yes"]); click_field(&mut h, "agree", 0); assert!(value(&h, "agree").is_empty()); click_field(&mut h, "size", 1); assert_eq!(value(&h, "size"), ["L"]); click_field(&mut h, "size", 0); assert_eq!(value(&h, "size"), ["S"]); click_field(&mut h, "country", 0); egui_kittest::kittest::Queryable::get_by_label(&h, "France").click(); h.run_steps(4); assert_eq!(value(&h, "country"), ["fr"]); // Clear form takes everything back; undo restores it. assert!(h.state_mut().execute("form.clear")); h.run_steps(2); assert!(value(&h, "country").is_empty() && value(&h, "size").is_empty()); h.state_mut().undo(); h.run_steps(2); assert_eq!(value(&h, "country"), ["fr"]); } #[test] fn tabbing_into_a_filled_field_selects_it_so_typing_replaces() { let mut h = harness(); click_field(&mut h, "country", 0); egui_kittest::kittest::Queryable::get_by_label(&h, "Canada").click(); h.run_steps(3); click_field(&mut h, "city", 0); h.event(egui::Event::Text("Paris".into())); h.run_steps(1); h.key_press(egui::Key::Enter); h.run_steps(3); assert_eq!(value(&h, "city"), ["Paris"]); // name → (Tab) country → (Tab) city, whose text is selected on entry. click_field(&mut h, "name", 0); h.key_press(egui::Key::Tab); h.run_steps(3); h.key_press(egui::Key::Tab); h.run_steps(4); assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("city")); h.event(egui::Event::Text("Lyon".into())); h.run_steps(1); h.key_press(egui::Key::Enter); h.run_steps(3); assert_eq!(value(&h, "city"), ["Lyon"]); } #[test] fn date_fields_offer_a_calendar() { use egui_kittest::kittest::Queryable; let mut h = harness(); h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { page: 0, rect: [50.0, 40.0, 200.0, 60.0], kind: pdfcraft_engine::NewField::Date, name: Some("due".into()), }); h.run_steps(4); click_field(&mut h, "due", 0); let (y, m, _) = h.state().session.today(); let month = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][(m - 1) as usize]; h.get_by_label(&format!("{month} {y}")); h.get_by_label("›").click(); h.run_steps(2); h.get_by_label("15").click(); h.run_steps(4); let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "picked in the field's format"); } #[test] fn lithuanian_calendar_localizes_months_and_weekdays_without_changing_field_values() { use egui_kittest::kittest::Queryable; let mut h = harness(); h.state_mut().set_option("language", "lt").unwrap(); h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { page: 0, rect: [50.0, 40.0, 200.0, 60.0], kind: pdfcraft_engine::NewField::Date, name: Some("due".into()), }); h.run_steps(4); click_field(&mut h, "due", 0); let (y, m, _) = h.state().session.today(); let months = ["sausis", "vasaris", "kovas", "balandis", "gegužė", "birželis", "liepa", "rugpjūtis", "rugsėjis", "spalis", "lapkritis", "gruodis"]; h.get_by_label(&format!("{y} m. {}", months[(m - 1) as usize])); for day in ["Sk", "Pr", "An", "Tr", "Kt", "Pn", "Št"] { h.get_by_label(day); } h.get_by_label("›").click(); h.run_steps(2); let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; h.get_by_label(&format!("{ny} m. {}", months[(nm - 1) as usize])); h.get_by_label("15").click(); h.run_steps(4); assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "the PDF field keeps its own date format"); } /// The value of `name` in a saved PDF, read back by opening it in a fresh app. fn saved_value(path: &std::path::Path, name: &str) -> Vec { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("saved.pdf", None, std::fs::read(path).unwrap()).unwrap(); app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() } fn scratch(name: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("pdfcraft-forms-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn saving_while_still_typing_in_a_field_saves_the_text() { // Issue #166: ⌘S with the field still focused saved it blank. let dir = scratch("save-focused"); let out = dir.join("saved.pdf"); let mut h = harness(); click_field(&mut h, "name", 0); h.event(egui::Event::Text("Browser Form 123".into())); h.run_steps(2); assert!(h.state().views[0].forms.focus.is_some(), "still typing"); h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); h.run_steps(3); assert_eq!(saved_value(&out, "name"), ["Browser Form 123"], "the saved PDF has the typed text"); assert_eq!(value(&h, "name"), ["Browser Form 123"]); let doc = h.state().session.get(h.state().views[0].id).unwrap(); assert!(!doc.dirty, "and the document is saved, not left with an unsaved change"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn tabbing_back_to_the_same_field_then_saving_keeps_the_committed_value() { // A form with one text field wraps Tab back to that field. Multiple widgets of one // field share its value too: opening the next widget must see the accepted edit. for (case, shared_widgets, backwards, same_frame) in [ ("single-forward", false, false, false), ("single-backward", false, true, false), ("shared-forward", true, false, false), ("shared-backward", true, true, false), ("same-frame", false, false, true), ] { let dir = scratch(case); let out = dir.join("saved.pdf"); let bytes = if shared_widgets { pdf_from_objects(&[ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", "<< /FT /Tx /T (qty) /V (4) /Kids [6 0 R 7 0 R] >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", "<< /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [20 350 280 372] /P 3 0 R /F 4 >>", "<< /Type /Annot /Subtype /Widget /Parent 4 0 R /Rect [20 250 280 272] /P 3 0 R /F 4 >>", ]) } else { validated_form() }; let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("tab.pdf", None, bytes).unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("zoom", "150").unwrap(); app }); h.run_steps(6); click_field(&mut h, "qty", 0); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text("42".into())); h.run_steps(2); h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); let modifiers = if backwards { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; if same_frame { one_frame(&mut h, vec![key(egui::Key::Tab, modifiers), key(egui::Key::S, egui::Modifiers::COMMAND)]); } else { h.key_press_modifiers(modifiers, egui::Key::Tab); h.run_steps(3); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); } h.run_steps(4); assert_eq!(saved_value(&out, "qty"), ["42"], "{case}: saving cannot restore the old value"); assert_eq!(value(&h, "qty"), ["42"], "{case}"); // One typing action is one history entry, even after saving. Escape closes the // untouched editor before undoing the document rather than the TextEdit buffer. h.key_press(egui::Key::Escape); h.state_mut().undo(); assert_eq!(value(&h, "qty"), ["4"], "{case}: a single undo restores the original"); h.state_mut().redo(); assert_eq!(value(&h, "qty"), ["42"], "{case}: redo restores the committed value"); let _ = std::fs::remove_dir_all(&dir); } } #[test] fn closing_while_still_typing_in_a_field_asks_to_save() { let mut h = harness(); click_field(&mut h, "name", 0); h.event(egui::Event::Text("Not lost".into())); h.run_steps(2); h.state_mut().request_close_tab(0); assert!(h.state().close_request.is_some(), "typed text is an unsaved change: the user is asked"); assert_eq!(h.state().views.len(), 1); } #[test] fn close_all_saves_text_typed_in_a_tab_that_is_no_longer_active() { let dir = scratch("close-all"); let out = dir.join("first.pdf"); let mut h = harness(); click_field(&mut h, "name", 0); h.event(egui::Event::Text("Typed in tab one".into())); h.run_steps(2); // Another document becomes active while the first one's field still has its draft. h.state_mut().open_bytes("other.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); h.run_steps(2); h.state_mut().close_all(); assert!(h.state().close_request.is_some(), "the first document has an unsaved change"); h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); let ctx = h.ctx.clone(); h.state_mut().resolve_close(&ctx, Some(true)); assert_eq!(saved_value(&out, "name"), ["Typed in tab one"]); let _ = std::fs::remove_dir_all(&dir); } /// A key press as one event (kittest's `key_press` runs a frame per queued event). fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event { egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } } /// Deliver `events` together, in one frame. fn one_frame(h: &mut Harness<'static, PdfCraftApp>, events: Vec) { h.input_mut().events.extend(events); h.run_steps(1); } /// Type `text` into field `name`, then press ⌘S in the same frame as `last` (more input). fn type_then_save_in_one_frame(h: &mut Harness<'static, PdfCraftApp>, name: &str, text: &str, last: egui::Event, out: &std::path::Path) { click_field(h, name, 0); h.event(egui::Event::Text(text.into())); h.run_steps(2); h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); one_frame(h, vec![last, key(egui::Key::S, egui::Modifiers::COMMAND)]); h.run_steps(3); } #[test] fn typing_and_saving_in_the_same_frame_saves_the_last_keystrokes() { let dir = scratch("same-frame"); let out = dir.join("saved.pdf"); let mut h = harness(); type_then_save_in_one_frame(&mut h, "name", "Browser", egui::Event::Text(" Form 123".into()), &out); assert_eq!(saved_value(&out, "name"), ["Browser Form 123"]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn escape_and_save_in_the_same_frame_discards_the_draft() { let dir = scratch("escape"); let out = dir.join("saved.pdf"); let mut h = harness(); let before = value(&h, "name"); type_then_save_in_one_frame(&mut h, "name", "Discard me", key(egui::Key::Escape, egui::Modifiers::NONE), &out); assert_eq!(value(&h, "name"), before, "Escape cancels typing, even with ⌘S right after it"); assert!(h.state().views[0].forms.focus.is_none()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn an_unrelated_command_leaves_the_field_being_typed_in_alone() { let mut h = harness(); click_field(&mut h, "name", 0); h.event(egui::Event::Text("Still typing".into())); h.run_steps(2); assert!(h.state_mut().execute("view.theme.dark")); h.run_steps(2); let focus = h.state().views[0].forms.focus.clone().expect("the editor stays open"); assert_eq!(focus.text, "Still typing"); assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "nothing was committed"); } #[test] fn exporting_form_data_while_typing_includes_the_text() { let dir = scratch("export"); let out = dir.join("data.xfdf"); let mut h = harness(); click_field(&mut h, "name", 0); h.event(egui::Event::Text("Exported value".into())); h.run_steps(2); h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); h.state_mut().export_data_dialog(false, true); assert!(std::fs::read_to_string(&out).unwrap().contains("Exported value")); let _ = std::fs::remove_dir_all(&dir); } /// One text field, `qty`, whose validation script refuses values over 100. fn validated_form() -> Vec { pdf_from_objects(&[ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (qty) /V (4) /Rect [20 350 280 372] /P 3 0 R /F 4 /AA << /V << /S /JavaScript /JS (if (event.value > 100) { app.alert('At most 100'); event.rc = false; }) >> >> >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", ]) } fn pdf_from_objects(objs: &[&str]) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { offsets.push(out.len()); out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); } let xref = out.len(); out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); for o in offsets { out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); } out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); out } #[test] fn a_rejected_value_keeps_the_typing_and_stops_the_save() { let dir = scratch("rejected"); let out = dir.join("saved.pdf"); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("order.pdf", None, validated_form()).unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("zoom", "150").unwrap(); app }); h.run_steps(6); click_field(&mut h, "qty", 0); h.event(egui::Event::Text("500".into())); h.run_steps(2); h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); h.run_steps(4); assert!(!out.exists(), "nothing is saved without the typed value"); assert_eq!(value(&h, "qty"), ["4"], "the field keeps its valid value"); let focus = h.state().views[0].forms.focus.clone().expect("the editor stays open"); assert_eq!(focus.text, "4500", "with the text (typed after the 4), so the user can fix it"); // And closing asks rather than dropping it. h.state_mut().request_close_tab(0); assert!(h.state().close_request.is_some()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_save_shortcut_pressed_while_typing_saves_only_the_document_it_was_pressed_in() { let dir = scratch("deferred-doc"); let out = dir.join("saved.pdf"); let mut h = harness(); click_field(&mut h, "name", 0); h.event(egui::Event::Text("Tab one".into())); h.run_steps(2); h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); one_frame(&mut h, vec![key(egui::Key::S, egui::Modifiers::COMMAND)]); assert!(!out.exists(), "⌘S while typing waits a frame for the field"); // Before the deferred ⌘S runs, another document becomes active. h.state_mut().open_bytes("other.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); h.run_steps(3); assert!(!out.exists(), "the other document is not saved by a shortcut pressed in the first"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_value_refused_on_enter_keeps_save_in_the_same_frame_from_running() { refused_value_stops_save(egui::Key::Enter, "enter-refused"); } #[test] fn a_value_refused_on_tab_keeps_save_in_the_same_frame_from_running() { refused_value_stops_save(egui::Key::Tab, "tab-refused"); } fn refused_value_stops_save(commit_key: egui::Key, case: &str) { let dir = scratch(case); let out = dir.join("saved.pdf"); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("order.pdf", None, validated_form()).unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("zoom", "150").unwrap(); app }); h.run_steps(6); click_field(&mut h, "qty", 0); h.event(egui::Event::Text("500".into())); h.run_steps(2); h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); one_frame(&mut h, vec![key(commit_key, egui::Modifiers::NONE), key(egui::Key::S, egui::Modifiers::COMMAND)]); h.run_steps(3); assert_eq!(value(&h, "qty"), ["4"]); assert!(!out.exists(), "nothing is saved right after the field refused its value"); // The refused text is back in the field, so a later ⌘S stops again instead of saving 4. assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some("4500")); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); h.run_steps(4); assert!(!out.exists()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn flattening_while_typing_flattens_the_typed_text() { let mut h = harness(); click_field(&mut h, "name", 0); h.event(egui::Event::Text("Flat Ada".into())); h.run_steps(2); assert!(h.state_mut().execute("form.flatten")); h.run_steps(2); let s = h.state(); let doc = s.session.get(s.views[0].id).unwrap(); assert!(doc.form.iter().all(|f| f.name != "name"), "the field is flattened"); let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); let text = r .render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }) .text .map(|t| t.plain_text()) .unwrap_or_default(); assert!(text.contains("Flat Ada"), "into the page: {text:?}"); } #[cfg(not(target_arch = "wasm32"))] mod revert_tests { use super::*; use egui_kittest::kittest::Queryable; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; struct RevertDir(PathBuf); impl RevertDir { fn new() -> Self { static NEXT: AtomicU64 = AtomicU64::new(0); for _ in 0..128 { let path = std::env::temp_dir().join(format!("pdfcraft-revert-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); match std::fs::create_dir(&path) { Ok(()) => return Self(path), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(error) => panic!("creating Revert test directory: {error}"), } } panic!("no unused Revert test directory after 128 attempts"); } } impl Drop for RevertDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } #[test] fn reverting_rejected_input_saves_the_original_field_and_appearance() { let dir = RevertDir::new(); let path = dir.0.join("saved.pdf"); let mut h = harness(); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldValue { name: "name".into(), value: pdfcraft_engine::FieldValue::Text("Saved original".into()), })); h.state_mut().save_override = Some(path.to_string_lossy().into_owned()); assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::InPlace)); let saved = std::fs::read(&path).unwrap(); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { name: "name".into(), props: Box::new(pdfcraft_engine::FieldProps { validate: Some(pdfcraft_engine::form_scripts::Validate::Range { min: Some(0.0), max: Some(10.0) }), ..Default::default() }), })); h.run_steps(3); click_field(&mut h, "name", 0); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text("99".into())); h.run_steps(2); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); h.run_steps(4); assert_eq!(h.state().views[0].forms.focus.as_ref().unwrap().text, "99"); assert_eq!(std::fs::read(&path).unwrap(), saved, "rejection leaves the saved file untouched"); assert!(h.state_mut().execute("file.revert")); h.run_steps(2); h.get_all_by_label("Revert").last().expect("confirmation button").click(); h.run_steps(4); assert!(h.state().views[0].forms.focus.is_none() && h.state().views[0].pending_edit.is_none()); assert_eq!(h.state().first_dirty(), None, "Revert discards rejected typing too"); assert_eq!(value(&h, "name"), ["Saved original"]); assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::InPlace)); assert_eq!(saved_value(&path, "name"), ["Saved original"]); let bytes = std::fs::read(&path).unwrap(); assert_eq!(bytes, saved, "later Save cannot resurrect the discarded draft"); let mut renderer = pdfcraft_render::PageRenderer::new(std::sync::Arc::new(bytes), Default::default()); let output = renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, ..Default::default() }); assert!(output.error.is_none(), "saved appearance is readable: {:?}", output.error); assert!(output.text.unwrap().plain_text().contains("Saved original"), "saved AP still draws the original value"); } } #[test] fn calculated_field_and_script_side_effect_on_another_page_re_render() { let mut h = harness(); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 300.0, height: 400.0 })); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { page: 1, rect: [20.0, 20.0, 100.0, 40.0], kind: pdfcraft_engine::NewField::Text { multiline: false }, name: Some("page2_field".into()), })); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldScript { name: "page2_field".into(), event: "calculate".into(), script: Some("event.value = 'from_' + getField('name').value;".into()), })); for _ in 0..60 { h.run_steps(2); if !h.state().render_pending() { break; } std::thread::sleep(std::time::Duration::from_millis(5)); } click_field(&mut h, "name", 0); h.event(egui::Event::Text("hello".into())); h.run_steps(2); h.key_press(egui::Key::Tab); h.run_steps(2); assert_eq!(value(&h, "page2_field"), ["from_hello"]); // Wait for all re-renders to complete. for _ in 0..60 { h.run_steps(2); if !h.state().render_pending() { break; } std::thread::sleep(std::time::Duration::from_millis(5)); } assert_eq!(value(&h, "name"), ["hello"]); assert_eq!(value(&h, "page2_field"), ["from_hello"]); } #[test] fn overlay_clears_after_fresh_tiles_zoom_then_edit_and_rejected_edit() { let mut h = harness(); // 1. Fresh tiles: edit puts field in unbaked, and after tiles arrive, unbaked clears. click_field(&mut h, "name", 0); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text("GoodVal".into())); h.run_steps(2); h.key_press(egui::Key::Tab); h.run_steps(2); assert_eq!(value(&h, "name"), ["GoodVal"]); for _ in 0..60 { h.run_steps(2); if !h.state().render_pending() { break; } std::thread::sleep(std::time::Duration::from_millis(5)); } assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0))); // 2. Rejected edit: typing rejected by validate script leaves unbaked clean. assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldScript { name: "city".into(), event: "validate".into(), script: Some("event.rc = false;".into()), })); click_field(&mut h, "city", 0); h.event(egui::Event::Text("BadVal".into())); h.run_steps(2); h.key_press(egui::Key::Tab); h.run_steps(2); assert!(!h.state().views[0].forms.unbaked.contains(&("city".to_string(), 0))); h.key_press(egui::Key::Escape); h.run_steps(2); // 3. Zoom then edit: zoom to another scale, let tiles settle, then edit. h.state_mut().views[0].zoom_step(true); for _ in 0..60 { h.run_steps(2); if !h.state().render_pending() { break; } std::thread::sleep(std::time::Duration::from_millis(5)); } click_field(&mut h, "name", 0); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text("ZoomedVal".into())); h.run_steps(2); h.key_press(egui::Key::Tab); h.run_steps(2); assert_eq!(value(&h, "name"), ["ZoomedVal"]); for _ in 0..60 { h.run_steps(2); if !h.state().render_pending() { break; } std::thread::sleep(std::time::Duration::from_millis(5)); } assert!(!h.state().views[0].forms.unbaked.contains(&("name".to_string(), 0))); } /// A field whose /P is a page number past the document's end (#1307) is listed under /// "Unplaced" instead of panicking while the Fields panel labels its page. #[test] fn the_fields_panel_lists_a_field_on_a_missing_page_as_unplaced() { use egui_kittest::kittest::Queryable; let bytes = pdf_from_objects(&[ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Annots [4 0 R] >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 10 100 30] /P 99 /F 4 >>", ]); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("orphan.pdf", None, bytes).unwrap(); app.set_option("panel", "fields").unwrap(); app }); h.run_steps(6); h.get_by_label("Unplaced"); assert_eq!(h.query_all_by_label("orphan").count(), 1); }