M3.4: view tests (layouts, actual size/fit width/fit page, link clicks, field list jump, field highlighting pixels, fonts tab); parity: 10 features to shipped (P0 35.2%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 12:29:05 -04:00
parent 4c4b17e5fa
commit 669196a67e
3 changed files with 223 additions and 31 deletions

View File

@ -135,6 +135,16 @@ pub enum ViewAction {
}
impl DocView {
/// Where `page` is drawn on screen this frame (`None` when it is not on screen).
pub fn page_screen_rect(&self, page: usize) -> Option<Rect> {
self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| xf.rect)
}
/// The document area on screen.
pub fn viewport_rect(&self) -> Rect {
self.viewport_screen
}
/// Pages that could not be rendered, with the reason (for automation; 0-based pages).
pub fn page_errors(&self) -> Vec<(usize, &str)> {
let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect();

View File

@ -0,0 +1,182 @@
//! Page layouts, zoom modes and link navigation, checked by where pages land on screen.
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use printcraft_ui_egui::PrintCraftApp;
/// Three 300×400 pt pages. Page 1 links to page 3.
const PAGES: &[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 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /Annots [6 0 R] >> endobj
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
5 0 obj << /Type /Page /Parent 2 0 R >> endobj
6 0 obj << /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /Dest [5 0 R /Fit] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn harness(options: &'static [(&'static str, &'static str)]) -> 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();
app.open_bytes("pages.pdf", None, PAGES.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "none").unwrap();
for (k, v) in options {
app.set_option(k, v).unwrap();
}
app
});
h.run_steps(8);
h
}
fn rect(h: &Harness<'static, PrintCraftApp>, page: usize) -> Option<egui::Rect> {
h.state().views[0].page_screen_rect(page)
}
#[test]
fn continuous_layout_stacks_pages_vertically() {
let mut h = harness(&[("layout", "continuous"), ("zoom", "50")]);
h.run_steps(4);
let (a, b) = (rect(&h, 0).expect("page 1 on screen"), rect(&h, 1).expect("page 2 on screen"));
assert!(b.min.y > a.max.y, "page 2 below page 1: {a:?} {b:?}");
assert!((a.center().x - b.center().x).abs() < 1.0, "same column");
}
#[test]
fn two_up_layout_places_pages_side_by_side() {
let mut h = harness(&[("layout", "two-up"), ("zoom", "50")]);
h.run_steps(4);
let (a, b) = (rect(&h, 0).expect("page 1"), rect(&h, 1).expect("page 2"));
assert!((a.min.y - b.min.y).abs() < 1.0, "same row: {a:?} {b:?}");
assert!(b.min.x > a.max.x, "page 2 right of page 1");
let c = rect(&h, 2).expect("page 3 on the next row");
assert!(c.min.y > a.max.y);
}
#[test]
fn single_page_layout_shows_one_page_at_a_time() {
let mut h = harness(&[("layout", "single"), ("zoom", "50")]);
h.run_steps(4);
assert!(rect(&h, 0).is_some() && rect(&h, 1).is_none(), "only page 1");
h.state_mut().set_option("page", "2").unwrap();
h.run_steps(4);
assert!(rect(&h, 0).is_none() && rect(&h, 1).is_some(), "only page 2");
}
#[test]
fn actual_size_fit_width_and_fit_page() {
let mut h = harness(&[]);
let pt = 96.0 / 72.0;
h.key_press_modifiers(Modifiers::COMMAND, Key::Num1); // actual size
h.run_steps(4);
let r = rect(&h, 0).expect("page 1");
assert!((r.width() - 300.0 * pt).abs() < 1.0, "100% is 300 pt at 96 dpi: {}", r.width());
h.key_press_modifiers(Modifiers::COMMAND, Key::Num2); // fit width
h.run_steps(4);
let (r, vp) = (rect(&h, 0).expect("page 1"), h.state().views[0].viewport_rect());
assert!(r.width() > vp.width() * 0.85 && r.width() <= vp.width(), "fit width: page {} in viewport {}", r.width(), vp.width());
h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); // fit page
h.run_steps(4);
let (r, vp) = (rect(&h, 0).expect("page 1"), h.state().views[0].viewport_rect());
assert!(r.height() <= vp.height() && r.height() > vp.height() * 0.85, "fit page: page {} in viewport {}", r.height(), vp.height());
assert!(r.width() < vp.width(), "a portrait page fits by height");
}
#[test]
fn clicking_a_link_goes_to_its_destination() {
// Default fit-width zoom: pages are taller than the window, so the target can scroll to the top.
let mut h = harness(&[]);
h.run_steps(4);
let r = rect(&h, 0).expect("page 1");
// The link spans x 50..250, y 300..350 in PDF space (y up) on a 300×400 page.
let at = egui::pos2(r.min.x + r.width() * (150.0 / 300.0), r.min.y + r.height() * (1.0 - 325.0 / 400.0));
h.hover_at(at);
h.run_steps(2);
h.drag_at(at); // press
h.drop_at(at); // release
h.run_steps(8);
assert_eq!(h.state().views[0].current, 2, "the link targets page 3");
let (target, vp) = (rect(&h, 2).expect("page 3 on screen"), h.state().views[0].viewport_rect());
assert!((target.min.y - vp.min.y).abs() < 40.0, "page 3 is scrolled to the top: {target:?} in {vp:?}");
}
/// Two pages with a text field on each and a non-embedded Helvetica.
const FORM: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /Annots [6 0 R] /Contents 5 0 R /Resources << /Font << /F1 8 0 R >> >> >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /Annots [7 0 R] >> endobj
5 0 obj << /Length 37 >> stream
BT /F1 12 Tf 20 20 Td (Form) Tj ET
endstream endobj
6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (fullname) /V (Ada) /Rect [50 300 250 330] /P 3 0 R >> endobj
7 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (postcode) /Rect [50 300 250 330] /P 4 0 R >> endobj
8 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn form_harness() -> Harness<'static, PrintCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("form.pdf", None, FORM.to_vec()).expect("opens");
app.set_option("left", "closed").unwrap();
app
});
h.run_steps(8);
h
}
#[test]
fn field_list_jumps_to_a_fields_page() {
use egui_kittest::kittest::Queryable;
let mut h = form_harness();
h.state_mut().set_option("panel", "fields").unwrap();
h.run_steps(4);
h.get_by_label("fullname");
h.get_by_label("postcode").click();
h.run_steps(8);
assert_eq!(h.state().views[0].current, 1, "postcode is on page 2");
}
#[test]
fn highlight_fields_tints_the_field_area() {
use egui_kittest::kittest::Queryable;
let mut h = form_harness();
h.state_mut().set_option("panel", "none").unwrap();
for _ in 0..100 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let r = rect(&h, 0).expect("page 1");
// The field spans x 50..250, y 300..330 (PDF space, y up) on a 300×400 page; sample inside it.
let at = egui::pos2(r.min.x + r.width() * (60.0 / 300.0), r.min.y + r.height() * (1.0 - 305.0 / 400.0));
let pixel = |h: &mut Harness<'static, PrintCraftApp>| {
let img = h.render().expect("renders");
let ppp = h.ctx.pixels_per_point();
*img.get_pixel((at.x * ppp) as u32, (at.y * ppp) as u32)
};
let before = pixel(&mut h);
h.get_by_label("Highlight fields").click();
h.run_steps(4);
assert!(h.state().views[0].highlight_fields);
h.get_by_label("Hide field highlights");
let after = pixel(&mut h);
assert_ne!(before, after, "the field area is tinted when highlighting is on");
}
#[test]
fn fonts_tab_lists_fonts_and_embedding() {
use egui_kittest::kittest::Queryable;
let mut h = form_harness();
h.state_mut().set_option("dialog", "fonts").unwrap();
h.run_steps(4);
h.get_by_label("Helvetica");
h.get_by_label_contains("Not embedded");
}

