Files
pdfcraft/crates/ui-egui/tests/it/prepare.rs
T
Jayesh Prajapati 86730ed144 Tests: UI tests wait for background work by time, not frame count (#4186) (#4242)
* Tests: UI tests wait for background work by time, not frame count (#4186)

A frame count is not a time: on a busy runner the worker had not finished
the open, or the page text had not arrived, within 30 frames or 50 hover
rounds. support::step_until (#[must_use]) steps frames until a condition
holds or 30 s pass, sleeping briefly between frames, and says whether it
held; each test asserts on that with its own message.

- control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has
  and mode_tabs_keep_the_hand_over_page_text_scrolled_under_them wait with it,
  and so does control.rs call() for its reply.
- support::settle waits for every pending render and fails if the pages are
  not rendered within 30 s. It replaces all the render-pending settle loops
  (40 to 3000 frames) and the local settle/wait_for_pages wrappers, except
  text_select's double-click wait, which is held to 8 frames on purpose to
  stay inside egui's multi-click window, and view.rs's loops that already
  wait by a deadline.
- wait_until is built on step_until, keeping its diagnostic message.
- tabbing_back_to_the_same_field_then_saving_keeps_the_committed_value already
  waited 30 s for its file. It failed because its "same-frame" case shared
  its scratch directory with typing_and_saving_in_the_same_frame_saves_the_last_keystrokes,
  which runs in the same test process (since ee1ee704, #1045) and deleted
  the directory mid-save. forms' and pickers' scratch() now give every call
  a directory of its own.

Fixes #4186

* M14: update ctutils to 0.4.5 for RUSTSEC-2026-0385

The dependency-advisory gate rejects ctutils 0.4.2 (non-constant-time
mask generation and u128 comparisons, fixed in 0.4.4). It comes in
through crypto-bigint for the signing crates. Only its version and
checksum change in Cargo.lock.

(cherry picked from commit 1f8811761a)
2026-10-24 02:05:33 -04:00

1089 lines
47 KiB
Rust
Raw 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.
//! Prepare a form in the real shell (egui_kittest): field tools, placing, selecting, moving,
//! Field Properties and deleting.
use egui::Pos2;
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::{Queryable, by};
use pdfcraft_engine::field_table::TableColumn;
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool};
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|cc| {
cc.egui_ctx.options_mut(|options| options.warn_on_id_clash = true);
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("zoom", "150").unwrap();
app
});
crate::support::settle(&mut h);
h
}
/// A point on page 1 at (x, y) points from its top-left.
fn at(h: &Harness<'static, PdfCraftApp>, x: f32, y: f32) -> Pos2 {
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = r.width() / 300.0;
r.min + egui::vec2(x * k, y * k)
}
fn drag(h: &mut Harness<'static, PdfCraftApp>, a: Pos2, b: Pos2) {
h.hover_at(a);
h.run_steps(1);
h.drag_at(a);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(a + (b - a) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(b);
h.run_steps(4);
}
fn names(h: &Harness<'static, PdfCraftApp>) -> Vec<String> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().map(|f| f.name.clone()).collect()
}
fn rect_of(h: &Harness<'static, PdfCraftApp>, name: &str) -> [f64; 4] {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().widgets[0].rect
}
#[test]
fn placing_moving_editing_and_deleting_a_field() {
let mut h = harness();
let before = names(&h).len();
assert!(h.state_mut().execute("form.add.text"));
h.run_steps(2);
h.get_by_label("Prepare a form");
// Drag out a text field in the empty lower part of the page.
let (a, b) = (at(&h, 40.0, 300.0), at(&h, 200.0, 322.0));
drag(&mut h, a, b);
let all = names(&h);
assert_eq!(all.len(), before + 1, "{all:?}");
assert_eq!(all.last().map(String::as_str), Some("Text1"));
assert_eq!(h.state().quick_tool, QuickTool::Select, "back to Select after one field");
h.run_steps(2);
assert_eq!(h.state().views[0].prepare.selected.as_ref().map(|s| s.0.as_str()), Some("Text1"), "the new field is selected");
let r = rect_of(&h, "Text1");
assert!((r[2] - r[0] - 160.0).abs() < 2.0 && (r[3] - r[1] - 22.0).abs() < 2.0, "{r:?}");
// Move it 20 pt right.
let c = at(&h, 120.0, 311.0);
let d = c + egui::vec2(at(&h, 20.0, 0.0).x - at(&h, 0.0, 0.0).x, 0.0);
drag(&mut h, c, d);
let m = rect_of(&h, "Text1");
assert!((m[0] - r[0] - 20.0).abs() < 1.5, "moved: {r:?} → {m:?}");
// Field Properties: rename and make it required (one undo step).
h.state_mut().execute("form.field.properties");
h.run_steps(2);
h.get_by_label("Text Field Properties");
{
let d = h.state_mut().field_props.as_mut().expect("open");
d.name = "email".into();
d.required = true;
}
h.get_by_label("OK").click();
h.run_steps(3);
assert!(names(&h).contains(&"email".to_string()));
{
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Change field properties"));
assert!(doc.form.iter().find(|f| f.name == "email").unwrap().has(pdfcraft_engine::field_flags::REQUIRED));
assert_eq!(s.views[0].prepare.selected.as_ref().map(|s| s.0.as_str()), Some("email"));
}
// Delete removes the selected field.
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(names(&h).len(), before);
// A click with the check box tool places a default-size one.
h.state_mut().execute("form.add.checkbox");
h.run_steps(1);
let p = at(&h, 40.0, 250.0);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(4);
let r = rect_of(&h, "Check Box1");
assert_eq!((r[2] - r[0], r[3] - r[1]), (14.0, 14.0));
}
/// The arrow keys nudge the selected field a point, Shift and an arrow ten points (#2608), as they
/// do for a selected object or image and as dragging it does. One press is one undo step, the page
/// does not turn with them, and the field stays selected for the next press.
#[test]
fn arrow_keys_nudge_the_selected_field_instead_of_turning_the_page() {
let mut h = harness();
h.state_mut().execute("form.add.text");
h.run_steps(1);
// A click places a default-size text field and selects it.
let p = at(&h, 40.0, 250.0);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(4);
let id = h.state().views[0].id;
let name = h.state().views[0].prepare.selected.clone().expect("the placed field is selected").0;
let near = |a: f64, b: f64| (a - b).abs() < 0.01;
let before = rect_of(&h, &name);
h.key_press(egui::Key::ArrowRight);
h.run_steps(2);
let right = rect_of(&h, &name);
assert!(near(right[0], before[0] + 1.0) && near(right[2], before[2] + 1.0), "{before:?} → {right:?}");
assert!(near(right[2] - right[0], before[2] - before[0]), "moved, not resized");
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Change field properties"));
h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::ArrowUp);
h.run_steps(2);
let up = rect_of(&h, &name);
assert!(near(up[1], right[1] + 10.0) && near(up[3], right[3] + 10.0), "{right:?} → {up:?}");
h.key_press(egui::Key::ArrowDown);
h.run_steps(2);
h.key_press(egui::Key::ArrowLeft);
h.run_steps(2);
let back = rect_of(&h, &name);
assert!(near(back[0], up[0] - 1.0) && near(back[1], up[1] - 1.0), "{up:?} → {back:?}");
assert!(h.state().views[0].prepare.selected.is_some(), "it stays selected for the next nudge");
assert_eq!(h.state().views[0].current, 0, "the arrows moved the field, not the page");
}
#[test]
fn format_validate_and_calculate_tabs() {
use pdfcraft_engine::form_scripts::{Calculate, Format, Validate};
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(2);
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
h.get_by_label("Format").click();
h.run_steps(2);
h.get_by_label("Select format category:");
{
let d = h.state_mut().field_props.as_mut().unwrap();
d.format = Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true };
d.validate = Validate::Range { min: Some(0.0), max: None };
d.calculate = Calculate::Notation("name".into());
}
h.run_steps(2);
h.get_by_label_contains("Example: -$1,234.50");
h.get_by_label("Calculate").click();
h.run_steps(2);
h.get_by_label("Simplified field notation:");
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().clone();
assert!(matches!(f.actions.format, Format::Number { decimals: 2, .. }));
assert_eq!(f.actions.validate, Validate::Range { min: Some(0.0), max: None });
assert_eq!(f.actions.calculate, Calculate::Notation("name".into()));
}
/// Renaming a field in Field Properties renames it in the formulas that read it, in the same undo
/// step, and says how many changed.
#[test]
fn renaming_a_field_says_how_many_calculations_now_use_the_new_name() {
use pdfcraft_engine::form_scripts::Calculate;
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(2);
let rename = |h: &mut Harness<'static, PdfCraftApp>, from: &str, to: &str| {
h.state_mut().open_field_props(from, 0);
h.run_steps(2);
h.state_mut().field_props.as_mut().expect("open").name = to.into();
h.get_by_label("OK").click();
h.run_steps(3);
};
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
h.state_mut().field_props.as_mut().expect("open").calculate = Calculate::Notation("name * 2".into());
h.get_by_label("OK").click();
h.run_steps(3);
let calculation = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().actions.calculate.clone()
};
h.state_mut().toast = None;
rename(&mut h, "name", "full_name");
assert_eq!(calculation(&h), Calculate::Notation("full_name * 2".into()));
assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), Some("1 calculation now uses the new name."));
h.state_mut().toast = None;
rename(&mut h, "city", "town");
assert!(names(&h).contains(&"town".to_string()));
assert_eq!(h.state().toast, None, "no formula reads the calculated field itself");
h.state_mut().execute("edit.undo");
h.state_mut().execute("edit.undo");
h.run_steps(2);
assert!(names(&h).contains(&"name".to_string()));
assert_eq!(calculation(&h), Calculate::Notation("name * 2".into()), "undone with the rename");
}
#[test]
fn appearance_tab_restyles_the_field() {
use pdfcraft_engine::{BorderStyle, FieldFont};
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(2);
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
h.get_by_label("Appearance").click();
h.run_steps(2);
h.get_by_label("Line Style:");
fn no_grid_id_warning(shape: &egui::Shape) -> bool {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().all(no_grid_id_warning),
egui::Shape::Text(text) => !text.galley.job.text.contains("Grid ID"),
_ => true,
}
}
// Check egui's actual painted diagnostics, rather than reproducing the ID formula.
assert!(h.output().shapes.iter().all(|shape| no_grid_id_warning(&shape.shape)), "palette grids must not share an ID");
let before = h.state().field_props.as_ref().unwrap().look.unwrap();
assert_eq!(h.get_all_by_label("No color").count(), 2);
h.get_all_by_label("No color").next().unwrap().click();
h.run_steps(2);
let border = h.state().field_props.as_ref().unwrap().look.as_ref().unwrap().border;
assert_eq!(border, if before.border.is_some() { None } else { Some([0.0; 3]) });
assert_eq!(h.state().field_props.as_ref().unwrap().look.as_ref().unwrap().fill, before.fill);
h.get_all_by_label("No color").nth(1).unwrap().click();
h.run_steps(2);
let look = h.state().field_props.as_ref().unwrap().look.as_ref().unwrap();
assert_eq!(look.border, border);
assert_eq!(look.fill, if before.fill.is_some() { None } else { Some([0.0; 3]) });
{
let d = h.state_mut().field_props.as_mut().unwrap();
let l = d.look.as_mut().expect("a look");
l.style = BorderStyle::Dashed;
l.font = FieldFont::Courier;
l.fill = Some([1.0, 1.0, 0.8]);
}
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let l = s.session.get(s.views[0].id).unwrap().field_look("city").unwrap();
assert_eq!((l.style, l.font, l.fill), (BorderStyle::Dashed, FieldFont::Courier, Some([1.0, 1.0, 0.8])));
}
#[test]
fn appearance_tab_keeps_auto_font_size() {
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(2);
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
h.get_by_label("Appearance").click();
h.run_steps(2);
h.state_mut().field_props.as_mut().unwrap().font_size = 0.0;
h.run_steps(3);
assert_eq!(h.state().field_props.as_ref().unwrap().font_size, 0.0, "Auto (0) must survive a frame");
}
#[test]
fn position_tab_rotates_a_field() {
let mut h = harness();
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
let widget = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap();
f.widgets[0].clone()
};
let before = widget(&h);
assert_eq!(before.rotation, 0);
assert!(h.state().field_props.as_ref().unwrap().props().is_none(), "an unchanged draft is not an edit");
h.get_by_label("Position").click();
h.run_steps(2);
h.get_by_label("Rotation:");
h.state_mut().field_props.as_mut().unwrap().rotation = 90;
assert!(h.state().field_props.as_ref().unwrap().props().is_some());
h.get_by_label("OK").click();
h.run_steps(3);
let turned = widget(&h);
assert_eq!(turned.rotation, 90);
let [x0, y0, x1, y1] = before.rect;
let (cx, cy, w, ht) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, x1 - x0, y1 - y0);
let expect = [cx - ht / 2.0, cy - w / 2.0, cx + ht / 2.0, cy + w / 2.0];
assert!(turned.rect.iter().zip(expect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} vs {expect:?}", turned.rect);
h.state_mut().execute("edit.undo");
h.run_steps(3);
let back = widget(&h);
assert_eq!(back.rotation, 0);
assert!(back.rect.iter().zip(before.rect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} vs {:?}", back.rect, before.rect);
}
#[test]
fn options_tab_sets_the_check_box_style() {
// #94: Check Box Style on the Options tab.
use pdfcraft_engine::CheckStyle;
let mut h = harness();
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
page: 0,
rect: [40.0, 300.0, 56.0, 316.0],
kind: pdfcraft_engine::NewField::CheckBox,
name: Some("agree".into()),
});
h.state_mut().open_field_props("agree", 0);
h.run_steps(2);
h.get_by_label("Options").click();
h.run_steps(2);
h.get_by_label("Check Box Style:");
assert_eq!(h.state().field_props.as_ref().unwrap().check_style, Some(CheckStyle::Check));
h.state_mut().field_props.as_mut().unwrap().check_style = Some(CheckStyle::Cross);
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().field_check_style("agree"), Some(CheckStyle::Cross));
}
#[test]
fn button_label_is_editable_without_renaming_the_field() {
use pdfcraft_engine::{Edit, NewField};
let mut h = harness();
// The byte comparisons below must not vary with the save's /ModDate.
let session = &mut h.state_mut().session;
*session = std::mem::take(session).with_clock(|| 1_700_000_000);
h.state_mut().apply_edit(Edit::AddField {
page: 0,
rect: [40.0, 300.0, 190.0, 330.0],
kind: NewField::Button { caption: String::new() },
name: Some("clear_button".into()),
});
h.state_mut().open_field_props("clear_button", 0);
h.run_steps(2);
h.get_by_label("Options").click();
h.run_steps(2);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").click();
h.run_steps(1);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").type_text("Clear Form");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
let app = h.state();
let id = app.views[0].id;
let field = app.session.get(id).unwrap().form.iter().find(|f| f.name == "clear_button").unwrap();
let bytes = app.session.save_bytes(id).unwrap();
let doc = pdfcraft_cos::Document::open(bytes).unwrap();
let widget = doc.get(field.widgets[0].obj);
let mk = doc.resolve(widget.as_dict().unwrap().get(b"MK").unwrap());
assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form");
assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Change field properties"));
// Read the existing label back, and leave both Cancel and an unchanged OK harmless.
let before = app.session.save_bytes(id).unwrap();
h.state_mut().open_field_props("clear_button", 0);
h.run_steps(2);
h.get_by_label("Options").click();
h.run_steps(2);
assert_eq!(h.state().field_props.as_ref().unwrap().caption, "Clear Form");
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").type_text("Discard this");
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(2);
assert_eq!(h.state().session.save_bytes(id).unwrap(), before);
h.state_mut().open_field_props("clear_button", 0);
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(2);
assert_eq!(h.state().session.save_bytes(id).unwrap(), before);
}
#[test]
fn dropdown_alignment_is_editable_and_preserved() {
choice_alignment(pdfcraft_engine::NewField::Combo { options: vec!["One".into(), "Two".into()], editable: false }, "Center", 1);
}
#[test]
fn list_box_alignment_is_editable_and_preserved() {
choice_alignment(pdfcraft_engine::NewField::List { options: vec!["One".into(), "Two".into()], multi: true }, "Left", 0);
}
fn choice_alignment(kind: pdfcraft_engine::NewField, label: &str, quadding: i64) {
use pdfcraft_engine::{Edit, FieldProps, FieldValue};
let mut h = harness();
h.state_mut().apply_edit(Edit::AddField { page: 0, rect: [40.0, 260.0, 240.0, 310.0], kind, name: Some("aligned_choice".into()) });
h.state_mut()
.apply_edit(Edit::SetFieldProps { name: "aligned_choice".into(), props: Box::new(FieldProps { quadding: Some(2), ..Default::default() }) });
h.state_mut().apply_edit(Edit::SetFieldValue { name: "aligned_choice".into(), value: FieldValue::Choice(vec!["Two".into()]) });
let field = |h: &Harness<'static, PdfCraftApp>| {
let app = h.state();
app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == "aligned_choice").unwrap().clone()
};
let original = field(&h);
h.state_mut().open_field_props("aligned_choice", 0);
h.run_steps(2);
h.get_by_label("Options").click();
h.run_steps(2);
h.get_by_label("Alignment:");
// The imported right alignment is displayed and unchanged until a choice is made.
h.get_by_value("Right").click();
h.run_steps(2);
h.get_by_label(label).click();
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(2);
assert_eq!(field(&h).quadding, 2, "cancel keeps the existing /Q");
h.state_mut().open_field_props("aligned_choice", 0);
h.run_steps(2);
h.get_by_label("Options").click();
h.run_steps(2);
h.get_by_value("Right").click();
h.run_steps(2);
h.get_by_label(label).click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
let changed = field(&h);
assert_eq!(changed.quadding, quadding);
assert_eq!((changed.options, changed.value, changed.flags), (original.options, original.value, original.flags));
let app = h.state();
let id = app.views[0].id;
assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Change field properties"));
let bytes = app.session.save_bytes(id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let reopened_id = reopened.open("saved.pdf", None, bytes, None).unwrap();
assert_eq!(reopened.get(reopened_id).unwrap().form.iter().find(|f| f.name == "aligned_choice").unwrap().quadding, quadding);
h.state_mut().execute("edit.undo");
h.run_steps(3);
assert_eq!(field(&h).quadding, 2);
h.state_mut().execute("edit.redo");
h.run_steps(3);
assert_eq!(field(&h).quadding, quadding);
}
#[test]
fn options_tab_sets_flags_alignment_and_defaults() {
use pdfcraft_engine::field_flags as ff;
let mut h = harness();
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
h.get_by_label("Options").click();
h.run_steps(2);
h.get_by_label("Check spelling").click();
h.get_by_label("Password").click();
h.run_steps(1);
{
let d = h.state_mut().field_props.as_mut().unwrap();
d.quadding = 2;
d.default = "Paris".into();
}
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").cloned().unwrap();
assert!(f.has(ff::PASSWORD) && f.has(ff::DO_NOT_SPELL_CHECK), "{:b}", f.flags);
assert_eq!((f.quadding, f.default.clone()), (2, vec!["Paris".to_string()]));
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Change field properties"));
}
#[test]
fn the_fields_panel_orders_tabs_manually() {
let mut h = harness();
h.state_mut().execute("form.prepare");
h.state_mut().set_option("panel", "fields").unwrap();
h.run_steps(3);
let order = |h: &Harness<'static, PdfCraftApp>| -> Vec<String> {
let s = h.state();
let form = &s.session.get(s.views[0].id).unwrap().form;
let mut v: Vec<(usize, String)> = form.iter().filter_map(|f| f.widgets.iter().map(|w| w.tab).min().map(|t| (t, f.name.clone()))).collect();
v.sort();
v.into_iter().map(|x| x.1).collect()
};
let before = order(&h);
assert!(before.len() >= 2);
let second = before[1].clone();
h.get_by_label(&format!("Earlier in tab order: {second}")).click();
h.run_steps(3);
let after = order(&h);
assert_eq!(after[0], second, "{before:?} → {after:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Set tab order"));
}
#[test]
fn duplicating_a_field_onto_every_page() {
let mut h = harness();
h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 300.0, height: 400.0 });
h.state_mut().execute("form.prepare");
h.run_steps(3);
let (n, p) = {
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == "city").unwrap();
(doc.info.pages.len(), pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen").center())
};
assert!(n >= 2, "the fixture has {n} pages");
h.hover_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
h.get_by_label("Duplicate…").click();
h.run_steps(2);
h.get_by_label("Duplicate Field");
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == "city").unwrap();
let mut pages: Vec<usize> = f.widgets.iter().filter_map(|w| w.page).collect();
pages.sort();
assert_eq!(pages, (0..n).collect::<Vec<_>>());
assert_eq!(doc.can_undo(), Some("Duplicate field"));
}
#[test]
fn aligning_distributing_and_sizing_several_fields() {
use pdfcraft_ui_egui::prepare::{Arrange, arrange};
let mut h = harness();
for (name, x, y, w) in [("a", 20.0, 300.0, 60.0), ("b", 120.0, 280.0, 80.0), ("c", 260.0, 260.0, 30.0)] {
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
page: 0,
rect: [x, y, x + w, y + 20.0],
kind: pdfcraft_engine::NewField::Text { multiline: false },
name: Some(name.into()),
});
}
h.run_steps(2);
let form = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().form.as_ref().clone();
let rect = |h: &Harness<'static, PdfCraftApp>, n: &str| form(h).iter().find(|f| f.name == n).unwrap().widgets[0].rect;
let a = ("a".to_string(), 0);
let others = vec![("b".to_string(), 0), ("c".to_string(), 0)];
// Align tops with a.
let e = arrange(&form(&h), &a, &others, Arrange::AlignTop).unwrap();
h.state_mut().apply_edit(e);
assert!(["b", "c"].iter().all(|n| rect(&h, n)[3] == 320.0));
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Align fields"));
// Distribute horizontally: equal gaps between a, b and c.
let e = arrange(&form(&h), &a, &others, Arrange::DistributeH).unwrap();
h.state_mut().apply_edit(e);
let (ra, rb, rc) = (rect(&h, "a"), rect(&h, "b"), rect(&h, "c"));
assert!(((rb[0] - ra[2]) - (rc[0] - rb[2])).abs() < 1e-6, "{ra:?} {rb:?} {rc:?}");
// Same width as a.
let e = arrange(&form(&h), &a, &others, Arrange::SameWidth).unwrap();
h.state_mut().apply_edit(e);
assert!(["b", "c"].iter().all(|n| (rect(&h, n)[2] - rect(&h, n)[0] - 60.0).abs() < 1e-6));
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Match field sizes"));
assert!(arrange(&form(&h), &a, &[("b".into(), 0)], Arrange::DistributeV).is_none(), "distributing needs three");
}
#[test]
fn deleting_several_selected_fields_and_detecting_fields_from_the_panel() {
let mut h = harness();
for (name, x) in [("a", 20.0), ("b", 120.0), ("c", 220.0)] {
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
page: 0,
rect: [x, 300.0, x + 60.0, 320.0],
kind: pdfcraft_engine::NewField::Text { multiline: false },
name: Some(name.into()),
});
}
assert!(h.state_mut().execute("form.prepare"));
h.run_steps(2);
// #93: field detection is in the panel, not only run when a form has no fields.
h.get_by_label("Detect form fields");
// #95: Delete removes every selected field, as one undoable step.
{
let p = &mut h.state_mut().views[0].prepare;
p.selected = Some(("a".into(), 0));
p.also = vec![("b".into(), 0), ("c".into(), 0)];
}
h.key_press(egui::Key::Delete);
h.run_steps(3);
let left = names(&h);
assert!(!["a", "b", "c"].iter().any(|n| left.iter().any(|l| l == n)), "{left:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Delete 3 fields"));
assert!(h.state().views[0].prepare.selected.is_none() && h.state().views[0].prepare.also.is_empty());
}
#[test]
fn preview_fills_the_form_and_locked_fields_keep_their_properties() {
let mut h = harness();
assert!(h.state_mut().execute("form.prepare"));
h.run_steps(2);
assert!(h.state().is_preparing());
h.get_by_label("Preview").click();
h.run_steps(2);
assert!(!h.state().is_preparing(), "Preview fills the form");
h.get_by_label("Edit fields").click();
h.run_steps(2);
assert!(h.state().is_preparing());
// Lock a field: its properties are then greyed out until unlocked.
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
h.get_by_label("Locked").click();
h.run_steps(1);
h.get_by_label("OK").click();
h.run_steps(3);
let locked = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().locked()
};
assert!(locked(&h));
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
h.get_by_label_contains("This field is locked");
h.get_by_label("Locked").click();
h.run_steps(1);
h.get_by_label("OK").click();
h.run_steps(3);
assert!(!locked(&h));
}
#[test]
fn image_fields_take_a_picture_when_clicked() {
let mut h = harness();
assert!(h.state_mut().execute("form.add.image"));
h.run_steps(2);
let (a, b) = (at(&h, 40.0, 220.0), at(&h, 140.0, 290.0));
drag(&mut h, a, b);
assert_eq!(names(&h).last().map(String::as_str), Some("Image1"));
// A picture to choose (the picker is bypassed in tests).
let path = std::env::temp_dir().join(format!("pdfcraft-image-field-{}.png", std::process::id()));
image::RgbImage::from_pixel(8, 4, image::Rgb([200, 30, 30])).save(&path).unwrap();
h.state_mut().save_override = Some(path.to_string_lossy().into_owned());
// Fill it in as a reader would.
h.get_by_label("Preview").click();
h.run_steps(2);
let c = at(&h, 90.0, 255.0);
h.hover_at(c);
h.run_steps(1);
h.drag_at(c);
h.run_steps(1);
h.drop_at(c);
h.run_steps(4);
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Set the image of Image1"));
}
#[test]
fn actions_tab_adds_and_removes_actions() {
use pdfcraft_engine::{FieldAction, FieldTrigger};
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(2);
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
h.get_by_label("Actions").click();
h.run_steps(2);
h.get_by_label("Select Trigger:");
h.get_by_label("No actions");
{
let d = h.state_mut().field_props.as_mut().unwrap();
d.new_action.trigger = FieldTrigger::OnBlur;
d.new_action.kind = 1;
d.new_action.text = "https://example.org".into();
}
h.run_steps(3);
h.get_by_label("Add").click();
h.run_steps(2);
h.get_by_label("Open a web link: https://example.org");
{
let d = h.state_mut().field_props.as_mut().unwrap();
d.new_action.trigger = FieldTrigger::MouseUp;
d.new_action.kind = 0;
d.new_action.text = "app.alert('x')".into();
}
h.run_steps(3);
h.get_by_label("Add").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let acts = s.session.get(s.views[0].id).unwrap().field_actions("city");
assert_eq!(
acts,
[
(FieldTrigger::MouseUp, FieldAction::JavaScript("app.alert('x')".into())),
(FieldTrigger::OnBlur, FieldAction::Uri("https://example.org".into()))
]
);
}
/// Text field table: pick the tool and drag out an area in the empty lower part of page 1, 200 by
/// 120 points from (40, 240) as displayed. The table's window opens for it.
fn draw_table(h: &mut Harness<'static, PdfCraftApp>) {
assert!(h.state_mut().execute("form.add.text_table"));
h.run_steps(2);
let (a, b) = (at(h, 40.0, 240.0), at(h, 240.0, 360.0));
drag(h, a, b);
assert_eq!(h.state().dialog, Some(Dialog::FieldTable));
}
fn columns(patterns: &[(&str, f64)]) -> Vec<TableColumn> {
patterns.iter().map(|(p, w)| TableColumn { pattern: (*p).to_owned(), width: *w }).collect()
}
#[test]
fn a_text_field_table_is_drawn_set_up_and_added_in_one_step() {
let mut h = harness();
let before = names(&h);
draw_table(&mut h);
assert_eq!(h.state().quick_tool, QuickTool::Select, "back to Select once the area is drawn");
{
let s = h.state_mut();
let id = s.views[0].id;
let d = s.field_table_draft.as_mut().expect("the table's window is open");
assert_eq!((d.doc, d.page), (id, 0));
let r = d.table.rect;
assert!([40.0, 240.0, 240.0, 360.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "drawn as displayed: {r:?}");
d.table.rows = 2;
d.table.columns = columns(&[("desc_N", 3.0), ("price_N", 1.0)]);
}
h.run_steps(2);
h.get_by_label("Add fields").click();
h.run_steps(3);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false), "added, the window closes");
let added: Vec<_> = names(&h).into_iter().filter(|n| !before.contains(n)).collect();
assert_eq!(added, ["desc_1", "price_1", "desc_2", "price_2"]);
// 150 and 50 points wide, 60 high, the first row at the top: user space has y up from 400.
let r = rect_of(&h, "desc_1");
assert!([40.0, 100.0, 190.0, 160.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "{r:?}");
let r = rect_of(&h, "price_2");
assert!([190.0, 40.0, 240.0, 100.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "{r:?}");
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Add text field table"));
h.state_mut().execute("edit.undo");
h.run_steps(2);
assert_eq!(names(&h), before, "one undo takes the whole table back");
}
#[test]
fn a_table_whose_names_are_taken_stays_open_saying_so() {
let mut h = harness();
draw_table(&mut h);
h.get_by_label("Add fields").click();
h.run_steps(3);
assert!(names(&h).contains(&"Table1_4".to_owned()), "{:?}", names(&h));
// The next table takes the next free names on its own.
draw_table(&mut h);
assert_eq!(h.state().field_table_draft.as_ref().unwrap().table.columns[0].pattern, "Table2_N");
// Named like the first, it can't be added: the window says why and keeps what was set up.
h.state_mut().field_table_draft.as_mut().unwrap().table.columns[0].pattern = "Table1_N".into();
h.run_steps(2);
let count = names(&h).len();
h.get_by_label("a field named \"Table1_1\" already exists");
h.get_by_label("Add fields").click();
h.run_steps(3);
assert_eq!(names(&h).len(), count, "nothing added");
assert_eq!(h.state().dialog, Some(Dialog::FieldTable), "still open");
// Escape closes it, adding nothing.
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some(), names(&h).len()), (None, false, count));
}
/// Enter commits a number being typed. It used to add the table there and then, before the rest
/// of it was set up.
#[test]
fn enter_in_a_number_field_does_not_add_the_table() {
let mut h = harness();
let before = names(&h).len();
draw_table(&mut h);
let rows = h.query_all(by().role(Role::SpinButton)).next().map(|n| n.rect()).expect("the Rows field");
h.hover_at(rows.center());
h.drag_at(rows.center());
h.run_steps(1);
h.drop_at(rows.center());
h.run_steps(2);
h.key_press(egui::Key::Backspace);
h.event(egui::Event::Text("6".into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(names(&h).len(), before, "nothing added");
assert_eq!(h.state().field_table_draft.as_ref().map(|d| d.table.rows), Some(6), "the number is in, the window open");
}
/// With 26 or more columns each pair of columns has less than twice the least share a divider
/// keeps, and dragging one panicked in `f64::clamp`.
#[test]
fn dragging_a_column_divider_between_many_columns() {
let mut h = harness();
draw_table(&mut h);
h.state_mut().field_table_draft.as_mut().unwrap().table.columns = columns(&[("c{c}_N", 1.0); 30]);
h.run_steps(2);
let (above, below) = (h.get_by_label("Distribute evenly").rect(), h.get_by_label("Field names").rect());
let p = egui::pos2(above.center().x - 120.0, (above.bottom() + below.top()) / 2.0);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
for dx in [-10.0, -20.0] {
h.hover_at(p + egui::vec2(dx, 0.0));
h.run_steps(1);
}
h.drop_at(p + egui::vec2(-20.0, 0.0));
h.run_steps(2);
let widths: Vec<f64> = h.state().field_table_draft.as_ref().unwrap().table.columns.iter().map(|c| c.width).collect();
assert!(widths.iter().any(|w| (w - 1.0).abs() > 1e-9), "a divider moved: {widths:?}");
assert!((widths.iter().sum::<f64>() - 30.0).abs() < 1e-9 && widths.iter().all(|w| *w > 0.0), "{widths:?}");
}
#[test]
fn the_table_window_closes_when_its_document_is_left() {
let mut h = harness();
let blank = pdfcraft_engine::Session::new().create_blank(300.0, 400.0, 1).unwrap().to_vec();
h.state_mut().open_bytes("other.pdf", None, blank).unwrap();
h.state_mut().active = Some(0);
h.run_steps(4);
let before = names(&h).len();
draw_table(&mut h);
// The other document's tab is still in reach of the pointer: choosing it closes the window.
let tab = h.get_all_by_label("other.pdf").next().map(|n| n.rect()).expect("its tab");
h.hover_at(tab.center());
h.run_steps(1);
h.drag_at(tab.center());
h.run_steps(1);
h.drop_at(tab.center());
h.run_steps(3);
assert_eq!(h.state().active, Some(1));
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false));
h.state_mut().active = Some(0);
h.run_steps(2);
assert_eq!(names(&h).len(), before, "nothing added to either document");
// So does leaving Prepare a form.
draw_table(&mut h);
h.state_mut().left_open = false;
h.run_steps(2);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false));
}
/// With its panel closed after the tool was picked, the form is still prepared and a table can be
/// drawn: its window opens Prepare a form again, which it belongs to. Drawing sends the tool back
/// to Select, so the window used to close itself at once, nothing being prepared any more.
#[test]
fn a_table_drawn_with_the_panel_closed_opens_it_again() {
let mut h = harness();
let before = names(&h).len();
assert!(h.state_mut().execute("form.add.text_table"));
h.run_steps(2);
h.state_mut().left_open = false;
h.run_steps(2);
assert!(h.state().is_preparing(), "the picked tool keeps the form prepared");
let (a, b) = (at(&h, 40.0, 240.0), at(&h, 240.0, 360.0));
drag(&mut h, a, b);
h.run_steps(3);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (Some(Dialog::FieldTable), true), "the window stays open");
assert!(h.state().left_open && h.state().is_preparing(), "Prepare a form is open again");
h.get_by_label("Add fields").click();
h.run_steps(3);
assert!(names(&h).len() > before, "the table is added");
}
/// The window belongs to the page as it was when it opened: rotated (or moved, deleted, undone)
/// under it, the page would put the area the table was set up for somewhere else.
#[test]
fn the_table_window_closes_when_the_document_changes_under_it() {
let mut h = harness();
let before = names(&h);
draw_table(&mut h);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }));
h.run_steps(2);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false), "closed by the rotation");
assert_eq!(names(&h), before, "nothing added");
}
/// Escape in a name field leaves the field, as it does in any field; it closed the window too,
/// and the column setup with it.
#[test]
fn escape_in_a_name_field_leaves_the_field_not_the_table() {
let mut h = harness();
draw_table(&mut h);
// The app's own text boxes (zoom, page number) are inputs too: take the one in the window.
let window = h.get_by_label("Text Field Table").rect();
let field =
h.query_all(by().role(Role::TextInput)).map(|n| n.rect()).find(|r| window.contains(r.center())).expect("the first column's name field");
h.hover_at(field.center());
h.drag_at(field.center());
h.run_steps(1);
h.drop_at(field.center());
h.run_steps(2);
h.event(egui::Event::Text("x".into()));
h.run_steps(1);
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert_eq!(h.state().dialog, Some(Dialog::FieldTable), "still open");
assert_eq!(h.state().field_table_draft.as_ref().map(|d| d.table.columns[0].pattern.as_str()), Some("Table1_Nx"), "what was typed stays");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false), "a second Escape closes it");
}
/// A click puts the default 180 × 110 table's corner at the pointer; near the page's edges it is
/// moved in so that all of it is on the page (here 5 pt from the right, 100 pt from the bottom).
#[test]
fn a_table_clicked_near_the_page_edge_stays_on_the_page() {
let mut h = harness();
assert!(h.state_mut().execute("form.add.text_table"));
h.run_steps(2);
let p = at(&h, 295.0, 300.0);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
let r = h.state().field_table_draft.as_ref().expect("the table's window").table.rect;
assert!([120.0, 290.0, 300.0, 400.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "on the 300 × 400 page: {r:?}");
}
/// Prepare a form ▸ Calculation order opens as a page of its own, with the form still being
/// prepared, says when nothing calculates, and Back returns to Prepare a form.
#[test]
fn the_calculation_order_is_a_page_of_prepare_a_form() {
use pdfcraft_ui_egui::LeftPanel;
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(3);
assert!(h.query_by_label("Field list").is_some());
h.get_by_label("Calculation order").click();
h.run_steps(3);
assert_eq!(h.state().left, LeftPanel::CalculationOrder);
assert!(h.state().is_preparing(), "the fields stay editable, so a row can select one");
// The page replaces the Prepare a form tools.
assert!(h.query_by_label("Field list").is_none());
assert!(h.query_by_label_contains("No field in this form calculates its value yet").is_some());
h.get_by_label("Back to Prepare a form").click();
h.run_steps(3);
assert_eq!(h.state().left, LeftPanel::Tool("form"));
assert!(h.query_by_label("Field list").is_some());
assert!(h.query_by_label("Back to Prepare a form").is_none());
// Opened from Preview, the page edits the fields again.
h.get_by_label("Preview").click();
h.run_steps(3);
assert!(h.state().form_preview);
h.get_by_label("Calculation order").click();
h.run_steps(3);
assert!(h.state().is_preparing() && !h.state().form_preview, "Preview ends, so a row can select its field");
// Opened (from ⌘K) while the page grid shows, the grid closes and the page shows.
h.state_mut().execute("page.organize");
h.run_steps(3);
assert!(h.state().views[0].organize);
h.state_mut().execute("form.calculation_order");
h.run_steps(3);
assert!(!h.state().views[0].organize);
assert!(h.query_by_label_contains("No field in this form calculates its value yet").is_some());
}
/// Prepare a form ▸ Calculation order: a card says which field shows an old value and why, each
/// row shows its field's value as the field does, the selected field's arrows, Alt+↑ and a drag
/// move it, Fix order puts each field after the fields it reads and the card's Undo takes that
/// back, every change is one undo step, and a row selects its field.
#[test]
fn the_calculation_order_is_moved_dragged_and_fixed() {
use egui::{Key, Modifiers};
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format};
use pdfcraft_engine::{Edit, FieldProps, FieldValue, NewField};
let mut h = harness();
for (i, name) in ["qty", "sub", "total", "other"].into_iter().enumerate() {
let y = 200.0 + 30.0 * i as f64;
let kind = NewField::Text { multiline: false };
h.state_mut().apply_edit(Edit::AddField { page: 0, rect: [20.0, y, 120.0, y + 20.0], kind, name: Some(name.into()) });
}
let calculate = |name: &str, c: Calculate| Edit::SetFieldProps {
name: name.into(),
props: Box::new(FieldProps { calculate: Some(c), ..Default::default() }),
};
// Defined backwards: total, which reads sub, calculates first.
h.state_mut().apply_edit(calculate("total", Calculate::Simple { op: CalcOp::Sum, fields: vec!["sub".into()] }));
h.state_mut().apply_edit(calculate("sub", Calculate::Notation("qty * 2".into())));
h.state_mut().apply_edit(calculate("other", Calculate::Notation("qty + 1".into())));
// other shows its value as currency.
let currency = Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true };
h.state_mut()
.apply_edit(Edit::SetFieldProps { name: "other".into(), props: Box::new(FieldProps { format: Some(currency), ..Default::default() }) });
h.state_mut().apply_edit(Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) });
h.state_mut().execute("form.prepare");
h.run_steps(3);
h.get_by_label("Calculation order").click();
h.run_steps(3);
let order = |h: &Harness<'static, PdfCraftApp>| -> Vec<String> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().calculation_order().fields.into_iter().map(|f| f.name).collect()
};
let undo_label = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().can_undo().map(str::to_owned);
assert_eq!(order(&h), ["total", "sub", "other"]);
// The card says what is wrong, and each row shows its value as the field does: total
// calculated before sub, so it still has sub's old value.
assert!(h.query_by_label("1 field shows an old value").is_some());
assert!(h.query_by_label("total calculates before sub, which it reads.").is_some());
for row in ["1. total: 0", "2. sub: 6", "3. other: $4.00"] {
assert!(h.query_by_label(row).is_some(), "{row}");
}
// The selected field's arrows move it.
assert!(h.query_by_label("Later in calculation order: total").is_none(), "arrows only on the selected field or the one pointed at");
h.get_by_label("1. total: 0").click();
h.run_steps(3);
h.get_by_label("Later in calculation order: total").click();
h.run_steps(3);
assert_eq!(order(&h), ["sub", "total", "other"]);
assert_eq!(undo_label(&h).as_deref(), Some("Set calculation order"));
// Alt+↑ moves it back.
h.key_press_modifiers(Modifiers::ALT, Key::ArrowUp);
h.run_steps(3);
assert_eq!(order(&h), ["total", "sub", "other"]);
// Dragging other onto the first row puts it first.
let from = h.get_by_label("3. other: $4.00").rect().center();
let to = h.get_by_label("1. total: 6").rect().center();
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for k in 1..=4 {
h.hover_at(from + (to - from) * (k as f32 / 4.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(4);
assert_eq!(order(&h), ["other", "total", "sub"]);
// Fix order moves only total, after sub, and the card says the order is right.
h.get_by_label("Fix order").click();
h.run_steps(3);
assert_eq!(order(&h), ["other", "sub", "total"]);
assert_eq!(undo_label(&h).as_deref(), Some("Set calculation order"));
assert!(h.query_by_label("Every field calculates after the fields it reads.").is_some());
assert!(h.query_by_label("Fix order").is_none());
// Its Undo takes Fix order back.
h.get_by_label("Undo").click();
h.run_steps(3);
assert_eq!(order(&h), ["other", "total", "sub"]);
// A row selects its field.
h.get_by_label("3. sub: 6").click();
h.run_steps(2);
assert_eq!(h.state().views[0].prepare.selected.as_ref().map(|s| s.0.as_str()), Some("sub"));
}
/// Fields that read each other get a card of their own, which Fix order leaves alone, and its
/// link opens the first one's formula.
#[test]
fn a_loop_has_its_own_card_that_opens_the_formula() {
use pdfcraft_engine::form_scripts::Calculate;
use pdfcraft_engine::{Edit, FieldProps, NewField};
use pdfcraft_ui_egui::prepare::FieldTab;
let mut h = harness();
for (i, (name, formula)) in [("a", "b + 1"), ("b", "a + 1")].into_iter().enumerate() {
let y = 200.0 + 30.0 * i as f64;
let kind = NewField::Text { multiline: false };
h.state_mut().apply_edit(Edit::AddField { page: 0, rect: [20.0, y, 120.0, y + 20.0], kind, name: Some(name.into()) });
let props = Box::new(FieldProps { calculate: Some(Calculate::Notation(formula.into())), ..Default::default() });
h.state_mut().apply_edit(Edit::SetFieldProps { name: name.into(), props });
}
h.state_mut().execute("form.prepare");
h.run_steps(3);
h.get_by_label("Calculation order").click();
h.run_steps(3);
assert!(h.query_by_label("a and b read each other").is_some());
assert!(h.query_by_label("Every field outside a loop calculates after the fields it reads.").is_some());
assert!(h.query_by_label("Fix order").is_none(), "no order fixes a loop");
h.get_by_label("Edit a's formula").click();
h.run_steps(3);
let draft = h.state().field_props.as_ref().map(|d| (d.field.clone(), d.tab));
assert_eq!(draft, Some(("a".to_owned(), FieldTab::Calculate)));
}