Files
pdfcraft/crates/ui-egui/tests/it/control.rs
T
skylartaylor ba77d792f1 Migrate PdfCraft to egui 0.37 with reconciled platform and editor behavior
Preserve current main, pin the reviewed forks, and cover hidden input,
IME lifecycle, bidi rendering, persisted settings, and editor geometry.

Repository gates pass; native/browser acceptance and paired app performance
remain explicitly open for human review.
2026-10-25 02:45:21 +01:00

2128 lines
106 KiB
Rust

//! The UI control channel (M3.9): an agent can see the widget tree, click, type, press keys,
//! run commands, change view options and take screenshots of the running app.
use std::sync::{Arc, Mutex};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
use pdfcraft_ui_egui::PdfCraftApp;
use pdfcraft_ui_egui::control::{ControlClient, Reply};
use serde_json::{Value, json};
/// The text each page shows, in order, as the app's session holds it.
fn page_texts(app: &PdfCraftApp) -> Vec<String> {
let doc = app.session.get(app.views[0].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()
}
fn fixture(n: usize) -> Vec<u8> {
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", 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
}
fn harness() -> (Harness<'static, PdfCraftApp>, ControlClient) {
harness_pages(5)
}
fn harness_pages(pages: usize) -> (Harness<'static, PdfCraftApp>, ControlClient) {
harness_pages_at(pages, 1400.0, 1.0)
}
fn harness_pages_at(pages: usize, width: f32, scale: f32) -> (Harness<'static, PdfCraftApp>, ControlClient) {
let slot: Arc<Mutex<Option<ControlClient>>> = Arc::default();
let s = slot.clone();
let mut h = Harness::builder().with_size(egui::vec2(width, 900.0)).with_pixels_per_point(scale).build_eframe(move |cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
*s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
app.open_bytes("doc.pdf", None, fixture(pages)).unwrap();
app
});
h.run_steps(4);
let client = slot.lock().unwrap().take().unwrap();
(h, client)
}
/// Send a request and run frames until it is answered.
fn call(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Reply {
let rx = c.send(method, params);
let mut reply = None;
let answered = crate::support::step_until(h, |_| {
reply = rx.try_recv().ok();
reply.is_some()
});
match reply {
Some(r) if answered => r,
_ => panic!("{method}: no reply in 30 s"),
}
}
fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Value {
call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}"))
}
/// Let the next real frames run after a control setting that changes the interface fonts or
/// opens a modal. `ui.set` answers in the input pass, and a font change discards the pass that
/// applies it, so the modal's labels are not in the accessibility tree until those frames have
/// been laid out.
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
h.run_steps(3);
}
#[test]
fn comment_pin_is_reported_and_persistent() {
let (mut h, c) = harness();
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["comment_tool_pinned"], false);
for pinned in [true, false] {
ok(&mut h, &c, "ui.set", json!({"key":"comment-tool-pinned","value":pinned}));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["comment_tool_pinned"], pinned);
let mut restored = PdfCraftApp::new();
restored.comment_prefs.pinned = !pinned;
restored.restore(&h.state().persist());
assert_eq!(restored.comment_prefs.pinned, pinned);
for invalid in [json!("yes"), json!(1), json!(null), json!([]), json!({})] {
assert!(call(&mut h, &c, "ui.set", json!({"key":"comment-tool-pinned","value":invalid})).is_err());
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["comment_tool_pinned"], pinned);
}
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["dirty"], false);
}
}
#[test]
fn thumbnail_size_is_settable_and_inspectable_through_the_control_channel() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key": "panel", "value": "pages"}));
ok(&mut h, &c, "ui.set", json!({"key": "thumbnail-zoom", "value": "50"}));
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["active"]["thumbnail_zoom"], 0.5);
assert_eq!(state["active"]["grid_zoom"], 1.0);
assert_eq!(state["documents"][0]["dirty"], false);
let controls = ok(&mut h, &c, "ui.inspect", json!({"query": "thumbnails"}));
let widgets = controls["widgets"].as_array().unwrap();
assert!(widgets.iter().any(|w| w["label"] == "Larger thumbnails"));
assert!(widgets.iter().any(|w| w["label"] == "Smaller thumbnails" && w["enabled"] == false));
assert!(call(&mut h, &c, "ui.set", json!({"key": "thumbnail-zoom", "value": "NaN"})).is_err());
ok(&mut h, &c, "ui.click", json!({"label": "Larger thumbnails"}));
h.run_steps(3);
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["thumbnail_zoom"], 0.75);
let sliders = ok(&mut h, &c, "ui.inspect", json!({"query": "Thumbnail size", "role": "Slider"}));
let slider = sliders["widgets"].as_array().unwrap().first().expect("the thumbnail size slider is labelled");
ok(&mut h, &c, "ui.focus", json!({"id": slider["id"]}));
ok(&mut h, &c, "ui.key", json!({"key": "ArrowRight"}));
h.run_steps(3);
assert!(ok(&mut h, &c, "ui.state", json!({}))["active"]["thumbnail_zoom"].as_f64().unwrap() > 0.75);
}
#[test]
fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() {
let (mut h, c) = harness();
for params in [
json!({}),
json!({"width": 800}),
json!({"width": "800", "height": 640}),
json!({"width": -1, "height": 640}),
json!({"width": 0, "height": 640}),
json!({"width": 800, "height": 319}),
json!({"width": 8193, "height": 640}),
json!({"width": 1e300, "height": 640}),
json!({"width": null, "height": 640}),
json!({"width": 800, "height": 640, "force": true}),
json!([]),
] {
assert!(call(&mut h, &c, "ui.resize", params).is_err());
let window = ok(&mut h, &c, "ui.state", json!({}))["window"].clone();
assert_eq!(window["width"], 1400.0);
assert_eq!(window["height"], 900.0);
}
let requested = ok(&mut h, &c, "ui.resize", json!({"width": 800, "height": 640}));
assert_eq!(requested["requested"], json!([800.0, 640.0]));
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["window"]["width"], 800.0);
assert_eq!(state["window"]["height"], 640.0);
assert_eq!(state["documents"][0]["pages"], 5);
assert_eq!(state["documents"][0]["dirty"], false);
}
/// Kittest calls only `App::logic` and `App::ui`, not eframe's `raw_input_hook`, so this checks
/// the widget tree and key handling but not the production focus-generation ordering. The
/// `production_*` tests above drive `prepare_raw_input` first and cover that ordering.
#[test]
fn control_focus_traverses_real_widgets_and_keyboard_activates() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.focus", json!({"label": "Convert"}));
let convert = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"}));
let focused =
|found: &serde_json::Value, label: &str| found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == label && w["focused"] == true);
assert!(focused(&convert, "Convert"), "{convert}");
assert!(convert["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Convert" && w["focusable"] == true));
ok(&mut h, &c, "ui.key", json!({"key": "Tab"}));
let sign = ok(&mut h, &c, "ui.inspect", json!({"query": "E-Sign", "role": "Button"}));
assert!(focused(&sign, "E-Sign"), "{sign}");
ok(&mut h, &c, "ui.key", json!({"key": "Tab", "modifiers": ["shift"]}));
let convert = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"}));
assert!(focused(&convert, "Convert"), "{convert}");
ok(&mut h, &c, "ui.key", json!({"key": "Enter"}));
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["mode"], "Convert");
assert_eq!(state["documents"][0]["dirty"], false);
}
/// A frame driven in production order: eframe's raw input hook runs before egui's input hooks, so
/// the control plugin sees the focus/dialog generation produced by the previous UI pass. The
/// kittest harness does not call `raw_input_hook`, which otherwise hides focus-change bugs.
fn production_step(h: &mut Harness<'static, PdfCraftApp>) {
let ctx = h.ctx.clone();
let mut raw = std::mem::take(h.input_mut());
h.state_mut().prepare_raw_input(&ctx, &mut raw);
*h.input_mut() = raw;
h.step();
}
fn call_production(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Reply {
let rx = c.send(method, params);
for _ in 0..30 {
production_step(h);
if let Ok(r) = rx.try_recv() {
return r;
}
}
panic!("{method}: no reply after 30 production-ordered frames");
}
#[test]
fn production_key_release_survives_the_focus_change_its_press_causes() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.focus", json!({"label": "Convert"}));
let reply = call_production(&mut h, &c, "ui.key", json!({"key": "Tab"}));
reply.unwrap_or_else(|e| panic!("a Tab press that moves focus must not leave its release unapplied: {e}"));
let sign = ok(&mut h, &c, "ui.inspect", json!({"query": "E-Sign", "role": "Button"}));
assert!(sign["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "E-Sign" && w["focused"] == true), "focus moved on the press: {sign}");
assert!(!h.ctx.input(|i| i.keys_down.contains(&egui::Key::Tab)), "the release reached egui: no stuck Tab");
}
#[test]
fn production_drag_releases_the_button_after_the_press_moves_focus() {
let (mut h, c) = harness_pages(2);
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 0, title: "Target".into(), page: 0, left: None, top: None });
ok(&mut h, &c, "ui.set", json!({"key": "panel", "value": "bookmarks"}));
let field = ok(&mut h, &c, "ui.inspect", json!({"query": "Search", "role": "TextInput"}));
let rect = field["widgets"][0]["rect"].as_array().expect("the search field has a rect");
let (x0, y0, x1, y1) = (rect[0].as_f64().unwrap(), rect[1].as_f64().unwrap(), rect[2].as_f64().unwrap(), rect[3].as_f64().unwrap());
let start = json!([x0 + 8.0, (y0 + y1) / 2.0]);
let end = json!([x1 - 8.0, (y0 + y1) / 2.0]);
call_production(&mut h, &c, "ui.drag", json!({"from": start, "to": end, "steps": 3}))
.unwrap_or_else(|e| panic!("the press's focus change must not cancel its moves and release: {e}"));
assert!(!h.ctx.input(|i| i.pointer.button_down(egui::PointerButton::Primary)), "the pointer button was released");
assert_eq!(h.ctx.input(|i| i.modifiers), egui::Modifiers::NONE, "no modifier is left held");
}
#[test]
fn control_button_focus_preserves_document_undo_redo_shortcuts() {
let (mut h, c) = harness_pages(2);
ok(&mut h, &c, "ui.focus", json!({"label": "Convert"}));
ok(&mut h, &c, "ui.key", json!({"key": "Enter"}));
ok(&mut h, &c, "ui.click", json!({"label": "All tools"}));
assert!(h.ctx.egui_wants_keyboard_input());
assert!(!h.ctx.text_edit_focused());
ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"}));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["pages"], 3);
ok(&mut h, &c, "ui.key", json!({"key": "Z", "modifiers": ["command"]}));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["pages"], 2);
ok(&mut h, &c, "ui.key", json!({"key": "Z", "modifiers": ["command", "shift"]}));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["pages"], 3);
}
#[test]
fn control_text_focus_retains_text_undo_ownership() {
let (mut h, c) = harness_pages(2);
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 0, title: "Target".into(), page: 0, left: None, top: None });
ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"}));
ok(&mut h, &c, "ui.set", json!({"key": "panel", "value": "bookmarks"}));
let widgets = ok(&mut h, &c, "ui.inspect", json!({"query": "Search", "role": "TextInput"}));
ok(&mut h, &c, "ui.focus", json!({"id": widgets["widgets"][0]["id"]}));
assert!(h.ctx.text_edit_focused());
ok(&mut h, &c, "ui.type", json!({"text": "target"}));
let search_id = egui::Id::unique(("bookmark_search", h.state().views[0].id.0));
assert_eq!(h.ctx.data(|d| d.get_temp::<String>(search_id)).as_deref(), Some("target"));
ok(&mut h, &c, "ui.key", json!({"key": "Z", "modifiers": ["command"]}));
assert_eq!(h.ctx.data(|d| d.get_temp::<String>(search_id)).as_deref(), Some(""));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["pages"], 3);
}
#[test]
fn control_focus_rejects_missing_ambiguous_disabled_and_nonfocusable_targets() {
let (mut h, c) = harness();
for params in [
json!({}),
json!({"label": "does not exist"}),
json!({"id": "not a number"}),
json!({"id": 0}),
json!({"id": 1, "label": "Convert"}),
json!({"label": false}),
json!({"label": "Convert", "force": true}),
json!([]),
] {
assert!(call(&mut h, &c, "ui.focus", params).is_err());
}
let widgets = ok(&mut h, &c, "ui.inspect", json!({}));
let nonfocusable = widgets["widgets"].as_array().unwrap().iter().find(|w| w["focusable"] == false).unwrap();
assert!(call(&mut h, &c, "ui.focus", json!({"id": nonfocusable["id"]})).is_err());
let convert = widgets["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Convert").unwrap();
ok(&mut h, &c, "ui.focus", json!({"id": convert["id"]}));
let found = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"}));
assert!(found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Convert" && w["focused"] == true), "{found}");
// Home has no active document, so its document-only toolbar controls are disabled.
ok(&mut h, &c, "ui.click", json!({"label": "Home"}));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["home"], true);
let widgets = ok(&mut h, &c, "ui.inspect", json!({}));
let disabled = widgets["widgets"].as_array().unwrap().iter().find(|w| w["enabled"] == false).unwrap();
assert!(call(&mut h, &c, "ui.focus", json!({"id": disabled["id"]})).is_err());
}
fn close_requested(h: &Harness<'static, PdfCraftApp>) -> bool {
h.output().viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Close)))
}
#[test]
fn control_quit_preserves_dirty_documents_and_cancel_keeps_them_open() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"}));
for params in [json!({"force": true}), json!({"discard": true}), json!([])] {
assert!(call(&mut h, &c, "ui.quit", params).is_err());
assert!(!close_requested(&h));
}
assert_eq!(ok(&mut h, &c, "ui.quit", json!({})), json!({"quitting": false, "needs_confirmation": true}));
assert!(!close_requested(&h));
assert_eq!(ok(&mut h, &c, "ui.quit", Value::Null), json!({"quitting": false, "needs_confirmation": true}));
assert!(!close_requested(&h));
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["close_prompt"], true);
assert_eq!(state["documents"][0]["dirty"], true);
assert_eq!(state["documents"][0]["pages"], 6);
ok(&mut h, &c, "ui.click", json!({"label": "Cancel"}));
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["close_prompt"], false);
assert_eq!(state["documents"].as_array().unwrap().len(), 1);
assert_eq!(state["documents"][0]["dirty"], true);
assert_eq!(state["documents"][0]["pages"], 6);
assert!(!close_requested(&h));
}
/// Run one frame and return the attention requests it sent to the window.
fn step_attention(h: &mut Harness<'static, PdfCraftApp>) -> Vec<egui::UserAttentionType> {
h.step();
let commands = h.output().viewport_output.values().flat_map(|v| v.commands.iter());
commands.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None }).collect()
}
/// Send a request, run frames until it is answered and three more, and collect every attention
/// request those frames sent.
fn attention_during(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Vec<egui::UserAttentionType> {
let rx = c.send(method, params);
let mut sent = Vec::new();
for _ in 0..30 {
sent.extend(step_attention(h));
if rx.try_recv().is_ok() {
(0..3).for_each(|_| sent.extend(step_attention(h)));
return sent;
}
}
panic!("{method}: no reply after 30 frames");
}
/// Open `name` with an unsaved change, in a tab of its own.
fn open_unsaved(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
let app = h.state_mut();
app.open_bytes(name, None, fixture(1)).unwrap();
app.active = Some(app.views.len() - 1);
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
}
/// An agent quits while another app is in front, then answers the prompts itself. Quitting asks
/// about each unsaved document in turn; the request made for the first question stands for the
/// next, and answering the last withdraws it, so the taskbar or the window list stops flagging a
/// window with nothing to ask. Each question is a new one: when the first opened in front, the
/// next one asks when it opens in the background.
#[test]
fn a_save_prompt_answered_in_the_background_withdraws_its_attention_request() {
use egui::UserAttentionType::{Critical, Reset};
let (mut h, c) = harness();
ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"}));
open_unsaved(&mut h, "second.pdf");
h.input_mut().focused = false;
assert_eq!(attention_during(&mut h, &c, "ui.quit", json!({})), [Critical]);
assert!(attention_during(&mut h, &c, "ui.click", json!({"label": "Don't save"})).is_empty(), "the request stands");
assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]);
open_unsaved(&mut h, "third.pdf");
h.input_mut().focused = true;
assert!(attention_during(&mut h, &c, "ui.quit", json!({})).is_empty(), "the first question opens in front");
h.input_mut().focused = false;
assert!(attention_during(&mut h, &c, "ui.state", json!({})).is_empty(), "leaving it open asks nothing");
assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Don't save"})), [Critical], "the next opens in the background");
assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]);
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["close_prompt"], false);
let left: Vec<_> = state["documents"].as_array().unwrap().iter().map(|d| (d["name"].clone(), d["dirty"].clone())).collect();
assert_eq!(left, [(json!("third.pdf"), json!(true))], "Don't save closed doc.pdf and second.pdf; Cancel kept third.pdf");
}
#[test]
fn control_quit_clean_document_requests_normal_window_close() {
for params in [json!({}), Value::Null] {
let (mut h, c) = harness();
assert_eq!(ok(&mut h, &c, "ui.quit", params), json!({"quitting": true, "needs_confirmation": false}));
assert!(close_requested(&h));
}
}
#[test]
fn bookmark_titles_can_be_searched_over_control() {
let (mut h, c) = harness();
for (index, title, page) in [(0, "Background", 0), (1, "Target chapter", 3)] {
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index, title: title.into(), page, left: None, top: None });
}
ok(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "bookmarks" }));
h.run_steps(3);
let widgets = ok(&mut h, &c, "ui.inspect", json!({ "query": "Search", "role": "TextInput" }));
let rect = &widgets["widgets"][0]["rect"];
let x = (rect[0].as_f64().unwrap() + rect[2].as_f64().unwrap()) / 2.0;
let y = (rect[1].as_f64().unwrap() + rect[3].as_f64().unwrap()) / 2.0;
ok(&mut h, &c, "ui.click", json!({ "x": x, "y": y }));
ok(&mut h, &c, "ui.type", json!({ "text": "target" }));
h.get_by_label("Target chapter");
assert!(h.query_by_label("Background").is_none());
ok(&mut h, &c, "ui.click", json!({ "label": "Target chapter" }));
assert_eq!(h.state().views[0].current, 3);
if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") {
let _gpu = crate::gpu();
h.render().unwrap().save(format!("{dir}/control-filtered-bookmarks.png")).unwrap();
}
ok(&mut h, &c, "ui.click", json!({ "label": "Clear" }));
h.get_by_label("Background");
h.get_by_label("Target chapter");
if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") {
let _gpu = crate::gpu();
h.render().unwrap().save(format!("{dir}/control-cleared-bookmarks.png")).unwrap();
}
}
#[test]
fn theme_commands_and_options_report_preference_and_effective_colours() {
let (mut h, c) = harness();
h.input_mut().system_theme = Some(egui::Theme::Dark);
h.run_steps(2);
for (id, preference, effective) in
[("view.theme.system", "System", "Dark"), ("view.theme.light", "Light", "Light"), ("view.theme.dark", "Dark", "Dark")]
{
ok(&mut h, &c, "ui.command", json!({ "id": id }));
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["theme_preference"], preference);
assert_eq!(state["theme"], effective);
}
ok(&mut h, &c, "ui.set", json!({ "key": "theme", "value": "system" }));
h.input_mut().system_theme = Some(egui::Theme::Light);
h.run_steps(2);
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["theme_preference"], "System");
assert_eq!(state["theme"], "Light");
assert!(call(&mut h, &c, "ui.set", json!({ "key": "theme", "value": "purple" })).is_err());
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["theme_preference"], "System");
}
#[test]
fn lithuanian_password_dialog_translates_dynamic_labels_and_validation() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "lt" }));
ok(&mut h, &c, "ui.set", json!({ "key": "dialog", "value": "protect" }));
h.run_steps(2);
h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Peržiūrėti");
h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Redaguoti").click();
h.run_steps(2);
assert!(!h.state().protect_draft.viewing);
h.get_by_role_and_label(egui::accesskit::Role::PasswordInput, "Įveskite slaptažodį");
h.get_by_role_and_label(egui::accesskit::Role::PasswordInput, "Pakartokite slaptažodį");
h.state_mut().protect_draft.password = "abc".into();
h.state_mut().protect_draft.confirm = "xyz".into();
h.run_steps(2);
h.get_by_label("Slaptažodžiai nesutampa.");
}
#[test]
fn lithuanian_inspector_title_updates_through_the_control_channel() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "comments" }));
h.run_steps(2);
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector");
let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "lt" }));
h.run_steps(2);
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspektorius");
assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").is_none());
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], documents);
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "en" }));
h.run_steps(2);
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector");
}
/// Switching the interface language changes labels only: documents, their dirty state and the
/// command ids agents drive stay exactly the same.
#[test]
fn language_switch_preserves_document_and_command_ids() {
use pdfcraft_ui_egui::i18n;
let (mut h, c) = harness();
let doc = h.state().views[0].id;
h.state_mut().session.apply(doc, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
assert_eq!(documents[0]["dirty"], true);
let commands = ok(&mut h, &c, "ui.commands", json!({}));
for code in ["ja", "zh-hans", "fr", "de", "uk", "lt", "en"] {
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": code }));
h.run_steps(2);
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["language"], code);
assert_eq!(state["documents"], documents);
assert_eq!(ok(&mut h, &c, "ui.commands", json!({})), commands);
let lang = i18n::Lang::from_code(code).unwrap();
h.get_by_label(i18n::tr(lang, "Menu")).click();
h.run_steps(2);
h.get_by_label(i18n::tr(lang, "File")).hover();
h.run_steps(3);
h.get_by_label_contains(i18n::tr(lang, "Open…"));
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
h.run_steps(2);
}
let error = call(&mut h, &c, "ui.set", json!({ "key": "language", "value": "xx" })).unwrap_err();
let codes = std::iter::once(i18n::AUTO).chain(i18n::Lang::all().map(i18n::Lang::code)).collect::<Vec<_>>().join(", ");
assert_eq!(error, format!("language must be one of {codes}"));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "en");
}
#[cfg(target_os = "linux")]
fn start_autoscroll(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient) -> egui::Pos2 {
let p = h.state().views[0].viewport_rect().center();
ok(h, c, "ui.click", json!({ "x": p.x, "y": p.y, "button": "middle" }));
assert!(h.state().views[0].auto_scrolling());
p
}
/// Measure document movement from rendered geometry without requiring an offscreen grid
/// cell to remain instantiated. Learn the grid's column count and row pitch before scrolling;
/// any currently rendered cell then identifies the same content origin. This tests actual
/// layout displacement, independently of the autoscroll velocity calculation.
#[cfg(target_os = "linux")]
struct CanvasPosition {
grid: Option<(usize, f32)>,
}
#[cfg(target_os = "linux")]
impl CanvasPosition {
fn new(h: &Harness<'static, PdfCraftApp>) -> Self {
let grid = h.state().views[0].organize.then(|| {
let first = h.get_by_label("Page 1").rect().top();
let (page, next_row) = Self::grid_cells(h).into_iter().find(|(_, top)| *top > first + 1.0).expect("a second grid row");
(page - 1, next_row - first)
});
Self { grid }
}
fn grid_cells(h: &Harness<'static, PdfCraftApp>) -> Vec<(usize, f32)> {
use egui_kittest::kittest::NodeT;
let mut cells: Vec<_> = h
.query_all_by_label_contains("Page ")
.filter_map(|node| {
let page = node.accesskit_node().label()?.strip_prefix("Page ")?.parse::<usize>().ok()?;
Some((page, node.rect().top()))
})
.collect();
cells.sort_unstable_by_key(|(page, _)| *page);
cells
}
fn top(&self, h: &Harness<'static, PdfCraftApp>) -> f32 {
match self.grid {
Some((columns, row_pitch)) => {
let (page, top) = Self::grid_cells(h).into_iter().next().expect("a rendered grid cell");
top - ((page - 1) / columns) as f32 * row_pitch
}
None => h.state().views[0].page_screen_rect(0).expect("the zoomed viewer page remains visible").top(),
}
}
}
#[cfg(target_os = "linux")]
#[test]
fn middle_click_autoscroll_latches_has_a_dead_zone_and_scrolls_both_directions() {
let (mut h, c) = harness();
// Keep the tracked page visible through the downward leg and reversal.
h.state_mut().set_option("zoom", "400").unwrap();
h.run_steps(3);
h.state_mut().views[0].go_to_page(0);
h.run_steps(2);
let p = start_autoscroll(&mut h, &c);
let top = h.state().views[0].page_screen_rect(0).unwrap().top();
ok(&mut h, &c, "ui.move", json!({ "x": p.x + 100.0, "y": p.y + 8.0 }));
h.run_steps(8);
assert_eq!(h.state().views[0].page_screen_rect(0).unwrap().top(), top, "horizontal motion and the dead zone do not scroll");
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 90.0 }));
h.run_steps(8);
let down = h.state().views[0].page_screen_rect(0).unwrap().top();
assert!(down < top - 30.0, "moving below the anchor scrolls down: {top} -> {down}");
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y - 50.0 }));
// The control request itself takes frames while the previous downward motion continues.
// Measure reversal after it has arrived and the scroll area's layout has caught up.
h.run_steps(2);
let reversing = h.state().views[0].page_screen_rect(0).unwrap().top();
h.run_steps(8);
let up = h.state().views[0].page_screen_rect(0).unwrap().top();
assert!(up > reversing + 15.0, "moving above the anchor scrolls up: {reversing} -> {up}");
ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y, "button": "middle" }));
assert!(!h.state().views[0].auto_scrolling(), "a second wheel click stops");
h.run_steps(2);
let stopped = h.state().views[0].page_screen_rect(0).unwrap().top();
h.run_steps(8);
assert_eq!(h.state().views[0].page_screen_rect(0).unwrap().top(), stopped, "no drift after cancellation");
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty, "scrolling never edits the PDF");
}
#[cfg(target_os = "linux")]
#[test]
fn moving_before_middle_button_release_keeps_scrolling_without_starting_page_tools() {
let (mut h, c) = harness();
// Keep the tracked page on screen while the faster gesture continues after release.
h.state_mut().set_option("zoom", "400").unwrap();
h.run_steps(3);
h.state_mut().views[0].go_to_page(0);
h.run_steps(2);
// A middle drag must not also draw with a selected tool (egui accepts any drag button).
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Crop;
let p = h.state().views[0].viewport_rect().center();
let top = h.state().views[0].page_screen_rect(0).unwrap().top();
ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y + 60.0], "steps": 12, "button": "middle" }));
h.run_steps(2);
assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < top - 20.0);
assert!(h.state().views[0].auto_scrolling(), "release keeps scrolling toggled on even after movement");
let released = h.state().views[0].page_screen_rect(0).unwrap().top();
h.run_steps(8);
assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < released - 30.0, "scrolling continues with no button held");
assert!(h.state().views[0].crop_drag.is_none(), "the Crop tool must not receive a wheel drag");
assert!(h.state().dialog.is_none());
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y + 60.0, "button": "middle" }));
assert!(!h.state().views[0].auto_scrolling(), "the next middle click toggles scrolling off");
}
#[cfg(target_os = "linux")]
#[test]
fn autoscroll_uses_the_initial_click_position_when_input_arrives_in_one_frame() {
let (mut h, c) = harness();
let p = h.state().views[0].viewport_rect().center();
let moved = p + egui::vec2(0.0, 60.0);
let top = h.state().views[0].page_screen_rect(0).unwrap().top();
h.event(egui::Event::PointerMoved(p));
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Middle, pressed: true, modifiers: egui::Modifiers::NONE });
h.event(egui::Event::PointerMoved(moved));
h.event(egui::Event::PointerButton { pos: moved, button: egui::PointerButton::Middle, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(8);
assert!(h.state().views[0].auto_scrolling());
assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < top - 20.0, "distance is measured from the click, not the last mouse event");
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
}
#[cfg(target_os = "linux")]
#[test]
fn farther_from_the_click_scrolls_faster_in_the_viewer_and_page_grid() {
for organize in [false, true] {
let (mut h, c) = harness_pages(40);
h.state_mut().views[0].organize = organize;
h.run_steps(3);
let position = CanvasPosition::new(&h);
let top = |h: &Harness<'static, PdfCraftApp>| position.top(h);
let p = start_autoscroll(&mut h, &c);
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 30.0 }));
let before_near = top(&h);
h.run_steps(8);
let near = before_near - top(&h);
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 100.0 }));
let before_far = top(&h);
h.run_steps(8);
let far = before_far - top(&h);
assert!(near > 0.0 && far > near * 3.0, "farther movement must be faster: organize={organize}, near={near}, far={far}");
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y }));
h.run_steps(2);
let at_anchor = top(&h);
h.run_steps(8);
assert_eq!(top(&h), at_anchor, "returning to the original click pauses scrolling");
assert!(h.state().views[0].auto_scrolling(), "the toggle stays on at the anchor");
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
}
}
#[cfg(target_os = "linux")]
#[test]
fn autoscroll_uses_elapsed_frame_time_and_preserves_fractional_motion_in_both_views() {
for (organize, scale, zoom) in [(false, 1.0, "100"), (false, 2.0, "100"), (false, 1.0, "400"), (true, 1.0, "100"), (true, 2.0, "100")] {
for frames in [30, 60, 120, 144] {
let (mut h, c) = harness_pages(40);
h.set_pixels_per_point(scale);
h.state_mut().set_option("zoom", zoom).unwrap();
h.state_mut().views[0].organize = organize;
h.run_steps(3);
assert_eq!(h.ctx.pixels_per_point(), scale);
let position = CanvasPosition::new(&h);
let top = |h: &Harness<'static, PdfCraftApp>| position.top(h);
let step = |h: &mut Harness<'static, PdfCraftApp>| {
// Deliberately differ from the harness's predicted frame interval: scrolling
// must follow the elapsed time rather than the display's predicted rate.
h.input_mut().time = Some(h.ctx.input(|i| i.time) + 1.0 / f64::from(frames));
h.step();
};
let p = start_autoscroll(&mut h, &c);
for (distance, expected) in [(16.0, 17.83), (50.0, 218.67)] {
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + distance }));
step(&mut h);
step(&mut h);
let before = top(&h);
for _ in 0..frames {
step(&mut h);
}
let travelled = before - top(&h);
assert!(
(travelled - expected).abs() < 1.0,
"organize={organize}, scale={scale}, zoom={zoom}, fps={frames}, distance={distance}, travelled={travelled}"
);
assert_eq!(
h.output().viewport_output[&egui::ViewportId::ROOT].repaint_delay,
std::time::Duration::ZERO,
"motion schedules the next display frame"
);
}
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 15.0 }));
step(&mut h);
let paused = top(&h);
for _ in 0..frames {
step(&mut h);
}
assert_eq!(top(&h), paused, "the dead zone pauses immediately, without coasting");
assert!(h.state().views[0].auto_scrolling());
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y - 50.0 }));
step(&mut h);
step(&mut h);
let before = top(&h);
for _ in 0..frames {
step(&mut h);
}
assert!((top(&h) - before - 218.67).abs() < 1.0, "resuming above the anchor reverses direction");
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
}
}
}
#[cfg(target_os = "linux")]
#[test]
fn escape_stops_autoscroll_without_closing_find() {
let (mut h, c) = harness();
h.state_mut().views[0].open_find();
h.run_steps(3);
// Focus the canvas so the text field no longer owns keyboard input.
let p = h.state().views[0].viewport_rect().center();
ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y }));
h.run_steps(2);
start_autoscroll(&mut h, &c);
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
assert!(!h.state().views[0].auto_scrolling());
assert!(h.state().views[0].find.is_some(), "Escape cancels the scrolling gesture first");
}
#[cfg(target_os = "linux")]
#[test]
fn autoscroll_cancels_on_click_wheel_focus_loss_and_pointer_exit() {
let (mut h, c) = harness();
let p = start_autoscroll(&mut h, &c);
ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y }));
assert!(!h.state().views[0].auto_scrolling());
start_autoscroll(&mut h, &c);
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -20.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
source: egui::MouseWheelSource::Unknown,
});
h.run_steps(2);
assert!(!h.state().views[0].auto_scrolling());
start_autoscroll(&mut h, &c);
h.input_mut().focused = false;
h.run_steps(2);
assert!(!h.state().views[0].auto_scrolling());
h.input_mut().focused = true;
h.run_steps(2);
start_autoscroll(&mut h, &c);
h.event(egui::Event::PointerGone);
h.run_steps(2);
assert!(!h.state().views[0].auto_scrolling());
}
#[cfg(target_os = "linux")]
#[test]
fn autoscroll_is_scoped_to_the_active_view_and_cannot_start_under_a_dialog() {
let (mut h, c) = harness();
let p = start_autoscroll(&mut h, &c);
h.state_mut().open_bytes("other.pdf", None, fixture(2)).unwrap();
h.run_steps(2);
assert!(!h.state().views[0].auto_scrolling());
h.state_mut().active = Some(0);
h.run_steps(2);
assert!(!h.state().views[0].auto_scrolling(), "returning to the tab must not resume");
start_autoscroll(&mut h, &c);
h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::About);
h.run_steps(2);
assert!(!h.state().views[0].auto_scrolling());
ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y, "button": "middle" }));
assert!(!h.state().views[0].auto_scrolling(), "a modal owns its input");
assert!(call(&mut h, &c, "ui.move", json!({ "x": "bad", "y": 2 })).is_err());
assert!(call(&mut h, &c, "ui.drag", json!({ "from": [1, 2], "to": [3, 4], "button": "bad" })).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn the_click_that_stops_autoscroll_preserves_the_page_selection() {
use egui_kittest::kittest::Queryable;
let (mut h, c) = harness_pages(40);
h.state_mut().execute("page.organize");
h.state_mut().views[0].select_pages(&[0]);
h.run_steps(3);
let page_two = h.get_by_label("Page 2").rect().center();
start_autoscroll(&mut h, &c);
ok(&mut h, &c, "ui.click", json!({ "x": page_two.x, "y": page_two.y }));
assert!(!h.state().views[0].auto_scrolling());
assert_eq!(h.state().views[0].target_pages(), vec![0], "the cancelling press and release belong to autoscroll");
// The next ordinary click still selects normally.
ok(&mut h, &c, "ui.click", json!({ "x": page_two.x, "y": page_two.y }));
assert_eq!(h.state().views[0].target_pages(), vec![1]);
}
#[cfg(target_os = "linux")]
#[test]
fn organize_pages_supports_autoscroll_without_selecting_or_reordering_pages() {
let (mut h, c) = harness_pages(40);
h.state_mut().execute("page.organize");
h.run_steps(3);
let position = CanvasPosition::new(&h);
let top = position.top(&h);
let p = start_autoscroll(&mut h, &c);
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 80.0 }));
h.run_steps(8);
assert!(position.top(&h) < top - 30.0, "the organize grid scrolls");
assert!(h.state().views[0].selected.is_empty());
assert!(h.state().views[0].org_drag.is_none());
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
// Holding the wheel over a page also must not create an organize drag.
ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y + 80.0], "button": "middle" }));
assert!(h.state().views[0].selected.is_empty());
assert!(h.state().views[0].org_drag.is_none());
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
assert!(h.state().views[0].auto_scrolling(), "releasing the wheel leaves the grid scrolling on");
h.state_mut().views[0].organize = false;
h.run_steps(2);
assert!(!h.state().views[0].auto_scrolling(), "changing canvas mode cancels the gesture");
}
#[cfg(not(target_os = "linux"))]
#[test]
fn middle_button_pans_while_held_outside_linux() {
for organize in [false, true] {
let (mut h, c) = harness_pages(40);
h.state_mut().views[0].organize = organize;
h.run_steps(3);
let p = h.state().views[0].viewport_rect().center();
ok(&mut h, &c, "ui.click", json!({ "x": p.x, "y": p.y, "button": "middle" }));
ok(&mut h, &c, "ui.move", json!({ "x": p.x, "y": p.y + 50.0 }));
h.run_steps(8);
assert!(!h.state().views[0].auto_scrolling(), "a click does not latch auto-scroll outside Linux: organize={organize}");
assert!(!h.state().views[0].middle_panning(), "a released click leaves nothing to pan: organize={organize}");
}
// A drag pans while the button is held, and the Crop tool never sees it.
let (mut h, c) = harness();
h.state_mut().set_option("zoom", "400").unwrap();
h.run_steps(3);
h.state_mut().views[0].go_to_page(1);
h.run_steps(2);
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Crop;
let p = h.state().views[0].viewport_rect().center();
let top = h.state().views[0].page_screen_rect(1).unwrap().top();
ok(&mut h, &c, "ui.drag", json!({ "from": [p.x, p.y], "to": [p.x, p.y - 60.0], "steps": 12, "button": "middle" }));
h.run_steps(2);
let moved = h.state().views[0].page_screen_rect(1).unwrap().top();
assert!((moved - (top - 60.0)).abs() < 1.0, "the page follows the pointer 1:1: {top} -> {moved}");
h.run_steps(8);
assert_eq!(h.state().views[0].page_screen_rect(1).unwrap().top(), moved, "no drift after release");
assert!(h.state().views[0].crop_drag.is_none(), "the Crop tool must not receive a wheel drag");
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
}
#[test]
fn state_and_view_options() {
let (mut h, c) = harness();
let s = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(s["documents"][0]["name"], "doc.pdf");
assert_eq!(s["documents"][0]["pages"], 5);
assert_eq!(s["active"]["page"], 1);
assert_eq!(s["active"]["page_errors"], json!([]));
ok(&mut h, &c, "ui.set", json!({ "key": "page", "value": 4 }));
h.run_steps(3);
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["page"], 4);
ok(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "bookmarks" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["right_panel"], "Bookmarks");
assert!(call(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "nonsense" })).is_err());
}
#[test]
fn japanese_controls_and_search_keep_command_ids() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "ja");
ok(&mut h, &c, "ui.click", json!({ "label": "変換" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" }));
// The command sets the dialog; its content reaches the accessibility tree in the next pass.
h.run_steps(2);
let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "表示言語" }));
assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}");
ok(&mut h, &c, "ui.click", json!({ "label": "OK" }));
for (query, translated) in [("整理", "ページを整理"), ("Split document", "文書を分割…"), ("page.split", "文書を分割…")] {
ok(&mut h, &c, "ui.command", json!({ "id": "view.palette" }));
ok(&mut h, &c, "ui.type", json!({ "text": query }));
let hits = ok(&mut h, &c, "ui.inspect", json!({ "query": translated }));
assert!(hits["count"].as_u64().unwrap() > 0, "{query}: {hits}");
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
h.state_mut().palette_query.clear();
}
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf");
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "en" }));
ok(&mut h, &c, "ui.click", json!({ "label": "All tools" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools");
}
#[test]
fn german_preferences_and_search_keep_command_ids() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "de" }));
ok(&mut h, &c, "ui.click", json!({ "label": "Konvertieren" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" }));
h.run_steps(2);
let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "Sprache der Oberfläche" }));
assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}");
let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": "Deutsch" }));
let combo = selector["widgets"].as_array().unwrap().iter().find(|w| w["role"] == "ComboBox").expect("language selector");
ok(&mut h, &c, "ui.click", json!({ "id": combo["id"] }));
ok(&mut h, &c, "ui.click", json!({ "label": "English" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "en");
let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": "English" }));
let combo = selector["widgets"].as_array().unwrap().iter().find(|w| w["role"] == "ComboBox").expect("language selector");
ok(&mut h, &c, "ui.click", json!({ "id": combo["id"] }));
ok(&mut h, &c, "ui.click", json!({ "label": "Deutsch" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "de");
ok(&mut h, &c, "ui.click", json!({ "label": "OK" }));
for query in ["Dokument teilen", "Split document", "page.split"] {
ok(&mut h, &c, "ui.command", json!({ "id": "view.palette" }));
ok(&mut h, &c, "ui.type", json!({ "text": query }));
let hits = ok(&mut h, &c, "ui.inspect", json!({ "query": "Dokument teilen…" }));
assert!(hits["count"].as_u64().unwrap() > 0, "{query}: {hits}");
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
h.state_mut().palette_query.clear();
}
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf");
}
#[test]
fn ukrainian_preferences_and_search_keep_command_ids() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "uk" }));
ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" }));
h.run_steps(2);
for label in ["Мова інтерфейсу", "Українська"] {
let found = ok(&mut h, &c, "ui.inspect", json!({ "query": label }));
assert!(found["count"].as_u64().unwrap() > 0, "{label}: {found}");
}
ok(&mut h, &c, "ui.click", json!({ "label": "OK" }));
for query in ["Розділити", "Split document", "page.split"] {
ok(&mut h, &c, "ui.command", json!({ "id": "view.palette" }));
ok(&mut h, &c, "ui.type", json!({ "text": query }));
let hits = ok(&mut h, &c, "ui.inspect", json!({ "query": "Розділити документ…" }));
assert!(hits["count"].as_u64().unwrap() > 0, "{query}: {hits}");
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
h.state_mut().palette_query.clear();
}
for (dialog, label) in [("properties", "Властивості документа"), ("protect", "Захистити паролем"), ("about", "Учасники")]
{
ok(&mut h, &c, "ui.set", json!({ "key": "dialog", "value": dialog }));
let found = ok(&mut h, &c, "ui.inspect", json!({ "query": label }));
assert!(found["count"].as_u64().unwrap() > 0, "{dialog}: {found}");
}
ok(&mut h, &c, "ui.set", json!({ "key": "dialog", "value": "none" }));
assert!(!h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![0, 1, 2, 3, 4] }));
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["language"], "uk");
assert_eq!(state["notice"], "Помилка «Видалити сторінки»: a document must keep at least one page");
assert_eq!(state["documents"][0]["name"], "doc.pdf");
}
#[test]
fn preferences_menu_and_shortcut_allow_switching_interface_languages() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" }));
ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" }));
h.run_steps(2);
// The open menu's Edit entry and the left panel's Edit tool share the visible label. Pick
// the popup entry by its id, not by its visible text.
let edits = ok(&mut h, &c, "ui.inspect", json!({ "query": "編集", "role": "Button" }));
// The tool button reports its toggle state; the popup's menu button does not.
let edit = edits["widgets"].as_array().unwrap().iter().find(|w| w.get("toggled").is_none()).expect("the Edit menu entry");
ok(&mut h, &c, "ui.click", json!({ "id": edit["id"] }));
h.run_steps(2);
let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "環境設定…" }));
let prefs = menu["widgets"].as_array().unwrap().iter().find(|w| w["clickable"] == true).expect("Preferences menu item");
ok(&mut h, &c, "ui.click", json!({ "id": prefs["id"] }));
for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")]
{
let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": current }));
let combo = selector["widgets"].as_array().unwrap().iter().find(|w| w["role"] == "ComboBox").expect("language selector");
ok(&mut h, &c, "ui.click", json!({ "id": combo["id"] }));
ok(&mut h, &c, "ui.click", json!({ "label": next }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], code);
}
ok(&mut h, &c, "ui.click", json!({ "label": "OK" }));
ok(&mut h, &c, "ui.key", json!({ "key": ",", "modifiers": ["command"] }));
let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "表示言語" }));
assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}");
ok(&mut h, &c, "ui.click", json!({ "label": "OK" }));
ok(&mut h, &c, "ui.command", json!({ "id": "help.shortcuts" }));
for label in ["キーボードショートカット", "開く", "環境設定", "次/前の検索結果", "ダブルクリック", "閉じる"]
{
let found = ok(&mut h, &c, "ui.inspect", json!({ "query": label }));
assert!(found["count"].as_u64().unwrap() > 0, "{label}: {found}");
}
let english = ok(&mut h, &c, "ui.inspect", json!({ "query": "Next / previous match" }));
assert_eq!(english["count"], 0);
ok(&mut h, &c, "ui.click", json!({ "label": "閉じる" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf");
}
#[test]
fn japanese_dialogs_errors_and_custom_action_names() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "ja"}));
for (dialog, label) in [
("properties", "文書のプロパティ"),
("protect", "パスワードで保護"),
("export-image", "画像に書き出し"),
("optimize", "PDF の最適化"),
("recognize-text", "テキストを認識"),
("accessibility-options", "アクセシビリティチェックのオプション"),
("js-console", "JavaScript コンソール"),
("compare-files", "ファイルを比較"),
("sign", "署名用のデジタル ID を設定"),
] {
ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": dialog}));
let found = ok(&mut h, &c, "ui.inspect", json!({"query": label}));
assert!(found["count"].as_u64().unwrap() > 0, "{dialog}: {found}");
}
ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "none"}));
assert!(!h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![0, 1, 2, 3, 4] }));
let state = ok(&mut h, &c, "ui.state", json!({}));
// The frame is translated; the engine's own error text is shown as it is.
assert_eq!(state["notice"], "操作「ページを削除」に失敗しました: a document must keep at least one page");
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf");
h.state_mut().custom_actions.push(pdfcraft_engine::actions::Action {
name: "Save".to_string(),
description: "Print".to_string(),
steps: Vec::new(),
builtin: false,
});
h.state_mut().wizard.selected = Some("Save".to_string());
ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "action-wizard"}));
for label in ["アクションウィザード", "Save", "Print"] {
let found = ok(&mut h, &c, "ui.inspect", json!({"query": label}));
assert!(found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == label || w["value"] == label), "{found}");
}
}
#[test]
fn simplified_chinese_about_tabs_and_credit_controls() {
let (mut h, c) = harness();
let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"}));
settle(&mut h);
ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "about"}));
settle(&mut h);
for label in ["关于", "贡献者", "模型"] {
let found = ok(&mut h, &c, "ui.inspect", json!({"query": label}));
assert!(found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == label), "{found}");
}
ok(&mut h, &c, "ui.click", json!({"label": "贡献者"}));
for label in ["用户名", "显示名称", "真实姓名", "排序", "名称列表", "表格", "首次提交"] {
let found = ok(&mut h, &c, "ui.inspect", json!({"query": label}));
assert!(found["count"].as_u64().unwrap() > 0, "{label}: {found}");
}
ok(&mut h, &c, "ui.click", json!({"label": "表格"}));
for label in ["新增行", "删除行", "净增行", "新增资源", "删除资源"] {
let found = ok(&mut h, &c, "ui.inspect", json!({"query": label}));
assert!(found["count"].as_u64().unwrap() > 0, "{label}: {found}");
}
ok(&mut h, &c, "ui.click", json!({"label": "模型"}));
let columns = ok(&mut h, &c, "ui.inspect", json!({"query": "占全部提交的比例"}));
let empty = ok(&mut h, &c, "ui.inspect", json!({"query": "此版本未包含模型贡献记录。"}));
assert!(columns["count"].as_u64().unwrap() > 0 || empty["count"].as_u64().unwrap() > 0, "{columns}, {empty}");
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], documents);
}
#[test]
fn simplified_chinese_signature_prompts_and_errors_keep_document_state() {
let (mut h, c) = harness();
let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"}));
settle(&mut h);
ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "signature"}));
settle(&mut h);
let typed = ok(&mut h, &c, "ui.inspect", json!({"query": "请输入您的签名。"}));
assert!(typed["count"].as_u64().unwrap() > 0, "{typed}");
// The shell also has a Draw control; the modal's button is registered after the shell.
let draw = ok(&mut h, &c, "ui.inspect", json!({"query": "绘制"}));
let id = draw["widgets"]
.as_array()
.unwrap()
.iter()
.rev()
.find(|w| w["label"] == "绘制" && w["clickable"] == true)
.expect("signature Draw button")["id"]
.clone();
ok(&mut h, &c, "ui.click", json!({"id": id}));
let drawn = ok(&mut h, &c, "ui.inspect", json!({"query": "请在下方绘制您的签名。"}));
assert!(drawn["count"].as_u64().unwrap() > 0, "{drawn}");
ok(&mut h, &c, "ui.click", json!({"label": "取消"}));
assert!(!h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![0, 1, 2, 3, 4] }));
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["notice"], "删除页面失败:a document must keep at least one page");
assert_eq!(state["documents"], documents, "a language change and failed edit must preserve the document");
}
#[test]
fn inspect_and_click_by_label_and_id() {
let (mut h, c) = harness();
let found = ok(&mut h, &c, "ui.inspect", json!({ "query": "convert" }));
let convert =
found["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Convert" && w["clickable"] == true).cloned().expect("a Convert tab");
let rect = convert["rect"].as_array().unwrap();
assert!(rect[2].as_f64().unwrap() > rect[0].as_f64().unwrap());
ok(&mut h, &c, "ui.click", json!({ "label": "Convert" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
let all = ok(&mut h, &c, "ui.inspect", json!({ "query": "all tools" }));
let id = all["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "All tools" && w["clickable"] == true).unwrap()["id"].clone();
ok(&mut h, &c, "ui.click", json!({ "id": id }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools");
// Clicking a point works too (here: the Convert tab's centre).
let [x0, y0, x1, y1] = [0, 1, 2, 3].map(|i| rect[i].as_f64().unwrap());
ok(&mut h, &c, "ui.click", json!({ "x": (x0 + x1) / 2.0, "y": (y0 + y1) / 2.0 }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
let err = call(&mut h, &c, "ui.click", json!({ "label": "No such button" })).unwrap_err();
assert!(err.contains("no enabled clickable widget"), "{err}");
assert!(call(&mut h, &c, "ui.click", json!({ "id": 12345 })).is_err());
}
#[test]
fn cover_page_command_needs_two_page_view() {
// Agents see the cover toggle as disabled, and get an error, until two-page view.
let (mut h, c) = harness();
let enabled = |list: Value| list["commands"].as_array().unwrap().iter().find(|x| x["id"] == "view.layout.cover").unwrap()["enabled"].clone();
assert_eq!(enabled(ok(&mut h, &c, "ui.commands", json!({}))), false);
let err = call(&mut h, &c, "ui.command", json!({ "id": "view.layout.cover" })).unwrap_err();
assert!(err.contains("disabled"), "{err}");
ok(&mut h, &c, "ui.command", json!({ "id": "view.layout.two_up" }));
assert_eq!(enabled(ok(&mut h, &c, "ui.commands", json!({}))), true);
ok(&mut h, &c, "ui.command", json!({ "id": "view.layout.cover" }));
assert!(h.state().views[0].cover);
}
#[test]
fn commands_keys_and_typing() {
let (mut h, c) = harness();
let list = ok(&mut h, &c, "ui.commands", json!({}));
let undo = list["commands"].as_array().unwrap().iter().find(|x| x["id"] == "edit.undo").unwrap().clone();
assert_eq!(undo["enabled"], false);
assert!(call(&mut h, &c, "ui.command", json!({ "id": "edit.undo" })).unwrap_err().contains("disabled"));
assert!(call(&mut h, &c, "ui.command", json!({ "id": "nope" })).is_err());
ok(&mut h, &c, "ui.command", json!({ "id": "page.rotate" }));
h.run_steps(2);
let list = ok(&mut h, &c, "ui.commands", json!({}));
assert_eq!(list["commands"].as_array().unwrap().iter().find(|x| x["id"] == "edit.undo").unwrap()["enabled"], true);
// ⌘K opens the palette; typing filters it; Escape closes it.
ok(&mut h, &c, "ui.key", json!({ "key": "K", "modifiers": ["command"] }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["palette_open"], true);
ok(&mut h, &c, "ui.type", json!({ "text": "split" }));
let hits = ok(&mut h, &c, "ui.inspect", json!({ "query": "split document" }));
assert!(hits["count"].as_u64().unwrap() >= 1, "{hits}");
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["palette_open"], false);
assert!(call(&mut h, &c, "ui.key", json!({ "key": "NotAKey" })).is_err());
assert!(call(&mut h, &c, "ui.frobnicate", json!({})).unwrap_err().contains("unknown method"));
}
#[test]
fn select_all_key_selects_every_page_in_organize() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "organize", "value": "on" }));
ok(&mut h, &c, "ui.set", json!({ "key": "select", "value": "3" }));
ok(&mut h, &c, "ui.key", json!({ "key": "A", "modifiers": ["command"] }));
assert_eq!(h.state().views[0].target_pages(), [0, 1, 2, 3, 4]);
assert_eq!(h.state().views[0].current, 2);
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["dirty"], false);
}
#[test]
fn state_reports_the_selected_pages() {
let (mut h, c) = harness();
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([]));
ok(&mut h, &c, "ui.set", json!({ "key": "select", "value": "2,4" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([2, 4]));
ok(&mut h, &c, "ui.set", json!({ "key": "select", "value": "4-2,3" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([2, 3, 4]));
assert!(call(&mut h, &c, "ui.set", json!({ "key": "select", "value": "0,6" })).is_err());
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([2, 3, 4]));
ok(&mut h, &c, "ui.set", json!({ "key": "select", "value": "" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([]));
}
#[test]
fn ctrl_dragging_a_thumbnail_through_the_control_channel_copies_the_page() {
let (mut h, c) = harness_pages(3);
ok(&mut h, &c, "ui.set", json!({ "key": "tool", "value": "organize" }));
h.run_steps(3);
// The grid cell of a page: the node labelled exactly "Page N" (the checkbox says "Select page N").
let cell = |h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, page: usize| -> [f64; 4] {
let nodes = ok(h, c, "ui.inspect", json!({ "query": &format!("page {page}") }));
let want = format!("Page {page}");
let r = nodes["widgets"]
.as_array()
.unwrap()
.iter()
.find(|w| w["label"].as_str() == Some(want.as_str()))
.and_then(|w| w["rect"].as_array().cloned())
.expect("the page's grid cell");
let f = |i: usize| r[i].as_f64().unwrap();
[f(0), f(1), f(2), f(3)]
};
let (a, b) = (cell(&mut h, &c, 1), cell(&mut h, &c, 3));
// The gesture reads the OS the context reports: Option on macOS, Ctrl elsewhere.
let copy = if h.ctx.os() == egui::os::OperatingSystem::Mac { "alt" } else { "ctrl" };
ok(
&mut h,
&c,
"ui.drag",
json!({
"from": [(a[0] + a[2]) / 2.0, (a[1] + a[3]) / 2.0],
"to": [b[2] - 4.0, (b[1] + b[3]) / 2.0],
"modifiers": [copy],
}),
);
h.run_steps(3);
let st = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(st["documents"][0]["pages"], 4, "the drag copied the page instead of moving it: {st}");
assert_eq!(st["active"]["selected_pages"], json!([4]), "{st}");
assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3", "Page 1"], "the copy landed at the drop gap, after page 3");
}
#[test]
fn organize_overview_is_reachable_through_the_control_channel() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "tool", "value": "organize" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["organize"], true);
h.run_steps(3);
for label in ["Select page 1", "Select page 3", "Rotate page 1 clockwise"] {
ok(&mut h, &c, "ui.click", json!({ "label": label }));
h.run_steps(3);
}
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([1, 3]));
let app = h.state();
let rotations: Vec<_> = app.session.get(app.views[0].id).unwrap().info.pages.iter().map(|p| p.rotation).collect();
assert_eq!(rotations, [90, 0, 0, 0, 0]);
}
#[test]
fn saved_signature_can_be_changed_through_the_control_channel() {
let (mut h, c) = harness();
h.state_mut().signature = Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("Ada Lovelace".into()));
ok(&mut h, &c, "ui.command", json!({ "id": "sign.fill.signature.change" }));
h.run_steps(3);
assert_eq!(h.state().signature_draft.text, "Ada Lovelace");
let fields = ok(&mut h, &c, "ui.inspect", json!({ "role": "TextInput" }));
let field = fields["widgets"].as_array().unwrap().iter().find(|w| w["value"] == "Ada Lovelace").expect("the signature text field");
let r = field["rect"].as_array().unwrap();
let [x0, y0, x1, y1] = [0, 1, 2, 3].map(|i| r[i].as_f64().unwrap());
ok(&mut h, &c, "ui.click", json!({ "x": (x0 + x1) / 2.0, "y": (y0 + y1) / 2.0 }));
ok(&mut h, &c, "ui.key", json!({ "key": "A", "modifiers": ["command"] }));
ok(&mut h, &c, "ui.type", json!({ "text": "Grace Hopper" }));
ok(&mut h, &c, "ui.click", json!({ "label": "Apply" }));
assert_eq!(h.state().signature, Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("Grace Hopper".into())));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["quick_tool"], "fill-signature");
ok(&mut h, &c, "ui.command", json!({ "id": "sign.fill.signature.remove" }));
assert_eq!(h.state().signature, None);
h.state_mut().initials = Some(pdfcraft_ui_egui::fill_sign::SavedSig::Typed("GH".into()));
ok(&mut h, &c, "ui.command", json!({ "id": "sign.fill.initials.remove" }));
assert_eq!(h.state().initials, None);
}
#[test]
fn drawing_a_comment_by_drag_and_its_context_menu() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.command", json!({ "id": "comment.square" }));
let st = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(st["quick_tool"], "square");
let r = &st["active"]["pages_on_screen"][0]["rect"];
let (x0, y0, x1, y1) = (r[0].as_f64().unwrap(), r[1].as_f64().unwrap(), r[2].as_f64().unwrap(), r[3].as_f64().unwrap());
let (a, b) = ([x0 + (x1 - x0) * 0.2, y0 + (y1 - y0) * 0.2], [x0 + (x1 - x0) * 0.5, y0 + (y1 - y0) * 0.4]);
ok(&mut h, &c, "ui.drag", json!({ "from": a, "to": b }));
h.run_steps(2);
let st = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(st["active"]["selected_comment"], json!({ "page": 1, "index": 1 }), "{st}");
assert_eq!(st["documents"][0]["dirty"], true);
// A right-click on it offers the comment menu.
ok(&mut h, &c, "ui.click", json!({ "x": (a[0] + b[0]) / 2.0, "y": (a[1] + b[1]) / 2.0, "button": "secondary" }));
let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "Set status" }));
assert!(menu["count"].as_u64().unwrap() >= 1, "{menu}");
assert!(call(&mut h, &c, "ui.drag", json!({ "from": [1, 2] })).unwrap_err().contains("to must be"));
}
#[test]
fn screenshots_of_window_and_region() {
let (mut h, c) = harness();
let shot = ok(&mut h, &c, "ui.screenshot", json!({}));
let ppp = shot["pixels_per_point"].as_f64().unwrap();
assert_eq!(shot["width"].as_f64().unwrap(), (1400.0 * ppp).round());
use base64::Engine as _;
let png = base64::engine::general_purpose::STANDARD.decode(shot["png_base64"].as_str().unwrap()).unwrap();
assert_eq!(&png[1..4], b"PNG");
let region = ok(&mut h, &c, "ui.screenshot", json!({ "region": [10, 20, 110, 70] }));
assert_eq!((region["width"].as_f64().unwrap(), region["height"].as_f64().unwrap()), ((100.0 * ppp).round(), (50.0 * ppp).round()));
assert!(call(&mut h, &c, "ui.screenshot", json!({ "region": [5, 5, 1, 1] })).is_err());
// A region that cannot be inside the window is refused at admission, before a frame or slot
// is used, with an actionable message.
let outside = call(&mut h, &c, "ui.screenshot", json!({ "region": [1300, 0, 1500, 10] })).unwrap_err();
assert!(outside.contains("inside the window"), "{outside}");
}
#[test]
fn loopback_transport_requires_the_token() {
use std::io::{BufRead, BufReader, Write};
let (mut h, c) = harness();
let ep = pdfcraft_ui_egui::control::serve(c).unwrap();
let talk = |lines: Vec<Value>| {
let port = ep.port;
std::thread::spawn(move || {
let s = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
let mut w = s.try_clone().unwrap();
let mut r = BufReader::new(s).lines();
let mut out = Vec::new();
for l in lines {
// The server closes the connection after a failed auth; later writes may fail.
if writeln!(w, "{l}").is_err() {
break;
}
match r.next() {
Some(Ok(reply)) => out.push(serde_json::from_str::<Value>(&reply).unwrap()),
_ => break,
}
}
out
})
};
let pump = |h: &mut Harness<'static, PdfCraftApp>, t: std::thread::JoinHandle<Vec<Value>>| {
while !t.is_finished() {
h.step();
}
t.join().unwrap()
};
// Wrong token: rejected and disconnected before any request reaches the app.
let bad = pump(
&mut h,
talk(vec![
json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": "guess" } }),
json!({ "jsonrpc": "2.0", "id": 2, "method": "ui.state" }),
]),
);
assert_eq!(bad.len(), 1);
assert_eq!(bad[0]["error"]["code"], -32001);
// No auth at all: same.
let none = pump(&mut h, talk(vec![json!({ "jsonrpc": "2.0", "id": 1, "method": "ui.state" })]));
assert_eq!(none[0]["error"]["code"], -32001);
let good = pump(
&mut h,
talk(vec![
json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } }),
json!({ "jsonrpc": "2.0", "id": 2, "method": "ui.state" }),
json!({ "jsonrpc": "2.0", "id": 3, "method": "ui.command", "params": { "id": "edit.undo" } }),
]),
);
assert_eq!(good[0]["result"]["ok"], true);
assert_eq!(good[1]["result"]["documents"][0]["name"], "doc.pdf");
assert!(good[2]["error"]["message"].as_str().unwrap().contains("disabled"));
}
#[test]
fn control_default_workspace_and_session_override() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key": "default-mode", "value": "edit"}));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["default_mode"], "edit");
assert!(call(&mut h, &c, "ui.set", json!({"key": "default-mode", "value": "unknown"})).is_err());
h.state_mut().open_bytes("next.pdf", None, fixture(1)).unwrap();
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Edit");
ok(&mut h, &c, "ui.set", json!({"key": "mode", "value": "read"}));
h.state_mut().open_bytes("another.pdf", None, fixture(1)).unwrap();
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
}
#[test]
fn measurement_tools_draw_live_calibrate_save_and_export() {
let (mut h, c) = harness_pages(1);
let doc = h.state().views[0].id;
h.state_mut().set_option("zoom", "100").unwrap();
ok(&mut h, &c, "ui.command", json!({"id":"measure.scale"}));
h.run_steps(3);
h.state_mut().views[0].measure.drawing_points = 10.0;
h.state_mut().views[0].measure.page_unit = Some(pdfcraft_engine::measure::PageUnit::Pt);
h.state_mut().views[0].measure.real_distance = 1.0;
h.state_mut().views[0].measure.unit = "m".into();
h.get_by_label("Apply scale").click();
h.run_steps(3);
let scale = h.state().session.get(doc).unwrap().measurement_scale(0, [20.0, 20.0]).unwrap();
assert!((scale.x - 0.1).abs() < 1e-10);
let click = |h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, x: f32, y: f32| {
let r = h.state().views[0].page_screen_rect(0).unwrap();
ok(h, c, "ui.click", json!({"x":r.left()+x*r.width()/200.0,"y":r.top()+y*r.height()/300.0}));
h.run_steps(2);
};
for (command, points) in [
("measure.distance", vec![(20.0, 30.0), (80.0, 110.0)]),
("measure.perimeter", vec![(20.0, 130.0), (80.0, 130.0), (80.0, 210.0)]),
("measure.area", vec![(100.0, 130.0), (160.0, 130.0), (160.0, 210.0), (100.0, 210.0)]),
] {
ok(&mut h, &c, "ui.command", json!({"id":command}));
for (x, y) in points {
click(&mut h, &c, x, y);
}
if command != "measure.distance" {
ok(&mut h, &c, "ui.key", json!({"key":"Enter"}));
h.run_steps(3);
}
}
let measurements = h.state().session.get(doc).unwrap().measurements().unwrap().measurements;
assert_eq!(measurements.len(), 3);
for (m, value) in measurements.iter().zip([10.0, 14.0, 48.0]) {
assert!((m.reading.value - value).abs() < 0.01, "{m:?}");
}
ok(&mut h, &c, "ui.command", json!({"id":"edit.undo"}));
h.run_steps(2);
assert_eq!(h.state().session.get(doc).unwrap().measurements().unwrap().measurements.len(), 2);
ok(&mut h, &c, "ui.command", json!({"id":"edit.redo"}));
h.run_steps(2);
let dir = std::env::temp_dir().join(format!("pdfcraft-measure-ui-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
h.state_mut().export_dir_override = Some(dir.to_string_lossy().into());
ok(&mut h, &c, "ui.command", json!({"id":"measure.export"}));
h.run_steps(2);
assert!(std::fs::read_to_string(dir.join("measurements.csv")).unwrap().contains("m^2"));
h.state_mut().set_option("quick", "measure-calibrate").unwrap();
click(&mut h, &c, 30.0, 50.0);
click(&mut h, &c, 130.0, 50.0);
assert!((h.state().views[0].measure.drawing_points - 100.0).abs() < 0.01);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_1_to_50_scale_in_millimetres_measures_a_10_mm_line_as_500_mm() {
// #2752: a plan drawn at 1:50 is "1 mm = 50 mm". The page side of the scale was fixed in
// points, so this stored 1 pt = 50 mm, 72 / 25.4 = 2.835 times too big.
use pdfcraft_engine::measure::{self, Kind};
let (mut h, c) = harness_pages(1);
let doc = h.state().views[0].id;
h.state_mut().set_option("zoom", "100").unwrap();
ok(&mut h, &c, "ui.command", json!({"id":"measure.scale"}));
h.run_steps(3);
let m = &mut h.state_mut().views[0].measure;
(m.drawing_points, m.real_distance, m.unit) = (1.0, 50.0, "mm".into());
h.get_by_label("Apply scale").click();
h.run_steps(3);
let mm = 72.0 / 25.4;
let line = [[20.0, 30.0], [20.0 + 10.0 * mm, 30.0]];
let stored = |h: &Harness<'static, PdfCraftApp>| {
let scale = h.state().session.get(doc).unwrap().measurement_scale(0, line[0]).unwrap();
(measure::reading(Kind::Distance, &line, &scale).unwrap().value, scale.ratio)
};
let (value, ratio) = stored(&h);
assert!((value - 500.0).abs() < 1e-9, "a 10 mm line at 1:50 reads {value} ({ratio})");
assert_eq!(ratio, "1 mm = 50 mm");
// The scale is one undoable edit.
ok(&mut h, &c, "ui.command", json!({"id":"edit.undo"}));
h.run_steps(2);
assert!((stored(&h).0 - 500.0).abs() > 1.0);
ok(&mut h, &c, "ui.command", json!({"id":"edit.redo"}));
h.run_steps(2);
assert_eq!(stored(&h), (value, ratio));
// Measuring the line on the page with the Distance tool reads the same.
ok(&mut h, &c, "ui.command", json!({"id":"measure.distance"}));
for [x, y] in line {
let r = h.state().views[0].page_screen_rect(0).unwrap();
ok(&mut h, &c, "ui.click", json!({"x":r.left()+x as f32*r.width()/200.0,"y":r.top()+y as f32*r.height()/300.0}));
h.run_steps(2);
}
let measurements = h.state().session.get(doc).unwrap().measurements().unwrap().measurements;
assert_eq!(measurements.len(), 1);
assert!((measurements[0].reading.value - 500.0).abs() < 0.05, "{:?}", measurements[0].reading);
assert_eq!(measurements[0].reading.unit, "mm");
}
#[test]
fn a_plan_scale_picked_or_typed_applies_at_once() {
use pdfcraft_engine::measure::{self, Kind};
let (mut h, c) = harness_pages(1);
let doc = h.state().views[0].id;
ok(&mut h, &c, "ui.command", json!({"id":"measure.scale"}));
h.run_steps(3);
h.state_mut().views[0].measure.unit = "m".into();
h.run_steps(2);
// 60 mm of paper.
let line = [[20.0, 20.0], [20.0 + 60.0 / 25.4 * 72.0, 20.0]];
let read = |h: &Harness<'static, PdfCraftApp>| {
let scale = h.state().session.get(doc).unwrap().measurement_scale(0, line[0]).unwrap();
(measure::reading(Kind::Distance, &line, &scale).unwrap().label, scale.ratio)
};
// Picked from the common scales: no Apply click.
h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().is_some_and(|v| v == "Common scales")).click();
h.run_steps(2);
h.get_by_label("1:200").click_accesskit();
h.run_steps(3);
assert_eq!(read(&h), ("12.00 m".to_string(), "1 mm = 0.2 m".to_string()));
// Typed, in the other notations; leaving the field applies.
fn field<'a>(h: &'a Harness<'static, PdfCraftApp>) -> egui_kittest::Node<'a> {
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Scale")
}
for (typed, label) in [("1/50", "3.00 m"), ("1:100", "6.00 m")] {
field(&h).click();
h.run_steps(1);
h.state_mut().views[0].measure.ratio_text.clear();
field(&h).type_text(typed);
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(read(&h).0, label, "{typed}");
}
// Nonsense is refused with a message and leaves the page scale alone.
let before = read(&h);
field(&h).click();
h.run_steps(1);
h.state_mut().views[0].measure.ratio_text.clear();
field(&h).type_text("1:0");
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(read(&h), before);
assert!(h.state().views[0].measure.error.is_some());
// The scale is one undoable edit.
ok(&mut h, &c, "ui.command", json!({"id":"edit.undo"}));
h.run_steps(2);
assert_ne!(read(&h), before);
}
#[test]
fn docking_pointer_group_float_close_restore_and_document_commands() {
use pdfcraft_ui_egui::docking::Panel;
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
h.run_steps(4);
// Tools docked alone has no tab strip (as Acrobat's tool pane), so it is grouped by command.
assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Tools").is_none());
ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
h.run_steps(4);
assert_eq!(h.state().docking.layout.location(&Panel::Inspector).unwrap().anchor, Some(Panel::Tools));
// Grouped, Tools has its tab again.
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools");
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary);
h.run_steps(3);
h.get_by_label("Float panel").click();
h.run_steps(5);
let rect = h.state().docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Inspector)).unwrap().rect;
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary);
h.run_steps(3);
// Scope to the open context menu; the Tools body also has a close icon.
h.get_by_label("Float panel").parent().unwrap().get_by_label("Close panel").click();
h.run_steps(4);
assert!(!h.state().docking.layout.contains(&Panel::Inspector));
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
h.run_steps(4);
assert_eq!(h.state().docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Inspector)).unwrap().rect, rect);
let saved = h.state().persist();
let expected = serde_json::to_value(&h.state().docking).unwrap();
let (mut reloaded, client) = harness();
reloaded.state_mut().restore(&saved);
reloaded.run_steps(5);
assert_eq!(serde_json::to_value(&reloaded.state().docking).unwrap(), expected);
ok(&mut reloaded, &client, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
reloaded.run_steps(4);
ok(&mut reloaded, &client, "ui.command", json!({"id":"page.insert_blank"}));
assert_eq!(ok(&mut reloaded, &client, "ui.state", json!({}))["documents"][0]["pages"], 6);
ok(&mut reloaded, &client, "ui.command", json!({"id":"edit.undo"}));
assert_eq!(ok(&mut reloaded, &client, "ui.state", json!({}))["documents"][0]["pages"], 5);
}
#[test]
fn invalid_docking_requests_are_atomic_and_do_not_change_documents() {
let (mut h, c) = harness();
let before = serde_json::to_value(&h.state().docking).unwrap();
for params in [
json!({"operation":"close", "panel":"canvas"}),
json!({"operation":"move", "panel":"tools", "target":"canvas", "zone":"center"}),
json!({"operation":"float", "panel":"tools", "rect":[0,0,-1,100]}),
json!({"operation":"move", "panel":"unknown", "target":"tools", "zone":"center"}),
] {
assert!(call(&mut h, &c, "ui.dock", params).is_err());
assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before);
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["documents"][0]["pages"], 5);
assert_eq!(state["documents"][0]["dirty"], false);
}
}
#[test]
fn docking_renders_real_pdf_default_floating_and_redocked() {
for width in [900.0, 1400.0] {
for scale in [1.0, 2.0] {
let (mut h, c) = harness_pages_at(5, width, scale);
ok(&mut h, &c, "ui.resize", json!({"width":width,"height":900}));
ok(&mut h, &c, "ui.set", json!({"key":"panel","value":"bookmarks"}));
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark {
parent: vec![],
index: 0,
title: "Overview".into(),
page: 0,
left: None,
top: None,
});
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark {
parent: vec![],
index: 1,
title: "Details".into(),
page: 2,
left: None,
top: None,
});
for state in ["default", "floating", "redocked"] {
if state == "floating" {
ok(&mut h, &c, "ui.dock", json!({"operation":"float", "panel":"inspector", "rect":[160,100,330,480]}));
} else if state == "redocked" {
ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
}
h.run_steps(5);
h.state().docking.validate().unwrap();
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
let _gpu = crate::gpu();
std::fs::create_dir_all(&directory).unwrap();
h.render().unwrap().save(format!("{directory}/pdf-{width}-{scale}-{state}.png")).unwrap();
}
}
}
}
}
#[test]
fn explicit_panel_requests_reveal_inactive_group_members() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"}));
ok(&mut h, &c, "ui.command", json!({"id":"form.fields"}));
h.run_steps(4);
h.get_by_label("Fields");
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click();
h.run_steps(4);
// Ordinary frames must preserve the user's selected tab.
h.get_by_label("View more");
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
h.run_steps(4);
h.get_by_label("This document has no bookmarks.");
ok(&mut h, &c, "ui.command", json!({"id":"edit.edit_text"}));
h.run_steps(4);
// The Edit panel's own row: the tool's name is on the floating toolbar too while it is on.
h.get_by_label("Header and footer");
}
/// #3580 review: a panel a script opens (`app.execMenuItem("ShowHideBookmarks")`) is shown even
/// when the inspector is a background tab of a custom docking layout.
#[test]
fn a_script_opened_panel_reveals_an_inactive_inspector() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"pages"}));
ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"}));
h.run_steps(3);
assert!(h.query_by_label("This document has no bookmarks.").is_none());
let id = h.state().active_ids().unwrap().1;
h.state_mut().run_button_script(id, "", "app.execMenuItem('ShowHideBookmarks')");
h.run_steps(4);
h.get_by_label("This document has no bookmarks.");
}
#[test]
fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() {
use pdfcraft_ui_egui::docking::Panel;
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
ok(&mut h, &c, "ui.dock", json!({"operation":"float", "panel":"inspector", "rect":[170,120,330,470]}));
let before = serde_json::to_value(&h.state().docking).unwrap();
for combine in [false, true] {
if combine {
h.state_mut().open_combine_tab();
} else {
h.state_mut().active = None;
}
h.run_steps(4);
assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").is_none());
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
let _gpu = crate::gpu();
std::fs::create_dir_all(&directory).unwrap();
let state = if combine { "combine" } else { "home" };
h.render().unwrap().save(format!("{directory}/availability-{state}.png")).unwrap();
}
assert!(h.state().docking.layout.contains(&Panel::Inspector));
assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before);
h.state_mut().active = Some(0);
h.state_mut().combine_tab.focused = false;
h.run_steps(4);
h.get_by_label("This document has no bookmarks.");
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
let _gpu = crate::gpu();
h.render().unwrap().save(format!("{directory}/availability-return.png")).unwrap();
}
assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before);
}
}
#[test]
fn malformed_docking_settings_preserve_other_preferences() {
let mut app = PdfCraftApp::new();
app.restore(r#"{"docking":{"layout":{"root":null,"floating":[]},"hidden":[]},"default_mode":"edit","flatten_fill_sign":true}"#);
assert_eq!(app.default_mode, pdfcraft_ui_egui::Mode::Edit);
app.docking.validate().unwrap();
let saved: Value = serde_json::from_str(&app.persist()).unwrap();
assert_eq!(saved["default_mode"], "edit");
assert_eq!(saved["flatten_fill_sign"], true);
}
#[test]
fn rail_reveals_its_remembered_inactive_inspector_then_closes_the_visible_panel() {
use pdfcraft_ui_egui::docking::Panel;
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"}));
h.run_steps(3);
let document = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
let mut preferences: Value = serde_json::from_str(&h.state().persist()).unwrap();
preferences.as_object_mut().unwrap().remove("docking");
assert!(h.query_by_label("This document has no bookmarks.").is_none());
h.get_by_role_and_label(egui::accesskit::Role::Button, "Bookmarks").click();
h.run_steps(4);
h.get_by_label("This document has no bookmarks.");
let mut after: Value = serde_json::from_str(&h.state().persist()).unwrap();
after.as_object_mut().unwrap().remove("docking");
assert_eq!(after, preferences, "revealing a group member only changes layout activation");
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], document);
h.get_by_role_and_label(egui::accesskit::Role::Button, "Bookmarks").click();
h.run_steps(4);
assert_eq!(h.state().right, None);
assert!(!h.state().docking.layout.contains(&Panel::Inspector));
assert!(h.query_by_label("This document has no bookmarks.").is_none());
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], document);
}
#[test]
fn control_quit_retains_inactive_fill_sign_typing_after_cancel() {
let (mut h, c) = harness();
h.state_mut().views[0].fill_text =
Some(pdfcraft_ui_egui::fill_sign::TypeBox { page: 0, at: [40.0, 200.0], text: "Unsaved signature note".into(), size: 10.0, focus: false });
h.state_mut().active = None;
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
assert_eq!(ok(&mut h, &c, "ui.quit", json!({})), json!({"quitting":false,"needs_confirmation":true}));
assert!(!close_requested(&h));
h.run_steps(3);
ok(&mut h, &c, "ui.click", json!({"label":"Cancel"}));
assert_eq!(h.state().views[0].fill_text.as_ref().unwrap().text, "Unsaved signature note");
assert_eq!(h.state().views.len(), 1);
assert!(!close_requested(&h));
}
#[test]
fn control_quit_retains_blocked_content_typing_after_cancel() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.command", json!({"id":"edit.text"}));
h.run_steps(3);
let point = h.state().views[0].page_screen_rect(0).unwrap().center();
ok(&mut h, &c, "ui.click", json!({"x":point.x,"y":point.y}));
h.event(egui::Event::Text("Keep 世界".into()));
h.run_steps(3);
assert_eq!(h.state().views[0].content.draft.as_ref().unwrap().text, "Keep 世界");
h.state_mut().active = None;
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
assert_eq!(ok(&mut h, &c, "ui.quit", json!({})), json!({"quitting":false,"needs_confirmation":true}));
h.run_steps(3);
ok(&mut h, &c, "ui.click", json!({"label":"Cancel"}));
assert_eq!(h.state().views[0].content.draft.as_ref().unwrap().text, "Keep 世界");
assert_eq!(h.state().views.len(), 1);
assert!(!close_requested(&h));
}
#[test]
fn read_mode_retains_existing_rail_click_to_close_policy() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
ok(&mut h, &c, "ui.set", json!({"key":"mode", "value":"read"}));
h.run_steps(3);
assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
h.get_by_role_and_label(egui::accesskit::Role::Button, "Bookmarks").click();
h.run_steps(3);
assert_eq!(h.state().right, None);
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Read);
}
#[test]
fn control_open_of_an_already_open_file_replies_ok_and_focuses_its_tab() {
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a.pdf").to_string_lossy().into_owned();
let b = dir.join("b.pdf").to_string_lossy().into_owned();
std::fs::write(&a, fixture(2)).unwrap();
std::fs::write(&b, fixture(3)).unwrap();
let slot: Arc<Mutex<Option<ControlClient>>> = Arc::default();
let s = slot.clone();
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();
// Inline opens: this test pins the synchronous open contract (focus vs open vs
// failure). Live apps schedule the worker instead and reply `pending` (#1620).
app.run_inline = true;
*s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
app
});
h.run_steps(4);
let c = slot.lock().unwrap().take().unwrap();
let first = ok(&mut h, &c, "ui.open", json!({ "path": a }));
assert_eq!(first["opened"], json!(a), "{first}");
ok(&mut h, &c, "ui.open", json!({ "path": b }));
// The file is already open: the tab focuses, and the reply must be Ok — an agent driving the
// app headlessly would otherwise read a focus as a read failure (#1620).
let again = ok(&mut h, &c, "ui.open", json!({ "path": a }));
assert_eq!(again["focused"], true, "{again}");
let state = ok(&mut h, &c, "ui.state", json!({}));
let docs = state["documents"].as_array().cloned().unwrap_or_default();
assert_eq!(docs.len(), 2, "opening an open file adds no tab: {state}");
let a_tab = docs.iter().find(|d| d["path"] == json!(a)).unwrap();
assert_eq!(a_tab["active"], true, "the already-open tab is in front: {state}");
let _ = std::fs::remove_dir_all(&dir);
}
/// The other half of the #1620 contract on a live UI: a fresh open is scheduled on the
/// background worker and answered `pending` (upstream's reply shape), the tab appears once
/// the open completes, and opening the file again then focuses that tab.
#[test]
fn control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has() {
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-open-pending-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("pending.pdf").to_string_lossy().into_owned();
std::fs::write(&a, fixture(2)).unwrap();
let slot: Arc<Mutex<Option<ControlClient>>> = Arc::default();
let s = slot.clone();
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();
*s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
app
});
h.run_steps(4);
let c = slot.lock().unwrap().take().unwrap();
let first = ok(&mut h, &c, "ui.open", json!({ "path": a }));
assert_eq!(first["accepted"], json!(a), "{first}");
assert_eq!(first["pending"], true, "{first}");
// The worker completes the open on later frames; the tab shows, and only then does a
// repeated open focus it instead of reporting another pending open. The worker is a thread
// of its own, so the wait is bounded by time: a busy runner can draw 30 frames before it has
// read the file.
let mut documents = 0;
let opened = crate::support::step_until(&mut h, |h| {
let state = ok(h, &c, "ui.state", json!({}));
documents = state["documents"].as_array().map(|d| d.len()).unwrap_or_default();
documents == 1
});
assert!(opened, "the pending open completed into a tab: {documents} tabs");
let again = ok(&mut h, &c, "ui.open", json!({ "path": a }));
assert_eq!(again["focused"], true, "{again}");
let state = ok(&mut h, &c, "ui.state", json!({}));
let docs = state["documents"].as_array().cloned().unwrap_or_default();
assert_eq!(docs.len(), 1, "focusing the completed open adds no tab: {state}");
let _ = std::fs::remove_dir_all(&dir);
}
fn navigation_frame(h: &mut Harness<'static, PdfCraftApp>, events: Vec<egui::Event>) {
h.input_mut().events.extend(events);
h.step();
}
fn navigation_wheel(h: &mut Harness<'static, PdfCraftApp>, pos: egui::Pos2, delta: f32, ctrl: bool) {
let modifiers = egui::Modifiers { ctrl, command: ctrl, ..egui::Modifiers::NONE };
navigation_frame(
h,
vec![
egui::Event::ModifiersChanged(modifiers),
egui::Event::PointerMoved(pos),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, delta),
modifiers,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
},
],
);
h.run_steps(20);
navigation_frame(h, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]);
}
#[test]
fn ctrl_wheel_anchors_visible_page_and_plain_wheel_only_scrolls() {
for percent in [100, 200, 400] {
for fraction in [0.1, 0.5, 0.9] {
let (mut h, c) = harness_pages(20);
ok(&mut h, &c, "ui.set", json!({"key":"zoom", "value":percent.to_string()}));
h.run_steps(4);
let viewport = h.state().views[0].viewport_rect();
let before = h.state().views[0].page_screen_rect(0).unwrap();
let visible = before.intersect(viewport).shrink(15.0);
let p = egui::pos2(visible.left() + fraction * visible.width(), visible.top() + fraction * visible.height());
let normalised = (p - before.min) / before.size();
let old_zoom = h.state().views[0].zoom;
navigation_wheel(&mut h, p, 20.0, true);
let enlarged = h.state().views[0].page_screen_rect(0).unwrap();
let anchored = enlarged.min + normalised * enlarged.size();
assert!(h.state().views[0].zoom > old_zoom);
assert!((anchored.y - p.y).abs() < 2.0, "percent={percent}, fraction={fraction}");
if before.width() > viewport.width() {
assert!((anchored.x - p.x).abs() < 2.0);
}
navigation_wheel(&mut h, p, -20.0, true);
assert!((h.state().views[0].zoom - old_zoom).abs() < 0.01);
let zoom = h.state().views[0].zoom;
let top = h.state().views[0].page_screen_rect(0).unwrap().top();
navigation_wheel(&mut h, p, -80.0, false);
assert_eq!(h.state().views[0].zoom, zoom);
assert!(h.state().views[0].page_screen_rect(0).unwrap().top() < top - 20.0);
}
}
}
#[test]
fn wheel_zoom_is_scoped_to_the_viewport_and_respects_existing_limits() {
let (mut h, c) = harness();
let outside = egui::pos2(5.0, 5.0);
let zoom = h.state().views[0].zoom;
navigation_wheel(&mut h, outside, 40.0, true);
assert_eq!(h.state().views[0].zoom, zoom);
for (percent, delta, expected) in [(6400, 80.0, 64.0), (8, -80.0, 0.08)] {
ok(&mut h, &c, "ui.set", json!({"key":"zoom", "value":percent.to_string()}));
h.run_steps(4);
let p = h.state().views[0].viewport_rect().center();
navigation_wheel(&mut h, p, delta, true);
assert_eq!(h.state().views[0].zoom, expected);
}
}
#[test]
fn initial_zoom_option_is_applied_before_the_first_viewport_layout() {
for percent in [50, 100, 200, 400] {
let h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("initial-zoom.pdf", None, fixture(3)).unwrap();
app.set_option("zoom", &percent.to_string()).unwrap();
assert_eq!(app.views[0].zoom, percent as f32 / 100.0, "initial options must work before the viewport exists");
app
});
assert_eq!(h.state().views[0].zoom, percent as f32 / 100.0);
}
}
/// #1306: a coordinate that only overflows once narrowed to a pointer position is refused by
/// click and drag, as move refuses it, before any pointer input is queued; the frames that follow
/// run, and the channel keeps working.
#[test]
fn click_and_drag_refuse_coordinates_that_overflow_like_move() {
let (mut h, c) = harness();
for (method, params) in [
("ui.move", json!({ "x": 1e39, "y": -1e39 })),
("ui.click", json!({ "x": 1e39, "y": -1e39 })),
("ui.click", json!({ "x": 40.0, "y": 1e39 })),
("ui.drag", json!({ "from": [1e39, -1e39], "to": [40.0, 40.0], "steps": 1 })),
("ui.drag", json!({ "from": [40.0, 40.0], "to": [40.0, -1e39], "steps": 1 })),
// Each end fits, but not the way from one to the other.
("ui.drag", json!({ "from": [-3e38, 40.0], "to": [3e38, 40.0], "steps": 1 })),
] {
let e = call(&mut h, &c, method, params.clone()).expect_err(&format!("{method} {params} is refused"));
assert!(e.starts_with(&format!("{method}: ")) && e.contains("finite"), "{e}");
h.run_steps(4);
assert_eq!(ok(&mut h, &c, "ui.inspect", json!({ "query": "Convert", "role": "Button" }))["count"], 1);
}
let convert = ok(&mut h, &c, "ui.inspect", json!({ "query": "Convert", "role": "Button" }))["widgets"][0]["rect"].clone();
let (x, y) = (convert[0].as_f64().unwrap() + 4.0, convert[1].as_f64().unwrap() + 4.0);
assert_eq!(ok(&mut h, &c, "ui.click", json!({ "x": x, "y": y }))["clicked"], json!([x, y]));
ok(&mut h, &c, "ui.drag", json!({ "from": [x, y], "to": [x + 10.0, y], "steps": 2 }));
}
#[test]
fn page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant() {
let (mut h, _c) = harness();
let area = h.state().views[0].viewport_rect();
let shown = |h: &Harness<'static, PdfCraftApp>| {
let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("page-size-hint"));
h.ctx.memory(|m| m.areas().visible_last_frame(&layer))
};
let away = area.center() + egui::vec2(300.0, -200.0);
h.event(egui::Event::PointerMoved(away));
h.run_steps(3);
assert!(!shown(&h), "top-right: hidden");
h.event(egui::Event::PointerMoved(area.left_bottom() + egui::vec2(80.0, -80.0)));
h.run_steps(3);
assert!(shown(&h), "bottom-left: shown");
if let Ok(dir) = std::env::var("PDFCRAFT_SHOT_DIR") {
let _gpu = crate::gpu();
h.render().unwrap().save(format!("{dir}/page-size-hint.png")).unwrap();
}
h.event(egui::Event::PointerMoved(away));
h.run_steps(3);
assert!(!shown(&h), "pointer left: hidden again");
}
#[test]
fn navigation_history_commands_and_rail_are_per_tab_and_non_editing() {
for os in [egui::os::OperatingSystem::Nix, egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Windows] {
navigation_history_for_os(os);
}
}
fn navigation_history_for_os(os: egui::os::OperatingSystem) {
let (mut h, c) = harness();
h.ctx.set_os(os);
h.run_steps(2);
let enabled =
|list: Value, id: &str| list["commands"].as_array().unwrap().iter().find(|cmd| cmd["id"] == id).unwrap()["enabled"].as_bool().unwrap();
assert!(!enabled(ok(&mut h, &c, "ui.commands", json!({})), "view.previous_view"));
assert!(call(&mut h, &c, "ui.command", json!({"id": "view.previous_view"})).is_err());
// Empty-history shortcuts were silent before registry integration and must stay silent.
for key in ["[", "]"] {
ok(&mut h, &c, "ui.key", json!({"key": key, "modifiers": ["command"]}));
assert!(h.state().toast.is_none(), "an unavailable history action must not ask to open the already-open document");
}
let before = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone();
ok(&mut h, &c, "ui.set", json!({"key": "page", "value": "4"}));
assert!(enabled(ok(&mut h, &c, "ui.commands", json!({})), "view.previous_view"));
// The real rail button is accessible and invokes the same history, not the adjacent page.
let shortcut = if h.ctx.os() == egui::os::OperatingSystem::Mac { "⌘[" } else { "Ctrl+[" };
ok(&mut h, &c, "ui.click", json!({"label": &format!("Previous view ({shortcut})")}));
assert_eq!(h.state().views[0].current, 0);
assert!(enabled(ok(&mut h, &c, "ui.commands", json!({})), "view.next_view"));
ok(&mut h, &c, "ui.command", json!({"id": "view.next_view"}));
assert_eq!(h.state().views[0].current, 3);
ok(&mut h, &c, "ui.set", json!({"key": "page", "value": "5"}));
ok(&mut h, &c, "ui.key", json!({"key": "[", "modifiers": ["command"]}));
assert_eq!(h.state().views[0].current, 3, "shortcut traverses exactly one history entry");
ok(&mut h, &c, "ui.key", json!({"key": "]", "modifiers": ["command"]}));
assert_eq!(h.state().views[0].current, 4);
ok(&mut h, &c, "ui.command", json!({"id": "view.previous_view"}));
h.state_mut().open_bytes("other.pdf", None, fixture(5)).unwrap();
assert!(!enabled(ok(&mut h, &c, "ui.commands", json!({})), "view.previous_view"));
ok(&mut h, &c, "ui.set", json!({"key": "page", "value": "2"}));
ok(&mut h, &c, "ui.command", json!({"id": "view.previous_view"}));
assert_eq!(h.state().views[1].current, 0);
assert_eq!(h.state().views[0].current, 3, "other tab's history does not move this document");
h.state_mut().active = Some(0);
ok(&mut h, &c, "ui.command", json!({"id": "view.previous_view"}));
assert_eq!(h.state().views[0].current, 0);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, before);
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
h.state_mut().open_bytes("reopened.pdf", None, before.as_ref().clone()).unwrap();
assert!(h.state().views.last().unwrap().back.is_empty());
assert!(h.state().views.last().unwrap().forward.is_empty());
}
#[test]
fn routine_continuous_scrolling_does_not_add_navigation_history() {
let (mut h, c) = harness_pages(20);
ok(&mut h, &c, "ui.set", json!({"key": "animation_effects", "value": "off"}));
h.hover_at(egui::pos2(700.0, 450.0));
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -1200.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
});
h.run_steps(8);
assert!(h.state().views[0].current > 0, "the wheel really moved to a later page");
assert!(h.state().views[0].back.is_empty());
assert!(h.state().views[0].forward.is_empty());
}