View File

@ -782,7 +782,7 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster.rs::scale_is_clamped_for_huge_pages"]
evidence = ["crates/render/src/raster.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster.rs::watchdog_skips_a_stuck_page_and_keeps_rendering"]
notes = ""
[[feature]]
@ -815,11 +815,11 @@ title = "Single page continuous scrolling"
area = "B"
tier = "P0"
milestone = "M3"
status = "partial"
status = "shipped"
commands = []
tools = []
evidence = []
notes = "works; no layout test yet"
evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically"]
notes = ""
[[feature]]
id = "view.layout-single-page"
@ -827,11 +827,11 @@ title = "Single page view"
area = "B"
tier = "P0"
milestone = "M3"
status = "partial"
status = "shipped"
commands = []
tools = []
evidence = []
notes = "works; no layout test yet"
evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time"]
notes = ""
[[feature]]
id = "view.layout-two-up"
@ -839,11 +839,11 @@ title = "Two-page view"
area = "B"
tier = "P0"
milestone = "M3"
status = "partial"
status = "shipped"
commands = []
tools = []
evidence = []
notes = "works; no layout test yet"
evidence = ["crates/ui-egui/tests/view.rs::two_up_layout_places_pages_side_by_side"]
notes = ""
[[feature]]
id = "view.layout-two-up-scrolling"
@ -923,11 +923,11 @@ title = "Actual size"
area = "B"
tier = "P0"
milestone = "M3"
status = "partial"
status = "shipped"
commands = []
tools = []
evidence = []
notes = "menu item exists; no test"
evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page"]
notes = ""
[[feature]]
id = "view.zoom-fit-page"
@ -935,11 +935,11 @@ title = "Fit page"
area = "B"
tier = "P0"
milestone = "M3"
status = "partial"
status = "shipped"
commands = []
tools = []
evidence = []
notes = "fit page / fit width toggle; no test"
evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page"]
notes = ""
[[feature]]
id = "view.zoom-fit-width"
@ -947,11 +947,11 @@ title = "Fit width"
area = "B"
tier = "P0"
milestone = "M3"
status = "partial"
status = "shipped"
commands = []
tools = []
evidence = []
notes = "fit page / fit width toggle; no test"
evidence = ["crates/ui-egui/tests/view.rs::actual_size_fit_width_and_fit_page"]
notes = ""
[[feature]]
id = "view.zoom-fit-height"
@ -1139,11 +1139,11 @@ title = "Follow links and destinations"
area = "B"
tier = "P0"
milestone = "M3"
status = "partial"
status = "shipped"
commands = []
tools = []
evidence = []
notes = "link clicks work in the viewer; no test; actions beyond GoTo/URI pending"
evidence = ["crates/ui-egui/tests/view.rs::clicking_a_link_goes_to_its_destination"]
notes = "GoTo and URI links; other action types (JavaScript, Launch, named actions) arrive with M6"
[[feature]]
id = "view.annotation-hover"
@ -3343,11 +3343,11 @@ title = "List fonts with type and embedding status"
area = "D"
tier = "P1"
milestone = "M3"
status = "partial"
status = "shipped"
commands = ["file.properties"]
tools = ["doc_info"]
evidence = []
notes = "Fonts tab works; no test yet"
evidence = ["crates/ui-egui/tests/view.rs::fonts_tab_lists_fonts_and_embedding"]
notes = ""
[[feature]]
id = "organize.initial-view"
@ -4263,11 +4263,11 @@ title = "Form fields panel listing every field and value"
area = "F"
tier = "P0"
milestone = "M3"
status = "partial"
status = "shipped"
commands = ["form.fields"]
tools = ["doc_info"]
evidence = []
notes = "read-only list works; no panel test yet; editing in M6"
evidence = ["crates/ui-egui/tests/view.rs::field_list_jumps_to_a_fields_page"]
notes = "read-only list with jump-to-field; filling and editing are separate features (M6)"
[[feature]]
id = "form.highlight-fields"
@ -4275,11 +4275,11 @@ title = "Highlight existing fields"
area = "F"
tier = "P0"
milestone = "M3"
status = "partial"
status = "shipped"
commands = []
tools = []
evidence = []
notes = "toggle exists; no test"
evidence = ["crates/ui-egui/tests/view.rs::highlight_fields_tints_the_field_area"]
notes = ""
[[feature]]
id = "form.field-appearance-display"