Files
pdfcraft/crates/ui-egui/tests/ui.rs
T
7437bde378 M3: draw a GNOME-style title bar on Wayland (#553) (#1530)
GNOME's Wayland compositor draws no window frames (it offers no
zxdg_decoration_manager_v1), so winit drew its own fallback title bar,
which ignores GNOME's theme, dark mode and button layout. PdfCraft now
asks the compositor at launch (apps/pdfcraft/src/decorations.rs) and,
where it draws no frames, turns winit's frame off and makes the tab strip
the header bar, as in GNOME's own apps
(crates/ui-egui/src/window_controls.rs):

- Minimize, maximize/restore and close in Adwaita's style (round buttons,
  symbolic glyphs drawn in code, no new assets), in the order and on the
  side named by org.gnome.desktop.wm.preferences button-layout, read
  through gsettings; Fedora's default `appmenu:close` shows only close.
  Hidden in full screen.
- Resize zones along the window's edges and L-shaped corners, above the
  rest of the UI so a press there resizes instead of reaching a
  scrollbar; a hairline outlines the window while it isn't maximized.
- Preferences > Use system title bar (shown only where the app draws the
  frame) gives the window back winit's frame at once, without a restart
  (part of #1001). Named as in VectorCraft and PhotoCraft, but GNOME draws
  no title bars for apps, so on GNOME this is winit's basic frame, not
  GNOME's own; the tooltip says so. Saved as `system_title_bar`;
  `system-title-bar` on the control channel.
- PDFCRAFT_TITLEBAR=app|system overrides the detection.

KDE, Sway, Hyprland, COSMIC and X11 offer server-side decorations and
keep the frame their desktop draws; macOS and Windows are unchanged.

Not done: right-clicking the header bar doesn't open GNOME's window menu
(egui has no command for it), the window has no shadow or rounded
corners, the resize zones lie just inside the window rather than outside
it, and Windows still uses its system frame (the rest of #1001).

The new labels (Minimize, Maximize, Restore, Use system title bar and its
tooltip) were added to all 21 translation catalogs. These translations
have not been checked by anyone fluent in those languages and should be
reviewed. The catalogs' hashes in ATTRIBUTION.toml are updated.

Made with Claude Code (Claude Opus 5.5, medium effort). It passes the
workspace quality gates (fmt, clippy, cargo test --workspace, xtask
assets) and was checked by hand on Fedora 44 GNOME (light and dark,
maximized and not, the preference toggled live), but it has not been
exhaustively regression tested.

Co-authored-by: bmh129 <11478682+bmh129@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 03:04:13 -04:00

1183 lines
52 KiB
Rust

//! Headless UI tests (egui_kittest + AccessKit). They drive the real app shell without a window.
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_ui_egui::PdfCraftApp;
/// A tiny PDF with two pages, two bookmarks and one sticky note.
const FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 6 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Annots [9 0 R] >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] >> endobj
6 0 obj << /Type /Outlines /First 7 0 R /Last 8 0 R /Count 2 >> endobj
7 0 obj << /Title (Alpha section) /Parent 6 0 R /Next 8 0 R /Dest [3 0 R /Fit] >> endobj
8 0 obj << /Title (Beta section) /Parent 6 0 R /Prev 7 0 R /Dest [4 0 R /Fit] >> endobj
9 0 obj << /Type /Annot /Subtype /Text /Rect [10 370 30 390] /T (Tester) /Contents (Check the numbers) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
setup(&mut app);
app
});
// Fonts install on frame 1 and apply on frame 2.
h.run_steps(4);
h
}
#[test]
fn home_shows_welcome_and_tools() {
let h = harness(|_| {});
h.get_by_label_contains("Welcome to PdfCraft");
assert!(h.query_all_by_label("Organize pages").count() >= 2, "tool list + home card");
h.get_by_label("Open file");
}
#[test]
fn tools_panel_side_preference_moves_and_persists() {
use pdfcraft_ui_egui::Dialog;
let mut h = harness(|_| {});
h.state_mut().dialog = Some(Dialog::Preferences);
h.run_steps(3);
h.get_by_label("Tools panel side");
h.get_by_label("Right").click();
h.run_steps(3);
assert!(h.state().docking.tools_on_right());
let saved = h.state().persist();
let mut restored = PdfCraftApp::new();
restored.restore(&saved);
assert!(restored.docking.tools_on_right());
h.get_by_label("Left").click();
h.run_steps(3);
assert!(!h.state().docking.tools_on_right());
}
/// Every piece of text painted on the last frame: its whole text, whether it was cut with "…",
/// and where it was drawn.
fn painted_text(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, bool, egui::Rect)> {
h.output()
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some((t.galley.text().to_owned(), t.galley.elided, t.visual_bounding_rect())),
_ => None,
})
.collect()
}
#[test]
fn long_recent_names_and_paths_are_cut_before_the_page_count() {
let name = format!("{}.pdf", "Quarterly report final version ".repeat(10));
let path = format!("/{}/{name}", "A folder with a long name".repeat(8));
let h = harness({
let (name, path) = (name.clone(), path.clone());
move |app| app.recent.push(pdfcraft_ui_egui::RecentFile { name, path, pages: 7, size: 2048 })
});
let text = painted_text(&h);
let detail = text.iter().find(|(s, ..)| s.starts_with("7 pages")).expect("the row shows its page count").2;
for whole in [&name, &path] {
let (_, cut, rect) = text.iter().find(|(s, ..)| s == whole).expect("the row shows the name and the path");
assert!(*cut, "{whole:?} is cut short");
assert!(rect.right() < detail.left(), "{whole:?} ends before the page count");
}
}
#[test]
fn home_removes_one_recent_file_or_clears_them_all() {
// Tall enough that the Recent list is on screen without scrolling.
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1600.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
for name in ["first.pdf", "second.pdf", "third.pdf"] {
app.recent.push(pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0 });
}
app
});
h.run_steps(4);
assert!(h.query_by_label("Remove second.pdf from Recent").is_none(), "the remove button shows only over its row");
h.get_by_label("second.pdf").hover();
h.run_steps(2);
h.get_by_label("Remove second.pdf from Recent").click();
h.run_steps(2);
let names: Vec<String> = h.state().recent.iter().map(|r| r.name.clone()).collect();
assert_eq!(names, ["first.pdf", "third.pdf"], "only that file left the list");
h.get_by_label("Clear Recent Files").click();
h.run_steps(2);
assert!(h.state().recent.is_empty(), "Clear empties the list");
h.get_by_label_contains("Files you open in PdfCraft appear here");
assert!(h.query_by_label("Clear Recent Files").is_none(), "and goes away with nothing left to clear");
}
#[test]
fn every_catalog_tool_is_listed_after_view_more() {
let mut h = harness(|_| {});
h.get_by_label("View more").click();
h.run_steps(3);
for g in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(h.query_all_by_label(g.label).count() >= 1, "tool {} missing from All tools", g.label);
}
}
#[test]
fn opening_a_pdf_shows_comments_and_bookmarks() {
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
// Documents with comments open on the Comments panel, like Acrobat.
h.get_by_label_contains("Check the numbers");
h.get_by_label("Bookmarks").click();
h.run_steps(3);
h.get_by_label("Alpha section");
h.get_by_label("Beta section");
}
/// #1590: side panes the user closed stay closed when PdfCraft restarts and opens another PDF,
/// even one with comments and bookmarks, which would otherwise open on the Comments panel.
#[test]
fn closed_side_panes_stay_closed_after_a_restart_and_opening_a_pdf() {
use pdfcraft_ui_egui::RightPanel;
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
assert_eq!(h.state().right, Some(RightPanel::Comments));
assert!(h.state().left_open);
h.state_mut().choose_right_panel(None);
h.state_mut().set_option("left", "closed").unwrap();
h.run_steps(3);
let saved = h.state().persist();
let mut h = harness(move |app| {
app.restore(&saved);
app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens");
});
assert_eq!(h.state().right, None, "the closed right pane reopened");
assert!(!h.state().left_open, "the closed left pane reopened");
assert!(h.query_by_label_contains("Check the numbers").is_none());
// Within a session too: another document opens with them closed.
h.state_mut().open_bytes("again.pdf", None, FIXTURE.to_vec()).unwrap();
h.run_steps(3);
assert_eq!(h.state().right, None);
assert!(!h.state().left_open);
}
/// Read mode hides the tools, not the navigation panes: the rail's Bookmarks button opens the
/// bookmarks when Read is the default workspace (#932), and the left tool pane stays hidden.
#[test]
fn read_mode_rail_buttons_open_their_panels() {
use pdfcraft_ui_egui::{Mode, RightPanel};
let mut h = harness(|app| {
app.default_mode = Mode::Read;
app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens");
});
assert_eq!(h.state().mode, Mode::Read);
assert_eq!(h.state().right, None, "Read mode opens with the panes closed");
assert!(h.query_by_label("Alpha section").is_none());
h.get_by_label("Bookmarks").click();
h.run_steps(3);
assert_eq!(h.state().right, Some(RightPanel::Bookmarks));
h.get_by_label("Alpha section");
h.get_by_label("Beta section");
// The same button closes them again (the open panel's title is also "Bookmarks").
h.get_by_role_and_label(egui::accesskit::Role::Button, "Bookmarks").click();
h.run_steps(3);
assert_eq!(h.state().right, None);
assert!(h.query_by_label("Alpha section").is_none());
// A document that asks for its outline (/PageMode /UseOutlines) shows it in Read mode too.
let pdf = String::from_utf8(FIXTURE.to_vec()).unwrap().replace("/Outlines 6 0 R", "/Outlines 6 0 R /PageMode /UseOutlines");
h.state_mut().open_bytes("outline.pdf", None, pdf.into_bytes()).unwrap();
h.run_steps(3);
assert_eq!(h.state().mode, Mode::Read);
h.get_by_label("Alpha section");
}
/// Switching to Read mode closes the open pane, and switching back reopens it.
#[test]
fn read_mode_closes_the_panes_and_leaving_it_brings_them_back() {
use pdfcraft_ui_egui::{Mode, RightPanel};
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
assert_eq!(h.state().right, Some(RightPanel::Comments));
h.get_by_label("Read").click();
h.run_steps(3);
assert_eq!(h.state().mode, Mode::Read);
assert_eq!(h.state().right, None);
assert!(h.query_by_label_contains("Check the numbers").is_none(), "the Comments panel is hidden");
h.state_mut().run_command("view.read_mode");
h.run_steps(3);
assert_eq!(h.state().mode, Mode::AllTools);
assert_eq!(h.state().right, Some(RightPanel::Comments));
// A pane opened in Read mode stays open on the way out.
h.state_mut().run_command("view.read_mode");
h.run_steps(2);
h.get_by_label("Page thumbnails").click();
h.run_steps(2);
h.get_by_label("All tools").click();
h.run_steps(3);
assert_eq!(h.state().right, Some(RightPanel::Pages));
}
/// A pane closed in Read mode stays closed on the way out: leaving Read mode doesn't bring back the
/// pane saved on the way in, and closing Comments keeps its close preference.
#[test]
fn a_pane_closed_in_read_mode_stays_closed_when_leaving_it() {
use pdfcraft_ui_egui::{Mode, RightPanel};
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
assert_eq!(h.state().right, Some(RightPanel::Comments));
h.get_by_label("Read").click();
h.run_steps(3);
assert_eq!(h.state().right, None);
// Open Comments from the rail, then close it again.
h.get_by_role_and_label(egui::accesskit::Role::Button, "Comments").click();
h.run_steps(3);
assert_eq!(h.state().right, Some(RightPanel::Comments));
h.get_by_role_and_label(egui::accesskit::Role::Button, "Comments").click();
h.run_steps(3);
assert_eq!(h.state().right, None);
assert!(h.state().comments_panel_closed);
// Leave Read mode through the toolbar.
h.get_by_label("All tools").click();
h.run_steps(3);
assert_eq!(h.state().mode, Mode::AllTools);
assert_eq!(h.state().right, None, "the pane closed in Read mode stays closed");
assert!(h.state().comments_panel_closed);
// The same through the Read mode command.
h.state_mut().choose_right_panel(Some(RightPanel::Comments));
h.state_mut().run_command("view.read_mode");
h.run_steps(2);
assert_eq!(h.state().mode, Mode::Read);
h.state_mut().choose_right_panel(Some(RightPanel::Comments));
h.state_mut().choose_right_panel(None);
h.state_mut().run_command("view.read_mode");
h.run_steps(3);
assert_ne!(h.state().mode, Mode::Read);
assert_eq!(h.state().right, None);
assert!(h.state().comments_panel_closed);
}
/// A right-to-left file name opens, lays out and paints; the tab's accessible name keeps the
/// logical text (only the painted label is put in visual order).
#[test]
fn a_tab_with_an_arabic_file_name_keeps_its_logical_accessible_name() {
let name = "واحد اثنين.pdf";
let mut h = harness(move |app| app.open_bytes(name, None, FIXTURE.to_vec()).expect("fixture opens"));
let tab = h.get_by_label(name).rect();
assert!(tab.width() > 64.0 && tab.height() > 0.0, "{tab:?}");
// A name longer than the tab's 28-character limit is cut on a character boundary.
let long = "واحد اثنين ثلاثة أربعة خمسة ستة سبعة ثمانية.pdf";
h.state_mut().open_bytes(long, None, FIXTURE.to_vec()).expect("fixture opens");
h.run_steps(3);
h.get_by_label(long);
}
#[test]
fn overflowing_tabs_can_be_reached_by_scrolling() {
let mut h = harness(|app| {
for i in 0..20 {
app.open_bytes(&format!("document-{i:02}.pdf"), None, FIXTURE.to_vec()).unwrap();
}
});
let last = h.get_by_label("document-19.pdf").rect();
assert!(last.center().x < 1200.0, "newly opened active tab must be visible: {last:?}");
h.state_mut().active = Some(0);
h.run_steps(4);
let first = h.get_by_label("document-00.pdf").rect();
assert!(first.center().x < 1200.0, "changing active tab must reveal it");
h.event(egui::Event::PointerMoved(first.center()));
for _ in 0..8 {
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(-500.0, 0.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(8);
}
let last = h.get_by_label("document-19.pdf").rect();
assert!(last.center().x > 0.0 && last.center().x < 1200.0, "last tab must be reachable: {last:?}");
h.get_by_label("document-19.pdf").click();
h.run_steps(3);
assert_eq!(h.state().active, Some(19));
assert!(h.get_by_label_contains("Display theme:").rect().center().x < 1400.0);
let close = last.right_center() - egui::vec2(16.0, 0.0);
h.event(egui::Event::PointerMoved(close));
h.event(egui::Event::PointerButton { pos: close, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.step();
h.event(egui::Event::PointerButton { pos: close, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(4);
assert_eq!(h.state().views.len(), 19);
assert!(h.state().active.is_some_and(|i| i < 19));
if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") {
settle(&mut h);
h.render().unwrap().save(format!("{dir}/overflow-tabs.png")).unwrap();
}
}
/// Whether a node labelled `label` is there and enabled (`None`: not there).
fn enabled(h: &Harness<'static, PdfCraftApp>, label: &str) -> Option<bool> {
h.query_by_label(label)?;
Some(h.query_by(|n| n.label().as_deref() == Some(label) && n.is_disabled()).is_none())
}
#[test]
fn tabs_shrink_to_fit_then_arrows_step_through_them_and_open_stays_in_sight() {
// Five long names are wider than the strip: the tabs shrink instead of running under the
// buttons on the right, Open sits after them, and no arrows show.
let name = |i: usize| format!("Statement {i} for the office records, scanned.pdf");
let mut h = harness(move |app| {
for i in 0..5 {
app.open_bytes(&name(i), None, FIXTURE.to_vec()).unwrap();
}
});
let open = h.get_by_label("Open").rect();
let discord = h.get_by_label("Discord").rect();
assert!(open.right() <= discord.left(), "Open {open:?} must not run under Discord {discord:?}");
let mut previous_right = f32::MIN;
for i in 0..5 {
let tab = h.get_by_label(&name(i)).rect();
assert!(tab.left() >= previous_right - 0.5, "tabs must not overlap: {i} at {tab:?}");
assert!(tab.right() <= open.left() + 0.5, "every tab fits before Open: {i} at {tab:?}, Open at {open:?}");
assert!(tab.width() >= 179.0, "tabs keep most of their name: {i} is {tab:?}");
previous_right = tab.right();
}
assert_eq!(enabled(&h, "Scroll tabs left"), None, "no arrows while every tab fits");
// Seven don't fit even at their narrowest: arrows show. The newest tab is active and in
// sight at the end, so only the left arrow can go further.
for i in 5..7 {
h.state_mut().open_bytes(&name(i), None, FIXTURE.to_vec()).unwrap();
}
h.run_steps(6);
assert_eq!((enabled(&h, "Scroll tabs left"), enabled(&h, "Scroll tabs right")), (Some(true), Some(false)));
let first = h.get_by_label(&name(0)).rect();
h.get_by_label("Scroll tabs left").click();
h.run_steps(4);
assert!(h.get_by_label(&name(0)).rect().left() > first.left(), "the left arrow scrolls the tabs back");
assert_eq!(enabled(&h, "Scroll tabs right"), Some(true), "and then there is more to the right");
let open = h.get_by_label("Open").rect();
let right = h.get_by_label("Scroll tabs right").rect();
assert!(right.right() <= open.left() + 0.5 && open.right() <= h.get_by_label("Discord").rect().left());
// Twenty: Open stays in sight left of Discord.
for i in 0..13 {
h.state_mut().open_bytes(&format!("more-{i:02}.pdf"), None, FIXTURE.to_vec()).unwrap();
}
h.run_steps(4);
let open = h.get_by_label("Open").rect();
let discord = h.get_by_label("Discord").rect();
assert!(open.left() > 0.0 && open.right() <= discord.left(), "Open {open:?} stays in sight left of Discord {discord:?}");
}
/// macOS (#789): with the integrated title bar, the strip is the 28 pt band AppKit centres the
/// traffic lights in, and everything in it fits that band and clears the lights.
#[cfg(target_os = "macos")]
#[test]
fn the_integrated_title_bar_is_the_traffic_light_band() {
let h = harness(|app| {
app.integrated_titlebar = true;
app.open_bytes("band.pdf", None, FIXTURE.to_vec()).unwrap();
});
let band = 28.0;
for label in ["Home", "band.pdf", "Open", "Discord", "Keyboard shortcuts"] {
let r = h.get_by_label(label).rect();
assert!(r.top() >= 0.0 && r.bottom() <= band, "{label}: {r:?} inside the {band} pt title bar");
assert!(r.height() >= 24.0, "{label}: {r:?} stays clickable");
}
// Content starts after the three lights (the last one ends 62 pt in) with a gap.
let home = h.get_by_label("Home").rect();
assert!(home.left() >= 62.0 + 4.0, "Home {home:?} clears the traffic lights");
}
#[test]
fn narrow_tab_strips_accept_vertical_wheel_scrolling() {
for width in [600.0, 900.0] {
let mut h = Harness::builder().with_size(egui::vec2(width, 700.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
for i in 0..12 {
app.open_bytes(&format!("narrow-{i:02}.pdf"), None, FIXTURE.to_vec()).unwrap();
}
app
});
h.run_steps(4);
let last = h.get_by_label("narrow-11.pdf").rect();
assert!(last.center().x > 28.0 && last.right() < width - 100.0, "active tab: {last:?}");
h.state_mut().active = Some(0);
h.run_steps(4);
let first = h.get_by_label("narrow-00.pdf").rect();
assert!(first.center().x > 28.0 && first.right() < width - 100.0);
h.event(egui::Event::PointerMoved(first.center()));
for _ in 0..8 {
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -500.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(8);
}
let last = h.get_by_label("narrow-11.pdf").rect();
assert!(last.center().x > 28.0 && last.right() < width - 100.0, "scrolled tab: {last:?}");
h.get_by_label("narrow-11.pdf").click();
h.run_steps(4);
assert_eq!(h.state().active, Some(11));
assert!(h.get_by_label_contains("Display theme:").rect().right() <= width);
if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") {
settle(&mut h);
h.render().unwrap().save(format!("{dir}/tabs-{width}.png")).unwrap();
}
}
}
#[test]
fn garbage_input_is_rejected_without_panicking() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
assert!(app.open_bytes("junk.pdf", None, b"this is not a pdf".to_vec()).is_err());
assert!(app.open_bytes("empty.pdf", None, Vec::new()).is_err());
let mut truncated = FIXTURE.to_vec();
truncated.truncate(FIXTURE.len() / 3);
// A truncated file either repairs or fails cleanly; it must not panic.
let _ = app.open_bytes("truncated.pdf", None, truncated);
}
#[test]
fn theme_and_view_options_apply() {
let mut h = harness(|app| {
app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens");
app.set_option("theme", "dark").unwrap();
app.set_option("panel", "pages").unwrap();
app.set_option("page", "2").unwrap();
});
h.run_steps(3);
h.get_by_label("Page 2");
assert!(h.state_mut().set_option("panel", "nope").is_err());
}
/// Two pages of real text.
const TEXT_FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >> endobj
5 0 obj << /Length 55 >> stream
BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj ET
endstream endobj
6 0 obj << /Length 58 >> stream
BT /F1 14 Tf 20 150 Td (A second brown animal) Tj ET
endstream endobj
7 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
for _ in 0..200 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[test]
fn find_counts_matches_across_pages() {
let mut h = harness(|app| {
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.set_option("find", "brown").unwrap();
});
settle(&mut h);
h.get_by_label_contains("of 2");
let v = &h.state().views[0];
let f = v.find.as_ref().expect("find open");
assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::<Vec<_>>(), vec![0, 1]);
}
#[test]
fn find_reports_no_matches() {
let mut h = harness(|app| {
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.set_option("find", "zebra").unwrap();
});
settle(&mut h);
h.get_by_label("No matches");
}
#[test]
fn drag_selects_text_and_copy_returns_it() {
let mut h = harness(|app| {
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
});
settle(&mut h);
// "The quick brown fox": glyph 4 = 'q', glyph 14 = 'n' of "brown".
let (a, b) = {
let v = &h.state().views[0];
(v.glyph_screen_pos(0, 4).expect("text layer loaded"), v.glyph_screen_pos(0, 14).expect("glyph"))
};
h.hover_at(a);
h.run_steps(1);
h.drag_at(a);
h.run_steps(1);
h.hover_at(a + (b - a) * 0.5);
h.run_steps(1);
h.hover_at(b);
h.run_steps(2);
h.drop_at(b);
h.run_steps(2);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick brown"));
}
#[test]
fn persistence_round_trips_and_tolerates_garbage() {
let mut a = PdfCraftApp::new();
a.set_option("language", "en").unwrap();
a.set_option("theme", "dark").unwrap();
let json = a.persist();
let mut b = PdfCraftApp::new();
b.set_option("language", "en").unwrap();
b.restore(&json);
assert_eq!(b.theme, pdfcraft_ui_egui::theme::ThemeKind::Dark);
b.restore("{not json");
b.restore("{\"recent\": 5, \"theme\": \"Purple\"}");
assert_eq!(b.theme, pdfcraft_ui_egui::theme::ThemeKind::Dark);
}
/// A tool's saved "no fill" (`"fill": null`) clears a fill it had as its default; a missing or
/// malformed fill keeps it (#340).
#[test]
fn a_saved_no_fill_clears_a_comment_tool_default_fill() {
use pdfcraft_ui_egui::comments::CommentTool;
let filled = |a: &mut PdfCraftApp| {
let mut s = a.comment_prefs.style(CommentTool::Rectangle);
s.fill = Some([0.2, 0.4, 0.6]);
a.comment_prefs.set_style(CommentTool::Rectangle, s);
};
let mut a = PdfCraftApp::new();
filled(&mut a);
a.restore(r#"{"comment_styles":[{"tool":"comment.square","fill":null}]}"#);
assert_eq!(a.comment_prefs.style(CommentTool::Rectangle).fill, None, "null clears the fill");
let mut b = PdfCraftApp::new();
filled(&mut b);
b.restore(r#"{"comment_styles":[{"tool":"comment.square","color":[0,0,1]},{"tool":"comment.square","fill":"red"}]}"#);
assert_eq!(b.comment_prefs.style(CommentTool::Rectangle).fill, Some([0.2, 0.4, 0.6]), "missing or malformed keeps it");
// A round trip keeps a cleared fill cleared.
let mut c = PdfCraftApp::new();
c.restore(&a.persist());
assert_eq!(c.comment_prefs.style(CommentTool::Rectangle).fill, None);
}
/// Persisted per-tool comment styles go through their clamping setters: hostile or malformed
/// values are clamped or ignored, never trusted (#340).
#[test]
fn comment_style_settings_clamp_and_refuse_hostile_values() {
use pdfcraft_ui_egui::comments::CommentTool;
let mut a = PdfCraftApp::new();
a.restore(
r#"{"comment_styles":[
{"tool":"comment.highlight","color":[2,-1,0.5],"opacity":99,"width":-4},
{"tool":"comment.note","color":"yellow","opacity":"none"},
{"tool":"comment.note","color":[0.1,null,0.3]},
{"tool":"bogus.command","color":[0,0,0]}
]}"#,
);
// Out-of-range numbers are clamped into range.
let st = a.comment_prefs.style(CommentTool::Highlight);
assert_eq!((st.color, st.opacity, st.width), ([1.0, 0.0, 0.5], 1.0, 0.5));
// A malformed colour is refused whole: the tool keeps its default.
let note = a.comment_prefs.style(CommentTool::Note);
assert_eq!(note.color, [1.0, 0.82, 0.0], "the default colour stays");
}
#[test]
fn zoom_keeps_the_point_under_the_cursor_still() {
let mut h = harness(|app| {
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "none").unwrap();
});
settle(&mut h);
let before = h.state().views[0].glyph_screen_pos(0, 4).expect("glyph on screen");
let z = h.state().views[0].zoom;
h.state_mut().views[0].zoom_at(z * 2.0, before);
h.run_steps(4);
let after = h.state().views[0].glyph_screen_pos(0, 4).expect("glyph still on screen");
assert!((after - before).length() < 2.0, "moved from {before:?} to {after:?}");
}
#[test]
fn trackpad_pinch_zooms_the_pdf_under_the_pointer() {
let mut h = harness(|app| {
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "none").unwrap();
});
settle(&mut h);
let before = h.state().views[0].glyph_screen_pos(0, 4).expect("glyph on screen");
let zoom = h.state().views[0].zoom;
h.hover_at(before);
h.run_steps(1);
h.event(egui::Event::Zoom(1.5));
h.run_steps(4);
let enlarged = &h.state().views[0];
assert!((enlarged.zoom - zoom * 1.5).abs() < 0.01);
let after = enlarged.glyph_screen_pos(0, 4).expect("glyph still on screen");
assert!((after - before).length() < 2.0, "moved from {before:?} to {after:?}");
h.event(egui::Event::Zoom(0.5));
h.run_steps(4);
assert!((h.state().views[0].zoom - zoom * 0.75).abs() < 0.01);
}
#[test]
fn selection_works_in_every_view_rotation() {
for deg in ["90", "180", "270"] {
let mut h = harness(move |app| {
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("rotate", deg).unwrap();
// Keep the whole rotated page on screen.
app.set_option("zoom", "60").unwrap();
});
settle(&mut h);
let (a, b) = {
let v = &h.state().views[0];
(v.glyph_screen_pos(0, 4).expect("glyph"), v.glyph_screen_pos(0, 14).expect("glyph"))
};
h.hover_at(a);
h.run_steps(1);
h.drag_at(a);
h.run_steps(1);
h.hover_at(a + (b - a) * 0.5);
h.run_steps(1);
h.hover_at(b);
h.run_steps(2);
h.drop_at(b);
h.run_steps(2);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick brown"), "rotation {deg}");
}
}
/// A file dropped on the window, as the windowing layer hands it over.
#[derive(Debug)]
struct Dropped {
path: std::path::PathBuf,
bytes: Vec<u8>,
}
impl egui::DroppedFile for Dropped {
fn path(&self) -> &std::path::Path {
&self.path
}
fn bytes(&self) -> Result<Vec<u8>, String> {
Ok(self.bytes.clone())
}
}
#[test]
fn dropping_a_pdf_on_the_window_opens_it() {
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app
});
h.run_steps(3);
assert!(h.state().views.is_empty());
let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: "dropped.pdf".into(), bytes: FIXTURE.to_vec() });
h.input_mut().dropped_files.push(file);
h.run_steps(3);
assert_eq!(h.state().views.len(), 1, "the dropped PDF opens in a tab");
h.get_by_label_contains("dropped.pdf");
}
#[test]
fn a_file_dropped_on_the_page_grid_is_read_from_disk_and_inserted() {
// The grid's drop reads a file from its path when its turn to be inserted comes, and inserts it
// at the end when the pointer doesn't say where it went (about a second).
let dir = std::env::temp_dir().join(format!("pdfcraft-grid-drop-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("dropped.pdf");
let setup = PdfCraftApp::new();
let pdf = setup.session.create_from_text("dropped", "from disk").unwrap();
std::fs::write(&path, pdf.as_slice()).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
let grid = app.session.create_from_text("grid", "one page").unwrap();
app.open_bytes("grid.pdf", None, grid.to_vec()).unwrap();
app.views[0].organize = true;
app
});
h.run_steps(3);
let pages = |h: &egui_kittest::Harness<'_, PdfCraftApp>| {
let app = h.state();
app.session.get(app.views[0].id).unwrap().info.pages.len()
};
assert_eq!(pages(&h), 1);
let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: path.clone(), bytes: Vec::new() });
h.input_mut().dropped_files.push(file);
for _ in 0..300 {
h.run_steps(1);
if pages(&h) == 2 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(pages(&h), 2, "the dropped file's page is inserted at the end of the grid");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pdfs_dropped_on_the_combine_tab_join_its_list() {
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app
});
h.run_steps(3);
h.state_mut().set_option("combine-view", "list").unwrap();
h.state_mut().execute("page.combine");
h.run_steps(2);
// An absolute path is read from disk, with its modified time.
let path = std::env::temp_dir().join(format!("pdfcraft-combine-drop-{}.pdf", std::process::id()));
std::fs::write(&path, FIXTURE).unwrap();
let on_disk: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: path.clone(), bytes: Vec::new() });
let in_memory: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: "second.pdf".into(), bytes: FIXTURE.to_vec() });
h.input_mut().dropped_files.extend([on_disk, in_memory]);
h.run_steps(3);
std::fs::remove_file(&path).ok();
let app = h.state();
assert!(app.views.is_empty(), "nothing opens");
assert_eq!(app.combine_draft.len(), 2);
assert!(app.combine_draft[0].modified.is_some() && app.combine_draft[1].modified.is_none());
h.get_by_label("just now");
}
#[test]
fn a_folder_dropped_on_the_combine_tab_adds_its_pdfs() {
let dir = std::env::temp_dir().join(format!("pdfcraft-combine-drop-dir-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("inner")).unwrap();
std::fs::write(dir.join("one.pdf"), FIXTURE).unwrap();
std::fs::write(dir.join("inner").join("two.pdf"), FIXTURE).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app
});
h.run_steps(3);
h.state_mut().execute("page.combine");
h.run_steps(2);
let folder: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: dir.clone(), bytes: Vec::new() });
h.input_mut().dropped_files.push(folder);
h.run_steps(3);
let _ = std::fs::remove_dir_all(&dir);
let names: Vec<_> = h.state().combine_draft.iter().map(|f| f.name.clone()).collect();
assert_eq!(names, ["two.pdf", "one.pdf"], "subfolders included, in path order");
}
#[test]
fn files_and_quit_from_the_operating_system() {
// #73: macOS hands Finder double-clicks, Open With and Dock drops over as Apple events.
use pdfcraft_ui_egui::OsEvent;
let name = format!("pdfcraft-os-open-{}.pdf", std::process::id());
let path = std::env::temp_dir().join(&name);
std::fs::write(&path, FIXTURE).unwrap();
let queue = std::rc::Rc::new(std::cell::RefCell::new(vec![OsEvent::Open(vec![path.to_string_lossy().into_owned()])]));
let q = queue.clone();
let mut h = harness(move |app| app.os_events = Some(Box::new(move || q.borrow_mut().drain(..).collect())));
std::fs::remove_file(&path).ok();
assert_eq!(h.state().views.len(), 1, "the file opens in a tab");
h.get_by_label_contains(&name);
// Quit closes the window like the close button, so unsaved changes are asked about.
queue.borrow_mut().push(OsEvent::Quit);
h.step();
let closing = h.output().viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Close)));
assert!(closing);
}
#[test]
fn default_workspace_mode_persists_and_tolerates_invalid_settings() {
use pdfcraft_ui_egui::Mode;
for mode in [Mode::AllTools, Mode::Read, Mode::Edit, Mode::Convert, Mode::Sign] {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.default_mode = mode;
app.set_option("mode", "sign").unwrap();
let mut restored = PdfCraftApp::new();
restored.set_option("language", "en").unwrap();
restored.restore(&app.persist());
assert_eq!(restored.default_mode, mode);
restored.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).unwrap();
assert_eq!(restored.mode, mode, "session override must not be persisted");
}
for json in ["{}", "{not json", r#"{"default_mode": null}"#, r#"{"default_mode": 5}"#, r#"{"default_mode": "unknown"}"#] {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.restore(json);
assert_eq!(app.default_mode, Mode::AllTools);
app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).unwrap();
assert_eq!(app.mode, Mode::AllTools);
}
}
#[test]
fn newly_opened_pdfs_use_the_default_workspace() {
use pdfcraft_ui_egui::{LeftPanel, Mode};
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.restore(r#"{"default_mode": "edit"}"#);
app.open_bytes("first.pdf", None, FIXTURE.to_vec()).unwrap();
assert_eq!(app.mode, Mode::Edit);
assert_eq!(app.left, LeftPanel::Tool("edit"));
app.default_mode = Mode::Read;
assert_eq!(app.mode, Mode::Edit, "changing the preference affects future opens");
app.open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap();
assert_eq!(app.mode, Mode::Read);
// PDF Initial View is independent of the workspace preference.
let pdf =
String::from_utf8(FIXTURE.to_vec()).unwrap().replace("/Outlines 6 0 R", "/Outlines 6 0 R /PageLayout /TwoColumnLeft /PageMode /UseOutlines");
app.open_bytes("initial-view.pdf", None, pdf.into_bytes()).unwrap();
assert_eq!(app.mode, Mode::Read);
assert_eq!(app.views.last().unwrap().layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp);
assert_eq!(app.right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
}
#[test]
fn explicit_mode_overrides_default_before_and_after_open() {
use pdfcraft_ui_egui::Mode;
for before in [false, true] {
for (value, mode) in [("all", Mode::AllTools), ("read", Mode::Read), ("edit", Mode::Edit), ("convert", Mode::Convert), ("sign", Mode::Sign)] {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.default_mode = Mode::Read;
if before {
app.set_option("mode", value).unwrap();
}
app.open_bytes("first.pdf", None, FIXTURE.to_vec()).unwrap();
if !before {
// Desktop startup applies CLI options after the initial files open.
app.set_option("mode", value).unwrap();
}
assert_eq!(app.mode, mode);
app.set_option("default-mode", "edit").unwrap();
app.open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap();
assert_eq!(app.mode, mode);
assert_eq!(app.default_mode, Mode::Edit);
}
}
}
#[test]
fn preferences_selects_default_workspace_for_next_open() {
use egui::accesskit::Role;
use pdfcraft_ui_egui::Mode;
let mut h = harness(|app| app.set_option("dialog", "preferences").unwrap());
h.get_by_label("Default workspace mode");
h.get_by_role_and_label(Role::RadioButton, "Read").scroll_to_me();
h.run_steps(4);
h.get_by_role_and_label(Role::RadioButton, "Read").click();
h.run_steps(2);
assert_eq!(h.state().default_mode, Mode::Read);
assert_eq!(h.state().mode, Mode::AllTools);
h.get_by_label("OK").click();
h.run_steps(2);
h.state_mut().open_bytes("fixture.pdf", None, FIXTURE.to_vec()).unwrap();
assert_eq!(h.state().mode, Mode::Read);
}
/// GNOME on Wayland: the tab strip carries the window buttons until Preferences ▸ Use system title
/// bar gives the window back its frame, at once and without a restart (#553, #1001). The choice
/// is saved; desktops that draw the frame themselves don't offer it.
#[test]
fn use_system_title_bar_swaps_the_header_bar_for_the_window_frame() {
use egui::accesskit::Role;
use pdfcraft_ui_egui::window_controls::Layout;
fn decorations(h: &Harness<'static, PdfCraftApp>) -> Option<bool> {
h.output().viewport_output.values().flat_map(|v| v.commands.iter()).find_map(|c| match c {
egui::ViewportCommand::Decorations(on) => Some(*on),
_ => None,
})
}
let mut h = harness(|app| app.window_controls = Some(Layout::default()));
// The window opened undecorated, so the first frame asks for nothing.
assert_eq!(decorations(&h), None);
assert!(h.query_by_label("Minimize").is_some(), "the header bar has the window buttons");
h.state_mut().set_option("dialog", "preferences").unwrap();
h.run_steps(2);
h.get_by_role_and_label(Role::CheckBox, "Use system title bar").scroll_to_me();
h.run_steps(4);
h.get_by_role_and_label(Role::CheckBox, "Use system title bar").click();
let mut asked = None;
for _ in 0..4 {
h.step();
asked = asked.or(decorations(&h));
}
assert!(h.state().system_title_bar);
assert_eq!(asked, Some(true), "the window gets its frame back");
assert!(h.query_by_label("Minimize").is_none(), "and the tab strip loses its buttons");
let saved = h.state().persist();
assert!(saved.contains(r#""system_title_bar":true"#), "{saved}");
let mut restored = PdfCraftApp::new();
restored.restore(&saved);
assert!(restored.system_title_bar);
restored.set_option("system-title-bar", "false").unwrap();
assert!(!restored.system_title_bar);
assert!(restored.set_option("system-title-bar", "maybe").is_err());
// Where the desktop draws the frame there is nothing to choose.
let mut h = harness(|app| app.set_option("dialog", "preferences").unwrap());
h.run_steps(2);
assert!(h.query_by_label("Use system title bar").is_none());
assert!(h.query_by_label("Minimize").is_none());
assert_eq!(decorations(&h), None);
}
/// Preferences ▸ Animation effects: the system's animation setting until the user chooses On or
/// Off. The choice is saved, and settings holding anything else leave it alone.
#[test]
fn preferences_choose_animation_effects_and_the_choice_is_saved() {
use egui::accesskit::Role;
use pdfcraft_ui_egui::motion::AnimationEffects;
let mut h = harness(|app| app.set_option("dialog", "preferences").unwrap());
assert_eq!(h.state().animation_effects, AnimationEffects::System);
h.get_by_label("Animation effects");
for (label, choice) in [
("Off", AnimationEffects::Off),
("On", AnimationEffects::On),
("Use system setting", AnimationEffects::System),
("Off", AnimationEffects::Off),
] {
h.get_by_role_and_label(Role::RadioButton, label).scroll_to_me();
h.run_steps(4);
h.get_by_role_and_label(Role::RadioButton, label).click();
h.run_steps(2);
assert_eq!(h.state().animation_effects, choice, "{label}");
}
let saved = h.state().persist();
assert!(saved.contains(r#""animation_effects":"off""#), "{saved}");
let mut restored = PdfCraftApp::new();
restored.restore(&saved);
assert_eq!(restored.animation_effects, AnimationEffects::Off);
let mut fresh = PdfCraftApp::new();
fresh.restore(&saved.replace(r#""animation_effects":"off""#, r#""animation_effects":"sideways""#));
assert_eq!(fresh.animation_effects, AnimationEffects::System);
// The control channel and the command line take the same three names.
fresh.set_option("animation_effects", "ON").unwrap();
assert_eq!(fresh.animation_effects, AnimationEffects::On);
assert!(fresh.set_option("animation_effects", "sideways").unwrap_err().contains("system, on, or off"));
assert_eq!(fresh.animation_effects, AnimationEffects::On);
}
#[test]
fn explicit_mode_preserves_independent_tool_and_panel_options() {
use pdfcraft_ui_egui::LeftPanel;
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.set_option("tool", "export").unwrap();
app.set_option("left", "closed").unwrap();
app.set_option("mode", "edit").unwrap();
assert_eq!(app.left, LeftPanel::Tool("export"));
assert!(!app.left_open);
app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).unwrap();
assert_eq!(app.left, LeftPanel::Tool("export"));
assert!(!app.left_open);
}
#[test]
fn opening_a_pdf_keeps_a_closed_left_panel_and_the_chosen_tool() {
use pdfcraft_ui_egui::{LeftPanel, Mode};
// `--left closed` without `--mode`, default workspace Edit: the panel stays closed.
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.default_mode = Mode::Edit;
app.set_option("left", "closed").unwrap();
app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).unwrap();
assert_eq!(app.mode, Mode::Edit);
assert!(!app.left_open);
// Default All Tools: opening another PDF leaves the tool panel the user picked.
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.set_option("tool", "export").unwrap();
app.open_bytes("first.pdf", None, FIXTURE.to_vec()).unwrap();
app.open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap();
assert_eq!(app.mode, Mode::AllTools);
assert_eq!(app.left, LeftPanel::Tool("export"));
}
fn three_tab_harness() -> Harness<'static, PdfCraftApp> {
harness(|app| {
for name in ["first.pdf", "middle.pdf", "last.pdf"] {
app.open_bytes(name, None, FIXTURE.to_vec()).unwrap();
}
})
}
fn close_named_tab(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
// The close button has no separate accessible label. Its centre is defined by
// chrome::tab relative to the actual accessible tab rectangle, not a viewport guess.
let pos = h.get_by_role_and_label(egui::accesskit::Role::Tab, name).rect().right_center() - egui::vec2(16.0, 0.0);
h.event(egui::Event::PointerMoved(pos));
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn closing_tabs_keeps_the_selected_document_or_selects_its_neighbor() {
for removed in [0, 1, 2] {
let mut h = three_tab_harness();
let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
h.get_by_label("middle.pdf").click();
h.run_steps(3);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[1]));
close_named_tab(&mut h, ["first.pdf", "middle.pdf", "last.pdf"][removed]);
let expected = if removed == 1 { ids[2] } else { ids[1] };
let app = h.state();
assert_eq!(app.active_ids().map(|(_, id)| id), Some(expected), "removed tab {removed}");
assert_eq!(
app.views.iter().map(|view| view.id).collect::<Vec<_>>(),
ids.iter().copied().enumerate().filter_map(|(i, id)| (i != removed).then_some(id)).collect::<Vec<_>>()
);
assert_eq!(app.session.docs().len(), 2);
assert!(app.session.get(ids[removed]).is_none());
assert!(app.close_request.is_none(), "clean tabs close without a prompt");
for doc in app.session.docs() {
assert_eq!(doc.bytes.as_slice(), FIXTURE);
assert!(!doc.dirty);
}
}
}
#[test]
fn closing_the_last_active_tab_selects_the_previous_one_then_home() {
let mut h = three_tab_harness();
let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
for (removed, name) in [(2usize, "last.pdf"), (1, "middle.pdf"), (0, "first.pdf")] {
close_named_tab(&mut h, name);
let app = h.state();
assert_eq!(app.active_ids().map(|(_, id)| id), removed.checked_sub(1).map(|index| ids[index]));
assert_eq!(app.views.len(), removed);
assert_eq!(app.session.docs().len(), removed);
assert!(app.session.get(ids[removed]).is_none());
}
assert_eq!(h.state().active, None);
h.get_by_label_contains("Welcome to PdfCraft");
}
#[test]
fn cancelling_then_discarding_an_earlier_dirty_tab_preserves_the_selected_document() {
let mut h = three_tab_harness();
let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect();
h.get_by_label("first.pdf").click();
h.run_steps(3);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Unsaved title".into() }));
h.run_steps(3);
h.get_by_label("middle.pdf").click();
h.run_steps(3);
close_named_tab(&mut h, "first.pdf (edited)");
assert_eq!(h.state().close_request, Some(pdfcraft_ui_egui::CloseRequest::Tab(ids[0])));
h.get_by_label("Cancel").click();
h.run_steps(3);
assert_eq!(h.state().views.len(), 3);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[1]));
assert!(h.state().session.get(ids[0]).unwrap().dirty);
close_named_tab(&mut h, "first.pdf (edited)");
h.get_by_label("Don't save").click();
h.run_steps(3);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[1]));
assert_eq!(h.state().views.iter().map(|view| view.id).collect::<Vec<_>>(), [ids[1], ids[2]]);
assert!(h.state().session.get(ids[0]).is_none());
assert!(h.state().close_request.is_none());
for doc in h.state().session.docs() {
assert_eq!(doc.bytes.as_slice(), FIXTURE);
assert!(!doc.dirty);
}
}
#[test]
fn long_german_tool_labels_stay_within_the_sidebar() {
use pdfcraft_ui_egui::{LeftPanel, Mode};
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
h.state_mut().set_option("language", "de").unwrap();
h.state_mut().mode = Mode::AllTools;
h.state_mut().left_open = true;
// The tool heading has to leave room for Back and Close even when translated.
h.state_mut().left = LeftPanel::Tool("fill_sign");
h.run_steps(4);
let heading =
painted_text(&h).into_iter().find(|(s, ..)| s.starts_with("Ausfüllen und")).expect("translated heading is painted, whole or elided");
assert!(heading.2.right() <= 272.0, "translated heading runs past the sidebar: {:?}", heading.2);
// A long sub-tool name must stop before its status chip, not paint over the PDF.
h.state_mut().left = LeftPanel::Tool("protect");
h.run_steps(4);
let item =
painted_text(&h).into_iter().find(|(s, ..)| s == "Ausgeblendete Informationen entfernen").expect("long translated sub-tool is painted");
assert!(item.2.right() <= 272.0, "translated tool item runs past the sidebar: {:?}", item.2);
}
#[test]
fn document_tabs_expose_selection_and_activate_from_keyboard() {
use egui::accesskit::Role;
for key in [egui::Key::Enter, egui::Key::Space] {
let mut h = three_tab_harness();
let first = h.get_by_role_and_label(Role::Tab, "first.pdf");
assert_eq!(first.accesskit_node().is_selected(), Some(false));
assert_eq!(h.get_by_role_and_label(Role::Tab, "last.pdf").accesskit_node().is_selected(), Some(true));
first.focus();
h.run_steps(2);
assert!(h.get_by_role_and_label(Role::Tab, "first.pdf").is_focused());
h.key_press(key);
h.run_steps(3);
assert_eq!(h.state().active, Some(0));
assert_eq!(h.get_by_role_and_label(Role::Tab, "first.pdf").accesskit_node().is_selected(), Some(true));
assert_eq!(h.get_by_role_and_label(Role::Tab, "last.pdf").accesskit_node().is_selected(), Some(false));
assert_eq!(h.state().views.len(), 3, "activation does not hit the close button");
for doc in h.state().session.docs() {
assert_eq!(doc.bytes.as_slice(), FIXTURE);
assert!(!doc.dirty);
}
if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") {
settle(&mut h);
h.render().unwrap().save(format!("{dir}/focused-tab-{key:?}.png")).unwrap();
}
}
}
#[test]
fn document_tab_identity_survives_dirty_labels_and_removing_an_earlier_document() {
use egui::accesskit::Role;
let mut h = three_tab_harness();
h.get_by_role_and_label(Role::Tab, "middle.pdf").click();
h.run_steps(3);
h.get_by_role_and_label(Role::Tab, "middle.pdf").focus();
h.run_steps(2);
let middle_id = h.get_by_role_and_label(Role::Tab, "middle.pdf").accesskit_node().id();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Unsaved title".into() }));
h.run_steps(3);
let dirty = h.get_by_role_and_label(Role::Tab, "middle.pdf (edited)");
assert_eq!(dirty.accesskit_node().id(), middle_id);
assert!(dirty.is_focused());
close_named_tab(&mut h, "first.pdf");
assert_eq!(h.get_by_role_and_label(Role::Tab, "middle.pdf (edited)").accesskit_node().id(), middle_id);
assert_eq!(h.state().active, Some(0));
assert_eq!(h.state().views.len(), 2);
assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty);
}
#[test]
fn combine_tab_uses_tab_interaction_without_changing_close_or_document_state() {
use egui::accesskit::Role;
let mut h = three_tab_harness();
let document_ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect();
h.state_mut().execute("page.combine");
h.run_steps(3);
assert_eq!(h.get_by_role_and_label(Role::Tab, "Combine files").accesskit_node().is_selected(), Some(true));
h.get_by_role_and_label(Role::Tab, "first.pdf").click();
h.run_steps(3);
h.get_by_role_and_label(Role::Tab, "Combine files").focus();
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(h.get_by_role_and_label(Role::Tab, "Combine files").accesskit_node().is_selected(), Some(true));
close_named_tab(&mut h, "Combine files");
assert!(h.query_by_role_and_label(Role::Tab, "Combine files").is_none());
assert_eq!(h.state().views.iter().map(|v| v.id).collect::<Vec<_>>(), document_ids);
}