mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 11:17:10 -04:00
fix(ui): preserve native open actions and password prompts
Carry selected tool actions through native file preparation, password retries and conversion, and defer startup intake until pending native opens finish. Await observable completion in native opening, password, recovery and linked-PDF tests while retaining their content, identity, dirty-state, cancellation and destination checks. Verify the expected validation error and preserve the original document after refused consent targets. M3; refs #1914.
This commit is contained in:
1 parent
7437bde378
commit
b5467d93a0
14 files changed
+490
-54
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -148,6 +149,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
|
||||
- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged.
|
||||
- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged.
|
||||
- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged.
|
||||
|
||||
@@ -121,7 +121,31 @@ fn non_utf8_discovered_names_are_reported_without_panicking() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join("input")).unwrap();
|
||||
std::fs::write(dir.path().join("input").join(std::ffi::OsStr::from_bytes(b"bad-\xff.pdf")), pdf()).unwrap();
|
||||
let invalid = dir.path().join("input").join(std::ffi::OsStr::from_bytes(b"bad-\xff.pdf"));
|
||||
if let Err(error) = std::fs::write(&invalid, pdf()) {
|
||||
// APFS rejects this filename before batch discovery can see it. Keep the
|
||||
// discovery assertions on filesystems that allow it; no other I/O error
|
||||
// is an unsupported fixture.
|
||||
#[cfg(target_os = "macos")]
|
||||
if error.raw_os_error() == Some(92) {
|
||||
assert!(!invalid.exists());
|
||||
assert_eq!(std::fs::read_dir(dir.path().join("input")).unwrap().count(), 0);
|
||||
assert!(!dir.path().join("out").exists());
|
||||
let valid = dir.path().join("input/valid-λ.pdf");
|
||||
std::fs::write(&valid, pdf()).unwrap();
|
||||
assert_eq!(std::fs::read(&valid).unwrap(), pdf());
|
||||
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
||||
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
|
||||
assert_eq!(report["completed"], 1, "{report}");
|
||||
assert_eq!(report["failed"], 0, "{report}");
|
||||
assert_eq!(std::fs::read(dir.path().join("out/valid-λ.pdf")).unwrap(), pdf());
|
||||
eprintln!(
|
||||
"non-UTF-8 fixture capability unavailable: macOS filesystem rejected creation with EILSEQ92; valid-Unicode batch companion passed; invalid-name discovery still requires Linux/FreeBSD or a supporting filesystem"
|
||||
);
|
||||
return;
|
||||
}
|
||||
panic!("could not create the non-UTF-8 fixture: {error}");
|
||||
}
|
||||
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
||||
let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"}));
|
||||
assert_eq!(report["failed"], 1);
|
||||
|
||||
+31
-18
@@ -1179,11 +1179,15 @@ impl PdfCraftApp {
|
||||
open_worker::ResultKind::Failed(reason) => {
|
||||
self.notify_fmt("Couldn't open {name}: {reason}", &[("name", &pending.name), ("reason", &reason)]);
|
||||
}
|
||||
open_worker::ResultKind::Conversion(bytes) => {
|
||||
if let Some(Err(error)) = self.open_converted(&pending.name, &bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]);
|
||||
open_worker::ResultKind::Conversion(bytes) => match self.open_converted(&pending.name, &bytes) {
|
||||
Some(Ok(())) => {
|
||||
if let Some(command) = pending.then {
|
||||
self.execute(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(error)) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]),
|
||||
None => {}
|
||||
},
|
||||
open_worker::ResultKind::Prepared { bytes, result } => match *result {
|
||||
Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) {
|
||||
Ok(id) => {
|
||||
@@ -1686,6 +1690,12 @@ impl PdfCraftApp {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
self.open_path_then(path, None);
|
||||
}
|
||||
|
||||
/// Keep a chosen tool with its file until preparation and any password entry finish.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open_path_then(&mut self, path: &str, then: Option<&'static str>) {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
|
||||
// Taken before reading: a build that rewrites the file meanwhile still counts as a change.
|
||||
let stamp = file_watch::Stamp::at(path);
|
||||
@@ -1698,10 +1708,15 @@ impl PdfCraftApp {
|
||||
let before = self.views.len();
|
||||
if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
} else if self.views.len() > before
|
||||
&& let Some(doc) = self.views.last().map(|view| view.id)
|
||||
{
|
||||
self.watch.known_as(doc, stamp);
|
||||
} else if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) {
|
||||
prompt.then = then;
|
||||
} else if self.views.len() > before {
|
||||
if let Some(doc) = self.views.last().map(|view| view.id) {
|
||||
self.watch.known_as(doc, stamp);
|
||||
}
|
||||
if let Some(command) = then {
|
||||
self.execute(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]),
|
||||
@@ -1718,7 +1733,7 @@ impl PdfCraftApp {
|
||||
activate: true,
|
||||
after_open: None,
|
||||
recovered: None,
|
||||
then: None,
|
||||
then,
|
||||
}) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
}
|
||||
@@ -1972,14 +1987,7 @@ impl PdfCraftApp {
|
||||
let command = spec.id;
|
||||
self.ask_one(pickers::Ask::File(dialog), None, move |app, path| {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let before = app.views.len();
|
||||
app.open_path(&path);
|
||||
if let Some(prompt) = app.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path.as_str())) {
|
||||
// Encrypted: the tool runs once the password is in.
|
||||
prompt.then = Some(command);
|
||||
} else if app.views.len() > before && app.active.is_some() {
|
||||
app.execute(command);
|
||||
}
|
||||
app.open_path_then(&path, Some(command));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2806,7 +2814,12 @@ impl eframe::App for PdfCraftApp {
|
||||
}
|
||||
// Don't replace a pending prompt or import. A startup file may need its own password
|
||||
// prompt, so its bytes stay queued until the current interaction finishes.
|
||||
if self.password_prompt.is_none()
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let opening = !self.pending_opens.is_empty();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let opening = false;
|
||||
if !opening
|
||||
&& self.password_prompt.is_none()
|
||||
&& self.image_import.is_none()
|
||||
&& self.dialog.is_none()
|
||||
&& self.close_request.is_none()
|
||||
|
||||
@@ -88,3 +88,261 @@ fn open_prepare_keeps_control_and_existing_tabs_live_until_atomic_commit() {
|
||||
assert_eq!(app.active, Some(1));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
struct ToolOpenFile(std::path::PathBuf);
|
||||
|
||||
impl Drop for ToolOpenFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
fn tool_open_file(case: &str, extension: &str, bytes: &[u8]) -> ToolOpenFile {
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-open-tool-{}-{case}.{extension}", std::process::id()));
|
||||
std::fs::write(&path, bytes).expect("write the synthetic tool input");
|
||||
ToolOpenFile(path)
|
||||
}
|
||||
|
||||
fn tool_open_harness() -> Harness<'static, PdfCraftApp> {
|
||||
let (mut harness, _) = harness();
|
||||
harness.state_mut().close_tab(0);
|
||||
harness.run_steps(2);
|
||||
harness
|
||||
}
|
||||
|
||||
struct HeldToolOpen {
|
||||
started: std::sync::mpsc::Receiver<()>,
|
||||
release: std::sync::mpsc::Sender<()>,
|
||||
finished: std::sync::mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
impl HeldToolOpen {
|
||||
fn install(harness: &mut Harness<'static, PdfCraftApp>) -> Self {
|
||||
let (gate, started, release, finished) = open_worker::prepare_gate();
|
||||
harness.state_mut().open_prepare_gate = Some(gate);
|
||||
Self { started, release, finished }
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
fn wait_started(&self) {
|
||||
self.started.recv_timeout(Duration::from_secs(5)).expect("the native open reached its preparation barrier");
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
fn complete(&self, harness: &mut Harness<'static, PdfCraftApp>) {
|
||||
self.release.send(()).expect("release the native preparation");
|
||||
self.finished.recv_timeout(Duration::from_secs(5)).expect("the native open returned its result");
|
||||
harness.run_steps(2);
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_export_tool(harness: &mut Harness<'static, PdfCraftApp>, file: &ToolOpenFile) {
|
||||
harness.state_mut().pick_override = Some(vec![file.0.to_string_lossy().into_owned()]);
|
||||
harness.state_mut().run_command("export.text");
|
||||
harness.run_steps(2);
|
||||
}
|
||||
|
||||
/// Picking a file for a tool must carry the tool through native preparation; another file
|
||||
/// opened while it is pending must not become the tool's source.
|
||||
#[allow(clippy::expect_used)]
|
||||
fn chosen_tool_runs_after_preparation(extension: &str, bytes: Vec<u8>) {
|
||||
let mut harness = tool_open_harness();
|
||||
let held = HeldToolOpen::install(&mut harness);
|
||||
let file = tool_open_file("pending", extension, &bytes);
|
||||
choose_export_tool(&mut harness, &file);
|
||||
held.wait_started();
|
||||
assert!(harness.state().views.is_empty());
|
||||
assert!(harness.state().dialog.is_none(), "the tool cannot run before its document exists");
|
||||
harness.state_mut().open_bytes("other.pdf", None, fixture(3)).expect("another document opens while preparation is held");
|
||||
let other = harness.state().active_ids().expect("the other document is active").1;
|
||||
held.complete(&mut harness);
|
||||
let app = harness.state();
|
||||
let source = app.active_ids().expect("the chosen file becomes active").1;
|
||||
assert_ne!(source, other);
|
||||
assert_eq!(app.views.len(), 2, "the chosen file commits once and retains the other document");
|
||||
assert_eq!(app.session.get(other).expect("the other document is retained").info.pages.len(), 3);
|
||||
assert!(!app.session.get(other).expect("the other document is retained").dirty);
|
||||
let chosen = app.session.get(source).expect("the tool's source exists");
|
||||
if extension == "pdf" {
|
||||
assert_eq!(chosen.path.as_deref(), Some(file.0.to_string_lossy().as_ref()));
|
||||
assert_eq!(chosen.info.pages.len(), 2);
|
||||
assert_eq!(chosen.bytes.as_slice(), bytes.as_slice(), "the selected PDF's contents are retained");
|
||||
} else {
|
||||
assert!(chosen.dirty && chosen.path.is_none(), "conversion produces a new unsaved PDF");
|
||||
assert_eq!(chosen.info.pages.len(), 1);
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(chosen.bytes.clone(), Default::default());
|
||||
let output = renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, ..Default::default() });
|
||||
let text = output.text.expect("read the converted text").plain_text();
|
||||
assert_eq!(text.split_whitespace().collect::<Vec<_>>().join(" "), std::str::from_utf8(&bytes).expect("synthetic input is UTF-8"));
|
||||
}
|
||||
assert!(matches!(app.dialog, Some(crate::Dialog::Export(_))), "the chosen Export tool runs after its source commits");
|
||||
assert!(app.pending_opens.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits() {
|
||||
chosen_tool_runs_after_preparation("pdf", fixture(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_tool_runs_on_converted_text_only_after_native_preparation_commits() {
|
||||
chosen_tool_runs_after_preparation("txt", b"Synthetic text input for the selected export tool".to_vec());
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
fn encrypted_tool_fixture() -> Vec<u8> {
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let id = session.open("secret.pdf", None, Arc::new(fixture(2)), None).expect("open the synthetic PDF");
|
||||
session
|
||||
.apply(id, pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection { open_password: Some("tool-password".into()), ..Default::default() }))
|
||||
.expect("encrypt the synthetic PDF");
|
||||
session.save_bytes(id).expect("write the encrypted synthetic PDF").as_ref().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::expect_used)]
|
||||
fn a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock() {
|
||||
let mut harness = tool_open_harness();
|
||||
let held = HeldToolOpen::install(&mut harness);
|
||||
let file = tool_open_file("encrypted-retry", "pdf", &encrypted_tool_fixture());
|
||||
choose_export_tool(&mut harness, &file);
|
||||
held.wait_started();
|
||||
held.complete(&mut harness);
|
||||
assert!(harness.state().password_prompt.is_some());
|
||||
assert!(harness.state().views.is_empty() && harness.state().dialog.is_none());
|
||||
|
||||
harness.state_mut().submit_password(Some("wrong-password".into()));
|
||||
held.wait_started();
|
||||
assert!(harness.state().password_prompt.is_none(), "the submitted password is still being checked");
|
||||
held.complete(&mut harness);
|
||||
let prompt = harness.state().password_prompt.as_ref().expect("the wrong password is reported");
|
||||
assert!(prompt.error.is_some());
|
||||
assert_eq!(prompt.path.as_deref(), Some(file.0.to_string_lossy().as_ref()));
|
||||
assert!(harness.state().views.is_empty() && harness.state().dialog.is_none());
|
||||
|
||||
harness.state_mut().submit_password(Some("tool-password".into()));
|
||||
held.wait_started();
|
||||
assert!(harness.state().dialog.is_none(), "the tool waits for authentication to succeed");
|
||||
held.complete(&mut harness);
|
||||
let app = harness.state();
|
||||
assert!(app.password_prompt.is_none() && app.pending_opens.is_empty());
|
||||
assert_eq!(app.views.len(), 1);
|
||||
let source = app.active_ids().expect("the unlocked document is active").1;
|
||||
assert_eq!(app.session.get(source).expect("the unlocked document exists").path.as_deref(), Some(file.0.to_string_lossy().as_ref()));
|
||||
assert!(matches!(app.dialog, Some(crate::Dialog::Export(_))), "the chosen tool survives the wrong-password retry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::expect_used)]
|
||||
fn cancelling_a_chosen_tools_picker_or_password_never_runs_it_on_another_document() {
|
||||
let mut harness = tool_open_harness();
|
||||
harness.state_mut().pick_override = Some(Vec::new());
|
||||
harness.state_mut().run_command("export.text");
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().pending_opens.is_empty());
|
||||
assert!(harness.state().views.is_empty() && harness.state().dialog.is_none());
|
||||
|
||||
let held = HeldToolOpen::install(&mut harness);
|
||||
let file = tool_open_file("encrypted-cancel", "pdf", &encrypted_tool_fixture());
|
||||
choose_export_tool(&mut harness, &file);
|
||||
held.wait_started();
|
||||
held.complete(&mut harness);
|
||||
assert!(harness.state().password_prompt.is_some());
|
||||
harness.state_mut().submit_password(None);
|
||||
harness.state_mut().open_bytes("other.pdf", None, fixture(3)).expect("a different document opens after cancellation");
|
||||
harness.run_steps(3);
|
||||
assert!(harness.state().password_prompt.is_none() && harness.state().pending_opens.is_empty());
|
||||
assert_eq!(harness.state().views.len(), 1);
|
||||
let id = harness.state().active_ids().expect("the other document is active").1;
|
||||
assert_eq!(harness.state().session.get(id).expect("the other document exists").name, "other.pdf");
|
||||
assert!(harness.state().dialog.is_none(), "cancelling discards the chosen tool's continuation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_tool_does_not_run_when_native_preparation_rejects_the_file() {
|
||||
let mut harness = tool_open_harness();
|
||||
let held = HeldToolOpen::install(&mut harness);
|
||||
let file = tool_open_file("invalid", "pdf", b"%PDF-1.7\nnot a PDF object graph");
|
||||
choose_export_tool(&mut harness, &file);
|
||||
held.wait_started();
|
||||
held.complete(&mut harness);
|
||||
assert!(harness.state().views.is_empty() && harness.state().pending_opens.is_empty());
|
||||
assert!(harness.state().dialog.is_none() && harness.state().password_prompt.is_none());
|
||||
assert!(harness.state().toast.as_ref().is_some_and(|(message, _)| message.contains("Couldn't open")), "the open failure is reported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::expect_used)]
|
||||
fn startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt() {
|
||||
let (mut harness, _) = harness();
|
||||
let existing = harness.state().active_ids().expect("the existing document is active").1;
|
||||
let original = harness.state().session.get(existing).expect("the existing document is open").bytes.clone();
|
||||
let encrypted = encrypted_tool_fixture();
|
||||
harness.state_mut().open_bytes("local-secret.pdf", None, encrypted.clone()).expect("the local PDF asks for its password");
|
||||
let held = HeldToolOpen::install(&mut harness);
|
||||
harness.state_mut().submit_password(Some("tool-password".into()));
|
||||
held.wait_started();
|
||||
harness.state().startup_inbox.lock().expect("startup inbox").push(("startup-secret.pdf".into(), encrypted.clone()));
|
||||
harness.run_steps(2);
|
||||
assert_eq!(harness.state().startup_inbox.lock().expect("startup inbox").len(), 1, "startup bytes stay queued during local authentication");
|
||||
assert!(harness.state().password_prompt.is_none(), "no unrelated prompt is created during the held open");
|
||||
assert_eq!(harness.state().active_ids().map(|(_, id)| id), Some(existing));
|
||||
|
||||
held.complete(&mut harness);
|
||||
let local = harness.state().active_ids().expect("the authenticated local document is active").1;
|
||||
assert_ne!(local, existing);
|
||||
assert_eq!(harness.state().session.get(local).expect("the local document is open").name, "local-secret.pdf");
|
||||
assert_eq!(harness.state().views.len(), 2);
|
||||
assert!(harness.state().startup_inbox.lock().expect("startup inbox").is_empty());
|
||||
let prompt = harness.state().password_prompt.as_ref().expect("the startup PDF retains its own password prompt after local completion");
|
||||
assert_eq!(prompt.name, "startup-secret.pdf");
|
||||
assert_eq!(prompt.bytes.as_slice(), encrypted.as_slice());
|
||||
assert!(!prompt.activate, "unlocking a late startup PDF must keep it in the background");
|
||||
|
||||
harness.state_mut().submit_password(Some("tool-password".into()));
|
||||
held.wait_started();
|
||||
assert_eq!(harness.state().active_ids().map(|(_, id)| id), Some(local));
|
||||
held.complete(&mut harness);
|
||||
let app = harness.state();
|
||||
assert!(app.password_prompt.is_none() && app.pending_opens.is_empty());
|
||||
assert_eq!(app.views.len(), 3);
|
||||
assert_eq!(app.active_ids().map(|(_, id)| id), Some(local), "startup authentication does not steal the local document's focus");
|
||||
let startup = app.session.docs().iter().find(|doc| doc.name == "startup-secret.pdf").expect("the startup document is available");
|
||||
assert_ne!(startup.id, local);
|
||||
assert_eq!(startup.info.pages.len(), 2);
|
||||
assert_eq!(app.session.get(existing).expect("the existing document remains open").bytes, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::expect_used)]
|
||||
fn startup_intake_waits_through_wrong_password_retry_and_cancel_preserves_the_existing_document() {
|
||||
let (mut harness, _) = harness();
|
||||
let existing = harness.state().active_ids().expect("the existing document is active").1;
|
||||
let original = harness.state().session.get(existing).expect("the existing document is open").bytes.clone();
|
||||
harness.state_mut().open_bytes("local-secret.pdf", None, encrypted_tool_fixture()).expect("the local PDF asks for its password");
|
||||
let held = HeldToolOpen::install(&mut harness);
|
||||
harness.state_mut().submit_password(Some("wrong-password".into()));
|
||||
held.wait_started();
|
||||
harness.state().startup_inbox.lock().expect("startup inbox").push(("startup.pdf".into(), fixture(1)));
|
||||
harness.run_steps(2);
|
||||
assert_eq!(harness.state().startup_inbox.lock().expect("startup inbox").len(), 1, "startup bytes wait while the password is checked");
|
||||
assert_eq!(harness.state().active_ids().map(|(_, id)| id), Some(existing));
|
||||
|
||||
held.complete(&mut harness);
|
||||
let prompt = harness.state().password_prompt.as_ref().expect("the local wrong-password prompt is retained");
|
||||
assert_eq!(prompt.name, "local-secret.pdf");
|
||||
assert!(prompt.error.is_some());
|
||||
assert_eq!(harness.state().startup_inbox.lock().expect("startup inbox").len(), 1, "startup bytes also wait for the user's retry or cancellation");
|
||||
assert_eq!(harness.state().views.len(), 1);
|
||||
harness.state_mut().submit_password(None);
|
||||
harness.run_steps(2);
|
||||
let app = harness.state();
|
||||
assert!(app.password_prompt.is_none() && app.pending_opens.is_empty());
|
||||
assert!(app.startup_inbox.lock().expect("startup inbox").is_empty());
|
||||
assert_eq!(app.views.len(), 2);
|
||||
assert_eq!(app.active_ids().map(|(_, id)| id), Some(existing), "cancelling the local open leaves the startup PDF in the background");
|
||||
assert!(!app.session.docs().iter().any(|doc| doc.name == "local-secret.pdf"));
|
||||
assert_eq!(app.session.docs().iter().find(|doc| doc.name == "startup.pdf").expect("the startup document is available").info.pages.len(), 1);
|
||||
assert_eq!(app.session.get(existing).expect("the existing document remains open").bytes, original);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The command registry drives menus, shortcuts and the palette: every registered command must
|
||||
//! be implemented, disabled commands must say why, and every surface must reach the same action.
|
||||
|
||||
mod support;
|
||||
|
||||
use egui::os::OperatingSystem;
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
@@ -184,9 +186,13 @@ fn a_tool_chosen_with_no_document_asks_for_one_and_runs_on_it() {
|
||||
h.run_steps(2);
|
||||
h.state_mut().run_command("export.text");
|
||||
assert!(h.state().toast.as_ref().is_none_or(|t| t.0 != "Open a document first"));
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "picked document and chosen tool", |app| app.active.is_some() && matches!(app.dialog, Some(Dialog::Export(_))));
|
||||
assert!(h.state().active.is_some(), "the picked file is open");
|
||||
assert!(matches!(h.state().dialog, Some(Dialog::Export(_))), "and the tool runs on it: {:?}", h.state().dialog);
|
||||
let (_, id) = h.state().active_ids().unwrap();
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
assert_eq!(doc.name, "picked.pdf");
|
||||
assert_eq!(doc.bytes.as_slice(), fixture(2).as_slice());
|
||||
// Cancelled: nothing opens and nothing runs.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
@@ -286,12 +292,16 @@ fn the_open_recent_menu_lists_files_and_opens_one() {
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Open Recent ⏵").hover();
|
||||
h.run_steps(3);
|
||||
let original = h.state().views[0].id;
|
||||
h.get_by_label_contains("recent.pdf").click();
|
||||
h.run_steps(4);
|
||||
support::wait_until(&mut h, "recent document", |app| app.views.len() == 2 && app.password_prompt.is_none());
|
||||
let app = h.state();
|
||||
assert_eq!(app.views.len(), 2, "the recent file opened in a new tab");
|
||||
let active = app.active.unwrap();
|
||||
assert_eq!(app.session.get(app.views[active].id).and_then(|d| d.path.as_deref()), Some(path.as_str()), "the active tab is the recent file");
|
||||
assert_eq!(app.session.get(app.views[active].id).unwrap().bytes.as_slice(), fixture(2).as_slice());
|
||||
assert!(app.views.iter().any(|view| view.id == original));
|
||||
assert_eq!(app.session.get(original).unwrap().bytes.as_slice(), fixture(3).as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Addresses a document asks to open (links, button URI actions, `app.launchURL`) wait for the
|
||||
//! user's permission, and anything but a web or email address is refused (#90, #91).
|
||||
|
||||
mod support;
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_ui_egui::{LinkOrigin, PdfCraftApp, PendingLink};
|
||||
@@ -175,6 +177,7 @@ fn a_local_gotor_link_opens_in_a_tab_and_applies_its_destination() {
|
||||
std::fs::write(dir.join("target.pdf"), linked_pages(2, None)).unwrap();
|
||||
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /FitH 200]");
|
||||
click_remote_link(&mut h);
|
||||
support::wait_until(&mut h, "remote document", |app| app.views.len() == 2);
|
||||
assert_eq!(h.state().views.len(), 2);
|
||||
assert_eq!(h.state().active, Some(1));
|
||||
assert_eq!(h.state().views[1].current, 1, "the remote page destination is applied");
|
||||
@@ -191,16 +194,24 @@ fn a_remote_destination_survives_password_retries_and_cancel_opens_no_tab() {
|
||||
|
||||
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /Fit]");
|
||||
click_remote_link(&mut h);
|
||||
support::wait_until(&mut h, "remote password prompt", |app| app.password_prompt.as_ref().is_some_and(|p| p.name == "target.pdf"));
|
||||
assert!(h.state().password_prompt.is_some());
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
h.state_mut().submit_password(Some("wrong-password".into()));
|
||||
support::wait_until(&mut h, "wrong-password reply", |app| {
|
||||
app.password_prompt.as_ref().is_some_and(|p| p.name == "target.pdf" && p.error.as_deref() == Some("Incorrect password. Try again."))
|
||||
});
|
||||
assert!(h.state().password_prompt.as_ref().is_some_and(|p| p.error.is_some()));
|
||||
h.state_mut().submit_password(Some("test-password".into()));
|
||||
support::wait_until(&mut h, "authenticated remote document", |app| app.views.len() == 2 && app.password_prompt.is_none());
|
||||
assert_eq!(h.state().views.len(), 2);
|
||||
assert_eq!(h.state().views[1].current, 1, "the destination survives a wrong-password retry");
|
||||
|
||||
let mut cancelled = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /Fit]");
|
||||
click_remote_link(&mut cancelled);
|
||||
support::wait_until(&mut cancelled, "cancelled target password prompt", |app| {
|
||||
app.password_prompt.as_ref().is_some_and(|p| p.name == "target.pdf")
|
||||
});
|
||||
cancelled.state_mut().submit_password(None);
|
||||
assert_eq!(cancelled.state().views.len(), 1, "cancelling does not leave a half-open target tab");
|
||||
}
|
||||
@@ -413,17 +424,21 @@ fn an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs() {
|
||||
assert!(h.state().toast.is_none(), "the nonexistent target is not accessed before consent");
|
||||
assert!(h.state().password_prompt.is_none());
|
||||
// The target only appears AFTER the confirmation is displayed.
|
||||
std::fs::write(&target, linked_pages(2, None)).unwrap();
|
||||
let target_bytes = linked_pages(2, None);
|
||||
std::fs::write(&target, &target_bytes).unwrap();
|
||||
h.state_mut().open_bytes("other.pdf", None, fixture()).unwrap();
|
||||
assert_eq!(h.state().active, Some(1));
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Allow").click();
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "approved absolute target", |app| app.views.len() == 3);
|
||||
assert!(h.state().pending_remote_link.is_none());
|
||||
assert_eq!(h.state().views.len(), 3);
|
||||
assert_eq!(h.state().views[2].current, 1);
|
||||
assert_eq!(h.state().views[2].fit, pdfcraft_ui_egui::canvas::Fit::Width);
|
||||
assert_eq!(h.state().active, Some(2));
|
||||
let target_doc = h.state().session.get(h.state().views[2].id).unwrap();
|
||||
assert_eq!(target_doc.path.as_deref(), Some(file));
|
||||
assert_eq!(target_doc.bytes.as_slice(), target_bytes.as_slice());
|
||||
assert!(!h.state().session.get(source).unwrap().dirty);
|
||||
assert_eq!(h.state().last_opened_url, None);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
@@ -470,25 +485,43 @@ fn consenting_to_absolute_gotor_still_validates_the_target() {
|
||||
std::fs::write(dir.join("notes.txt"), b"text").unwrap();
|
||||
std::fs::write(dir.join("broken.pdf"), b"not a PDF").unwrap();
|
||||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||||
let mut cases = vec!["missing.pdf", "folder.pdf", "notes.txt", "broken.pdf"];
|
||||
let mut cases = vec![
|
||||
("missing.pdf", "Couldn't follow linked PDF: the linked file couldn't be read:"),
|
||||
("folder.pdf", "Couldn't follow linked PDF: the linked path is not a regular file"),
|
||||
("notes.txt", "Couldn't follow linked PDF: the linked file is not a .pdf file"),
|
||||
("broken.pdf", "Couldn't open broken.pdf: the file is not a readable PDF:"),
|
||||
];
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink("/dev/zero", dir.join("zero.pdf")).unwrap();
|
||||
cases.push("zero.pdf");
|
||||
cases.push(("zero.pdf", "Couldn't follow linked PDF: the linked path is not a regular file"));
|
||||
std::fs::File::create(dir.join("large.pdf")).unwrap().set_len(pdfcraft_engine::MAX_REMOTE_DOCUMENT_BYTES + 1).unwrap();
|
||||
cases.push("large.pdf");
|
||||
cases.push(("large.pdf", "Couldn't follow linked PDF: the linked file is too large to open"));
|
||||
}
|
||||
for name in cases {
|
||||
for (name, expected_error) in cases {
|
||||
let target = dir.join(name);
|
||||
let mut h = remote_harness(None, target.to_str().unwrap(), "[0 /Fit]");
|
||||
let source = h.state().views[0].id;
|
||||
let before = h.state().session.get(source).unwrap().bytes.clone();
|
||||
click_remote_link(&mut h);
|
||||
assert!(h.state().pending_remote_link.is_some());
|
||||
assert!(h.state().toast.is_none(), "{name} isn't checked before consent");
|
||||
h.get_by_label("Allow").click();
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "approved invalid target refusal", |app| {
|
||||
app.toast.as_ref().is_some_and(|(message, _)| message.starts_with(expected_error))
|
||||
});
|
||||
assert!(h.state().pending_remote_link.is_none());
|
||||
assert_eq!(h.state().views.len(), 1, "{name}");
|
||||
assert!(h.state().toast.is_some(), "{name} is refused after consent");
|
||||
assert_eq!(h.state().session.docs().len(), 1);
|
||||
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(source));
|
||||
assert_eq!(h.state().views[0].current, 0);
|
||||
let doc = h.state().session.get(source).unwrap();
|
||||
assert_eq!(doc.bytes, before);
|
||||
assert!(!doc.dirty);
|
||||
assert!(doc.can_undo().is_none());
|
||||
assert!(h.state().password_prompt.is_none());
|
||||
assert!(h.state().last_opened_url.is_none());
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -506,16 +539,23 @@ fn an_approved_absolute_gotor_preserves_password_retries_and_cancellation() {
|
||||
click_remote_link(&mut h);
|
||||
assert!(h.state().password_prompt.is_none());
|
||||
h.get_by_label("Allow").click();
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "approved encrypted target prompt", |app| {
|
||||
app.password_prompt.as_ref().is_some_and(|p| p.name == "encrypted.pdf")
|
||||
});
|
||||
assert!(h.state().password_prompt.is_some());
|
||||
h.state_mut().submit_password(Some("wrong-password".into()));
|
||||
support::wait_until(&mut h, "absolute target wrong-password reply", |app| {
|
||||
app.password_prompt.as_ref().is_some_and(|p| p.name == "encrypted.pdf" && p.error.as_deref() == Some("Incorrect password. Try again."))
|
||||
});
|
||||
assert!(h.state().password_prompt.as_ref().unwrap().error.is_some());
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
if cancel {
|
||||
h.state_mut().submit_password(None);
|
||||
assert!(h.state().password_prompt.is_none());
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
} else {
|
||||
h.state_mut().submit_password(Some("test-password".into()));
|
||||
support::wait_until(&mut h, "authenticated absolute target", |app| app.password_prompt.is_none() && app.views.len() == 2);
|
||||
assert_eq!(h.state().views.len(), 2);
|
||||
assert_eq!(h.state().views[1].current, 1);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Headless UI tests for editing and saving: the organize toolbar, selection, undo/redo keys,
|
||||
//! save/save-as, the unsaved-changes prompt and editable document properties.
|
||||
|
||||
mod support;
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
@@ -1670,10 +1672,18 @@ fn files_dropped_before_the_pointer_is_known_wait_for_it() {
|
||||
#[test]
|
||||
fn files_dropped_outside_the_page_grid_still_open_as_documents() {
|
||||
let mut h = harness(2, |_| {});
|
||||
let original = h.state().views[0].id;
|
||||
drop_files(&mut h, vec![Dropped("other.pdf", fixture(1))]);
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "dropped document", |app| app.views.len() == 2 && app.password_prompt.is_none());
|
||||
assert_eq!(h.state().views.len(), 2);
|
||||
assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]);
|
||||
assert!(h.state().views.iter().any(|view| view.id == original));
|
||||
assert_eq!(h.state().session.get(original).unwrap().bytes.as_slice(), fixture(2).as_slice());
|
||||
let (_, id) = h.state().active_ids().unwrap();
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
assert_eq!(doc.name, "other.pdf");
|
||||
assert_eq!(doc.info.pages.len(), 1);
|
||||
assert_eq!(doc.bytes.as_slice(), fixture(1).as_slice());
|
||||
}
|
||||
|
||||
fn grid_zoom(h: &Harness<'static, PdfCraftApp>) -> f32 {
|
||||
@@ -1907,7 +1917,7 @@ fn password_prompt_opens_and_security_tab_reports_the_details() {
|
||||
field.type_text("pw");
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(4);
|
||||
support::wait_until(&mut h, "unlocked document", |app| app.views.len() == 1);
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
h.state_mut().set_option("dialog", "properties").unwrap();
|
||||
h.run_steps(2);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! used on a later frame. `pick_override` stands in for the native panel and, like it, answers
|
||||
//! on a later frame.
|
||||
|
||||
mod support;
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_render::PageRenderer;
|
||||
@@ -56,7 +58,7 @@ fn harness(docs: &[usize]) -> Harness<'static, PdfCraftApp> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_open_returns_at_once_and_opens_the_pick_on_the_next_frame() {
|
||||
fn file_open_returns_at_once_and_opens_the_pick_when_preparation_finishes() {
|
||||
let dir = std::env::temp_dir().join(format!("printcraft-pickers-open-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("picked.pdf");
|
||||
@@ -66,9 +68,13 @@ fn file_open_returns_at_once_and_opens_the_pick_on_the_next_frame() {
|
||||
h.state_mut().pick_override = Some(vec![file.to_string_lossy().into_owned()]);
|
||||
assert!(h.state_mut().execute("file.open"));
|
||||
assert!(h.state().views.is_empty(), "the command must not wait for the picker");
|
||||
h.run_steps(2);
|
||||
support::wait_until(&mut h, "picked document", |app| app.views.len() == 1 && app.password_prompt.is_none());
|
||||
assert_eq!(h.state().views.len(), 1, "the picked file opens on a later frame");
|
||||
assert_eq!(pages(h.state(), 0), 2);
|
||||
let (_, id) = h.state().active_ids().unwrap();
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
assert_eq!(doc.path.as_deref(), Some(file.to_string_lossy().as_ref()));
|
||||
assert_eq!(doc.bytes.as_slice(), fixture(2).as_slice());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -621,17 +627,37 @@ fn encrypted_fixture() -> Vec<u8> {
|
||||
fn a_startup_url_does_not_replace_another_documents_password_prompt() {
|
||||
for cancel in [false, true] {
|
||||
let mut h = harness(&[]);
|
||||
h.state_mut().open_bytes("local-encrypted.pdf", None, encrypted_fixture()).unwrap();
|
||||
deliver_startup(&h, "late.pdf", fixture(2));
|
||||
let local_bytes = encrypted_fixture();
|
||||
let late_bytes = fixture(2);
|
||||
h.state_mut().open_bytes("local-encrypted.pdf", None, local_bytes.clone()).unwrap();
|
||||
deliver_startup(&h, "late.pdf", late_bytes.clone());
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().password_prompt.as_ref().unwrap().name, "local-encrypted.pdf");
|
||||
assert!(h.state().views.is_empty());
|
||||
assert_eq!(h.state().startup_inbox.lock().unwrap().len(), 1, "startup bytes wait for the prompt");
|
||||
h.state_mut().submit_password((!cancel).then(|| "test-password".into()));
|
||||
h.run_steps(2);
|
||||
support::wait_until(&mut h, "local password completion and startup open", |app| {
|
||||
app.password_prompt.is_none() && app.views.len() == if cancel { 1 } else { 2 }
|
||||
});
|
||||
assert!(h.state().password_prompt.is_none());
|
||||
assert!(h.state().startup_inbox.lock().unwrap().is_empty());
|
||||
assert_eq!(h.state().active, if cancel { None } else { Some(0) });
|
||||
let app = h.state();
|
||||
let late: Vec<_> = app.session.docs().iter().filter(|doc| doc.name == "late.pdf").collect();
|
||||
assert_eq!(late.len(), 1);
|
||||
assert_eq!(late[0].bytes.as_slice(), late_bytes.as_slice());
|
||||
assert_eq!(late[0].info.pages.len(), 2);
|
||||
if cancel {
|
||||
assert_eq!(app.active, None);
|
||||
assert_eq!(app.session.docs().len(), 1);
|
||||
assert!(!app.session.docs().iter().any(|doc| doc.name == "local-encrypted.pdf"));
|
||||
} else {
|
||||
let (_, id) = app.active_ids().expect("the authenticated local document is active");
|
||||
let local = app.session.get(id).unwrap();
|
||||
assert_eq!(local.name, "local-encrypted.pdf");
|
||||
assert_eq!(local.bytes.as_slice(), local_bytes.as_slice());
|
||||
assert_eq!(local.info.pages.len(), 1);
|
||||
assert_eq!(app.session.docs().len(), 2);
|
||||
}
|
||||
assert_eq!(h.state().views.len(), if cancel { 1 } else { 2 });
|
||||
}
|
||||
}
|
||||
@@ -639,19 +665,27 @@ fn a_startup_url_does_not_replace_another_documents_password_prompt() {
|
||||
#[test]
|
||||
fn unlocking_a_late_startup_url_keeps_it_in_the_background() {
|
||||
let mut h = harness(&[3]);
|
||||
deliver_startup(&h, "late-encrypted.pdf", encrypted_fixture());
|
||||
let original = h.state().views[0].id;
|
||||
let late_bytes = encrypted_fixture();
|
||||
deliver_startup(&h, "late-encrypted.pdf", late_bytes.clone());
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, Some(0));
|
||||
assert_eq!(h.state().password_prompt.as_ref().unwrap().name, "late-encrypted.pdf");
|
||||
h.state_mut().submit_password(Some("wrong-password".into()));
|
||||
h.run_steps(2);
|
||||
support::wait_until(&mut h, "startup wrong-password reply", |app| {
|
||||
app.password_prompt.as_ref().is_some_and(|p| p.name == "late-encrypted.pdf" && p.error.as_deref() == Some("Incorrect password. Try again."))
|
||||
});
|
||||
assert!(h.state().password_prompt.as_ref().unwrap().error.is_some());
|
||||
assert_eq!(h.state().active, Some(0));
|
||||
h.state_mut().submit_password(Some("test-password".into()));
|
||||
h.run_steps(2);
|
||||
support::wait_until(&mut h, "unlocked startup document", |app| app.password_prompt.is_none() && app.views.len() == 2);
|
||||
assert!(h.state().password_prompt.is_none());
|
||||
assert_eq!(h.state().active, Some(0));
|
||||
assert_eq!(h.state().views.len(), 2);
|
||||
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(original));
|
||||
assert_eq!(h.state().session.get(original).unwrap().bytes.as_slice(), fixture(3).as_slice());
|
||||
let late = h.state().session.docs().iter().find(|doc| doc.name == "late-encrypted.pdf").unwrap();
|
||||
assert_eq!(late.bytes.as_slice(), late_bytes.as_slice());
|
||||
h.get_by_label("late-encrypted.pdf").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, Some(1));
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! Autosave and crash recovery: a session is "crashed" by dropping the app without a clean
|
||||
//! quit, and a new session must offer, recover or discard what was autosaved.
|
||||
|
||||
mod support;
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use pdfcraft_engine::Edit;
|
||||
use pdfcraft_ui_egui::{PdfCraftApp, RecoveryStore};
|
||||
|
||||
@@ -80,7 +82,7 @@ fn a_crashed_session_is_offered_and_recovered_unsaved() {
|
||||
h.get_by_label("Recover unsaved documents?");
|
||||
h.get_by_label("work.pdf");
|
||||
h.get_by_label("Recover").click();
|
||||
h.run_steps(4);
|
||||
support::wait_until(&mut h, "recovered document", |app| app.views.len() == 1);
|
||||
let app = h.state();
|
||||
assert_eq!(app.views.len(), 1);
|
||||
let doc = app.session.get(app.views[0].id).unwrap();
|
||||
@@ -180,13 +182,13 @@ fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password()
|
||||
let mut h = harness(s.clone());
|
||||
h.get_by_label_contains("password-protected");
|
||||
h.get_by_label("Recover").click();
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "recovery password prompt", |app| app.password_prompt.is_some());
|
||||
let field = h.get_by_role(Role::PasswordInput);
|
||||
field.focus();
|
||||
field.type_text("pw");
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(4);
|
||||
support::wait_until(&mut h, "unlocked recovery", |app| app.views.len() == 1);
|
||||
let app = h.state();
|
||||
assert_eq!(app.views.len(), 1);
|
||||
let d = app.session.get(app.views[0].id).unwrap();
|
||||
@@ -205,8 +207,15 @@ fn a_cancelled_recovery_password_does_not_attach_to_the_next_unlocked_document()
|
||||
assert_eq!(s.list().len(), 1);
|
||||
let mut h = harness(s.clone());
|
||||
h.get_by_label("Recover").click();
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "recovery password prompt", |app| app.password_prompt.is_some());
|
||||
assert!(h.state().password_prompt.is_some(), "the snapshot asks for its password");
|
||||
// The new modal registers its layer before its controls become interactive.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while h.query_by_label("Cancel").is_none_or(|node| node.accesskit_node().is_disabled()) {
|
||||
h.step();
|
||||
assert!(std::time::Instant::now() < deadline, "the password Cancel button did not become enabled");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().password_prompt.is_none());
|
||||
@@ -215,7 +224,7 @@ fn a_cancelled_recovery_password_does_not_attach_to_the_next_unlocked_document()
|
||||
let app = h.state_mut();
|
||||
app.open_bytes("B.pdf", Some("/docs/B.pdf".into()), encrypted(fixture(1), "pw-b")).unwrap();
|
||||
app.submit_password(Some("pw-b".into()));
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "unlocked B", |app| app.views.len() == 1);
|
||||
let app = h.state();
|
||||
assert_eq!(app.views.len(), 1);
|
||||
let b = app.session.get(app.views[0].id).unwrap();
|
||||
@@ -291,7 +300,7 @@ fn missing_corrupt_or_mismatched_metadata_recovers_the_snapshot_without_an_origi
|
||||
}
|
||||
let mut h = harness(s.clone());
|
||||
h.get_by_label("Recover").click();
|
||||
h.run_steps(4);
|
||||
support::wait_until(&mut h, "recovery without valid metadata", |app| app.views.len() == 1);
|
||||
let app = h.state();
|
||||
assert_eq!(app.views.len(), 1, "{case}");
|
||||
let doc = app.session.get(app.views[0].id).unwrap();
|
||||
@@ -316,14 +325,14 @@ fn an_encrypted_snapshot_without_metadata_still_requires_its_password() {
|
||||
std::fs::remove_file(s.dir().join(format!("{}.json", meta.key))).unwrap();
|
||||
let mut h = harness(s.clone());
|
||||
h.get_by_label("Recover").click();
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "orphan recovery password prompt", |app| app.password_prompt.is_some());
|
||||
assert!(h.state().views.is_empty());
|
||||
let field = h.get_by_role(Role::PasswordInput);
|
||||
field.focus();
|
||||
field.type_text("pw");
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(4);
|
||||
support::wait_until(&mut h, "unlocked orphan recovery", |app| app.views.len() == 1);
|
||||
let app = h.state();
|
||||
assert_eq!(app.views.len(), 1);
|
||||
let doc = app.session.get(app.views[0].id).unwrap();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//! State-based waits for native preparation, which completes between UI frames.
|
||||
use egui_kittest::Harness;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
h.step();
|
||||
if ready(h.state()) {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for {what}: views={}, active={:?}, password={:?}, toast={:?}",
|
||||
h.state().views.len(),
|
||||
h.state().active,
|
||||
h.state().password_prompt.as_ref().map(|p| (&p.name, &p.error)),
|
||||
h.state().toast
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Headless UI tests (egui_kittest + AccessKit). They drive the real app shell without a window.
|
||||
|
||||
mod support;
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
@@ -681,9 +683,13 @@ fn dropping_a_pdf_on_the_window_opens_it() {
|
||||
assert!(h.state().views.is_empty());
|
||||
let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: "dropped.pdf".into(), bytes: FIXTURE.to_vec() });
|
||||
h.input_mut().dropped_files.push(file);
|
||||
h.run_steps(3);
|
||||
support::wait_until(&mut h, "dropped document", |app| app.views.len() == 1 && app.password_prompt.is_none());
|
||||
assert_eq!(h.state().views.len(), 1, "the dropped PDF opens in a tab");
|
||||
h.get_by_label_contains("dropped.pdf");
|
||||
let (_, id) = h.state().active_ids().unwrap();
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
assert_eq!(doc.name, "dropped.pdf");
|
||||
assert_eq!(doc.bytes.as_slice(), FIXTURE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1206,6 +1206,9 @@ fn selection_mouse_wheel_resumes_after_point_scrolling() {
|
||||
});
|
||||
h.run_steps(delay);
|
||||
let before = h.state().views[view].page_screen_rect(0).unwrap().top();
|
||||
// One Line notch follows this platform's configured speed. A Point tail can
|
||||
// still add travel in the one-frame case, so require at least the whole notch.
|
||||
let expected = h.ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, -1.0),
|
||||
@@ -1215,6 +1218,10 @@ fn selection_mouse_wheel_resumes_after_point_scrolling() {
|
||||
h.run_steps(40);
|
||||
let after = h.state().views[view].page_screen_rect(0).unwrap().top();
|
||||
eprintln!("Point-to-Line delay={delay}: point={}, line={}", start - before, before - after);
|
||||
assert!(before - after > 50.0, "Line input must resume after Point: {before} -> {after}");
|
||||
let travelled = before - after;
|
||||
assert!(travelled >= expected - 1.0, "Line input must resume after Point at the configured speed: {travelled} vs {expected}");
|
||||
if delay >= 16 {
|
||||
assert!((travelled - expected).abs() < 1.0, "settled Line travel must match its configured speed: {travelled} vs {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -90,7 +91,7 @@ total matches the parity estimate.
|
||||
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). |
|
||||
|
||||
@@ -22,7 +22,7 @@ milestone = "M1"
|
||||
status = "shipped"
|
||||
commands = ["file.open"]
|
||||
tools = ["doc_open"]
|
||||
evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system"]
|
||||
evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits"]
|
||||
notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)"
|
||||
|
||||
[[feature]]
|
||||
@@ -166,7 +166,7 @@ milestone = "M1"
|
||||
status = "shipped"
|
||||
commands = ["file.open"]
|
||||
tools = ["doc_open"]
|
||||
evidence = ["crates/ui-egui/tests/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking"]
|
||||
evidence = ["crates/ui-egui/tests/editing.rs::password_prompt_opens_and_security_tab_reports_the_details", "crates/crypt/src/tests.rs::empty_user_password_opens_without_asking", "crates/ui-egui/src/open_worker_tests.rs::a_chosen_tool_survives_wrong_password_retry_and_runs_after_unlock", "crates/ui-egui/src/open_worker_tests.rs::startup_intake_waits_for_local_authentication_and_keeps_its_own_background_password_prompt"]
|
||||
notes = ""
|
||||
|
||||
[[feature]]
|
||||
|
||||
Reference in new issue
Block a user