From 9d6ae81f0ead2de73e83e2439d3176e564d558cb Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:18:17 +0000 Subject: [PATCH] refactor(ui-egui): move editing.rs's tests to editing/tests.rs --- crates/ui-egui/src/editing.rs | 357 +--------------------------- crates/ui-egui/src/editing/tests.rs | 354 +++++++++++++++++++++++++++ 2 files changed, 355 insertions(+), 356 deletions(-) create mode 100644 crates/ui-egui/src/editing/tests.rs diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 9a13d570d9..8847093163 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1240,362 +1240,7 @@ fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> } #[cfg(test)] -mod tests { - use super::{SaveTarget, is_mail_temp_copy, save_dialog_folder, write_atomically, write_atomically_syncing, write_atomically_with}; - - #[test] - fn save_dialogs_open_in_the_documents_folder() { - use std::path::PathBuf; - assert_eq!(save_dialog_folder(Some("/home/a/plans/f.pdf")), Some(PathBuf::from("/home/a/plans"))); - // A new document has no folder yet; a bare name has an empty parent; the root has none. - assert_eq!(save_dialog_folder(None), None); - assert_eq!(save_dialog_folder(Some("f.pdf")), None); - assert_eq!(save_dialog_folder(Some("/")), None); - // An email attachment's temporary copy is not offered. - assert_eq!(save_dialog_folder(Some("C:\\Users\\a\\AppData\\Local\\Microsoft\\Windows\\INetCache\\Content.Outlook\\AB12\\f.pdf")), None); - assert_eq!(save_dialog_folder(Some("/Users/a/Library/Containers/com.apple.mail/Data/Library/Mail Downloads/UUID/f.pdf")), None); - } - - /// The Windows rules for `Path::parent` (UNC share roots included) only apply on Windows. - #[cfg(windows)] - #[test] - fn save_dialog_folder_handles_unc_paths() { - use std::path::PathBuf; - assert_eq!(save_dialog_folder(Some(r"\\nas\projets\2026\f.pdf")), Some(PathBuf::from(r"\\nas\projets\2026"))); - assert_eq!(save_dialog_folder(Some(r"\\nas\projets\f.pdf")), Some(PathBuf::from(r"\\nas\projets\"))); - assert_eq!(save_dialog_folder(Some(r"\\nas\projets")), None); - assert_eq!(save_dialog_folder(Some(r"C:\Users\a\f.pdf")), Some(PathBuf::from(r"C:\Users\a"))); - } - use pdfcraft_platform::staging::{STAGING_ATTEMPTS, staging_suffixes}; - use std::path::{Path, PathBuf}; - - /// A fresh, empty folder for one staging test. - fn staging_dir(test: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-ui-staging-{}-{test}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn staged(dir: &Path, suffix: u64) -> PathBuf { - dir.join(format!(".out.pdf.pdfcraft-{suffix:016x}.tmp")) - } - - fn read(p: &Path) -> String { - std::fs::read_to_string(p).unwrap() - } - - /// A symbolic link to a file, where the system allows one (Windows needs Developer Mode or an - /// administrator for it). - fn file_symlink(target: &Path, link: &Path) -> std::io::Result<()> { - #[cfg(unix)] - { - std::os::unix::fs::symlink(target, link) - } - #[cfg(windows)] - { - std::os::windows::fs::symlink_file(target, link) - } - #[cfg(not(any(unix, windows)))] - { - Err(std::io::Error::other(format!("no symbolic links here: {} {}", target.display(), link.display()))) - } - } - - #[test] - fn saving_never_writes_through_a_file_planted_at_the_staging_name() { - let dir = staging_dir("planted"); - let outside = dir.join("outside.txt"); - std::fs::write(&outside, "PRECIOUS").unwrap(); - let target = dir.join("out.pdf"); - let path = target.to_string_lossy().into_owned(); - // A hard link needs no privileges on any system, and writing to it writes to `outside`. - std::fs::hard_link(&outside, staged(&dir, 1)).unwrap(); - std::fs::write(staged(&dir, 2), "PLANTED").unwrap(); - write_atomically_with(&path, b"NEW", [1, 2, 3]).unwrap(); - assert_eq!(read(&target), "NEW"); - assert_eq!(read(&outside), "PRECIOUS"); - assert_eq!(read(&staged(&dir, 1)), "PRECIOUS"); - assert_eq!(read(&staged(&dir, 2)), "PLANTED"); - assert!(!staged(&dir, 3).exists(), "the staging file was renamed into place"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn saving_never_writes_through_a_symbolic_link_at_the_staging_name() { - let dir = staging_dir("symlink"); - let outside = dir.join("outside.txt"); - std::fs::write(&outside, "PRECIOUS").unwrap(); - let target = dir.join("out.pdf"); - if let Err(e) = file_symlink(&outside, &staged(&dir, 1)) { - eprintln!("symbolic links not checked: {e}"); - return; - } - write_atomically_with(&target.to_string_lossy(), b"NEW", [1, 2]).unwrap(); - assert_eq!(read(&target), "NEW"); - assert_eq!(read(&outside), "PRECIOUS"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn saving_never_creates_a_file_through_a_dangling_link_at_the_staging_name() { - let dir = staging_dir("dangling"); - let unborn = dir.join("created-through-a-link.txt"); - let target = dir.join("out.pdf"); - if let Err(e) = file_symlink(&unborn, &staged(&dir, 1)) { - eprintln!("symbolic links not checked: {e}"); - return; - } - write_atomically_with(&target.to_string_lossy(), b"NEW", [1, 2]).unwrap(); - assert_eq!(read(&target), "NEW"); - assert!(!unborn.exists(), "nothing was created through the dangling link"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_folder_at_the_staging_name_is_left_alone() { - let dir = staging_dir("folder"); - std::fs::create_dir(staged(&dir, 1)).unwrap(); - std::fs::write(staged(&dir, 1).join("inside.txt"), "PLANTED").unwrap(); - let target = dir.join("out.pdf"); - write_atomically_with(&target.to_string_lossy(), b"NEW", [1, 2]).unwrap(); - assert_eq!(read(&target), "NEW"); - assert_eq!(read(&staged(&dir, 1).join("inside.txt")), "PLANTED"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn saving_gives_up_rather_than_reuse_a_taken_name() { - let dir = staging_dir("taken"); - let target = dir.join("out.pdf"); - std::fs::write(&target, "OLD").unwrap(); - for s in 1..=STAGING_ATTEMPTS as u64 { - std::fs::write(staged(&dir, s), "PLANTED").unwrap(); - } - assert!(write_atomically_with(&target.to_string_lossy(), b"NEW", 1..).is_err()); - assert_eq!(read(&target), "OLD"); - for s in 1..=STAGING_ATTEMPTS as u64 { - assert_eq!(read(&staged(&dir, s)), "PLANTED"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// The names in a folder: what a test can see was left behind. - fn listing(dir: &Path) -> Vec { - let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); - names.sort(); - names - } - - #[test] - fn saving_a_long_name_leaves_only_the_saved_file() { - assert_ne!(staging_suffixes().next(), staging_suffixes().next(), "each save draws new names"); - // 60 four-byte characters: a 244-byte name, within every system's limit. Its staging name - // must be too (on Linux the whole name in it would be 275 bytes). - let dir = staging_dir("clean"); - let name = format!("{}.pdf", "\u{1F600}".repeat(60)); - let path = dir.join(&name).to_string_lossy().into_owned(); - write_atomically(&path, b"NEW").unwrap(); - write_atomically(&path, b"NEWER").unwrap(); - assert_eq!(read(&dir.join(&name)), "NEWER"); - assert_eq!(listing(&dir), [name], "no staging file is left behind"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A read-only target is refused before anything is staged, so no staging file is left - /// behind. - #[cfg(windows)] - #[test] - fn a_failed_save_removes_the_staging_file() { - let dir = staging_dir("readonly"); - let target = dir.join("out.pdf"); - std::fs::write(&target, "OLD").unwrap(); - let mut perms = std::fs::metadata(&target).unwrap().permissions(); - perms.set_readonly(true); - std::fs::set_permissions(&target, perms.clone()).unwrap(); - let e = write_atomically_with(&target.to_string_lossy(), b"NEW", [7]).unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "a read-only target is refused: {e}"); - assert_eq!(read(&target), "OLD"); - assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); - #[allow(clippy::permissions_set_readonly_false)] - perms.set_readonly(false); - std::fs::set_permissions(&target, perms).unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - /// On macOS `sync_all` asks for a full flush, which SMB and FUSE volumes (a Cryptomator vault, - /// #981) refuse as unsupported: ENOTSUP (45) or ENOTTY (25). The save still goes through. - #[cfg(target_os = "macos")] - #[test] - fn a_volume_without_full_flush_still_saves() { - for errno in [45, 25] { - let dir = staging_dir(&format!("nofullsync-{errno}")); - let target = dir.join("out.pdf"); - std::fs::write(&target, "OLD").unwrap(); - let asked = std::cell::Cell::new(false); - let refuse = |_: &std::fs::File| { - asked.set(true); - Err(std::io::Error::from_raw_os_error(errno)) - }; - write_atomically_syncing(&target.to_string_lossy(), b"NEW", [7], refuse).unwrap(); - assert!(asked.get(), "the full flush was tried"); - assert_eq!(read(&target), "NEW"); - assert_eq!(listing(&dir), ["out.pdf"], "no staging file is left behind"); - let _ = std::fs::remove_dir_all(&dir); - } - } - - /// Only "not supported" is let through: any other flush failure fails the save and keeps the - /// original. - #[test] - fn a_failed_flush_keeps_the_original() { - let dir = staging_dir("syncfails"); - let target = dir.join("out.pdf"); - std::fs::write(&target, "OLD").unwrap(); - let e = write_atomically_syncing(&target.to_string_lossy(), b"NEW", [7], |_| Err(std::io::Error::other("disk gone"))).unwrap_err(); - assert_eq!(e.to_string(), "disk gone"); - assert_eq!(read(&target), "OLD"); - assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(target_os = "macos")] - #[test] - fn a_save_preserves_opening_settings_changed_while_staging() { - let dir = staging_dir("opening-metadata-changed"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - xattr::set(&target, "com.apple.LaunchServices.OpenWith", b"old handler").unwrap(); - xattr::set(&target, "com.apple.quarantine", b"0082;1234;Preview;").unwrap(); - let suffixes = std::iter::once_with(|| { - xattr::set(&target, "com.apple.LaunchServices.OpenWith", b"new handler").unwrap(); - xattr::set(&target, "com.apple.quarantine", b"0082;5678;another app;").unwrap(); - 7 - }); - write_atomically_with(&target.to_string_lossy(), b"NEW", suffixes).unwrap(); - assert_eq!(read(&target), "NEW"); - assert_eq!(xattr::get(&target, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"new handler".to_vec())); - assert_eq!(xattr::get(&target, "com.apple.quarantine").unwrap(), Some(b"0082;5678;another app;".to_vec())); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn outlook_attachment_copies_are_recognised_on_either_separator_and_in_any_case() { - for p in [ - r"C:\Users\me\AppData\Local\Microsoft\Windows\INetCache\Content.Outlook\AB12CD34\Quote.pdf", - r"c:\users\me\appdata\local\microsoft\windows\inetcache\content.outlook\ab12cd34\quote (2).pdf", - r"C:\Users\me\AppData\Local\Microsoft\Windows\Temporary Internet Files\Content.Outlook\AB12CD34\Quote.pdf", - r"C:\Users\me\AppData\Local\Packages\Microsoft.OutlookForWindows_8wekyb3d8bbwe\LocalCache\Quote.pdf", - "C:/Users/me/AppData/Local/Microsoft/Windows/INetCache/Content.Outlook/AB12CD34/Quote.pdf", - ] { - assert!(is_mail_temp_copy(p), "{p}"); - } - } - - #[test] - fn apple_mail_attachment_copies_are_recognised() { - for p in [ - "/Users/me/Library/Containers/com.apple.mail/Data/Library/Mail Downloads/6F1C2A9E-3B4D-4E5F-8A7B-9C0D1E2F3A4B/booking.pdf", - "/Users/me/library/containers/COM.APPLE.MAIL/data/library/mail downloads/6F1C2A9E/Booking (2).pdf", - ] { - assert!(is_mail_temp_copy(p), "{p}"); - } - } - - #[test] - fn ordinary_paths_are_not_mail_copies() { - for p in [ - r"C:\Users\me\Documents\Quote.pdf", - r"C:\Users\me\OneDrive - Contoso\Content.Outlook notes\Quote.pdf", - r"C:\Users\me\AppData\Local\Microsoft\Windows\INetCache\IE\Quote.pdf", - "/home/microsoft.outlookforwindows/report.pdf", - "/x/inetcache/content.outlook/y.pdf", - "/x/temporary internet files/content.outlook/y.pdf", - "/home/me/Documents/Quote.pdf", - "/Users/me/Downloads/Mail Downloads/booking.pdf", - "/Users/me/Library/Containers/com.apple.Notes/Data/Library/Mail Downloads/booking.pdf", - "Quote.pdf", - ] { - assert!(!is_mail_temp_copy(p), "{p}"); - } - } - - /// A one-page document with a proper xref table. - fn one_page_pdf() -> Vec { - let objs = [ - "<< /Type /Catalog /Pages 2 0 R >>", - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", - "<< /Type /Page /Parent 2 0 R >>", - ]; - 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 - } - - #[test] - fn save_commits_the_open_text_box() { - let dir = staging_dir("open-text-box"); - let dest = dir.join("out.pdf"); - let mut app = crate::PdfCraftApp::new(); - app.set_option("language", "en").unwrap(); - app.open_bytes("a.pdf", None, one_page_pdf()).unwrap(); - app.save_override = Some(dest.to_string_lossy().into_owned()); - let style = crate::content_ui::default_style(); - app.views[0].content.draft = Some(crate::content_ui::TextDraft { - page: 0, - index: None, - rect: [20.0, 20.0, 120.0, 40.0], - text: "Typed text".into(), - style, - focus: true, - }); - - assert!(app.save_active(SaveTarget::InPlace)); - let id = app.views[0].id; - let added = app.session.get(id).unwrap().added(); - assert_eq!(added.len(), 1, "the open text box was committed before the save"); - assert!(app.views[0].content.draft.is_none()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alone() { - let dir = staging_dir("mail-temp"); - let cache = dir.join("INetCache").join("Content.Outlook").join("AB12CD34"); - std::fs::create_dir_all(&cache).unwrap(); - let attachment = cache.join("Quote.pdf"); - let original = one_page_pdf(); - std::fs::write(&attachment, &original).unwrap(); - let kept = dir.join("Quote.pdf"); - - let mut app = crate::PdfCraftApp::new(); - app.set_option("language", "en").unwrap(); - app.open_bytes("Quote.pdf", Some(attachment.to_string_lossy().into_owned()), original.clone()).unwrap(); - assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); - app.pick_override = Some(vec![kept.to_string_lossy().into_owned()]); - - assert!(!app.save_active(SaveTarget::InPlace), "Save asks instead of writing the temporary copy"); - app.process_picked(); - assert_eq!(std::fs::read(&attachment).unwrap(), original, "the attachment's copy is untouched"); - assert!(std::fs::read(&kept).unwrap().starts_with(b"%PDF"), "the document went where the user chose"); - - // The chosen file is the document's file now: the next Save writes it without asking. - app.pick_override = None; - assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); - assert!(app.save_active(SaveTarget::InPlace)); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; #[cfg(test)] mod revert_draft_tests { diff --git a/crates/ui-egui/src/editing/tests.rs b/crates/ui-egui/src/editing/tests.rs new file mode 100644 index 0000000000..7d86e35a7a --- /dev/null +++ b/crates/ui-egui/src/editing/tests.rs @@ -0,0 +1,354 @@ +use super::{SaveTarget, is_mail_temp_copy, save_dialog_folder, write_atomically, write_atomically_syncing, write_atomically_with}; + +#[test] +fn save_dialogs_open_in_the_documents_folder() { + use std::path::PathBuf; + assert_eq!(save_dialog_folder(Some("/home/a/plans/f.pdf")), Some(PathBuf::from("/home/a/plans"))); + // A new document has no folder yet; a bare name has an empty parent; the root has none. + assert_eq!(save_dialog_folder(None), None); + assert_eq!(save_dialog_folder(Some("f.pdf")), None); + assert_eq!(save_dialog_folder(Some("/")), None); + // An email attachment's temporary copy is not offered. + assert_eq!(save_dialog_folder(Some("C:\\Users\\a\\AppData\\Local\\Microsoft\\Windows\\INetCache\\Content.Outlook\\AB12\\f.pdf")), None); + assert_eq!(save_dialog_folder(Some("/Users/a/Library/Containers/com.apple.mail/Data/Library/Mail Downloads/UUID/f.pdf")), None); +} + +/// The Windows rules for `Path::parent` (UNC share roots included) only apply on Windows. +#[cfg(windows)] +#[test] +fn save_dialog_folder_handles_unc_paths() { + use std::path::PathBuf; + assert_eq!(save_dialog_folder(Some(r"\\nas\projets\2026\f.pdf")), Some(PathBuf::from(r"\\nas\projets\2026"))); + assert_eq!(save_dialog_folder(Some(r"\\nas\projets\f.pdf")), Some(PathBuf::from(r"\\nas\projets\"))); + assert_eq!(save_dialog_folder(Some(r"\\nas\projets")), None); + assert_eq!(save_dialog_folder(Some(r"C:\Users\a\f.pdf")), Some(PathBuf::from(r"C:\Users\a"))); +} +use pdfcraft_platform::staging::{STAGING_ATTEMPTS, staging_suffixes}; +use std::path::{Path, PathBuf}; + +/// A fresh, empty folder for one staging test. +fn staging_dir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-staging-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn staged(dir: &Path, suffix: u64) -> PathBuf { + dir.join(format!(".out.pdf.pdfcraft-{suffix:016x}.tmp")) +} + +fn read(p: &Path) -> String { + std::fs::read_to_string(p).unwrap() +} + +/// A symbolic link to a file, where the system allows one (Windows needs Developer Mode or an +/// administrator for it). +fn file_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link) + } + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(target, link) + } + #[cfg(not(any(unix, windows)))] + { + Err(std::io::Error::other(format!("no symbolic links here: {} {}", target.display(), link.display()))) + } +} + +#[test] +fn saving_never_writes_through_a_file_planted_at_the_staging_name() { + let dir = staging_dir("planted"); + let outside = dir.join("outside.txt"); + std::fs::write(&outside, "PRECIOUS").unwrap(); + let target = dir.join("out.pdf"); + let path = target.to_string_lossy().into_owned(); + // A hard link needs no privileges on any system, and writing to it writes to `outside`. + std::fs::hard_link(&outside, staged(&dir, 1)).unwrap(); + std::fs::write(staged(&dir, 2), "PLANTED").unwrap(); + write_atomically_with(&path, b"NEW", [1, 2, 3]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&outside), "PRECIOUS"); + assert_eq!(read(&staged(&dir, 1)), "PRECIOUS"); + assert_eq!(read(&staged(&dir, 2)), "PLANTED"); + assert!(!staged(&dir, 3).exists(), "the staging file was renamed into place"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn saving_never_writes_through_a_symbolic_link_at_the_staging_name() { + let dir = staging_dir("symlink"); + let outside = dir.join("outside.txt"); + std::fs::write(&outside, "PRECIOUS").unwrap(); + let target = dir.join("out.pdf"); + if let Err(e) = file_symlink(&outside, &staged(&dir, 1)) { + eprintln!("symbolic links not checked: {e}"); + return; + } + write_atomically_with(&target.to_string_lossy(), b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&outside), "PRECIOUS"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn saving_never_creates_a_file_through_a_dangling_link_at_the_staging_name() { + let dir = staging_dir("dangling"); + let unborn = dir.join("created-through-a-link.txt"); + let target = dir.join("out.pdf"); + if let Err(e) = file_symlink(&unborn, &staged(&dir, 1)) { + eprintln!("symbolic links not checked: {e}"); + return; + } + write_atomically_with(&target.to_string_lossy(), b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert!(!unborn.exists(), "nothing was created through the dangling link"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_folder_at_the_staging_name_is_left_alone() { + let dir = staging_dir("folder"); + std::fs::create_dir(staged(&dir, 1)).unwrap(); + std::fs::write(staged(&dir, 1).join("inside.txt"), "PLANTED").unwrap(); + let target = dir.join("out.pdf"); + write_atomically_with(&target.to_string_lossy(), b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&staged(&dir, 1).join("inside.txt")), "PLANTED"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn saving_gives_up_rather_than_reuse_a_taken_name() { + let dir = staging_dir("taken"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + for s in 1..=STAGING_ATTEMPTS as u64 { + std::fs::write(staged(&dir, s), "PLANTED").unwrap(); + } + assert!(write_atomically_with(&target.to_string_lossy(), b"NEW", 1..).is_err()); + assert_eq!(read(&target), "OLD"); + for s in 1..=STAGING_ATTEMPTS as u64 { + assert_eq!(read(&staged(&dir, s)), "PLANTED"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// The names in a folder: what a test can see was left behind. +fn listing(dir: &Path) -> Vec { + let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + names +} + +#[test] +fn saving_a_long_name_leaves_only_the_saved_file() { + assert_ne!(staging_suffixes().next(), staging_suffixes().next(), "each save draws new names"); + // 60 four-byte characters: a 244-byte name, within every system's limit. Its staging name + // must be too (on Linux the whole name in it would be 275 bytes). + let dir = staging_dir("clean"); + let name = format!("{}.pdf", "\u{1F600}".repeat(60)); + let path = dir.join(&name).to_string_lossy().into_owned(); + write_atomically(&path, b"NEW").unwrap(); + write_atomically(&path, b"NEWER").unwrap(); + assert_eq!(read(&dir.join(&name)), "NEWER"); + assert_eq!(listing(&dir), [name], "no staging file is left behind"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A read-only target is refused before anything is staged, so no staging file is left +/// behind. +#[cfg(windows)] +#[test] +fn a_failed_save_removes_the_staging_file() { + let dir = staging_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let e = write_atomically_with(&target.to_string_lossy(), b"NEW", [7]).unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "a read-only target is refused: {e}"); + assert_eq!(read(&target), "OLD"); + assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// On macOS `sync_all` asks for a full flush, which SMB and FUSE volumes (a Cryptomator vault, +/// #981) refuse as unsupported: ENOTSUP (45) or ENOTTY (25). The save still goes through. +#[cfg(target_os = "macos")] +#[test] +fn a_volume_without_full_flush_still_saves() { + for errno in [45, 25] { + let dir = staging_dir(&format!("nofullsync-{errno}")); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let asked = std::cell::Cell::new(false); + let refuse = |_: &std::fs::File| { + asked.set(true); + Err(std::io::Error::from_raw_os_error(errno)) + }; + write_atomically_syncing(&target.to_string_lossy(), b"NEW", [7], refuse).unwrap(); + assert!(asked.get(), "the full flush was tried"); + assert_eq!(read(&target), "NEW"); + assert_eq!(listing(&dir), ["out.pdf"], "no staging file is left behind"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +/// Only "not supported" is let through: any other flush failure fails the save and keeps the +/// original. +#[test] +fn a_failed_flush_keeps_the_original() { + let dir = staging_dir("syncfails"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let e = write_atomically_syncing(&target.to_string_lossy(), b"NEW", [7], |_| Err(std::io::Error::other("disk gone"))).unwrap_err(); + assert_eq!(e.to_string(), "disk gone"); + assert_eq!(read(&target), "OLD"); + assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_save_preserves_opening_settings_changed_while_staging() { + let dir = staging_dir("opening-metadata-changed"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + xattr::set(&target, "com.apple.LaunchServices.OpenWith", b"old handler").unwrap(); + xattr::set(&target, "com.apple.quarantine", b"0082;1234;Preview;").unwrap(); + let suffixes = std::iter::once_with(|| { + xattr::set(&target, "com.apple.LaunchServices.OpenWith", b"new handler").unwrap(); + xattr::set(&target, "com.apple.quarantine", b"0082;5678;another app;").unwrap(); + 7 + }); + write_atomically_with(&target.to_string_lossy(), b"NEW", suffixes).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(xattr::get(&target, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"new handler".to_vec())); + assert_eq!(xattr::get(&target, "com.apple.quarantine").unwrap(), Some(b"0082;5678;another app;".to_vec())); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn outlook_attachment_copies_are_recognised_on_either_separator_and_in_any_case() { + for p in [ + r"C:\Users\me\AppData\Local\Microsoft\Windows\INetCache\Content.Outlook\AB12CD34\Quote.pdf", + r"c:\users\me\appdata\local\microsoft\windows\inetcache\content.outlook\ab12cd34\quote (2).pdf", + r"C:\Users\me\AppData\Local\Microsoft\Windows\Temporary Internet Files\Content.Outlook\AB12CD34\Quote.pdf", + r"C:\Users\me\AppData\Local\Packages\Microsoft.OutlookForWindows_8wekyb3d8bbwe\LocalCache\Quote.pdf", + "C:/Users/me/AppData/Local/Microsoft/Windows/INetCache/Content.Outlook/AB12CD34/Quote.pdf", + ] { + assert!(is_mail_temp_copy(p), "{p}"); + } +} + +#[test] +fn apple_mail_attachment_copies_are_recognised() { + for p in [ + "/Users/me/Library/Containers/com.apple.mail/Data/Library/Mail Downloads/6F1C2A9E-3B4D-4E5F-8A7B-9C0D1E2F3A4B/booking.pdf", + "/Users/me/library/containers/COM.APPLE.MAIL/data/library/mail downloads/6F1C2A9E/Booking (2).pdf", + ] { + assert!(is_mail_temp_copy(p), "{p}"); + } +} + +#[test] +fn ordinary_paths_are_not_mail_copies() { + for p in [ + r"C:\Users\me\Documents\Quote.pdf", + r"C:\Users\me\OneDrive - Contoso\Content.Outlook notes\Quote.pdf", + r"C:\Users\me\AppData\Local\Microsoft\Windows\INetCache\IE\Quote.pdf", + "/home/microsoft.outlookforwindows/report.pdf", + "/x/inetcache/content.outlook/y.pdf", + "/x/temporary internet files/content.outlook/y.pdf", + "/home/me/Documents/Quote.pdf", + "/Users/me/Downloads/Mail Downloads/booking.pdf", + "/Users/me/Library/Containers/com.apple.Notes/Data/Library/Mail Downloads/booking.pdf", + "Quote.pdf", + ] { + assert!(!is_mail_temp_copy(p), "{p}"); + } +} + +/// A one-page document with a proper xref table. +fn one_page_pdf() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]; + 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 +} + +#[test] +fn save_commits_the_open_text_box() { + let dir = staging_dir("open-text-box"); + let dest = dir.join("out.pdf"); + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, one_page_pdf()).unwrap(); + app.save_override = Some(dest.to_string_lossy().into_owned()); + let style = crate::content_ui::default_style(); + app.views[0].content.draft = Some(crate::content_ui::TextDraft { + page: 0, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed text".into(), + style, + focus: true, + }); + + assert!(app.save_active(SaveTarget::InPlace)); + let id = app.views[0].id; + let added = app.session.get(id).unwrap().added(); + assert_eq!(added.len(), 1, "the open text box was committed before the save"); + assert!(app.views[0].content.draft.is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alone() { + let dir = staging_dir("mail-temp"); + let cache = dir.join("INetCache").join("Content.Outlook").join("AB12CD34"); + std::fs::create_dir_all(&cache).unwrap(); + let attachment = cache.join("Quote.pdf"); + let original = one_page_pdf(); + std::fs::write(&attachment, &original).unwrap(); + let kept = dir.join("Quote.pdf"); + + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("Quote.pdf", Some(attachment.to_string_lossy().into_owned()), original.clone()).unwrap(); + assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + app.pick_override = Some(vec![kept.to_string_lossy().into_owned()]); + + assert!(!app.save_active(SaveTarget::InPlace), "Save asks instead of writing the temporary copy"); + app.process_picked(); + assert_eq!(std::fs::read(&attachment).unwrap(), original, "the attachment's copy is untouched"); + assert!(std::fs::read(&kept).unwrap().starts_with(b"%PDF"), "the document went where the user chose"); + + // The chosen file is the document's file now: the next Save writes it without asking. + app.pick_override = None; + assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + assert!(app.save_active(SaveTarget::InPlace)); + let _ = std::fs::remove_dir_all(&dir); +}