//! Headless UI tests for the Combine files grid: a thumbnail card per file, selected, moved and //! undone like the list's rows; thumbnails rendered lazily, a few at a time, and never stale. use std::time::Duration; use egui::{Key, Modifiers, Pos2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use pdfcraft_ui_egui::{CombineThumb, CombineView, FilePurpose, PdfCraftApp}; /// An `n`-page document with a proper xref table; page `i` shows "Page i+1". fn fixture(n: usize) -> Vec { fixture_sized(n, 200.0, 300.0) } /// The same, with pages of `w` × `h` points. fn fixture_sized(n: usize, w: f32, h: f32) -> Vec { let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 {w} {h}] >>", kids.join(" "))); objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); for i in 0..n { objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); } let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { offsets.push(out.len()); out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); } let xref = out.len(); out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); for o in offsets { out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); } out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); out } /// A one-page document whose page takes a while to draw (tens of thousands of shapes), so a /// thumbnail render is still under way when a test acts. fn heavy() -> Vec { let mut body = String::from("0.2 0.4 0.8 rg\n"); for i in 0..60_000 { body.push_str(&format!("{} {} 1 1 re f\n", i % 200, (i / 200) % 300)); } let objs = [ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_string(), format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { offsets.push(out.len()); out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); } let xref = out.len(); out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); for o in offsets { out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); } out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); out } /// A two-page fixture that opens with `user` and allows everything with `owner`. fn protected(user: &str, owner: &str) -> Vec { protected_with(pdfcraft_cos::Algorithm::Aes256, user, owner) } fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str) -> Vec { let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap(); doc.set_encryption(&pdfcraft_cos::NewEncryption { algorithm, user_password: user, owner_password: owner, permissions: -1, encrypt_metadata: true, seed: [4; 32], }) .unwrap(); pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() } /// The Combine tab with these files (name, bytes), in its default (grid) view. fn grid_of(files: Vec<(&str, Vec)>, size: egui::Vec2) -> Harness<'static, PdfCraftApp> { grid_after(files, size, 4) } /// The same, after only `steps` frames (1: the first thumbnails have only just been asked for). fn grid_after(files: Vec<(&str, Vec)>, size: egui::Vec2, steps: usize) -> Harness<'static, PdfCraftApp> { let files: Vec<(String, Vec)> = files.into_iter().map(|(n, b)| (n.to_string(), b)).collect(); let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.use_files(FilePurpose::Combine, files); app }); h.run_steps(steps); h } /// The grid on the frame its first thumbnails start rendering (a render started in one frame /// is collected in a later one, so there always is such a frame). fn grid_after_one(files: Vec<(&str, Vec)>, size: egui::Vec2) -> Harness<'static, PdfCraftApp> { let mut h = grid_after(files, size, 0); for _ in 0..20 { h.run_steps(1); if h.state().combine_thumbnails_rendering() > 0 { break; } } h } fn grid_of_pages(names: &[(&str, usize)]) -> Harness<'static, PdfCraftApp> { grid_of(names.iter().map(|(n, p)| (*n, fixture(*p))).collect(), vec2(1400.0, 900.0)) } fn names(h: &Harness<'static, PdfCraftApp>) -> Vec { h.state().combine_draft.iter().map(|f| f.name.clone()).collect() } fn check_duplicate_warnings(view: &str) { let first = fixture_sized(1, 200.0, 300.0); let other = fixture_sized(1, 300.0, 200.0); assert_eq!(first.len(), other.len()); assert_ne!(first, other); let mut h = grid_of(vec![("report.pdf", first.clone()), ("report.pdf", other)], vec2(1400.0, 900.0)); h.state_mut().set_option("combine-view", view).unwrap(); h.run_steps(2); let warning_count = |h: &mut Harness<'static, PdfCraftApp>| { if view == "grid" { let at = h.get_all_by_label("report.pdf").next().unwrap().rect().center(); h.hover_at(at); h.run_steps(60); } h.query_all_by_label_contains("Added more than once").count() }; assert_eq!(warning_count(&mut h), 0, "different equal-sized PDFs are not duplicates in {view}"); h.state_mut().use_files(FilePurpose::Combine, vec![("report.pdf".into(), first)]); h.run_steps(3); assert_eq!(warning_count(&mut h), if view == "grid" { 1 } else { 2 }, "only the two identical files are warned"); h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(3); assert_eq!(warning_count(&mut h), 0, "undo removes the duplicate warning"); h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); h.run_steps(3); assert_eq!(warning_count(&mut h), if view == "grid" { 1 } else { 2 }, "redo restores the duplicate warning"); } #[test] fn list_duplicate_warnings_compare_contents_and_follow_undo() { check_duplicate_warnings("list"); } #[test] fn grid_duplicate_warnings_compare_contents_and_follow_undo() { check_duplicate_warnings("grid"); } /// Run frames until `done`, giving render threads time; whether it happened. fn settle(h: &mut Harness<'static, PdfCraftApp>, done: impl Fn(&PdfCraftApp) -> bool) -> bool { for _ in 0..400 { h.run_steps(1); if done(h.state()) { return true; } std::thread::sleep(Duration::from_millis(10)); } false } fn ready(t: &CombineThumb) -> bool { matches!(t, CombineThumb::Ready { .. }) } /// Press, move in steps and release, as a user drags. fn drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { h.hover_at(from); h.run_steps(1); h.drag_at(from); h.run_steps(1); for k in 1..=6 { h.hover_at(from + (to - from) * (k as f32 / 6.0)); h.run_steps(1); } h.drop_at(to); h.run_steps(4); } /// Where a file's card is. fn card(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Rect { h.get_by_label(name).rect() } fn texts_of(app: &PdfCraftApp, tab: usize) -> Vec { let doc = app.session.get(app.views[tab].id).unwrap(); let mut r = PageRenderer::new(doc.bytes.clone(), Default::default()); (0..r.page_count()) .map(|p| { let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() }); out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default() }) .collect() } #[test] fn combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 2)]); assert_eq!(h.state().combine_view, CombineView::Grid); h.get_by_label_contains("Files are combined in the order shown"); assert!(h.query_by_label("File name").is_none(), "no table headings in the grid"); // The totals stay on screen below the grid. let totals = h.get_by_label_contains("2 files · 3 pages").rect(); assert!(totals.bottom() <= 900.0, "the totals line is visible: {totals:?}"); h.get_by_label("List view").click(); h.run_steps(2); assert_eq!(h.state().combine_view, CombineView::List); h.get_by_label("File name"); // Kept in the settings, and read back. let saved = h.state().persist(); let mut fresh = PdfCraftApp::new(); assert_eq!(fresh.combine_view, CombineView::Grid); fresh.restore(&saved); assert_eq!(fresh.combine_view, CombineView::List); // A damaged setting keeps the default. let mut damaged = PdfCraftApp::new(); damaged.restore(r#"{"combine_view": "tiles"}"#); assert_eq!(damaged.combine_view, CombineView::Grid); // Scriptable, with a clear refusal for anything else. let app = h.state_mut(); app.set_option("combine-view", "grid").unwrap(); assert_eq!(app.combine_view, CombineView::Grid); assert_eq!(app.set_option("combine-view", "tiles"), Err("combine-view must be grid or list".into())); } #[test] fn cards_select_like_the_list_and_the_selection_survives_switching_views() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1), ("e.pdf", 1)]); h.get_by_label("b.pdf").click(); h.run_steps(1); h.get_by_label("d.pdf").click_modifiers(Modifiers::SHIFT); h.run_steps(1); assert_eq!(h.state().combine_selection(), [1, 2, 3]); h.get_by_label("c.pdf").click_modifiers(Modifiers::COMMAND); h.run_steps(1); assert_eq!(h.state().combine_selection(), [1, 3], "Ctrl/⌘ toggles one card"); h.get_by_label("2 selected"); assert!(h.get_by_label("b.pdf").accesskit_node().is_selected() == Some(true), "cards say they are selected"); h.get_by_label("List view").click(); h.run_steps(2); assert_eq!(h.state().combine_selection(), [1, 3]); h.get_by_label("Grid view").click(); h.run_steps(2); assert_eq!(h.state().combine_selection(), [1, 3]); // The toolbar acts on it in the grid too. h.get_by_label("Remove 2 files").click(); h.run_steps(2); assert_eq!(names(&h), ["a.pdf", "c.pdf", "e.pdf"]); } #[test] fn dragging_a_card_moves_it_and_undo_puts_it_back() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1)]); // Sorted first: a move by hand ends the sort, as in the list. h.state_mut().combine_tab.sort = Some((pdfcraft_ui_egui::SortKey::Name, true)); let (a, c) = (card(&h, "a.pdf"), card(&h, "c.pdf")); drag(&mut h, a.center(), c.center() + vec2(c.width() * 0.3, 0.0)); assert_eq!(names(&h), ["b.pdf", "c.pdf", "a.pdf", "d.pdf"]); assert_eq!(h.state().combine_selection(), [2], "the moved card is selected"); assert_eq!(h.state().combine_tab.sort, None); // The list shows the same order. h.get_by_label("List view").click(); h.run_steps(2); let rows: Vec = ["b.pdf", "c.pdf", "a.pdf", "d.pdf"].iter().map(|n| h.get_by_label(n).rect().top()).collect(); assert!(rows.windows(2).all(|w| w[0] < w[1]), "the table lists them in the same order: {rows:?}"); h.get_by_label("Grid view").click(); h.run_steps(2); h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(2); assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf", "d.pdf"]); h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); h.run_steps(2); assert_eq!(names(&h), ["b.pdf", "c.pdf", "a.pdf", "d.pdf"]); } #[test] fn a_dragged_selection_moves_together_to_the_first_and_last_gaps() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1), ("e.pdf", 1)]); // A selection with a gap in it. h.state_mut().select_combine_rows(&[1, 3]); h.run_steps(1); let (b, e) = (card(&h, "b.pdf"), card(&h, "e.pdf")); drag(&mut h, b.center(), e.center() + vec2(e.width() * 0.3, 0.0)); assert_eq!(names(&h), ["a.pdf", "c.pdf", "e.pdf", "b.pdf", "d.pdf"], "to the end, in their order"); assert_eq!(h.state().combine_selection(), [3, 4]); // And back to the very start. let (d, a) = (card(&h, "d.pdf"), card(&h, "a.pdf")); drag(&mut h, d.center(), a.center() - vec2(a.width() * 0.3, 0.0)); assert_eq!(names(&h), ["b.pdf", "d.pdf", "a.pdf", "c.pdf", "e.pdf"]); // A file that isn't selected moves alone, and becomes the selection. let (c, b) = (card(&h, "c.pdf"), card(&h, "b.pdf")); drag(&mut h, c.center(), b.center() - vec2(b.width() * 0.3, 0.0)); assert_eq!(names(&h), ["c.pdf", "b.pdf", "d.pdf", "a.pdf", "e.pdf"]); assert_eq!(h.state().combine_selection(), [0]); } #[test] fn a_drop_that_changes_nothing_or_lands_outside_the_grid_leaves_no_undo_step() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]); // The same drag gesture, to a real gap, does move (and is undone). let (b, c) = (card(&h, "b.pdf"), card(&h, "c.pdf")); drag(&mut h, b.center(), c.center() + vec2(c.width() * 0.3, 0.0)); assert_eq!(names(&h), ["a.pdf", "c.pdf", "b.pdf"]); h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(2); assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"]); h.state_mut().combine_tab.sort = Some((pdfcraft_ui_egui::SortKey::Name, true)); let b = card(&h, "b.pdf"); // Onto its own place. drag(&mut h, b.center(), b.center() + vec2(b.width() * 0.3, 0.0)); assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"]); // Onto the toolbar, outside the grid. let toolbar = h.get_by_label("Grid view").rect().center(); drag(&mut h, b.center(), toolbar); assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"]); assert!(h.state().combine_tab.sort.is_some(), "a move that didn't happen keeps the sort"); // The only step is still the adding of the files: one undo empties the list. h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(2); assert!(names(&h).is_empty()); assert!(h.get_by_label("Undo").accesskit_node().is_disabled()); } #[test] fn arrow_keys_move_by_card_and_by_row_and_shift_extends() { let files: Vec<(String, usize)> = (0..9).map(|i| (format!("f{i}.pdf"), 1)).collect(); let named: Vec<(&str, usize)> = files.iter().map(|(n, p)| (n.as_str(), *p)).collect(); // Narrow enough for several rows. let mut h = grid_of(named.iter().map(|(n, p)| (*n, fixture(*p))).collect(), vec2(760.0, 1200.0)); let top = card(&h, "f0.pdf").top(); let cols = (0..9).filter(|i| (card(&h, &format!("f{i}.pdf")).top() - top).abs() < 1.0).count(); assert!((2..9).contains(&cols), "several cards per row and several rows ({cols} per row)"); h.get_by_label("f0.pdf").click(); h.run_steps(1); h.key_press(Key::ArrowRight); h.run_steps(1); assert_eq!(h.state().combine_selection(), [1]); h.key_press(Key::ArrowDown); h.run_steps(1); assert_eq!(h.state().combine_selection(), [1 + cols], "down one row, same column"); h.key_press(Key::ArrowUp); h.run_steps(1); assert_eq!(h.state().combine_selection(), [1]); h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowDown); h.run_steps(1); assert_eq!(h.state().combine_selection(), (1..=1 + cols).collect::>(), "Shift extends from the anchor"); // On the first row, Up stays. h.get_by_label("f0.pdf").click(); h.run_steps(1); h.key_press(Key::ArrowUp); h.run_steps(1); assert_eq!(h.state().combine_selection(), [0]); // Alt+arrows still move the selection. h.key_press_modifiers(Modifiers::ALT, Key::ArrowDown); h.run_steps(1); assert_eq!(names(&h).first().map(String::as_str), Some("f1.pdf")); } #[test] fn shift_arrows_extend_the_selection_in_the_list_too() { // Regression: a plain arrow matched with Shift held, so Shift+Up/Down only stepped. let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1)]); h.state_mut().set_option("combine-view", "list").unwrap(); h.run_steps(2); h.get_by_label("b.pdf").click(); h.run_steps(1); h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowDown); h.run_steps(1); h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowDown); h.run_steps(1); assert_eq!(h.state().combine_selection(), [1, 2, 3]); h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowUp); h.run_steps(1); assert_eq!(h.state().combine_selection(), [1, 2]); h.key_press(Key::ArrowUp); h.run_steps(1); assert_eq!(h.state().combine_selection(), [1], "a plain arrow still steps"); } #[test] fn thumbnails_show_the_first_page_each_file_takes() { let mut h = grid_of_pages(&[("three.pdf", 3), ("one.pdf", 1)]); h.state_mut().combine_draft[0].range = "3, 1".into(); assert!(settle(&mut h, |app| app.combine_thumbnails().iter().all(ready)), "{:?}", h.state().combine_thumbnails()); let thumbs = h.state().combine_thumbnails(); assert!(matches!(thumbs[0], CombineThumb::Ready { page: 2, .. }), "\"3, 1\" starts with page 3: {:?}", thumbs[0]); assert!(matches!(thumbs[1], CombineThumb::Ready { page: 0, .. })); let CombineThumb::Ready { width, height, .. } = thumbs[1] else { unreachable!() }; assert!(width > 0 && height > 0 && width <= 768 && height <= 768, "{width}×{height}"); // A new range is a new thumbnail, never the old page. h.state_mut().combine_draft[0].range = "2".into(); assert!(settle(&mut h, |app| matches!(app.combine_thumbnails()[0], CombineThumb::Ready { page: 1, .. }))); // A range that can't be used shows no page at all. h.state_mut().combine_draft[0].range = "9".into(); h.run_steps(2); assert_eq!(h.state().combine_thumbnails()[0], CombineThumb::None); } #[test] fn a_locked_file_shows_no_page_until_unlocked_and_undo_locks_it_again() { let mut h = grid_of(vec![("one.pdf", fixture(1)), ("secret.pdf", protected("pw", "owner"))], vec2(1400.0, 900.0)); assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[0]))); assert_eq!(h.state().combine_thumbnails()[1], CombineThumb::None, "nothing is drawn of a locked file"); assert!(h.state_mut().combine_unlock_rows(&[1], "pw")); assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[1])), "{:?}", h.state().combine_thumbnails()); // Undo brings back the locked file: its unlocked thumbnail goes with the password. h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(3); assert!(h.state().combine_draft[1].lock.is_some()); assert_eq!(h.state().combine_thumbnails()[1], CombineThumb::None); // Unlocked again (a new branch of the history): drawn again, from this unlock. assert!(h.state_mut().combine_unlock_rows(&[1], "pw")); assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[1])), "{:?}", h.state().combine_thumbnails()); } #[test] fn many_files_render_only_what_is_in_view_a_few_at_a_time() { // The first few are slow to draw, so renders are still under way while the frames are watched // (small pages can all be drawn before the first look). let files: Vec<(String, Vec)> = (0..200).map(|i| (format!("scan{i:03}.pdf"), if i < 6 { heavy() } else { fixture(1) })).collect(); let mut h = grid_after(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1200.0, 800.0), 0); let mut most = 0; let mut done = false; for _ in 0..800 { h.run_steps(1); // Every render thread counts, including those of finished renders not yet exited. most = most.max(h.state_mut().combine_thumbnail_threads()); let app = h.state(); if app.combine_thumbnails().first().is_some_and(ready) && app.combine_thumbnails_rendering() == 0 { done = true; break; } std::thread::sleep(Duration::from_millis(5)); } assert!(done, "the first cards render"); assert!(most > 0, "renders were seen under way"); assert!(most <= 3, "never more than three render threads at once ({most})"); let thumbs = h.state().combine_thumbnails(); let drawn = thumbs.iter().filter(|t| ready(t)).count(); assert!(drawn > 0 && drawn < 60, "only the cards in view (and a row either side) are rendered: {drawn} of 200"); assert_eq!(thumbs.last(), Some(&CombineThumb::None), "the last card, far out of view, isn't rendered"); } #[test] fn removing_a_file_while_it_renders_and_closing_the_tab_leave_nothing_behind() { let files: Vec<(String, Vec)> = (0..12).map(|i| (format!("f{i}.pdf"), fixture(2))).collect(); let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); // Remove files straight away, while their thumbnails may still be rendering. h.state_mut().select_combine_rows(&[0, 1, 2]); h.key_press(Key::Delete); assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0 && app.combine_thumbnails().iter().take(4).all(ready))); assert_eq!(h.state().combine_thumbnails().len(), 9); // Undo brings them back, and their thumbnails follow. h.key_press_modifiers(Modifiers::COMMAND, Key::Z); assert!(settle(&mut h, |app| app.combine_draft.len() == 12 && app.combine_thumbnails().iter().take(3).all(ready))); // Closing the tab forgets everything. h.state_mut().select_combine_rows(&[0]); h.key_press(Key::Delete); h.run_steps(1); h.state_mut().close_combine_tab(); h.run_steps(2); assert!(h.state().combine_thumbnails().is_empty()); assert_eq!(h.state().combine_thumbnails_rendering(), 0); assert_eq!(h.state().combine_thumbnail_textures(), 0, "no thumbnail texture is kept"); assert!(settle(&mut h, |app| app.combine_thumbnail_threads_exited()), "every render thread exits"); } #[test] fn switching_to_the_list_lets_renders_under_way_finish() { let files: Vec<(String, Vec)> = (0..6).map(|i| (format!("f{i}.pdf"), heavy())).collect(); let mut h = grid_after_one(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); assert!(h.state().combine_thumbnails_rendering() > 0, "renders are under way"); // Straight to the list, while the grid's first thumbnails are still rendering. h.state_mut().set_option("combine-view", "list").unwrap(); assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0), "renders finish and let go of their threads"); for _ in 0..20 { h.run_steps(1); assert_eq!(h.state().combine_thumbnails_rendering(), 0, "nothing new starts in the list"); } assert!(h.state().combine_thumbnails().contains(&CombineThumb::None), "the rest wait for the grid"); assert!(settle(&mut h, |app| app.combine_thumbnail_threads_exited()), "and their threads exit"); // Back in the grid, the rest follow. h.get_by_label("Grid view").click(); assert!(settle(&mut h, |app| app.combine_thumbnails().iter().all(ready))); } #[test] fn combine_follows_the_order_shown_in_the_grid() { let mut h = grid_of_pages(&[("one.pdf", 2), ("two.pdf", 1), ("three.pdf", 1)]); let (three, one) = (card(&h, "three.pdf"), card(&h, "one.pdf")); drag(&mut h, three.center(), one.center() - vec2(one.width() * 0.3, 0.0)); assert_eq!(names(&h), ["three.pdf", "one.pdf", "two.pdf"]); h.get_by_label("Combine").click(); h.run_steps(3); let app = h.state(); assert_eq!(app.views.len(), 1, "the result opens"); assert_eq!(texts_of(app, 0), ["Page 1", "Page 1", "Page 2", "Page 1"]); let doc = app.session.get(app.views[0].id).unwrap(); assert_eq!(doc.info.outline.iter().map(|o| o.title.as_str()).collect::>(), ["three", "one", "two"]); } #[test] fn long_and_unusual_names_are_shortened_on_the_card_but_kept_whole() { let long = "Überweisungsbestätigung für das dritte Quartal 2026 – Endfassung (unterschrieben).pdf"; let mut h = grid_of(vec![(long, fixture(1)), ("日本語のファイル名.pdf", fixture(1)), ("a&b 'd'.pdf", fixture(1))], vec2(1400.0, 900.0)); // The card's label is the whole name; only the drawing is shortened. h.get_by_label(long); h.get_by_label("日本語のファイル名.pdf"); h.get_by_label("a&b 'd'.pdf"); assert!(settle(&mut h, |app| app.combine_thumbnails().iter().all(ready))); } #[test] fn long_thin_pages_get_thumbnails_within_the_size_limits() { // Regression: the scale came from the rounded width alone, so a strip 1 pt wide and 999 pt // long asked for a 9×8192 raster, over the GPU texture limit (a panic in load_texture). let files = vec![ ("strip.pdf", fixture_sized(1, 1.0, 999.0)), ("tall.pdf", fixture_sized(1, 10.0, 3000.0)), ("wide.pdf", fixture_sized(1, 3000.0, 10.0)), ("big.pdf", fixture_sized(1, 14400.0, 14400.0)), ]; let mut h = grid_of(files, vec2(1400.0, 900.0)); assert!(settle(&mut h, |app| app.combine_thumbnails().iter().all(|t| *t != CombineThumb::None && *t != CombineThumb::Pending))); for t in h.state().combine_thumbnails() { match t { CombineThumb::Ready { width, height, .. } => assert!(width <= 768 && height <= 768, "{width}×{height}"), other => panic!("each strip gets a thumbnail: {other:?}"), } } } #[test] fn a_bad_page_range_on_a_card_says_where_to_fix_it() { let mut h = grid_of_pages(&[("a.pdf", 3), ("b.pdf", 1)]); h.state_mut().combine_draft[0].range = "9".into(); h.run_steps(2); let a = card(&h, "a.pdf"); h.hover_at(a.center()); for _ in 0..60 { h.run_steps(1); if h.query_by_label_contains("Switch to List view to change the pages it takes").is_some() { break; } } h.get_by_label_contains("Switch to List view to change the pages it takes"); } #[test] fn a_file_whose_pages_cant_be_measured_says_there_is_no_preview() { // An RC4 owner password opens the file for combining but not for reading it here, so the // page's size is unknown and nothing is drawn. Regression: the card was a blank white page. // The fixture must keep the size unknown: if the inspector learns RC4 owner passwords, this // needs another file it can't measure (the check below says so rather than pass vacuously). let mut h = grid_of(vec![("one.pdf", fixture(1)), ("old.pdf", protected_with(pdfcraft_cos::Algorithm::Rc4_128, "pw", "owner"))], vec2(1400.0, 900.0)); assert!(h.state_mut().combine_unlock_rows(&[1], "owner")); assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[0]))); assert!(h.state().combine_draft[1].lock.is_none(), "unlocked for combining"); assert_eq!(h.state().combine_thumbnails()[1], CombineThumb::None, "nothing rendered (is the page size still unknown?)"); assert_eq!(h.state().combine_thumbnails_rendering(), 0); // Screen readers hear it too, not only in the tooltip. let description = |h: &Harness<'static, PdfCraftApp>, name: &str| h.get_by_label(name).accesskit_node().description().unwrap_or_default(); assert!(description(&h, "old.pdf").contains("No preview"), "{}", description(&h, "old.pdf")); assert_eq!(description(&h, "one.pdf"), "1 page"); let tip_of = |h: &mut Harness<'static, PdfCraftApp>, name: &str, text: &str| { let at = card(h, name).center(); h.hover_at(at); (0..60).any(|_| { h.run_steps(1); h.query_by_label_contains(text).is_some() }) }; assert!(tip_of(&mut h, "old.pdf", "No preview"), "the card says why it shows no page"); h.hover_at(egui::pos2(5.0, 890.0)); h.run_steps(3); assert!(!tip_of(&mut h, "one.pdf", "No preview"), "a card with its page doesn't"); } #[test] fn thumbnails_under_way_finish_on_home_and_in_a_document_tab() { // Regression: they were only collected while the Combine page was drawn. let files: Vec<(String, Vec)> = (0..6).map(|i| (format!("f{i}.pdf"), heavy())).collect(); let mut h = grid_after_one(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); assert!(h.state().combine_thumbnails_rendering() > 0, "renders are under way"); // Home. h.state_mut().combine_tab.focused = false; h.run_steps(1); assert!(!h.state().combine_showing()); assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0 && app.combine_thumbnail_threads_exited())); // A document tab: the same. h.state_mut().open_combine_tab(); h.run_steps(1); h.state_mut().open_bytes("doc.pdf", None, fixture(1)).unwrap(); h.run_steps(1); assert!(h.state().active.is_some()); assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0 && app.combine_thumbnail_threads_exited())); } #[test] fn a_focused_card_still_takes_the_arrow_keys() { // Regression: the keys were ignored whenever any widget, a card included, had focus. let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]); h.get_by_label("a.pdf").click(); h.run_steps(1); h.get_by_label("a.pdf").focus(); h.run_steps(1); h.key_press(Key::ArrowRight); h.run_steps(1); assert_eq!(h.state().combine_selection(), [1]); } #[test] fn a_drag_is_dropped_when_the_list_changes_under_it() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1)]); h.state_mut().select_combine_rows(&[3]); h.key_press(Key::Delete); h.run_steps(2); assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"]); // A drag begins; undo brings d.pdf back while it is under way; then the card is dropped. let (a, c) = (card(&h, "a.pdf"), card(&h, "c.pdf")); let to = c.center() + vec2(c.width() * 0.3, 0.0); h.hover_at(a.center()); h.run_steps(1); h.drag_at(a.center()); h.run_steps(1); for k in 1..=3 { h.hover_at(a.center() + (to - a.center()) * (k as f32 / 6.0)); h.run_steps(1); } h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(1); assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf", "d.pdf"]); h.hover_at(to); h.run_steps(1); h.drop_at(to); h.run_steps(3); assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf", "d.pdf"], "the gap it was aimed at no longer means the same: nothing moves"); } #[test] fn a_card_dragged_to_the_bottom_edge_scrolls_the_grid_and_still_drops() { let files: Vec<(String, Vec)> = (0..30).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(820.0, 860.0)); let from = card(&h, "f00.pdf").center(); let totals = h.get_by_label_contains("30 files").rect(); let edge = egui::pos2(from.x, totals.top() - 30.0); h.hover_at(from); h.run_steps(1); h.drag_at(from); h.run_steps(1); h.hover_at(edge); // Held at the edge, the grid scrolls; the card dragged leaves the rows drawn. for _ in 0..80 { h.run_steps(1); } assert!(h.query_by_label("f00.pdf").is_none(), "scrolled away from the first row"); // A card wholly in view (rows just outside it are drawn too, to scroll smoothly). let top = h.get_by_label("Grid view").rect().bottom() + 40.0; let (target_name, target) = (1..30) .map(|i| format!("f{i:02}.pdf")) .find_map(|n| h.query_by_label(&n).map(|c| c.rect()).filter(|r| r.top() > top && r.bottom() < totals.top() - 20.0).map(|r| (n, r))) .expect("a card in view"); let to = target.center() - vec2(target.width() * 0.3, 0.0); h.hover_at(to); h.run_steps(1); h.drop_at(to); h.run_steps(3); let order = names(&h); let at = order.iter().position(|n| n == "f00.pdf").unwrap(); assert!(at > 0, "moved from the start: {order:?}"); assert_eq!(order.get(at + 1), Some(&target_name), "dropped before {target_name}: {order:?}"); } #[test] fn a_locked_card_offers_unlock_on_a_right_click() { let mut h = grid_of(vec![("one.pdf", fixture(1)), ("secret.pdf", protected("pw", "owner"))], vec2(1400.0, 900.0)); h.get_by_label("secret.pdf").click_secondary(); h.run_steps(2); // The menu's item comes after the toolbar's button of the same name. h.get_all_by_label("Unlock…").last().unwrap().click(); h.run_steps(2); h.get_by_label("Unlock secret.pdf"); assert_eq!(h.state().combine_selection(), [1], "the card right-clicked is the one unlocked"); } fn disabled(h: &Harness<'static, PdfCraftApp>, label: &str) -> bool { h.get_by_label(label).accesskit_node().is_disabled() } /// Ctrl/⌘ with the wheel at `at` (a positive `dy` zooms in). fn ctrl_wheel(h: &mut Harness<'static, PdfCraftApp>, at: Pos2, dy: f32) { h.hover_at(at); h.run_steps(1); h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::COMMAND, }); h.run_steps(3); } #[test] fn the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 2)]); assert_eq!(h.state().combine_zoom, 1.0); let before = card(&h, "a.pdf"); h.get_by_label("Larger pages").click(); h.run_steps(3); assert_eq!(h.state().combine_zoom, 1.25); let after = card(&h, "a.pdf"); assert!(after.width() > before.width() && after.height() > before.height(), "{before:?} → {after:?}"); // Larger until it can't go further: 200%, and the button says so. for _ in 0..10 { if disabled(&h, "Larger pages") { break; } h.get_by_label("Larger pages").click(); h.run_steps(2); } assert_eq!(h.state().combine_zoom, 2.0); assert!(disabled(&h, "Larger pages")); // The percentage resets it. h.get_by_label("Reset page size").click(); h.run_steps(2); assert_eq!(h.state().combine_zoom, 1.0); for _ in 0..10 { if disabled(&h, "Smaller pages") { break; } h.get_by_label("Smaller pages").click(); h.run_steps(2); } assert_eq!(h.state().combine_zoom, 0.6); let smallest = card(&h, "a.pdf"); assert!(smallest.width() < before.width() && smallest.height() < before.height(), "{smallest:?}"); // Kept in the settings, and read back; a damaged setting is clamped or ignored. let saved = h.state().persist(); let mut fresh = PdfCraftApp::new(); fresh.restore(&saved); assert_eq!(fresh.combine_zoom, 0.6); fresh.restore(r#"{"combine_zoom": 99}"#); assert_eq!(fresh.combine_zoom, 2.0); fresh.restore(r#"{"combine_zoom": "big"}"#); assert_eq!(fresh.combine_zoom, 2.0); // Scriptable in percent, with a clear refusal outside the range. let app = h.state_mut(); app.set_option("combine-zoom", "150%").unwrap(); assert_eq!(app.combine_zoom, 1.5); for bad in ["59", "201", "big", "NaN", "inf", ""] { assert!(app.set_option("combine-zoom", bad).is_err(), "{bad:?}"); } assert_eq!(app.combine_zoom, 1.5); // The list has no card size. h.get_by_label("List view").click(); h.run_steps(2); assert!(h.query_by_label("Larger pages").is_none() && h.query_by_label("Page size").is_none()); } #[test] fn the_slider_sizes_the_cards_from_smallest_to_largest() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1)]); let slider = h.get_by_label("Page size").rect(); drag(&mut h, slider.center(), slider.right_center() + vec2(40.0, 0.0)); assert_eq!(h.state().combine_zoom, 2.0); let slider = h.get_by_label("Page size").rect(); drag(&mut h, slider.center(), slider.left_center() - vec2(40.0, 0.0)); assert_eq!(h.state().combine_zoom, 0.6); // The cards are still there and in order. assert_eq!(names(&h), ["a.pdf", "b.pdf"]); assert!(card(&h, "a.pdf").left() < card(&h, "b.pdf").left()); } #[test] fn ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]); h.key_press_modifiers(Modifiers::COMMAND, Key::Equals); h.run_steps(3); assert_eq!(h.state().combine_zoom, 1.25); h.key_press_modifiers(Modifiers::COMMAND, Key::Plus); h.run_steps(3); assert_eq!(h.state().combine_zoom, 1.5); h.key_press_modifiers(Modifiers::COMMAND, Key::Minus); h.run_steps(3); assert_eq!(h.state().combine_zoom, 1.25); h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); h.run_steps(3); assert_eq!(h.state().combine_zoom, 1.0); assert_eq!(h.ctx.zoom_factor(), 1.0, "the window's text keeps its size"); // Ctrl/⌘ with the wheel over the grid: in, then out. let at = card(&h, "b.pdf").center(); ctrl_wheel(&mut h, at, 120.0); let zoomed = h.state().combine_zoom; assert!(zoomed > 1.0, "{zoomed}"); let at = card(&h, "b.pdf").center(); ctrl_wheel(&mut h, at, -240.0); assert!(h.state().combine_zoom < zoomed, "{}", h.state().combine_zoom); // Not over the grid (the toolbar): nothing. let now = h.state().combine_zoom; let toolbar = h.get_by_label("Grid view").rect().center(); ctrl_wheel(&mut h, toolbar, 120.0); assert_eq!(h.state().combine_zoom, now); // Nor in the list. h.get_by_label("List view").click(); h.run_steps(2); h.key_press_modifiers(Modifiers::COMMAND, Key::Equals); h.run_steps(2); assert_eq!(h.state().combine_zoom, now); } #[test] fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank() { let files: Vec<(String, Vec)> = (0..40).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); // Scroll down a little, so the grid isn't simply at its top. let first = card(&h, "f00.pdf"); h.hover_at(first.center()); h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -300.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, }); h.run_steps(6); // A card fully in view, below the toolbar, with its thumbnail drawn. let toolbar = h.get_by_label("Grid view").rect().bottom(); let pick = files .iter() .map(|(n, _)| n.as_str()) .find(|n| h.query_by_label(n).is_some_and(|c| c.rect().top() > toolbar + 80.0 && c.rect().bottom() < 820.0)) .expect("a card in view") .to_string(); let i = names(&h).iter().position(|n| *n == pick).unwrap(); assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[i]))); let CombineThumb::Ready { height: small, .. } = h.state().combine_thumbnails()[i] else { unreachable!() }; let before = card(&h, &pick); h.hover_at(before.center()); h.run_steps(1); h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 120.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::COMMAND, }); // The wheel zooms smoothly over several frames; in every one the card stays put. let mut zooms = Vec::new(); for frame in 0..12 { h.run_steps(1); zooms.push(h.state().combine_zoom); let now = card(&h, &pick); assert!((now.top() - before.top()).abs() < 2.0, "frame {frame}: the card under the pointer moved: {before:?} → {now:?}"); } assert!(h.state().combine_zoom > 1.0, "{zooms:?}"); // Its thumbnail never went blank: the smaller one stands in until the sharper one arrives. for _ in 0..400 { h.run_steps(1); match h.state().combine_thumbnails()[i] { CombineThumb::Ready { height, .. } if height > small => break, CombineThumb::Ready { .. } => {} ref other => panic!("went blank: {other:?}"), } std::thread::sleep(Duration::from_millis(10)); } assert!(matches!(h.state().combine_thumbnails()[i], CombineThumb::Ready { height, .. } if height > small), "re-rendered larger"); } #[test] fn dragging_and_arrow_keys_work_at_the_smallest_and_largest_sizes() { let mut columns = Vec::new(); for zoom in ["60", "200"] { let files: Vec<(String, Vec)> = (0..24).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); h.state_mut().set_option("combine-zoom", zoom).unwrap(); h.run_steps(3); let (a, c) = (card(&h, "f00.pdf"), card(&h, "f02.pdf")); drag(&mut h, a.center(), c.center() + vec2(c.width() * 0.3, 0.0)); assert_eq!(&names(&h)[..4], ["f01.pdf", "f02.pdf", "f00.pdf", "f03.pdf"], "at {zoom}%"); // Down moves one row: as many files as fit across. h.get_by_label("f01.pdf").click(); h.run_steps(1); h.key_press(Key::ArrowDown); h.run_steps(2); let row = h.state().combine_selection(); assert_eq!(row.len(), 1); assert!(row[0] > 1, "at {zoom}%: {row:?}"); columns.push(row[0]); } assert!(columns[0] > columns[1], "more cards fit across when they are smaller: {columns:?}"); } #[test] fn zooming_quickly_never_runs_more_than_three_render_threads() { let files: Vec<(String, Vec)> = (0..60).map(|i| (format!("f{i:02}.pdf"), if i < 6 { heavy() } else { fixture(1) })).collect(); let mut h = grid_after(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0), 0); let mut most = 0; for k in 0..80 { let key = if (k / 5) % 2 == 0 { Key::Equals } else { Key::Minus }; h.key_press_modifiers(Modifiers::COMMAND, key); h.run_steps(1); let threads = h.state_mut().combine_thumbnail_threads(); most = most.max(threads); assert!(threads <= 3, "frame {k}: {threads} render threads"); std::thread::sleep(Duration::from_millis(3)); } assert!(most > 0, "renders were seen under way"); // And it settles: the cards in view all get their thumbnails. assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0 && app.combine_thumbnails().first().is_some_and(ready))); } /// The grid with 40 one-page files, scrolled down by `dy`; and where the top of the grid is. fn scrolled_grid(dy: f32) -> (Harness<'static, PdfCraftApp>, f32) { let files: Vec<(String, Vec)> = (0..40).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); // Unscrolled, the first card sits a fixed space (12 points) below the top of the grid. let top = card(&h, "f00.pdf").top() - 12.0; let at = card(&h, "f00.pdf").center(); h.hover_at(at); h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, }); h.run_steps(8); (h, top) } /// The first card (in list order) still showing below `top`. fn first_in_view(h: &Harness<'static, PdfCraftApp>, top: f32) -> String { names(h).into_iter().find(|n| h.query_by_label(n).is_some_and(|c| c.rect().bottom() > top)).expect("a card in view") } #[test] fn zooming_from_the_toolbar_while_scrolled_keeps_the_top_cards_in_place() { // Regression: with the pointer over the toolbar, a card drawn just above the view (under the // toolbar) was taken as the one to keep in place, and the cards in view jumped. for how in ["keys", "button", "slider"] { let (mut h, top) = scrolled_grid(700.0); let anchor = first_in_view(&h, top); let before = card(&h, &anchor).top(); let over_toolbar = egui::pos2(card(&h, &anchor).center().x, h.get_by_label("Grid view").rect().center().y); h.hover_at(over_toolbar); h.run_steps(1); match how { "keys" => h.key_press_modifiers(Modifiers::COMMAND, Key::Equals), "button" => h.get_by_label("Larger pages").click(), _ => { let s = h.get_by_label("Page size").rect(); drag(&mut h, s.center(), s.right_center() + vec2(40.0, 0.0)); } } h.run_steps(3); assert!(h.state().combine_zoom > 1.0, "{how}"); let after = card(&h, &anchor).top(); assert!((after - before).abs() < 2.0, "{how}: {anchor} moved from {before} to {after}"); } } #[test] fn zooming_out_at_the_bottom_leaves_no_gap_below_the_last_card() { let (mut h, _) = scrolled_grid(20_000.0); h.state_mut().set_option("combine-zoom", "200").unwrap(); h.run_steps(2); // Over the middle of the grid. h.hover_at(egui::pos2(900.0, 600.0)); h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -20_000.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, }); h.run_steps(8); let bottom = card(&h, "f39.pdf").bottom(); h.get_by_label("Smaller pages").click(); for frame in 0..6 { h.run_steps(1); let now = card(&h, "f39.pdf").bottom(); assert!((now - bottom).abs() < 2.0, "frame {frame}: the last card left a gap below it ({bottom} → {now})"); } assert_eq!(h.state().combine_zoom, 1.75); } #[test] fn the_size_slider_takes_the_arrow_keys_when_it_has_the_keyboard() { // Regression: the grid's arrow keys moved the selection instead. let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]); h.get_by_label("a.pdf").click(); h.run_steps(1); h.get_by_label("Page size").focus(); h.run_steps(1); h.key_press(Key::ArrowRight); h.run_steps(2); assert!(h.state().combine_zoom > 1.0, "{}", h.state().combine_zoom); assert_eq!(h.state().combine_selection(), [0], "the selection stays"); let node = h.get_by_label("Page size"); assert!(node.accesskit_node().numeric_value().is_some_and(|v| v > 100.0), "read out as a percentage"); } #[test] fn the_toolbar_fits_the_smallest_window() { // 820 points is the narrowest the window goes. let mut h = grid_after(vec![("a.pdf", fixture(1)), ("b.pdf", fixture(1))], vec2(820.0, 700.0), 4); h.get_by_label("a.pdf").click(); h.run_steps(1); h.get_by_label("b.pdf").click_modifiers(Modifiers::COMMAND); h.run_steps(2); let controls: Vec = [ "Add open documents", "Undo", "Redo", "Grid view", "List view", "2 selected", "Smaller pages", "Page size", "Reset page size", "Larger pages", ] .iter() .map(|l| h.get_by_label(l).rect()) .collect(); for (i, a) in controls.iter().enumerate() { assert!(a.right() <= 820.0, "{i}: {a:?} is off the window"); for b in controls.iter().skip(i + 1) { assert!(!a.intersects(*b), "{a:?} overlaps {b:?}"); } } } /// The pointer over a card (its centre, below the bar of actions at the top of its page). fn hover_card(h: &mut Harness<'static, PdfCraftApp>, name: &str) { let at = card(h, name).center(); h.hover_at(at); h.run_steps(2); } /// A primary click at a point, as a user makes one. fn click_at(h: &mut Harness<'static, PdfCraftApp>, at: Pos2) { h.hover_at(at); h.run_steps(1); for pressed in [true, false] { h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); h.run_steps(1); } h.run_steps(1); } #[test] fn hovering_a_card_shows_its_trash_and_magnifier_and_only_then() { let mut h = grid_of(vec![("a.pdf", fixture(1)), ("b.pdf", fixture(3)), ("secret.pdf", protected("pw", "owner"))], vec2(1400.0, 900.0)); h.hover_at(egui::pos2(5.0, 890.0)); h.run_steps(2); assert!(h.query_by_label("Preview").is_none() && h.query_by_label("Remove a.pdf").is_none(), "nothing until a card is hovered"); hover_card(&mut h, "a.pdf"); h.get_by_label("Preview"); h.get_by_label("Remove a.pdf"); // Over the bar itself it stays (the pointer is still on the card). let bar = h.get_by_label("Preview").rect().center(); h.hover_at(bar); h.run_steps(2); h.get_by_label("Remove a.pdf"); // Another card: its own. hover_card(&mut h, "b.pdf"); h.get_by_label("Remove b.pdf"); assert!(h.query_by_label("Remove a.pdf").is_none()); // A locked file has nothing to show large, but can still be removed. hover_card(&mut h, "secret.pdf"); h.get_by_label("Remove secret.pdf"); assert!(h.query_by_label("Preview").is_none(), "no magnifier on a locked file"); } #[test] fn the_card_trash_removes_only_that_file_and_undo_brings_it_back() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1)]); h.get_by_label("b.pdf").click(); h.run_steps(1); h.get_by_label("c.pdf").click_modifiers(Modifiers::COMMAND); h.run_steps(1); // The trash of a card that isn't selected: that file only, the selection stays. hover_card(&mut h, "a.pdf"); h.get_by_label("Remove a.pdf").click(); h.run_steps(2); assert_eq!(names(&h), ["b.pdf", "c.pdf", "d.pdf"]); assert_eq!(h.state().combine_selection(), [0, 1], "b and c stay selected"); // The trash of a selected card: still that file only. hover_card(&mut h, "b.pdf"); h.get_all_by_label("Remove b.pdf").last().unwrap().click(); h.run_steps(2); assert_eq!(names(&h), ["c.pdf", "d.pdf"]); assert_eq!(h.state().combine_selection(), [0], "c stays selected"); // One undo step each. h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(2); assert_eq!(names(&h), ["b.pdf", "c.pdf", "d.pdf"]); h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(2); assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf", "d.pdf"]); } #[test] fn the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4)]); h.state_mut().combine_draft[1].range = "3, 1".into(); h.get_by_label("a.pdf").click(); h.run_steps(1); assert!(settle(&mut h, |app| ready(&app.combine_thumbnails()[1]))); let CombineThumb::Ready { width: card_width, .. } = h.state().combine_thumbnails()[1] else { unreachable!() }; hover_card(&mut h, "b.pdf"); h.get_by_label("Preview").click(); h.run_steps(2); // The card under it says nothing over it (its tooltip, its actions). for _ in 0..150 { h.run_steps(1); } assert!(h.query_by_label_contains("4 pages ·").is_none(), "no card tooltip over the preview"); assert!(h.query_by_label("Remove b.pdf").is_none(), "no card actions over the preview"); // The first page it adds: page 3. let (name, page, _) = h.state().combine_preview().expect("shown large"); assert_eq!((name.as_str(), page), ("b.pdf", 2)); h.get_by_label("Page 1 of 2"); assert!(disabled(&h, "Previous page")); // Rendered larger than its card. assert!( settle(&mut h, |app| matches!(app.combine_preview(), Some((_, _, CombineThumb::Ready { width, .. })) if width > card_width)), "{:?}", h.state().combine_preview() ); // The arrow keys flip, and stop at the ends; the grid's selection doesn't move. h.key_press(Key::ArrowRight); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| p.1), Some(0), "then page 1"); h.get_by_label("Page 2 of 2"); assert!(disabled(&h, "Next page")); h.key_press(Key::ArrowRight); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| p.1), Some(0)); h.get_by_label("Previous page").click(); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| p.1), Some(2)); assert_eq!(h.state().combine_selection(), [0]); // Esc closes it; so do the close button and a click outside. h.key_press(Key::Escape); h.run_steps(2); assert!(h.state().combine_preview().is_none()); for how in ["close", "outside"] { hover_card(&mut h, "b.pdf"); h.get_by_label("Preview").click(); h.run_steps(2); assert!(h.state().combine_preview().is_some(), "{how}"); match how { "close" => h.get_by_label("Close").click(), _ => click_at(&mut h, egui::pos2(6.0, 6.0)), } h.run_steps(2); assert!(h.state().combine_preview().is_none(), "{how}"); } assert_eq!(names(&h), ["a.pdf", "b.pdf"], "nothing else changed"); } #[test] fn a_right_click_on_a_card_offers_preview_and_remove() { let mut h = grid_of_pages(&[("a.pdf", 2), ("b.pdf", 1)]); h.get_by_label("a.pdf").click_secondary(); h.run_steps(2); // The menu's items come after the card's own buttons of the same name. h.get_all_by_label("Preview").last().unwrap().click(); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| (p.0, p.1)), Some(("a.pdf".to_string(), 0))); h.key_press(Key::Escape); h.run_steps(2); h.get_by_label("b.pdf").click_secondary(); h.run_steps(2); h.get_all_by_label("Remove b.pdf").last().unwrap().click(); h.run_steps(2); assert_eq!(names(&h), ["a.pdf"]); } #[test] fn the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads() { let files: Vec<(String, Vec)> = (0..12).map(|i| (format!("f{i:02}.pdf"), heavy())).collect(); let mut h = grid_after_one(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); hover_card(&mut h, "f03.pdf"); h.get_by_label("Preview").click(); let (mut most, mut rendered) = (0, false); for frame in 0..60 { h.run_steps(1); let threads = h.state_mut().combine_thumbnail_threads(); most = most.max(threads); assert!(threads <= 3, "frame {frame}: {threads} render threads"); // The large page itself is rendered (under way, or done) among them. rendered |= matches!(h.state().combine_preview(), Some((_, _, CombineThumb::Pending | CombineThumb::Ready { .. }))); std::thread::sleep(Duration::from_millis(5)); } assert!(most > 0); assert!(rendered, "the preview's own render ran: {:?}", h.state().combine_preview()); // The file leaves the list (as undo of its adding would): the preview closes, nothing stays. h.state_mut().combine_draft.retain(|f| f.name != "f03.pdf"); h.run_steps(2); assert!(h.state().combine_preview().is_none()); assert!(settle(&mut h, |app| app.combine_thumbnails_rendering() == 0 && app.combine_thumbnail_threads_exited())); } #[test] fn space_shows_the_selected_file_large_in_the_grid_and_the_list() { // The way to the preview without a pointer (screen readers, the keyboard). let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 2), ("c.pdf", 1)]); h.key_press(Key::Space); h.run_steps(2); assert!(h.state().combine_preview().is_none(), "nothing selected, nothing shown"); h.get_by_label("a.pdf").click(); h.run_steps(1); h.key_press(Key::ArrowRight); h.run_steps(1); h.key_press(Key::Space); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| p.0), Some("b.pdf".to_string()), "the file the keyboard is on"); // While it shows, the grid's keys wait: an arrow flips its pages, Delete removes nothing. h.key_press(Key::ArrowRight); h.key_press(Key::Delete); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| p.1), Some(1)); assert_eq!(names(&h).len(), 3); assert_eq!(h.state().combine_selection(), [1]); h.key_press(Key::Escape); h.run_steps(2); assert!(h.state().combine_preview().is_none()); // The list too. h.get_by_label("List view").click(); h.run_steps(2); h.key_press(Key::Space); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| p.0), Some("b.pdf".to_string())); } #[test] fn flipping_quickly_ends_on_the_page_shown_and_leaving_the_tab_closes_the_preview() { let mut h = grid_of_pages(&[("a.pdf", 6)]); hover_card(&mut h, "a.pdf"); h.get_by_label("Preview").click(); h.run_steps(1); // A page a frame, faster than they render. for _ in 0..4 { h.key_press(Key::ArrowRight); h.run_steps(1); } assert_eq!(h.state().combine_preview().map(|p| p.1), Some(4)); assert!( settle(&mut h, |app| matches!(app.combine_preview(), Some((_, 4, CombineThumb::Ready { page: 4, .. })))), "the render shown is of the page shown: {:?}", h.state().combine_preview() ); // Home, then back: the preview is gone, not waiting there. h.state_mut().combine_tab.focused = false; h.run_steps(2); assert!(h.state().combine_preview().is_none()); h.state_mut().open_combine_tab(); h.run_steps(2); assert!(h.state().combine_preview().is_none()); assert!(h.query_by_label("Page 5 of 6").is_none()); } #[test] fn space_on_a_focused_button_presses_it_and_previews_only_what_can_be_shown() { // Regression: with a file selected, Space opened the preview whatever had the keyboard. let mut h = grid_of(vec![("a.pdf", fixture(2)), ("old.pdf", protected_with(pdfcraft_cos::Algorithm::Rc4_128, "pw", "owner"))], vec2(1400.0, 900.0)); h.get_by_label("a.pdf").click(); h.run_steps(1); h.get_by_label("List view").focus(); h.run_steps(1); h.key_press(Key::Space); h.run_steps(2); assert_eq!(h.state().combine_view, CombineView::List, "the focused button was pressed"); assert!(h.state().combine_preview().is_none()); // A file whose pages can't be measured has nothing to show large (its card has no magnifier). h.get_by_label("Grid view").click(); h.run_steps(1); assert!(h.state_mut().combine_unlock_rows(&[1], "owner")); h.run_steps(2); h.get_by_label("old.pdf").click(); h.run_steps(1); h.key_press(Key::Space); h.run_steps(2); assert!(h.state().combine_preview().is_none(), "nothing to show"); } #[test] fn a_drag_held_while_the_tab_closes_and_reopens_moves_nothing() { // Regression: closing the tab started the list's revisions over, so a drag begun before could // match a new list and move one of its files. let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]); let (a, c) = (card(&h, "a.pdf"), card(&h, "c.pdf")); h.hover_at(a.center()); h.run_steps(1); h.drag_at(a.center()); h.run_steps(1); h.hover_at(a.center() + vec2(30.0, 0.0)); h.run_steps(1); // While held: the tab closes, and the same files come back. h.state_mut().close_combine_tab(); h.state_mut().use_files(FilePurpose::Combine, ["a.pdf", "b.pdf", "c.pdf"].iter().map(|n| (n.to_string(), fixture(1))).collect()); h.run_steps(2); let to = c.center() + vec2(c.width() * 0.3, 0.0); h.hover_at(to); h.run_steps(1); h.drop_at(to); h.run_steps(3); assert_eq!(names(&h), ["a.pdf", "b.pdf", "c.pdf"], "the old drag moves nothing in the new list"); } // ---- Stage 3: a file shown as its pages ---- /// The label of a page card: "Page 3 · b.pdf". fn page_card(page: usize, name: &str) -> String { format!("Page {page} · {name}") } /// Expand a file from its card's hover bar. fn expand(h: &mut Harness<'static, PdfCraftApp>, name: &str) { hover_card(h, name); h.get_by_label("Expand").click(); h.run_steps(2); } #[test] fn expanding_a_file_shows_the_pages_it_adds_in_order_with_their_numbers() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4)]); h.state_mut().combine_draft[1].range = "3, 1, 4".into(); h.run_steps(2); // A single page has nothing to expand. hover_card(&mut h, "a.pdf"); assert!(h.query_by_label("Expand").is_none()); expand(&mut h, "b.pdf"); assert_eq!(h.state().combine_expanded(), [1]); assert!(h.query_by_label("b.pdf").is_none(), "the file's card gives way to its pages"); // The pages it adds, in the range's order, each saying which page of which file. let xs: Vec = [3, 1, 4].iter().map(|p| card(&h, &page_card(*p, "b.pdf")).left()).collect(); assert!(xs.windows(2).all(|w| w[0] < w[1]), "pages 3, 1, 4 in that order: {xs:?}"); assert!(h.query_by_label(&page_card(2, "b.pdf")).is_none(), "page 2 isn't added, so isn't shown"); // Each renders its own page. assert!(settle(&mut h, |app| { let pages = app.combine_page_thumbnails(1); pages.len() == 3 && pages.iter().all(|(p, t)| matches!(t, CombineThumb::Ready { page, .. } if page == p)) })); // A page card collapses the file again. hover_card(&mut h, &page_card(1, "b.pdf")); h.get_by_label("Collapse").click(); h.run_steps(2); assert!(h.state().combine_expanded().is_empty()); h.get_by_label("b.pdf"); } #[test] fn expand_all_and_collapse_all_from_the_toolbar_and_a_right_click() { let mut h = grid_of( vec![("a.pdf", fixture(1)), ("b.pdf", fixture(3)), ("c.pdf", fixture(2)), ("secret.pdf", protected("pw", "owner"))], vec2(1400.0, 900.0), ); assert!(disabled(&h, "Collapse all"), "nothing to collapse yet"); h.get_by_label("Expand all").click(); h.run_steps(2); assert_eq!(h.state().combine_expanded(), [1, 2], "every file adding more than one page; not a single page, not a locked file"); assert!(disabled(&h, "Expand all")); h.get_by_label("Collapse all").click(); h.run_steps(2); assert!(h.state().combine_expanded().is_empty()); // On a right click too (the pointer then off the card, so its hover bar is gone and the // label is the menu's alone). h.get_by_label("c.pdf").click_secondary(); h.run_steps(2); h.hover_at(egui::pos2(5.0, 890.0)); h.run_steps(2); h.get_by_label("Expand").click(); h.run_steps(2); assert_eq!(h.state().combine_expanded(), [2]); h.get_by_label(&page_card(2, "c.pdf")).click_secondary(); h.run_steps(2); h.hover_at(egui::pos2(5.0, 890.0)); h.run_steps(2); h.get_by_label("Collapse").click(); h.run_steps(2); assert!(h.state().combine_expanded().is_empty()); } #[test] fn expanding_is_a_view_kept_across_views_and_zoom_and_never_an_undo_step() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 3)]); let undo = disabled(&h, "Undo"); expand(&mut h, "b.pdf"); assert_eq!(disabled(&h, "Undo"), undo, "not an edit: the undo history is as it was"); assert!(disabled(&h, "Redo")); h.get_by_label("List view").click(); h.run_steps(2); h.get_by_label("Grid view").click(); h.run_steps(2); h.get_by_label("Larger pages").click(); h.run_steps(2); assert_eq!(h.state().combine_expanded(), [1], "still shown as its pages"); h.get_by_label(&page_card(3, "b.pdf")); // Its range edited down to one page: that page's card (a part of a file can be one page). h.state_mut().combine_draft[1].range = "2".into(); h.run_steps(2); h.get_by_label(&page_card(2, "b.pdf")); // A bad range: its card again, no longer expanded, and it doesn't come back expanded by // itself when the range is fixed. h.state_mut().combine_draft[1].range = "9".into(); h.run_steps(2); h.get_by_label("b.pdf"); assert!(h.state().combine_expanded().is_empty(), "no longer expanded"); assert!(disabled(&h, "Collapse all")); h.state_mut().combine_draft[1].range = String::new(); h.run_steps(2); h.get_by_label("b.pdf"); assert!(h.state().combine_expanded().is_empty()); expand(&mut h, "b.pdf"); h.get_by_label(&page_card(2, "b.pdf")); // The file removed (its file selected with the keys, then Delete): forgotten; undo brings // the file back as its card. h.get_by_label("a.pdf").click(); h.run_steps(1); h.key_press(Key::ArrowRight); h.run_steps(1); assert_eq!(h.state().combine_selection(), [1]); h.key_press(Key::Delete); h.run_steps(2); assert_eq!(names(&h), ["a.pdf"]); assert!(h.state().combine_expanded().is_empty()); h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(2); assert_eq!(names(&h), ["a.pdf", "b.pdf"]); h.get_by_label("b.pdf"); } #[test] fn a_page_click_selects_that_page_and_the_arrows_step_rows_of_cards() { // a, then b's 6 pages, then eight more files: rows of cards below b's last page whatever // the window's width. let mut files = vec![("a.pdf".to_string(), fixture(1)), ("b.pdf".to_string(), fixture(6))]; files.extend((0..8).map(|i| (format!("f{i}.pdf"), fixture(1)))); let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1000.0, 1100.0)); expand(&mut h, "b.pdf"); let top = card(&h, "a.pdf").top(); let cols = ["a.pdf".to_string(), page_card(1, "b.pdf"), page_card(2, "b.pdf"), page_card(3, "b.pdf"), page_card(4, "b.pdf")] .iter() .filter(|n| (card(&h, n).top() - top).abs() < 1.0) .count(); assert!((2..5).contains(&cols), "a row of cards is shorter than a + b's pages ({cols})"); h.get_by_label(&page_card(2, "b.pdf")).click(); h.run_steps(2); assert_eq!(h.state().combine_selected_pages(), [(1, 1)], "that page"); assert!(h.state().combine_selection().is_empty(), "no file"); assert!(h.get_by_label(&page_card(2, "b.pdf")).accesskit_node().is_selected() == Some(true)); assert!(h.get_by_label(&page_card(5, "b.pdf")).accesskit_node().is_selected() == Some(false), "only that page"); // Ctrl adds, Shift takes a run. h.get_by_label(&page_card(4, "b.pdf")).click_modifiers(Modifiers::COMMAND); h.run_steps(2); assert_eq!(h.state().combine_selected_pages(), [(1, 1), (1, 3)]); h.get_by_label(&page_card(6, "b.pdf")).click_modifiers(Modifiers::SHIFT); h.run_steps(2); assert_eq!(h.state().combine_selected_pages(), [(1, 3), (1, 4), (1, 5)], "from the last page clicked"); // Down goes a row of cards from b's last page, to the file below it; Up comes back to b. let last = card(&h, &page_card(6, "b.pdf")); h.key_press(Key::ArrowDown); h.run_steps(2); let below = h.state().combine_selection(); assert_eq!(below.len(), 1); assert!(h.state().combine_selected_pages().is_empty(), "the keys select files again"); let name = names(&h)[below[0]].clone(); assert!(below[0] > 1, "a file after b: {name}"); let r = card(&h, &name); assert!((r.left() - last.left()).abs() < 1.0 && r.top() > last.top(), "the card right below b's last page: {r:?} vs {last:?}"); h.key_press(Key::ArrowUp); h.run_steps(2); assert_eq!(h.state().combine_selection(), [1]); h.key_press(Key::ArrowLeft); h.run_steps(2); assert_eq!(h.state().combine_selection(), [0], "left and right step by file"); } #[test] fn dragging_a_page_moves_that_page_even_between_another_files_pages() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4), ("c.pdf", 1)]); expand(&mut h, "b.pdf"); // b's page 2 dragged after c: that page alone moves; b is now in two parts. let (p2, c) = (card(&h, &page_card(2, "b.pdf")), card(&h, "c.pdf")); drag(&mut h, p2.center(), c.center() + vec2(c.width() * 0.3, 0.0)); let parts = |h: &Harness<'static, PdfCraftApp>| h.state().combine_parts_shown().into_iter().map(|(n, p)| format!("{n}:{p:?}")).collect::>(); assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 2, 3]", "c.pdf:[0]", "b.pdf:[1]"]); // One undo step puts it back, as one file again. h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(2); assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 1, 2, 3]", "c.pdf:[0]"]); // Within its file: only its order changes (page 4 to the front). let (p4, p1) = (card(&h, &page_card(4, "b.pdf")), card(&h, &page_card(1, "b.pdf"))); drag(&mut h, p4.center(), p1.center() - vec2(p1.width() * 0.3, 0.0)); assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[3, 0, 1, 2]", "c.pdf:[0]"]); // Selected pages go together, in their order: pages 1 and 3 (now 2nd and 4th) before a. h.get_by_label(&page_card(1, "b.pdf")).click(); h.run_steps(1); h.get_by_label(&page_card(3, "b.pdf")).click_modifiers(Modifiers::COMMAND); h.run_steps(2); let (p3, a) = (card(&h, &page_card(3, "b.pdf")), card(&h, "a.pdf")); drag(&mut h, p3.center(), a.center() - vec2(a.width() * 0.3, 0.0)); assert_eq!(parts(&h), ["b.pdf:[0, 2]", "a.pdf:[0]", "b.pdf:[3, 1]", "c.pdf:[0]"]); // A file's card still moves its whole file, and a drop among a file's pages goes to its nearer end. let (cc, early) = (card(&h, "c.pdf"), card(&h, &page_card(3, "b.pdf"))); drag(&mut h, cc.center(), early.center() - vec2(early.width() * 0.3, 0.0)); assert_eq!(names(&h), ["b.pdf", "c.pdf", "a.pdf", "b.pdf"], "the second of that part's two pages: its end"); } #[test] fn a_long_file_expanded_renders_only_its_pages_in_view_within_three_threads() { let mut h = grid_of(vec![("long.pdf", fixture(300)), ("b.pdf", fixture(1))], vec2(1400.0, 900.0)); h.get_by_label("Expand all").click(); let mut most = 0; for _ in 0..200 { h.run_steps(1); let threads = h.state_mut().combine_thumbnail_threads(); most = most.max(threads); assert!(threads <= 3, "{threads} render threads"); if h.state().combine_thumbnails_rendering() == 0 && h.state().combine_page_thumbnails(0).first().is_some_and(|(_, t)| ready(t)) { break; } std::thread::sleep(Duration::from_millis(5)); } let pages = h.state().combine_page_thumbnails(0); assert_eq!(pages.len(), 300); let drawn = pages.iter().filter(|(_, t)| ready(t)).count(); assert!(drawn > 0 && drawn < 60, "only the pages in view (and a row either side): {drawn} of 300"); assert!(h.query_by_label(&page_card(300, "long.pdf")).is_none(), "the last page, far out of view, isn't even laid out as a widget"); } #[test] fn a_page_cards_magnifier_opens_the_preview_on_that_page() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 5)]); // Pages 3, 1, 4: page 4 is the third the file adds. h.state_mut().combine_draft[1].range = "3, 1, 4".into(); h.run_steps(2); expand(&mut h, "b.pdf"); hover_card(&mut h, &page_card(4, "b.pdf")); h.get_by_label("Preview").click(); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| (p.0, p.1)), Some(("b.pdf".to_string(), 3))); h.get_by_label("Page 3 of 3"); // A page card's own actions: Collapse, Remove page, Preview (its file can't be removed from // it); nothing is selected yet, so no "Remove b.pdf" anywhere. h.get_by_label("Remove page"); h.key_press(Key::Escape); h.run_steps(2); hover_card(&mut h, &page_card(4, "b.pdf")); h.get_by_label("Collapse"); assert!(h.query_by_label("Remove b.pdf").is_none()); // Space on a page card that has the keyboard: that page too. h.get_by_label(&page_card(1, "b.pdf")).click(); h.run_steps(2); h.get_by_label(&page_card(1, "b.pdf")).focus(); h.run_steps(2); h.key_press(Key::Space); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| p.1), Some(0), "page 1, the second the file adds"); h.get_by_label("Page 2 of 3"); } #[test] fn combining_with_files_expanded_gives_the_same_document() { let mut h = grid_of_pages(&[("one.pdf", 2), ("two.pdf", 3)]); h.state_mut().combine_draft[1].range = "3, 1".into(); h.get_by_label("Expand all").click(); h.run_steps(2); assert_eq!(h.state().combine_expanded(), [0, 1]); h.get_by_label("Combine").click(); h.run_steps(3); let app = h.state(); assert_eq!(app.views.len(), 1, "the result opens"); assert_eq!(texts_of(app, 0), ["Page 1", "Page 2", "Page 3", "Page 1"]); } #[test] fn collapsing_brings_the_files_card_into_view() { // Many files after it, so the grid stays long once it collapses (it would otherwise scroll // back by itself). let mut files = vec![("long.pdf".to_string(), fixture(60))]; files.extend((0..40).map(|i| (format!("f{i:02}.pdf"), fixture(1)))); let mut h = grid_of(files.iter().map(|(n, b)| (n.as_str(), b.clone())).collect(), vec2(1400.0, 900.0)); expand(&mut h, "long.pdf"); // Down to its last pages. h.hover_at(egui::pos2(900.0, 600.0)); for _ in 0..80 { if h.query_by_label(&page_card(60, "long.pdf")).is_some_and(|c| c.rect().top() > 300.0 && c.rect().bottom() < 880.0) { break; } h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -120.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, }); h.run_steps(3); } hover_card(&mut h, &page_card(60, "long.pdf")); h.get_by_label("Collapse").click(); for _ in 0..20 { h.run_steps(1); } let r = card(&h, "long.pdf"); let toolbar = h.get_by_label("Grid view").rect().bottom(); assert!(r.bottom() > toolbar && r.top() < 900.0, "the file's card is in view: {r:?}"); } #[test] fn a_file_too_long_to_spread_out_offers_no_expand() { // 2001 pages: more cards than are laid out comfortably every frame. let mut h = grid_of(vec![("huge.pdf", fixture(2001)), ("b.pdf", fixture(3))], vec2(1400.0, 900.0)); hover_card(&mut h, "huge.pdf"); assert!(h.query_by_label("Expand").is_none()); h.get_by_label("Expand all").click(); h.run_steps(2); assert_eq!(h.state().combine_expanded(), [1], "only the file that can be"); } #[test] fn removing_pages_from_the_hover_bar_the_toolbar_and_delete_never_touches_the_file() { let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4)]); let original = h.state().combine_draft[1].bytes.clone(); expand(&mut h, "b.pdf"); let parts = |h: &Harness<'static, PdfCraftApp>| h.state().combine_parts_shown().into_iter().map(|(n, p)| format!("{n}:{p:?}")).collect::>(); // A page card's trash: that page. hover_card(&mut h, &page_card(2, "b.pdf")); h.get_by_label("Remove page").click(); h.run_steps(2); assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 2, 3]"]); // Two selected: the toolbar's trash says so, and takes both. h.get_by_label(&page_card(1, "b.pdf")).click(); h.run_steps(1); h.get_by_label(&page_card(4, "b.pdf")).click_modifiers(Modifiers::COMMAND); h.run_steps(2); h.hover_at(egui::pos2(5.0, 890.0)); h.run_steps(1); h.get_by_label("Remove 2 pages").click(); h.run_steps(2); assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[2]"]); // Delete on the last page left: the file leaves the list. h.get_by_label(&page_card(3, "b.pdf")).click(); h.run_steps(2); h.key_press(Key::Delete); h.run_steps(2); assert_eq!(parts(&h), ["a.pdf:[0]"]); // Each was one undo step; the file itself was never changed. for expected in [vec!["a.pdf:[0]", "b.pdf:[2]"], vec!["a.pdf:[0]", "b.pdf:[0, 2, 3]"], vec!["a.pdf:[0]", "b.pdf:[0, 1, 2, 3]"]] { h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(2); assert_eq!(parts(&h), expected); } assert!(std::sync::Arc::ptr_eq(&h.state().combine_draft[1].bytes, &original), "the same, untouched bytes"); } #[test] fn a_split_file_counts_once_and_combines_with_one_bookmark() { let mut h = grid_of_pages(&[("one.pdf", 2), ("two.pdf", 3)]); expand(&mut h, "one.pdf"); expand(&mut h, "two.pdf"); let totals = |h: &Harness<'static, PdfCraftApp>| { let n = h.get_by_label_contains(" pages · "); let a = n.accesskit_node(); a.label().or(a.value()).unwrap_or_default().to_string() }; let before = totals(&h); assert!(before.starts_with("2 files · 5 pages · "), "{before}"); // one's page 2 between two's pages 1 and 2. let (p2, t2) = (card(&h, &page_card(2, "one.pdf")), card(&h, &page_card(2, "two.pdf"))); drag(&mut h, p2.center(), t2.center() - vec2(t2.width() * 0.3, 0.0)); assert_eq!(names(&h), ["one.pdf", "two.pdf", "one.pdf", "two.pdf"]); // Still two files, of the same size (each counted once), and the parts aren't "added more // than once". assert_eq!(totals(&h), before); hover_card(&mut h, &page_card(2, "one.pdf")); for _ in 0..60 { h.run_steps(1); } assert!(h.query_by_label_contains("Added more than once").is_none()); h.hover_at(egui::pos2(5.0, 890.0)); h.run_steps(2); h.get_by_label("Combine").click(); h.run_steps(3); let app = h.state(); assert_eq!(app.views.len(), 1, "the result opens"); assert_eq!(texts_of(app, 0), ["Page 1", "Page 1", "Page 2", "Page 2", "Page 3"], "the order shown"); let doc = app.session.get(app.views[0].id).unwrap(); assert_eq!(doc.info.outline.iter().map(|o| o.title.as_str()).collect::>(), ["one", "two"], "one bookmark per file"); } #[test] fn pages_picked_out_of_sight_or_before_an_undo_are_let_go_of() { // Regression: a page picked, then hidden (its file collapsed, or the list shown) or undone, // stayed picked, and the trash or Delete took it. let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 4), ("c.pdf", 1)]); let parts = |h: &Harness<'static, PdfCraftApp>| h.state().combine_parts_shown().into_iter().map(|(n, p)| format!("{n}:{p:?}")).collect::>(); expand(&mut h, "b.pdf"); // Collapsed from its hover bar: nothing picked, Delete takes nothing. h.get_by_label(&page_card(2, "b.pdf")).click(); h.run_steps(2); hover_card(&mut h, &page_card(3, "b.pdf")); h.get_by_label("Collapse").click(); h.run_steps(2); assert!(h.state().combine_selected_pages().is_empty()); h.key_press(Key::Delete); h.run_steps(2); assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 1, 2, 3]", "c.pdf:[0]"]); // In the list: the same. expand(&mut h, "b.pdf"); h.get_by_label(&page_card(2, "b.pdf")).click(); h.run_steps(2); h.get_by_label("List view").click(); h.run_steps(2); assert!(h.state().combine_selected_pages().is_empty()); h.get_by_label("Grid view").click(); h.run_steps(2); // After an undo: let go of, and a Delete then changes nothing and keeps the redo. let (p2, c) = (card(&h, &page_card(2, "b.pdf")), card(&h, "c.pdf")); drag(&mut h, p2.center(), c.center() + vec2(c.width() * 0.3, 0.0)); assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 2, 3]", "c.pdf:[0]", "b.pdf:[1]"]); // Page 3, the second of b's first part: after the undo that place is page 2, another page. h.get_by_label(&page_card(3, "b.pdf")).click(); h.run_steps(2); h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(2); assert!(h.state().combine_selected_pages().is_empty()); assert!(!disabled(&h, "Redo")); h.key_press(Key::Delete); h.run_steps(2); assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 1, 2, 3]", "c.pdf:[0]"]); assert!(!disabled(&h, "Redo"), "a Delete with nothing picked makes no undo step"); // A file picked (by dragging its card) lets go of the pages: Delete takes the file. h.get_by_label(&page_card(2, "b.pdf")).click(); h.run_steps(2); let (cc, a) = (card(&h, "c.pdf"), card(&h, "a.pdf")); drag(&mut h, cc.center(), a.center() - vec2(a.width() * 0.3, 0.0)); assert_eq!(h.state().combine_selection(), [0]); assert!(h.state().combine_selected_pages().is_empty()); h.key_press(Key::Delete); h.run_steps(2); assert_eq!(parts(&h), ["a.pdf:[0]", "b.pdf:[0, 1, 2, 3]"]); } #[test] fn a_files_parts_join_when_what_was_between_them_goes_and_one_file_cannot_be_combined() { let mut h = grid_of_pages(&[("one.pdf", 2), ("two.pdf", 1)]); let parts = |h: &Harness<'static, PdfCraftApp>| h.state().combine_parts_shown().into_iter().map(|(n, p)| format!("{n}:{p:?}")).collect::>(); expand(&mut h, "one.pdf"); // one's page 2 after two: one is in two parts. let (p2, two) = (card(&h, &page_card(2, "one.pdf")), card(&h, "two.pdf")); drag(&mut h, p2.center(), two.center() + vec2(two.width() * 0.3, 0.0)); assert_eq!(parts(&h), ["one.pdf:[0]", "two.pdf:[0]", "one.pdf:[1]"]); // two goes: one's parts meet and are one entry again, in the same undo step. hover_card(&mut h, "two.pdf"); h.get_by_label("Remove two.pdf").click(); h.run_steps(2); assert_eq!(parts(&h), ["one.pdf:[0, 1]"]); // One file is nothing to combine, however it is split. assert!(h.get_by_label("Combine").accesskit_node().is_disabled()); h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(2); assert_eq!(parts(&h), ["one.pdf:[0]", "two.pdf:[0]", "one.pdf:[1]"]); } #[test] fn files_picked_by_automation_let_go_of_the_pages_picked() { // Regression (found by review): selecting rows through the API left a page picked, and // Delete took that page instead of the file. let mut h = grid_of_pages(&[("a.pdf", 1), ("b.pdf", 3)]); expand(&mut h, "b.pdf"); h.get_by_label(&page_card(2, "b.pdf")).click(); h.run_steps(2); assert_eq!(h.state().combine_selected_pages(), [(1, 1)]); h.state_mut().select_combine_rows(&[0]); h.run_steps(2); assert!(h.state().combine_selected_pages().is_empty()); h.key_press(Key::Delete); h.run_steps(2); assert_eq!(h.state().combine_parts_shown(), [("b.pdf".to_string(), vec![0, 1, 2])], "the file picked goes, b keeps its pages"); } #[test] fn open_documents_already_in_the_list_are_not_added_again() { // #1356: Add open documents left out none of the open documents, but added again the ones // already listed. Two documents with the same name and the same bytes are still two documents. let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); for (name, pages) in [("a.pdf", 1), ("b.pdf", 2), ("report.pdf", 3)] { app.open_bytes(name, None, fixture(pages)).unwrap(); } app }); h.run_steps(4); h.state_mut().execute("page.combine"); h.run_steps(3); let add_all = |h: &mut Harness<'static, PdfCraftApp>| { h.get_by_label("Add open documents").click(); h.run_steps(2); h.get_by_label("All open documents").click(); h.run_steps(3); }; add_all(&mut h); assert_eq!(names(&h), ["a.pdf", "b.pdf", "report.pdf"]); add_all(&mut h); assert_eq!(names(&h), ["a.pdf", "b.pdf", "report.pdf"], "nothing is added twice"); // Another report.pdf, opened while the first is listed: a different document all the same. h.state_mut().open_bytes("report.pdf", None, fixture(3)).unwrap(); h.state_mut().execute("page.combine"); h.run_steps(3); add_all(&mut h); assert_eq!(names(&h), ["a.pdf", "b.pdf", "report.pdf", "report.pdf"]); // A document taken out of the list can be added again. h.state_mut().select_combine_rows(&[1]); h.run_steps(2); h.key_press(Key::Delete); h.run_steps(2); add_all(&mut h); assert_eq!(names(&h), ["a.pdf", "report.pdf", "report.pdf", "b.pdf"]); }