Files
pdfcraft/crates/ui-egui/tests/forms.rs
tdslot 4bfd3c2729 M14: add Lithuanian interface and date localization (#1401)
* M14: add Lithuanian interface localization

Register Lithuanian UI and date languages with three integer plural forms
and a 2,572-entry contributor-original catalog. Cover dynamic dialog labels,
contextual calendar and certificate wording, commands, tools, and every key
in the other bundled catalogs. Add real-shell calendar, inspector and
password-dialog regressions; preserve PDF values and document state.

Update localization documentation and asset attribution. Native-speaker/VLKK
terminology review remains outstanding.

Validation: cargo xtask ci passed; cargo-deny was skipped because it is not
installed. Five rendered dialogs and contributor manual testing checked UI.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* M14: preserve multilingual localization documentation

Restore the existing language descriptions instead of replacing general
localization guidance with Lithuanian-specific coverage. List every
registered language and control-channel code; keep Lithuanian details
alongside the existing catalog descriptions.

Include the previously omitted Kazakh, Polish, Slovenian, Greek and Turkish
catalogs in the shipped-language table without changing the historical
coverage snapshot or existing assessments. Keep Lithuanian roadmap notes
concise and additive.

Validation: all 22 registered languages appear in documentation; every
existing descriptive paragraph is retained. cargo xtask ci passed, with
cargo-deny skipped because it is not installed.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* M14: record completed Lithuanian native-speaker review

The contributor confirms they are a native Lithuanian speaker and have
reviewed the translation. Remove obsolete pending-review labels from the
asset manifest and documentation; regenerate ATTRIBUTION.md. Remove the
redundant generated-message disclaimer from the Lithuanian description,
keeping the existing general localization documentation unchanged.

Validation: cargo xtask ci passed; cargo-deny was skipped because it is
not installed. Catalog content and its SHA-256 are unchanged.

Co-Authored-By: OpenAI gpt-6.1-sol <noreply@openai.com>

* Lithuanian: cover main's Copy Image labels, list lt last, check its glyphs

Adds the six Copy Image rows from #1363, moves lt to the end of LANGUAGES
and DATE_LANGUAGES (after tr), adds lt.tsv to the primary-font coverage
test, writes the JavaScript preferences path with '>' as the other
catalogs do (Inter has no ▸), and refreshes counts and hashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: OpenAI gpt-6.1-sol <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:54:15 -04:00

704 lines
29 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String> {
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<String> {
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<egui::Event>) {
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<u8> {
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<u8> {
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);
}