M3: snapshot tests of every side panel

Each of the eleven right-hand panels (Comments, Bookmarks, Pages, Fields,
Layers, Attachments, Signatures, Accessibility, Tags, Search, Compare) opens
on one synthetic document, and its AccessKit tree is compared with a text
file in crates/ui-egui/tests/snapshots/. Text rather than pixels: the same
on every platform and GPU, not an asset under AGENTS.md §1, and readable in
a diff. Positions are left out, so a moved widget is not a change but an
added, removed or renamed one is. UPDATE_SNAPSHOTS=1 rewrites the files.

An exhaustive match over RightPanel stops the file compiling when a panel
is added without a snapshot.

Seen failing: renaming the Signatures panel's "Validate all" button fails
signatures_panel alone, showing both trees. Stable under TZ=UTC,
America/Los_Angeles and Asia/Tokyo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dubblbindong
2026-10-20 20:46:47 +02:00
co-authored by Claude Opus 5.5
parent 22bedbf2af
commit b0e751500b
12 changed files with 458 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
//! Snapshot tests of every side panel (M3): each panel opens on one synthetic document and its
//! AccessKit tree (the roles, labels, values and states a screen reader gets) is compared with a
//! text file in `tests/snapshots/`. Text rather than pixels: it is the same on every platform and
//! GPU, it is not an asset (AGENTS.md §1), and a reviewer can read a change in the diff. Positions
//! are left out, so moving a widget is not a change but adding, removing or renaming one is.
//!
//! `UPDATE_SNAPSHOTS=1 cargo test -p pdfcraft-ui-egui --test panel_snapshots` rewrites the files.
use egui::accesskit::{Role, Toggled};
use egui_kittest::kittest::{NodeT, Queryable};
use egui_kittest::{Harness, Node};
use pdfcraft_engine::Session;
use pdfcraft_ui_egui::{PdfCraftApp, RightPanel};
mod support;
/// Two pages with something for every panel: bookmarks, a layer, an attachment, a text field, an
/// unsigned signature field, a comment and a structure tree.
fn fixture() -> Vec<u8> {
let page1 = "/P << /MCID 0 >> BDC BT /F1 18 Tf 72 720 Td (Panel sample) Tj ET EMC\n\
/OC /L1 BDC BT /F1 10 Tf 72 690 Td (Draft notes) Tj ET EMC\n";
let page2 = "BT /F1 12 Tf 72 720 Td (Second page sample text) Tj ET\n";
let objs: Vec<String> = vec![
// 1 catalog
"<< /Type /Catalog /Pages 2 0 R /Outlines 6 0 R /OCProperties << /OCGs [9 0 R] /D << /ON [9 0 R] /Order [9 0 R] >> >> \
/Names << /EmbeddedFiles << /Names [(notes.txt) 11 0 R] >> >> /AcroForm << /Fields [12 0 R 13 0 R] >> \
/MarkInfo << /Marked true >> /StructTreeRoot 15 0 R >>"
.into(),
// 2 page tree, 3 and 4 its pages
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 17 0 R /StructParents 0 /Resources << /Font << /F1 5 0 R >> /Properties << /L1 9 0 R >> >> \
/Annots [12 0 R 13 0 R 14 0 R] >>"
.into(),
"<< /Type /Page /Parent 2 0 R /Contents 18 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
// 5 font
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
// 6 outline, 7 and 8 its items
"<< /Type /Outlines /First 7 0 R /Last 8 0 R /Count 2 >>".into(),
"<< /Title (Introduction) /Parent 6 0 R /Next 8 0 R /Dest [3 0 R /Fit] >>".into(),
"<< /Title (Details) /Parent 6 0 R /Prev 7 0 R /Dest [4 0 R /Fit] >>".into(),
// 9 layer
"<< /Type /OCG /Name (Draft notes) >>".into(),
// 10 embedded file, 11 its file specification
"<< /Type /EmbeddedFile /Length 5 >>\nstream\nhello\nendstream".into(),
"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 10 0 R >> >>".into(),
// 12 text field, 13 signature field, 14 comment
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /Rect [72 600 272 624] /P 3 0 R /F 4 >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [72 520 272 560] /P 3 0 R /F 4 >>".into(),
"<< /Type /Annot /Subtype /Text /Rect [400 700 420 720] /P 3 0 R /T (Reviewer) /Contents (Check this heading) \
/M (D:20260101120000Z) /CreationDate (D:20260101120000Z) >>"
.into(),
// 15 structure tree root, 16 its paragraph
"<< /Type /StructTreeRoot /K [16 0 R] /ParentTree << /Nums [0 [16 0 R]] >> >>".into(),
"<< /S /P /P 15 0 R /Pg 3 0 R /K 0 >>".into(),
// 17 and 18 page contents
format!("<< /Length {} >>\nstream\n{page1}endstream", page1.len()),
format!("<< /Length {} >>\nstream\n{page2}endstream", page2.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn harness(panel: &'static str) -> Harness<'static, PdfCraftApp> {
let bytes = fixture();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("panels.pdf", None, bytes).expect("opens");
app.set_option("panel", panel).unwrap();
app
});
h.run_steps(6);
h
}
/// The side panel's contents: the dock node after the Inspector tab.
fn panel<'h>(h: &'h Harness<'static, PdfCraftApp>) -> Node<'h> {
let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector"));
let dock = tab.parent().expect("the Inspector tab sits in a dock node");
let mut after = dock.children().skip_while(|n| n.accesskit_node().id() != tab.accesskit_node().id()).skip(1);
after.next().expect("the panel follows its tab")
}
/// One line per node that says something: layout containers are skipped (their children move up a
/// level) and text runs repeat their label.
fn describe(node: &Node<'_>, depth: usize, out: &mut String) {
let n = node.accesskit_node();
let quiet = n.role() == Role::GenericContainer && n.label().is_none() && n.value().is_none();
let next = if quiet || n.role() == Role::TextRun {
depth
} else {
let mut line = format!("{:indent$}{:?}", "", n.role(), indent = depth * 2);
if let Some(l) = n.label() {
line.push_str(&format!(" {l:?}"));
}
if let Some(v) = n.value().filter(|v| !v.is_empty() && Some(v) != n.label().as_ref()) {
line.push_str(&format!(" = {v:?}"));
}
match n.toggled() {
Some(Toggled::True) => line.push_str(" [on]"),
Some(Toggled::False) => line.push_str(" [off]"),
Some(Toggled::Mixed) => line.push_str(" [mixed]"),
None => {}
}
if n.is_selected() == Some(true) {
line.push_str(" [selected]");
}
if n.is_disabled() {
line.push_str(" [disabled]");
}
out.push_str(&line);
out.push('\n');
depth + 1
};
if n.role() == Role::TextRun {
return;
}
for c in node.children() {
describe(&c, next, out);
}
}
/// Compares the panel with `tests/snapshots/panel_<name>.txt`, or writes it with `UPDATE_SNAPSHOTS`.
fn check(h: &Harness<'static, PdfCraftApp>, name: &str) {
let mut got = String::new();
describe(&panel(h), 0, &mut got);
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots").join(format!("panel_{name}.txt"));
if std::env::var_os("UPDATE_SNAPSHOTS").is_some() {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, &got).unwrap();
return;
}
let want = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}; run with UPDATE_SNAPSHOTS=1 to create it.\n{got}", path.display()));
assert!(want == got, "{name} panel differs from {} (UPDATE_SNAPSHOTS=1 rewrites it)\n--- want\n{want}\n--- got\n{got}", path.display());
}
#[test]
fn comments_panel() {
check(&harness("comments"), "comments");
}
#[test]
fn bookmarks_panel() {
check(&harness("bookmarks"), "bookmarks");
}
#[test]
fn pages_panel() {
check(&harness("pages"), "pages");
}
#[test]
fn fields_panel() {
check(&harness("fields"), "fields");
}
#[test]
fn layers_panel() {
check(&harness("layers"), "layers");
}
#[test]
fn attachments_panel() {
check(&harness("attachments"), "attachments");
}
#[test]
fn signatures_panel() {
check(&harness("signatures"), "signatures");
}
#[test]
fn tags_panel() {
check(&harness("tags"), "tags");
}
#[test]
fn accessibility_panel() {
let mut h = harness("none");
assert!(h.state_mut().execute("a11y.check"));
h.run_steps(2);
h.get_by_label("Start Checking").click();
h.run_steps(3);
assert_eq!(h.state().right, Some(RightPanel::Accessibility));
check(&h, "accessibility");
}
#[test]
fn search_panel() {
let mut h = harness("none");
assert!(h.state_mut().execute("edit.advanced_search"));
h.run_steps(2);
h.state_mut().views[0].find.as_mut().expect("Advanced Search opens a search").query = "sample".into();
support::wait_until(&mut h, "both pages searched", |app| app.views[0].find.as_ref().is_some_and(|f| f.matches.len() == 2));
h.run_steps(2);
check(&h, "search");
}
#[test]
fn compare_panel() {
let s = Session::new();
let old = s.create_from_text("t", "Delivery within five days. Returns accepted.").unwrap().to_vec();
let new = s.create_from_text("t", "Delivery within three days. Returns accepted for a week.").unwrap().to_vec();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("old.pdf", None, old.clone()).unwrap();
app.open_bytes("new.pdf", None, new.clone()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().execute("doc.compare"));
h.run_steps(2);
h.get_by_label("Compare").click();
h.run_steps(3);
assert_eq!(h.state().right, Some(RightPanel::Compare));
check(&h, "compare");
}
/// Every panel has a snapshot test above. The match has no wildcard on purpose: a new
/// `RightPanel` stops this file compiling until it gets a test and is listed here.
#[test]
fn every_panel_has_a_snapshot() {
let tested = |p: RightPanel| match p {
RightPanel::Comments => "comments",
RightPanel::Bookmarks => "bookmarks",
RightPanel::Pages => "pages",
RightPanel::Fields => "fields",
RightPanel::Layers => "layers",
RightPanel::Attachments => "attachments",
RightPanel::Signatures => "signatures",
RightPanel::Accessibility => "accessibility",
RightPanel::Tags => "tags",
RightPanel::Search => "search",
RightPanel::Compare => "compare",
};
let mut app = PdfCraftApp::new();
for p in [
RightPanel::Comments,
RightPanel::Bookmarks,
RightPanel::Pages,
RightPanel::Fields,
RightPanel::Layers,
RightPanel::Attachments,
RightPanel::Signatures,
RightPanel::Accessibility,
RightPanel::Tags,
RightPanel::Search,
RightPanel::Compare,
] {
let name = tested(p);
app.set_option("panel", name).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(app.right, Some(p), "{name} opens its own panel");
let snapshot = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("tests/snapshots/panel_{name}.txt"));
assert!(snapshot.exists() || std::env::var_os("UPDATE_SNAPSHOTS").is_some(), "{} is missing", snapshot.display());
}
}
@@ -0,0 +1,85 @@
Label = "Accessibility Checker"
Button "Close" [off]
Label = "7 issues"
Button "Check again"
Button "Report"
Label = "Needs manual check: 5"
Button "Collapse" [off]
Label = "Document (2 issues)"
Image
Label = "Accessibility permission flag - Passed"
Image
Label = "Image-only PDF - Passed"
Image
Label = "Tagged PDF - Passed"
Image
Label = "Logical Reading Order - Needs manual check"
Image
Label = "Primary language - Failed"
Image
Label = "Title - Failed"
Image
Label = "Bookmarks - Passed"
Image
Label = "Color contrast - Skipped"
Button "Collapse" [off]
Label = "Page Content (3 issues)"
Image
Label = "Tagged content - Failed"
Image
Label = "Tagged annotations - Failed"
Image
Label = "Tab order - Failed"
Image
Label = "Character encoding - Passed"
Image
Label = "Tagged multimedia - Passed"
Image
Label = "Screen flicker - Needs manual check"
Image
Label = "Scripts - Needs manual check"
Image
Label = "Timed responses - Needs manual check"
Image
Label = "Navigation links - Needs manual check"
Button "Collapse" [off]
Label = "Forms (2 issues)"
Image
Label = "Tagged form fields - Failed"
Image
Label = "Field descriptions - Failed"
Button "Collapse" [off]
Label = "Alternate Text"
Image
Label = "Figures alternate text - Passed"
Image
Label = "Nested alternate text - Passed"
Image
Label = "Associated with content - Passed"
Image
Label = "Hides annotation - Passed"
Image
Label = "Other elements alternate text - Passed"
Button "Collapse" [off]
Label = "Tables"
Image
Label = "Rows - Passed"
Image
Label = "TH and TD - Passed"
Image
Label = "Headers - Passed"
Image
Label = "Regularity - Passed"
Image
Label = "Summary - Passed"
Button "Collapse" [off]
Label = "Lists"
Image
Label = "List items - Passed"
Image
Label = "Lbl and LBody - Passed"
Button "Collapse" [off]
Label = "Headings"
Image
Label = "Appropriate nesting - Passed"
ScrollBar
@@ -0,0 +1,7 @@
Label = "Attachments"
Label = "1"
Button "Close" [off]
Image
Label = "notes.txt"
Label = "5 bytes"
Button "Save attachment…" [off]
@@ -0,0 +1,9 @@
Label = "Bookmarks"
Button "Close" [off]
Button "Bookmark options" [off]
Button "New bookmark (Ctrl+B)" [off]
Label = "Search"
TextInput "Search"
Button "Clear" [off]
Button "Introduction"
Button "Details"
@@ -0,0 +1,14 @@
Label = "Comments"
Label = "1"
Button "Close" [off]
Button "More options" [off]
Button "Filter comments" [off]
Button "Search comments" [off]
TextInput
Button "Collapse" [off]
Label = "Page 1"
Label = "1"
Label = "Reviewer"
Label = "2026-01-01 12:00"
Label = "Check this heading"
Unknown
@@ -0,0 +1,16 @@
Label = "Compare"
Label = "2"
Button "Close" [off]
Label = "2 Replaced"
Label = "0 Inserted"
Label = "0 Deleted"
Label = "1 Visual"
Button "Side by side"
Button "New file only"
Button "Mark as comments"
Button "Report…"
Button "Clear"
Label = "Replaced · page 1"
Button "\"five\" → \"three\"" [off]
Label = "Replaced · page 1"
Button "\"accepted.\" → \"accepted for a week.\"" [off]
@@ -0,0 +1,6 @@
Label = "Fields"
Label = "2"
Button "Close" [off]
Label = "Page 1"
Button "Name" [off]
Button "Approval" [off]
@@ -0,0 +1,4 @@
Label = "Layers"
Label = "1"
Button "Close" [off]
CheckBox "Draft notes" [on]
@@ -0,0 +1,7 @@
Label = "Pages"
Label = "2"
Button "Close" [off]
Button "Page 1" [off]
Label = "1"
Button "Page 2" [off]
Label = "2"
@@ -0,0 +1,11 @@
Label = "Search"
Button "Close" [off]
Label = "What word or phrase would you like to search for?"
TextInput "What word or phrase would you like to search for?" = "sample"
CheckBox "Whole words only" [off]
CheckBox "Case-sensitive" [off]
Label = "2 instances"
Label = "Page 1"
Button "…Panel sample Draft notes…" [on]
Label = "Page 2"
Button "…Second page sample text…" [off]
@@ -0,0 +1,7 @@
Label = "Signatures"
Label = "0"
Button "Close" [off]
Button "Validate all"
Button "Expand" [off]
Image
Label = "Unsigned signature field: Approval"
@@ -0,0 +1,24 @@
Label = "Tags"
Button "Close" [off]
Label = "Find a tag"
TextInput "Find a tag"
Label = "1 tags"
Button "P · 16" [on]
Label = "16 0 R"
Button "Go to page 1"
Label = "Tag type"
TextInput "Tag type" = "P"
Label = "Title"
TextInput "Title"
Label = "Actual text"
MultilineTextInput "Actual text"
Label = "Alternate text"
MultilineTextInput "Alternate text"
Label = "Language"
TextInput "Language"
Button "Save tag properties" [disabled]
Button "Revert changes" [disabled]
Button "Move tag earlier" [disabled]
Button "Move tag later" [disabled]
Button "Mark as artifact"
Button "MCID 0 · Go to page 1 · Panel sample"