Files
pdfcraft/crates/ui-egui/tests/ui.rs
T

215 lines
7.7 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::Queryable;
use printcraft_ui_egui::PrintCraftApp;
/// 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 PrintCraftApp) + 'static) -> Harness<'static, PrintCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PrintCraftApp::new();
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 PrintCraft");
assert!(h.query_all_by_label("Organize pages").count() >= 2, "tool list + home card");
h.get_by_label("Open file");
}
#[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 printcraft_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");
}
#[test]
fn garbage_input_is_rejected_without_panicking() {
let mut app = PrintCraftApp::new();
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, PrintCraftApp>) {
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 = PrintCraftApp::new();
a.theme = printcraft_ui_egui::theme::ThemeKind::Dark;
let json = a.persist();
let mut b = PrintCraftApp::new();
b.restore(&json);
assert_eq!(b.theme, printcraft_ui_egui::theme::ThemeKind::Dark);
b.restore("{not json");
b.restore("{\"recent\": 5, \"theme\": \"Purple\"}");
assert_eq!(b.theme, printcraft_ui_egui::theme::ThemeKind::Dark);
}
#[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 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}");
}
}