mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 18:59:48 +00:00
4bfd3c2729
* 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>
704 lines
29 KiB
Rust
704 lines
29 KiB
Rust
//! 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);
|
||
}
|