diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 7f375e8c1..9b04472ed 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -482,721 +482,4 @@ impl WordApp { } #[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::rc::Rc; - use std::sync::mpsc::{Sender, channel}; - - use super::*; - use crate::Services; - use wordcraft_engine::Session; - - const UNSAVED: &str = "My unsaved novel chapter"; - - /// Original Word 97 compound file: one compressed text piece, built from MS-DOC's FIB/Clx. - fn legacy_word_bytes() -> Vec { - use std::io::{Cursor, Write}; - let text = b"Legacy contract\r"; - let mut word = vec![0u8; 0x600]; - for (at, value) in [(0, 0xA5ECu16), (2, 0x00C1), (0x0A, 0x1000), (0x0C, 0x00BF), (0x20, 14), (0x3E, 22), (0x98, 0x005D)] { - word[at..at + 2].copy_from_slice(&value.to_le_bytes()); - } - word[0x40..0x44].copy_from_slice(&0x1000u32.to_le_bytes()); - word[0x4C..0x50].copy_from_slice(&(text.len() as u32).to_le_bytes()); - let mut clx = vec![2]; - clx.extend_from_slice(&16u32.to_le_bytes()); - clx.extend_from_slice(&0u32.to_le_bytes()); - clx.extend_from_slice(&(text.len() as u32).to_le_bytes()); - clx.extend_from_slice(&[0, 0]); - clx.extend_from_slice(&(0x4000_0000u32 | (2 * 0x600)).to_le_bytes()); - clx.extend_from_slice(&[0, 0]); - let pair = 0x9A + 33 * 8; - word[pair..pair + 4].copy_from_slice(&0x40u32.to_le_bytes()); - word[pair + 4..pair + 8].copy_from_slice(&(clx.len() as u32).to_le_bytes()); - word.extend_from_slice(text); - let mut table = vec![0; 0x40]; - table.extend_from_slice(&clx); - let mut file = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap(); - for (name, data) in [("/WordDocument", word), ("/0Table", table)] { - file.create_stream(name).unwrap().write_all(&data).unwrap(); - } - file.into_inner().into_inner() - } - - /// #2445: Save on an imported binary Word file asks for DOCX, preserving the import on Cancel. - #[test] - fn imported_word_save_asks_for_docx_and_preserves_the_original() { - for ext in ["doc", "DOT", "DOC", "dot"] { - let dir = scratch(&format!("imported-{ext}")); - let original = dir.join(format!("contract.{ext}")); - let bytes = legacy_word_bytes(); - std::fs::write(&original, &bytes).unwrap(); - let (mut a, shown) = async_app(); - a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap(); - assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Legacy contract")); - a.run("text.insert", json!({"text": "Revised "})).unwrap(); - let draft = a.session.doc.clone(); - let revision = a.session.rev(); - let result = a.run("file.save", json!({})).unwrap(); - assert_eq!(result["pending"], "saveAs"); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None }); - answer(&shown, None); - assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], false); - assert_eq!(a.session.doc, draft); - assert_eq!(a.session.rev(), revision); - assert!(a.session.dirty); - assert_eq!(a.session.path.as_deref(), Some(original.as_path())); - assert_eq!(std::fs::read(&original).unwrap(), bytes); - - a.run("file.save", json!({})).unwrap(); - let saved = dir.join("revised.docx"); - answer(&shown, Some(&saved.to_string_lossy())); - assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], true); - assert!(!a.session.dirty); - assert_eq!(a.session.path.as_deref(), Some(saved.as_path())); - assert!(saved_text(&saved).contains("Revised Legacy contract")); - assert_eq!(std::fs::read(&original).unwrap(), bytes); - // The new supported path saves directly, without another chooser. - a.run("text.insert", json!({"text": "Again "})).unwrap(); - assert_eq!(a.run("file.save", json!({})).unwrap()["saved"], true); - assert!(shown.borrow().is_empty()); - assert!(saved_text(&saved).contains("Again ")); - let _ = std::fs::remove_dir_all(dir); - } - } - - #[test] - fn imported_word_close_waits_for_save_as_and_keeps_failed_or_cancelled_drafts() { - let dir = scratch("imported-close"); - let original = dir.join("contract.doc"); - let bytes = legacy_word_bytes(); - std::fs::write(&original, &bytes).unwrap(); - let (mut a, shown) = async_app(); - a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap(); - a.run("text.insert", json!({"text": "Revised "})).unwrap(); - let draft = a.session.doc.clone(); - - a.run("file.close", json!({})).unwrap(); - assert_eq!(a.run("ui.saveChanges", json!({"answer": "save"})).unwrap()["pending"], "saveAs"); - assert!(a.has_document() && !a.quit_requested); - answer(&shown, Some(&dir.join("missing").join("contract.docx").to_string_lossy())); - let error = a.poll_file_dialog().unwrap().unwrap_err(); - assert!(error.contains("still open"), "{error}"); - assert!(matches!(a.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. }))); - assert!(a.has_document() && a.session.dirty && !a.quit_requested); - assert_eq!(a.session.path.as_deref(), Some(original.as_path())); - assert_eq!(std::fs::read(&original).unwrap(), bytes); - assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), draft.plain_text(wordcraft_doc::StoryRef::Body)); - - a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - answer(&shown, None); - assert_eq!(a.poll_file_dialog().unwrap().unwrap()["done"], false); - assert!(a.has_document() && a.session.dirty && !a.quit_requested); - assert_eq!(a.session.path.as_deref(), Some(original.as_path())); - - a.run("file.close", json!({})).unwrap(); - a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - let saved = dir.join("contract.docx"); - answer(&shown, Some(&saved.to_string_lossy())); - assert_eq!(a.poll_file_dialog().unwrap().unwrap()["done"], true); - // File › Close keeps the window (#1712): the document closes once the DOCX save succeeds. - assert!(!a.has_document() && !a.quit_requested, "Close runs only after the DOCX save succeeds"); - assert!(saved_text(&saved).contains("Revised Legacy contract")); - assert_eq!(std::fs::read(&original).unwrap(), bytes); - let _ = std::fs::remove_dir_all(dir); - } - - /// A late Save As answer belongs to the imported document that opened the picker: once that - /// document is closed, it writes nothing. - #[test] - fn imported_word_stale_save_as_never_writes_the_replacement_document() { - for close in [false, true] { - let dir = scratch(if close { "imported-stale-close" } else { "imported-stale-save" }); - let original = dir.join("contract.doc"); - let bytes = legacy_word_bytes(); - std::fs::write(&original, &bytes).unwrap(); - let destination = dir.join("existing.docx"); - let sentinel = b"Existing destination must survive"; - std::fs::write(&destination, sentinel).unwrap(); - let (mut a, shown) = async_app(); - a.run("file.open", json!({"path": original})).unwrap(); - typed(&mut a); - if close { - a.run("file.close", json!({})).unwrap(); - a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - } else { - a.run("file.save", json!({})).unwrap(); - } - assert!(a.file_dialog_open()); - let previous = a.session.document_id(); - // Automation bypasses the user prompt and can close a document during a picker. - a.execute("file.closeDocument", json!({"force": true})).unwrap(); - typed(&mut a); - assert_ne!(previous, a.session.document_id()); - let draft = a.session.doc.clone(); - let revision = a.session.rev(); - answer(&shown, Some(&destination.to_string_lossy())); - let _ = a.poll_file_dialog().unwrap(); - assert!(std::fs::read(&destination).unwrap() == sentinel, "stale picker overwrote the destination"); - assert_eq!(std::fs::read(&original).unwrap(), bytes); - assert_eq!(a.session.doc, draft); - assert_eq!(a.session.rev(), revision); - assert!(a.session.dirty && a.session.path.is_none() && !a.quit_requested); - } - } - - #[test] - fn imported_word_programmatic_and_explicit_saves_never_open_dialogs() { - let dir = scratch("imported-explicit"); - let original = dir.join("contract.doc"); - let bytes = legacy_word_bytes(); - std::fs::write(&original, &bytes).unwrap(); - let (mut a, shown) = async_app(); - a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap(); - a.run("text.insert", json!({"text": "Revised "})).unwrap(); - let error = a.execute("file.save", json!({})).unwrap_err(); - assert!(error.contains("can't save as `.doc`"), "{error}"); - let error = a.run("file.save", json!({"path": original.to_string_lossy()})).unwrap_err(); - assert!(error.contains("can't save as `.doc`"), "{error}"); - assert!(shown.borrow().is_empty()); - assert!(a.session.dirty); - assert_eq!(a.session.path.as_deref(), Some(original.as_path())); - assert_eq!(std::fs::read(&original).unwrap(), bytes); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn imported_word_save_controls_ask_for_docx() { - use egui_kittest::kittest::Queryable; - let dir = scratch("imported-entry-points"); - let original = dir.join("contract.doc"); - let bytes = legacy_word_bytes(); - std::fs::write(&original, &bytes).unwrap(); - let (mut a, shown) = async_app(); - a.run("ui.language", json!({"value": "en"})).unwrap(); - a.run("file.open", json!({"path": original})).unwrap(); - typed(&mut a); - a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( - |ui, app: &mut WordApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - a, - ); - h.run_steps(4); - h.get_by_label("Save").click(); - h.run_steps(3); - assert!(h.state().file_dialog_open(), "the Save button asks for a supported target"); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None }); - answer(&shown, None); - h.run_steps(3); - - h.state_mut().run("ui.backstage", json!({"value": false})).unwrap(); - h.state_mut().canvas.want_focus = true; - h.run_steps(6); - assert!(h.state().canvas.focused, "Mod+S starts in the editor"); - h.event(egui::Event::Key { key: egui::Key::S, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); - h.run_steps(3); - assert!(h.state().file_dialog_open(), "the focused editor's Mod+S asks too"); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None }); - answer(&shown, None); - h.run_steps(3); - assert!(h.state().session.dirty); - assert_eq!(h.state().session.path.as_deref(), Some(original.as_path())); - assert_eq!(std::fs::read(&original).unwrap(), bytes); - } - - /// Dialogs the fake host was asked to show, each with the channel its answer goes through. - type Shown = Rc)>>>; - - /// An app whose host shows dialogs asynchronously: nothing is answered until the test does. - fn async_app() -> (WordApp, Shown) { - let shown: Shown = Rc::default(); - let log = shown.clone(); - let services = Services { - file_dialog: Some(Box::new(move |req| { - let (tx, rx) = channel(); - log.borrow_mut().push((req, tx)); - rx - })), - ..Default::default() - }; - (WordApp::new(Session::new(wordcraft_doc::Document::new()), services), shown) - } - - /// Answer the dialog the host is showing (the last one asked for). - fn answer(shown: &Shown, picked: Option<&str>) { - answer_all(shown, picked.as_slice()); - } - - /// Answer the dialog with several picked paths (none: cancelled). - fn answer_all(shown: &Shown, picked: &[&str]) { - let (_, tx) = shown.borrow_mut().pop().unwrap(); - tx.send(Ok(picked.iter().map(|p| p.to_string()).collect())).unwrap(); - } - - fn typed(a: &mut WordApp) { - a.run("text.insert", json!({"text": UNSAVED})).unwrap(); - assert!(a.session.dirty); - } - - fn body_text(a: &WordApp) -> String { - a.session.doc.plain_text(wordcraft_doc::StoryRef::Body) - } - - fn scratch(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("wordcraft-ui-dialogs-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn saved_text(path: &std::path::Path) -> String { - wordcraft_engine::io::open_path(path).unwrap().plain_text(wordcraft_doc::StoryRef::Body) - } - - /// Save as Word Template (#2852) suggests a .dotx in the user templates folder, which is made - /// on first use; the picked path is saved as a template. A folder named in File › Options - /// replaces the app's own. - #[test] - fn save_as_template_starts_in_the_templates_folder() { - let dir = scratch("save-template"); - let own = dir.join("Templates"); - let (mut a, shown) = async_app(); - a.services.templates_dir = Some(own.clone()); - typed(&mut a); - let r = a.run("ui.saveAsTemplate", json!({})).unwrap(); - assert!(own.is_dir(), "the app's templates folder is made on first use"); - assert_eq!(r["dir"], json!(own.to_string_lossy())); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.dotx".into(), dir: Some(own.to_string_lossy().into_owned()) }); - let path = own.join("Memo.dotx"); - answer(&shown, Some(&path.to_string_lossy())); - let ctx = egui::Context::default(); - ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); - assert!(path.is_file(), "saved as a template"); - assert_eq!(saved_text(&path), UNSAVED); - let listed = wordcraft_engine::cmd::user_templates::list_templates(&own.to_string_lossy()).unwrap(); - assert_eq!(listed.templates.iter().map(|t| t.label.as_str()).collect::>(), ["Memo"]); - - // A folder chosen in Options wins. - let chosen = dir.join("Chosen"); - std::fs::create_dir_all(&chosen).unwrap(); - a.run("file.options", json!({"userTemplatesFolder": chosen.to_string_lossy()})).unwrap(); - assert_eq!(a.user_templates_dir(), Some(chosen.clone())); - a.run("ui.saveAsTemplate", json!({})).unwrap(); - assert!( - matches!(&shown.borrow()[0].0, FileDialogRequest::Save { name, dir: Some(d) } if name == "Memo.dotx" && *d == chosen.to_string_lossy()) - ); - answer(&shown, None); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Save As returns at once (the UI keeps running); the frame loop saves once the answer arrives. - #[test] - fn save_as_answer_arriving_later_is_saved() { - let dir = scratch("later"); - let path = dir.join("novel.docx"); - let (mut a, shown) = async_app(); - typed(&mut a); - a.save_as_dialog(); - assert!(a.file_dialog_open()); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into(), dir: None }); - let ctx = egui::Context::default(); - let frame = |a: &mut WordApp| ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); - frame(&mut a); - assert!(a.session.dirty, "nothing saved before the user answers"); - answer(&shown, Some(&path.to_string_lossy())); - frame(&mut a); - assert!(!a.file_dialog_open()); - assert!(!a.session.dirty); - assert!(saved_text(&path).contains(UNSAVED)); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #689: Save As honours the filename typed in the dialog, not just the folder: a document - /// opened as `a/one.docx` and saved as `b/two.docx` is written there and takes that path. - #[test] - fn save_as_with_a_new_filename_saves_under_it() { - let dir = scratch("rename"); - let opened = dir.join("one.docx"); - let picked = dir.join("sub").join("two.docx"); - std::fs::create_dir_all(dir.join("sub")).unwrap(); - wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("original")) - .run("file.save", &json!({"path": opened.to_string_lossy()})) - .unwrap(); - let (mut a, shown) = async_app(); - a.run("file.open", json!({"path": opened.to_string_lossy()})).unwrap(); - assert_eq!(a.session.path.as_deref(), Some(opened.as_path())); - a.save_as_dialog(); - answer(&shown, Some(&picked.to_string_lossy())); - let r = a.poll_file_dialog().unwrap().unwrap(); - assert_eq!(r, json!({"saved": true}), "the save-as succeeded"); - assert_eq!(a.session.path.as_deref(), Some(picked.as_path()), "the picked filename is the document's path"); - assert!(picked.is_file(), "the file is written under the picked name"); - assert!(saved_text(&picked).contains("original")); - // F12 (file.saveAs without a path) lands in the same dialog, suggesting the new name. - let r = a.run("file.saveAs", json!({})).unwrap(); - assert_eq!(r, json!({"saved": false})); - assert!(a.file_dialog_open(), "F12's saveAs request opens the dialog"); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "two.docx".into(), dir: None }); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Backstage File > Save > Save As page > Browse… picks a folder that can't be written to - /// (missing, no permission...): the write fails, but the page previously said nothing about - /// it, leaving the user with a Browse… button and no document, no clue why. The failure now - /// reaches the status bar, and the document (and its unsaved text) stays open regardless. - #[test] - fn a_failed_save_as_says_so_and_keeps_the_document() { - let dir = scratch("write-fails"); - let bogus = dir.join("does-not-exist").join("out.docx"); - let (mut a, shown) = async_app(); - typed(&mut a); - a.run("ui.backstage", json!({"value": true, "page": "saveAs"})).unwrap(); - a.save_as_dialog(); - answer(&shown, Some(&bogus.to_string_lossy())); - let r = a.poll_file_dialog().unwrap().unwrap(); - assert_eq!(r["saved"], false, "the write failed"); - assert!(!bogus.exists()); - assert!(a.ui.backstage, "backstage stays open: nothing was actually saved"); - assert!(a.has_document() && body_text(&a).contains(UNSAVED), "the document and its text are kept"); - let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); - assert!(!msg.is_empty(), "the failure is no longer silent"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// The AutoSave switch's own Save As (clicked while blocked on `Unsaved`, chrome.rs) goes - /// through the same `AfterPick::SaveAs` arm as Backstage's, under `AfterSave::AutoSave` - /// instead of `AfterSave::Nothing`: a write failure there must reach the status bar too - /// (visible here, since the switch only shows while Backstage is closed), and AutoSave must - /// stay off rather than claim coverage it doesn't have (the raw `autosave` flag defaults to - /// `true`; what actually governs coverage is `autosaves()`, the flag plus the block). - #[test] - fn a_failed_autosave_switch_save_as_says_so_and_leaves_autosave_off() { - let dir = scratch("autosave-switch-write-fails"); - let bogus = dir.join("does-not-exist").join("out.docx"); - let (mut a, shown) = async_app(); - typed(&mut a); - assert!(matches!(a.autosave_block(), Some(crate::AutoSaveBlock::Unsaved)), "a new document blocks AutoSave until saved"); - assert!(!a.autosaves(), "blocked: AutoSave isn't covering the document yet"); - a.save_as(AfterSave::AutoSave); - answer(&shown, Some(&bogus.to_string_lossy())); - let r = a.poll_file_dialog().unwrap().unwrap(); - assert_eq!(r["saved"], false, "the write failed"); - assert!(!a.autosaves(), "still blocked: the save that would have lifted it never happened"); - assert!(a.session.path.is_none(), "unsaved: the write never actually landed"); - assert!(a.has_document() && body_text(&a).contains(UNSAVED), "the document and its text are kept"); - let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); - assert!(!msg.is_empty(), "the failure is no longer silent"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// While a dialog is open, more Save As / Open / Export / Picture clicks don't open a second one. - #[test] - fn a_second_dialog_while_one_is_open_is_ignored() { - let (mut a, shown) = async_app(); - a.save_as_dialog(); - a.save_as_dialog(); - a.open_dialog(); - a.export_dialog("pdf"); - a.pick_picture(); - assert_eq!(shown.borrow().len(), 1, "only the first dialog was shown"); - answer(&shown, None); - a.poll_file_dialog(); - assert!(!a.file_dialog_open()); - a.open_dialog(); - assert_eq!(shown.borrow().len(), 1, "a new dialog once the first was answered"); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() }); - } - - /// #2313: Export PDF from the command search (no path given) asks where to save it, as the - /// Backstage's Export button does, instead of failing with "bad parameters: `path` (string) - /// is required" on the status bar. - #[test] - fn export_pdf_without_a_path_asks_where_to_save() { - let (mut a, shown) = async_app(); - let r = a.run("file.exportPdf", json!({})).unwrap(); - assert_eq!(r, json!({"exported": false})); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: format!("{}.pdf", a.title_stem()), dir: None }); - let dir = scratch("export-pdf"); - let picked = dir.join("out.pdf"); - answer(&shown, Some(&picked.to_string_lossy())); - let r = a.poll_file_dialog().unwrap().unwrap(); - assert_eq!(r["path"], json!(picked.to_string_lossy().to_string())); - let bytes = std::fs::read(&picked).unwrap(); - assert!(bytes.starts_with(b"%PDF"), "a PDF is written to the picked path"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Open, Save As and Export PNG from the command search (no path) ask for a file the same way. - #[test] - fn file_commands_without_a_path_ask_for_one() { - let (mut a, shown) = async_app(); - a.run("file.open", json!({})).unwrap(); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() }); - // A second dialog can't open while one is up, so cancel before the next command. - answer(&shown, None); - a.poll_file_dialog(); - a.run("file.saveAs", json!({})).unwrap(); - assert!(matches!(shown.borrow()[0].0, FileDialogRequest::Save { .. })); - answer(&shown, None); - a.poll_file_dialog(); - a.run("file.exportPng", json!({})).unwrap(); - assert!(matches!(shown.borrow()[0].0, FileDialogRequest::Save { ref name, .. } if name.ends_with(".png"))); - } - - /// #1445: "Save changes?" → Save on a new document while another file dialog is still open: - /// nothing can be asked, so nothing happens, and the status bar says why. - #[test] - fn save_changes_while_a_file_dialog_is_open_says_so() { - let (mut a, shown) = async_app(); - typed(&mut a); - a.open_dialog(); - assert_eq!(shown.borrow().len(), 1); - a.run("file.close", json!({})).unwrap(); - a.status_msg = None; - let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - assert_eq!(r["done"], false); - assert!(!a.quit_requested); - assert!(a.session.dirty && body_text(&a).contains(UNSAVED)); - let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); - assert!(msg.contains("file dialog is already open"), "the status bar says why: {msg:?}"); - } - - /// "Save changes?" → Save on a new document: the held-back Close runs only once the later - /// Save As has actually saved; a cancelled Save As cancels it. - #[test] - fn save_changes_waits_for_the_save_as_answer() { - let dir = scratch("prompt"); - let path = dir.join("novel.docx"); - let (mut a, shown) = async_app(); - typed(&mut a); - a.run("file.close", json!({})).unwrap(); - a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - answer(&shown, None); - a.poll_file_dialog(); - assert!(body_text(&a).contains(UNSAVED), "cancelled Save As keeps the document"); - assert!(a.session.dirty); - - a.run("file.close", json!({})).unwrap(); - let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - assert_eq!(r["pending"], "saveAs"); - a.poll_file_dialog(); - assert!(a.has_document(), "nothing closes before the save"); - answer(&shown, Some(&path.to_string_lossy())); - a.poll_file_dialog(); - assert!(!a.has_document(), "closed once saved"); - assert!(saved_text(&path).contains(UNSAVED)); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Desktop (#240 on #94): Use an Existing List… asks the host's file dialog for a recipient - /// list and, once the answer arrives, loads it as the mail-merge recipients, not as a document. - #[test] - fn a_recipient_list_picked_later_loads_recipients() { - let dir = scratch("recipients"); - let csv = dir.join("people.csv"); - std::fs::write(&csv, "First Name,City\nAda,London\nAlan,Wilmslow\n").unwrap(); - let (mut a, shown) = async_app(); - typed(&mut a); - a.run("ui.openRecipientList", json!({})).unwrap(); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "recipients".into() }); - assert!(a.file_dialog_open()); - answer(&shown, Some(&csv.to_string_lossy())); - a.poll_file_dialog().unwrap().unwrap(); - assert_eq!(a.session.merge.headers, vec!["First Name", "City"]); - assert_eq!(a.session.merge.rows.len(), 2); - assert!(body_text(&a).contains(UNSAVED), "the document stays open"); - // Cancelling loads nothing. - a.run("ui.openRecipientList", json!({})).unwrap(); - answer(&shown, None); - assert_eq!(a.poll_file_dialog().unwrap().unwrap(), json!({"cancelled": true})); - assert_eq!(a.session.merge.rows.len(), 2); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #2640 (desktop): Manage Sources › Import… hands the picked Word bibliography file - /// (`Sources.xml`, `b:Sources`) to `references.sources`, and the status says how many - /// sources joined the document. Cancelling imports nothing, and importing the same file - /// twice adds nothing the second time. - #[test] - fn an_imported_sources_file_adds_its_sources() { - let dir = scratch("sources"); - let xml = dir.join("Sources.xml"); - std::fs::write( - &xml, - r#" - Riv21BookShared Spaces2021 -"#, - ) - .unwrap(); - let (mut a, _) = async_app(); - a.finish_pick(AfterPick::Sources, Some(xml.to_string_lossy().to_string())).unwrap(); - assert_eq!(a.session.doc.sources.len(), 1); - assert_eq!(a.session.doc.sources[0].tag, "Riv21"); - assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("Imported 1 source")), "{:?}", a.status_msg); - assert_eq!(a.finish_pick(AfterPick::Sources, None).unwrap(), json!({"cancelled": true})); - a.finish_pick(AfterPick::Sources, Some(xml.to_string_lossy().to_string())).unwrap(); - assert_eq!(a.session.doc.sources.len(), 1, "the tag is already here"); - assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m == "No new sources in that file"), "{:?}", a.status_msg); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A workbook with several sheets (#334) shows Select Table; its answer loads that sheet. - #[test] - fn a_workbook_with_several_sheets_asks_which_one() { - use std::io::Write; - let dir = scratch("workbook"); - let book = dir.join("people.ods"); - let table = |name: &str, cells: &[&str]| { - let cells: String = cells - .iter() - .map(|c| { - format!( - r#"{c}"# - ) - }) - .collect(); - format!(r#"{cells}"#) - }; - let content = format!( - r#"{}{}"#, - table("Notes", &["n"]), - table("Guests", &["Name", "Ada", "Alan"]) - ); - let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for (name, body) in [("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", content.as_str())] { - z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - z.write_all(body.as_bytes()).unwrap(); - } - std::fs::write(&book, z.finish().unwrap().into_inner()).unwrap(); - let (mut a, shown) = async_app(); - a.run("ui.openRecipientList", json!({})).unwrap(); - answer(&shown, Some(&book.to_string_lossy())); - a.poll_file_dialog().unwrap().unwrap(); - let Some(crate::dialogs::Dialog::SelectTable { sheets, params, headers: true, .. }) = &a.dialog else { panic!("Select Table expected") }; - assert_eq!(sheets, &["Notes", "Guests"]); - assert!(a.session.merge.headers.is_empty(), "nothing loads before a sheet is chosen"); - let mut p = params.clone(); - p["sheet"] = json!(2); - a.load_recipients(p).unwrap(); - assert_eq!(a.session.merge.headers, vec!["Name"]); - assert_eq!(a.session.merge.rows.len(), 2); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn load_system_fonts_runs_hook_when_present() { - use std::cell::Cell; - let called = Rc::new(Cell::new(false)); - let called2 = called.clone(); - let services = Services { load_system_fonts: Some(Box::new(move || called2.set(true))), ..Default::default() }; - let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services); - a.run("font.loadSystem", json!({})).unwrap(); - assert!(called.get()); - } - - #[test] - fn load_system_fonts_without_hook_reports_status() { - let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); - a.run("font.loadSystem", json!({})).unwrap(); - assert_eq!(a.status_msg.as_ref().map(|(s, _)| s.as_str()), Some("All system fonts are already available.")); - } - - /// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it - /// is a font the database doesn't have. - fn renamed_font(file: &str, original: &str, family: &str) -> Vec { - assert_eq!(family.len(), original.len()); - let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); - let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); - for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] { - let mut i = 0; - while let Some(at) = data.get(i..).and_then(|d| d.windows(from.len()).position(|w| w == from)) { - data[i + at..i + at + to.len()].copy_from_slice(&to); - i += at + to.len(); - } - } - data - } - - fn font_families(a: &mut WordApp) -> Vec { - let list = a.run("font.list", json!({"all": true})).unwrap(); - list["families"].as_array().unwrap().iter().filter_map(|f| f.as_str().map(str::to_string)).collect() - } - - /// #1789: Add Font asks for several files, and every one picked is added; an unreadable file - /// is reported and the rest still go in. - #[test] - fn add_font_imports_every_selected_file() { - const ALPHA: &str = "Mfpick Alpha 1"; - const BRAVO: &str = "Mfpick Bravo 2"; - let dir = scratch("fonts"); - let alpha = dir.join("MfpickAlpha1-Regular.ttf"); - let junk = dir.join("Junk.ttf"); - let bravo = dir.join("MfpickBravo2-Regular.ttf"); - std::fs::write(&alpha, renamed_font("JetBrainsMono-Regular.ttf", "JetBrains Mono", ALPHA)).unwrap(); - std::fs::write(&junk, b"this is not a font").unwrap(); - std::fs::write(&bravo, renamed_font("JetBrainsMono-Regular.ttf", "JetBrains Mono", BRAVO)).unwrap(); - - let (mut a, shown) = async_app(); - a.pick_font(); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::OpenMany { purpose: "font".into() }, "Add Font is multi-select"); - let paths: Vec = [&alpha, &junk, &bravo].iter().map(|p| p.to_string_lossy().into_owned()).collect(); - answer_all(&shown, &paths.iter().map(String::as_str).collect::>()); - let r = a.poll_file_dialog().unwrap().unwrap(); - assert_eq!(r["added"], 2); - assert_eq!(r["failed"].as_array().map(Vec::len), Some(1)); - let families = font_families(&mut a); - assert!(families.iter().any(|f| f == ALPHA) && families.iter().any(|f| f == BRAVO), "both valid fonts were added"); - let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); - assert_eq!(msg, "Added 2 fonts; 1 couldn't be read: Junk.ttf"); - for f in [ALPHA, BRAVO] { - a.run("font.remove", json!({"family": f})).unwrap(); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// Only Add Font is multi-select; a cancelled pick adds nothing. - #[test] - fn only_add_font_asks_for_several_files() { - let (mut a, shown) = async_app(); - a.open_dialog(); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() }); - answer(&shown, None); - a.poll_file_dialog(); - a.pick_picture(); - assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "picture".into() }); - answer(&shown, None); - a.poll_file_dialog(); - a.pick_font(); - assert!(matches!(shown.borrow()[0].0, FileDialogRequest::OpenMany { .. })); - answer_all(&shown, &[]); - assert_eq!(a.poll_file_dialog().unwrap().unwrap(), json!({"cancelled": true})); - assert!(!a.file_dialog_open()); - } - - /// A hostile selection is bounded: only the first MAX_FONTS_PER_PICK are tried. - #[test] - fn a_huge_font_selection_is_capped() { - let (mut a, _) = async_app(); - let paths: Vec = (0..MAX_FONTS_PER_PICK + 50).map(|i| format!("/nonexistent/f{i}.ttf")).collect(); - let r = a.add_fonts(paths).unwrap(); - assert_eq!(r["added"], 0); - assert_eq!(r["failed"].as_array().map(Vec::len), Some(MAX_FONTS_PER_PICK)); - assert_eq!(r["skipped"], 50); - let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); - assert_eq!(msg, "Added 0 fonts; 2000 couldn't be read: f0.ttf, f1.ttf, f2.ttf and 1997 more; 50 skipped (limit 2000)"); - } - - /// Past three failures the status line names three and counts the rest. - #[test] - fn many_failed_fonts_end_with_a_count() { - let (mut a, _) = async_app(); - let paths: Vec = (0..5).map(|i| format!("/nonexistent/f{i}.ttf")).collect(); - a.add_fonts(paths).unwrap(); - let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); - assert_eq!(msg, "Added 0 fonts; 5 couldn't be read: f0.ttf, f1.ttf, f2.ttf and 2 more"); - } -} +mod tests; diff --git a/crates/ui-egui/src/file_dialogs/tests.rs b/crates/ui-egui/src/file_dialogs/tests.rs new file mode 100644 index 000000000..8acb162d6 --- /dev/null +++ b/crates/ui-egui/src/file_dialogs/tests.rs @@ -0,0 +1,712 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::mpsc::{Sender, channel}; + +use super::*; +use crate::Services; +use wordcraft_engine::Session; + +const UNSAVED: &str = "My unsaved novel chapter"; + +/// Original Word 97 compound file: one compressed text piece, built from MS-DOC's FIB/Clx. +fn legacy_word_bytes() -> Vec { + use std::io::{Cursor, Write}; + let text = b"Legacy contract\r"; + let mut word = vec![0u8; 0x600]; + for (at, value) in [(0, 0xA5ECu16), (2, 0x00C1), (0x0A, 0x1000), (0x0C, 0x00BF), (0x20, 14), (0x3E, 22), (0x98, 0x005D)] { + word[at..at + 2].copy_from_slice(&value.to_le_bytes()); + } + word[0x40..0x44].copy_from_slice(&0x1000u32.to_le_bytes()); + word[0x4C..0x50].copy_from_slice(&(text.len() as u32).to_le_bytes()); + let mut clx = vec![2]; + clx.extend_from_slice(&16u32.to_le_bytes()); + clx.extend_from_slice(&0u32.to_le_bytes()); + clx.extend_from_slice(&(text.len() as u32).to_le_bytes()); + clx.extend_from_slice(&[0, 0]); + clx.extend_from_slice(&(0x4000_0000u32 | (2 * 0x600)).to_le_bytes()); + clx.extend_from_slice(&[0, 0]); + let pair = 0x9A + 33 * 8; + word[pair..pair + 4].copy_from_slice(&0x40u32.to_le_bytes()); + word[pair + 4..pair + 8].copy_from_slice(&(clx.len() as u32).to_le_bytes()); + word.extend_from_slice(text); + let mut table = vec![0; 0x40]; + table.extend_from_slice(&clx); + let mut file = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap(); + for (name, data) in [("/WordDocument", word), ("/0Table", table)] { + file.create_stream(name).unwrap().write_all(&data).unwrap(); + } + file.into_inner().into_inner() +} + +/// #2445: Save on an imported binary Word file asks for DOCX, preserving the import on Cancel. +#[test] +fn imported_word_save_asks_for_docx_and_preserves_the_original() { + for ext in ["doc", "DOT", "DOC", "dot"] { + let dir = scratch(&format!("imported-{ext}")); + let original = dir.join(format!("contract.{ext}")); + let bytes = legacy_word_bytes(); + std::fs::write(&original, &bytes).unwrap(); + let (mut a, shown) = async_app(); + a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap(); + assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Legacy contract")); + a.run("text.insert", json!({"text": "Revised "})).unwrap(); + let draft = a.session.doc.clone(); + let revision = a.session.rev(); + let result = a.run("file.save", json!({})).unwrap(); + assert_eq!(result["pending"], "saveAs"); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None }); + answer(&shown, None); + assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], false); + assert_eq!(a.session.doc, draft); + assert_eq!(a.session.rev(), revision); + assert!(a.session.dirty); + assert_eq!(a.session.path.as_deref(), Some(original.as_path())); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + + a.run("file.save", json!({})).unwrap(); + let saved = dir.join("revised.docx"); + answer(&shown, Some(&saved.to_string_lossy())); + assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], true); + assert!(!a.session.dirty); + assert_eq!(a.session.path.as_deref(), Some(saved.as_path())); + assert!(saved_text(&saved).contains("Revised Legacy contract")); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + // The new supported path saves directly, without another chooser. + a.run("text.insert", json!({"text": "Again "})).unwrap(); + assert_eq!(a.run("file.save", json!({})).unwrap()["saved"], true); + assert!(shown.borrow().is_empty()); + assert!(saved_text(&saved).contains("Again ")); + let _ = std::fs::remove_dir_all(dir); + } +} + +#[test] +fn imported_word_close_waits_for_save_as_and_keeps_failed_or_cancelled_drafts() { + let dir = scratch("imported-close"); + let original = dir.join("contract.doc"); + let bytes = legacy_word_bytes(); + std::fs::write(&original, &bytes).unwrap(); + let (mut a, shown) = async_app(); + a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap(); + a.run("text.insert", json!({"text": "Revised "})).unwrap(); + let draft = a.session.doc.clone(); + + a.run("file.close", json!({})).unwrap(); + assert_eq!(a.run("ui.saveChanges", json!({"answer": "save"})).unwrap()["pending"], "saveAs"); + assert!(a.has_document() && !a.quit_requested); + answer(&shown, Some(&dir.join("missing").join("contract.docx").to_string_lossy())); + let error = a.poll_file_dialog().unwrap().unwrap_err(); + assert!(error.contains("still open"), "{error}"); + assert!(matches!(a.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. }))); + assert!(a.has_document() && a.session.dirty && !a.quit_requested); + assert_eq!(a.session.path.as_deref(), Some(original.as_path())); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), draft.plain_text(wordcraft_doc::StoryRef::Body)); + + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + answer(&shown, None); + assert_eq!(a.poll_file_dialog().unwrap().unwrap()["done"], false); + assert!(a.has_document() && a.session.dirty && !a.quit_requested); + assert_eq!(a.session.path.as_deref(), Some(original.as_path())); + + a.run("file.close", json!({})).unwrap(); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + let saved = dir.join("contract.docx"); + answer(&shown, Some(&saved.to_string_lossy())); + assert_eq!(a.poll_file_dialog().unwrap().unwrap()["done"], true); + // File › Close keeps the window (#1712): the document closes once the DOCX save succeeds. + assert!(!a.has_document() && !a.quit_requested, "Close runs only after the DOCX save succeeds"); + assert!(saved_text(&saved).contains("Revised Legacy contract")); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + let _ = std::fs::remove_dir_all(dir); +} + +/// A late Save As answer belongs to the imported document that opened the picker: once that +/// document is closed, it writes nothing. +#[test] +fn imported_word_stale_save_as_never_writes_the_replacement_document() { + for close in [false, true] { + let dir = scratch(if close { "imported-stale-close" } else { "imported-stale-save" }); + let original = dir.join("contract.doc"); + let bytes = legacy_word_bytes(); + std::fs::write(&original, &bytes).unwrap(); + let destination = dir.join("existing.docx"); + let sentinel = b"Existing destination must survive"; + std::fs::write(&destination, sentinel).unwrap(); + let (mut a, shown) = async_app(); + a.run("file.open", json!({"path": original})).unwrap(); + typed(&mut a); + if close { + a.run("file.close", json!({})).unwrap(); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + } else { + a.run("file.save", json!({})).unwrap(); + } + assert!(a.file_dialog_open()); + let previous = a.session.document_id(); + // Automation bypasses the user prompt and can close a document during a picker. + a.execute("file.closeDocument", json!({"force": true})).unwrap(); + typed(&mut a); + assert_ne!(previous, a.session.document_id()); + let draft = a.session.doc.clone(); + let revision = a.session.rev(); + answer(&shown, Some(&destination.to_string_lossy())); + let _ = a.poll_file_dialog().unwrap(); + assert!(std::fs::read(&destination).unwrap() == sentinel, "stale picker overwrote the destination"); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + assert_eq!(a.session.doc, draft); + assert_eq!(a.session.rev(), revision); + assert!(a.session.dirty && a.session.path.is_none() && !a.quit_requested); + } +} + +#[test] +fn imported_word_programmatic_and_explicit_saves_never_open_dialogs() { + let dir = scratch("imported-explicit"); + let original = dir.join("contract.doc"); + let bytes = legacy_word_bytes(); + std::fs::write(&original, &bytes).unwrap(); + let (mut a, shown) = async_app(); + a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap(); + a.run("text.insert", json!({"text": "Revised "})).unwrap(); + let error = a.execute("file.save", json!({})).unwrap_err(); + assert!(error.contains("can't save as `.doc`"), "{error}"); + let error = a.run("file.save", json!({"path": original.to_string_lossy()})).unwrap_err(); + assert!(error.contains("can't save as `.doc`"), "{error}"); + assert!(shown.borrow().is_empty()); + assert!(a.session.dirty); + assert_eq!(a.session.path.as_deref(), Some(original.as_path())); + assert_eq!(std::fs::read(&original).unwrap(), bytes); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn imported_word_save_controls_ask_for_docx() { + use egui_kittest::kittest::Queryable; + let dir = scratch("imported-entry-points"); + let original = dir.join("contract.doc"); + let bytes = legacy_word_bytes(); + std::fs::write(&original, &bytes).unwrap(); + let (mut a, shown) = async_app(); + a.run("ui.language", json!({"value": "en"})).unwrap(); + a.run("file.open", json!({"path": original})).unwrap(); + typed(&mut a); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + h.get_by_label("Save").click(); + h.run_steps(3); + assert!(h.state().file_dialog_open(), "the Save button asks for a supported target"); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None }); + answer(&shown, None); + h.run_steps(3); + + h.state_mut().run("ui.backstage", json!({"value": false})).unwrap(); + h.state_mut().canvas.want_focus = true; + h.run_steps(6); + assert!(h.state().canvas.focused, "Mod+S starts in the editor"); + h.event(egui::Event::Key { key: egui::Key::S, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.run_steps(3); + assert!(h.state().file_dialog_open(), "the focused editor's Mod+S asks too"); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None }); + answer(&shown, None); + h.run_steps(3); + assert!(h.state().session.dirty); + assert_eq!(h.state().session.path.as_deref(), Some(original.as_path())); + assert_eq!(std::fs::read(&original).unwrap(), bytes); +} + +/// Dialogs the fake host was asked to show, each with the channel its answer goes through. +type Shown = Rc)>>>; + +/// An app whose host shows dialogs asynchronously: nothing is answered until the test does. +fn async_app() -> (WordApp, Shown) { + let shown: Shown = Rc::default(); + let log = shown.clone(); + let services = Services { + file_dialog: Some(Box::new(move |req| { + let (tx, rx) = channel(); + log.borrow_mut().push((req, tx)); + rx + })), + ..Default::default() + }; + (WordApp::new(Session::new(wordcraft_doc::Document::new()), services), shown) +} + +/// Answer the dialog the host is showing (the last one asked for). +fn answer(shown: &Shown, picked: Option<&str>) { + answer_all(shown, picked.as_slice()); +} + +/// Answer the dialog with several picked paths (none: cancelled). +fn answer_all(shown: &Shown, picked: &[&str]) { + let (_, tx) = shown.borrow_mut().pop().unwrap(); + tx.send(Ok(picked.iter().map(|p| p.to_string()).collect())).unwrap(); +} + +fn typed(a: &mut WordApp) { + a.run("text.insert", json!({"text": UNSAVED})).unwrap(); + assert!(a.session.dirty); +} + +fn body_text(a: &WordApp) -> String { + a.session.doc.plain_text(wordcraft_doc::StoryRef::Body) +} + +fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wordcraft-ui-dialogs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn saved_text(path: &std::path::Path) -> String { + wordcraft_engine::io::open_path(path).unwrap().plain_text(wordcraft_doc::StoryRef::Body) +} + +/// Save as Word Template (#2852) suggests a .dotx in the user templates folder, which is made +/// on first use; the picked path is saved as a template. A folder named in File › Options +/// replaces the app's own. +#[test] +fn save_as_template_starts_in_the_templates_folder() { + let dir = scratch("save-template"); + let own = dir.join("Templates"); + let (mut a, shown) = async_app(); + a.services.templates_dir = Some(own.clone()); + typed(&mut a); + let r = a.run("ui.saveAsTemplate", json!({})).unwrap(); + assert!(own.is_dir(), "the app's templates folder is made on first use"); + assert_eq!(r["dir"], json!(own.to_string_lossy())); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.dotx".into(), dir: Some(own.to_string_lossy().into_owned()) }); + let path = own.join("Memo.dotx"); + answer(&shown, Some(&path.to_string_lossy())); + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); + assert!(path.is_file(), "saved as a template"); + assert_eq!(saved_text(&path), UNSAVED); + let listed = wordcraft_engine::cmd::user_templates::list_templates(&own.to_string_lossy()).unwrap(); + assert_eq!(listed.templates.iter().map(|t| t.label.as_str()).collect::>(), ["Memo"]); + + // A folder chosen in Options wins. + let chosen = dir.join("Chosen"); + std::fs::create_dir_all(&chosen).unwrap(); + a.run("file.options", json!({"userTemplatesFolder": chosen.to_string_lossy()})).unwrap(); + assert_eq!(a.user_templates_dir(), Some(chosen.clone())); + a.run("ui.saveAsTemplate", json!({})).unwrap(); + assert!(matches!(&shown.borrow()[0].0, FileDialogRequest::Save { name, dir: Some(d) } if name == "Memo.dotx" && *d == chosen.to_string_lossy())); + answer(&shown, None); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Save As returns at once (the UI keeps running); the frame loop saves once the answer arrives. +#[test] +fn save_as_answer_arriving_later_is_saved() { + let dir = scratch("later"); + let path = dir.join("novel.docx"); + let (mut a, shown) = async_app(); + typed(&mut a); + a.save_as_dialog(); + assert!(a.file_dialog_open()); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into(), dir: None }); + let ctx = egui::Context::default(); + let frame = |a: &mut WordApp| ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); + frame(&mut a); + assert!(a.session.dirty, "nothing saved before the user answers"); + answer(&shown, Some(&path.to_string_lossy())); + frame(&mut a); + assert!(!a.file_dialog_open()); + assert!(!a.session.dirty); + assert!(saved_text(&path).contains(UNSAVED)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #689: Save As honours the filename typed in the dialog, not just the folder: a document +/// opened as `a/one.docx` and saved as `b/two.docx` is written there and takes that path. +#[test] +fn save_as_with_a_new_filename_saves_under_it() { + let dir = scratch("rename"); + let opened = dir.join("one.docx"); + let picked = dir.join("sub").join("two.docx"); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("original")) + .run("file.save", &json!({"path": opened.to_string_lossy()})) + .unwrap(); + let (mut a, shown) = async_app(); + a.run("file.open", json!({"path": opened.to_string_lossy()})).unwrap(); + assert_eq!(a.session.path.as_deref(), Some(opened.as_path())); + a.save_as_dialog(); + answer(&shown, Some(&picked.to_string_lossy())); + let r = a.poll_file_dialog().unwrap().unwrap(); + assert_eq!(r, json!({"saved": true}), "the save-as succeeded"); + assert_eq!(a.session.path.as_deref(), Some(picked.as_path()), "the picked filename is the document's path"); + assert!(picked.is_file(), "the file is written under the picked name"); + assert!(saved_text(&picked).contains("original")); + // F12 (file.saveAs without a path) lands in the same dialog, suggesting the new name. + let r = a.run("file.saveAs", json!({})).unwrap(); + assert_eq!(r, json!({"saved": false})); + assert!(a.file_dialog_open(), "F12's saveAs request opens the dialog"); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "two.docx".into(), dir: None }); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Backstage File > Save > Save As page > Browse… picks a folder that can't be written to +/// (missing, no permission...): the write fails, but the page previously said nothing about +/// it, leaving the user with a Browse… button and no document, no clue why. The failure now +/// reaches the status bar, and the document (and its unsaved text) stays open regardless. +#[test] +fn a_failed_save_as_says_so_and_keeps_the_document() { + let dir = scratch("write-fails"); + let bogus = dir.join("does-not-exist").join("out.docx"); + let (mut a, shown) = async_app(); + typed(&mut a); + a.run("ui.backstage", json!({"value": true, "page": "saveAs"})).unwrap(); + a.save_as_dialog(); + answer(&shown, Some(&bogus.to_string_lossy())); + let r = a.poll_file_dialog().unwrap().unwrap(); + assert_eq!(r["saved"], false, "the write failed"); + assert!(!bogus.exists()); + assert!(a.ui.backstage, "backstage stays open: nothing was actually saved"); + assert!(a.has_document() && body_text(&a).contains(UNSAVED), "the document and its text are kept"); + let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(!msg.is_empty(), "the failure is no longer silent"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The AutoSave switch's own Save As (clicked while blocked on `Unsaved`, chrome.rs) goes +/// through the same `AfterPick::SaveAs` arm as Backstage's, under `AfterSave::AutoSave` +/// instead of `AfterSave::Nothing`: a write failure there must reach the status bar too +/// (visible here, since the switch only shows while Backstage is closed), and AutoSave must +/// stay off rather than claim coverage it doesn't have (the raw `autosave` flag defaults to +/// `true`; what actually governs coverage is `autosaves()`, the flag plus the block). +#[test] +fn a_failed_autosave_switch_save_as_says_so_and_leaves_autosave_off() { + let dir = scratch("autosave-switch-write-fails"); + let bogus = dir.join("does-not-exist").join("out.docx"); + let (mut a, shown) = async_app(); + typed(&mut a); + assert!(matches!(a.autosave_block(), Some(crate::AutoSaveBlock::Unsaved)), "a new document blocks AutoSave until saved"); + assert!(!a.autosaves(), "blocked: AutoSave isn't covering the document yet"); + a.save_as(AfterSave::AutoSave); + answer(&shown, Some(&bogus.to_string_lossy())); + let r = a.poll_file_dialog().unwrap().unwrap(); + assert_eq!(r["saved"], false, "the write failed"); + assert!(!a.autosaves(), "still blocked: the save that would have lifted it never happened"); + assert!(a.session.path.is_none(), "unsaved: the write never actually landed"); + assert!(a.has_document() && body_text(&a).contains(UNSAVED), "the document and its text are kept"); + let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(!msg.is_empty(), "the failure is no longer silent"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// While a dialog is open, more Save As / Open / Export / Picture clicks don't open a second one. +#[test] +fn a_second_dialog_while_one_is_open_is_ignored() { + let (mut a, shown) = async_app(); + a.save_as_dialog(); + a.save_as_dialog(); + a.open_dialog(); + a.export_dialog("pdf"); + a.pick_picture(); + assert_eq!(shown.borrow().len(), 1, "only the first dialog was shown"); + answer(&shown, None); + a.poll_file_dialog(); + assert!(!a.file_dialog_open()); + a.open_dialog(); + assert_eq!(shown.borrow().len(), 1, "a new dialog once the first was answered"); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() }); +} + +/// #2313: Export PDF from the command search (no path given) asks where to save it, as the +/// Backstage's Export button does, instead of failing with "bad parameters: `path` (string) +/// is required" on the status bar. +#[test] +fn export_pdf_without_a_path_asks_where_to_save() { + let (mut a, shown) = async_app(); + let r = a.run("file.exportPdf", json!({})).unwrap(); + assert_eq!(r, json!({"exported": false})); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: format!("{}.pdf", a.title_stem()), dir: None }); + let dir = scratch("export-pdf"); + let picked = dir.join("out.pdf"); + answer(&shown, Some(&picked.to_string_lossy())); + let r = a.poll_file_dialog().unwrap().unwrap(); + assert_eq!(r["path"], json!(picked.to_string_lossy().to_string())); + let bytes = std::fs::read(&picked).unwrap(); + assert!(bytes.starts_with(b"%PDF"), "a PDF is written to the picked path"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Open, Save As and Export PNG from the command search (no path) ask for a file the same way. +#[test] +fn file_commands_without_a_path_ask_for_one() { + let (mut a, shown) = async_app(); + a.run("file.open", json!({})).unwrap(); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() }); + // A second dialog can't open while one is up, so cancel before the next command. + answer(&shown, None); + a.poll_file_dialog(); + a.run("file.saveAs", json!({})).unwrap(); + assert!(matches!(shown.borrow()[0].0, FileDialogRequest::Save { .. })); + answer(&shown, None); + a.poll_file_dialog(); + a.run("file.exportPng", json!({})).unwrap(); + assert!(matches!(shown.borrow()[0].0, FileDialogRequest::Save { ref name, .. } if name.ends_with(".png"))); +} + +/// #1445: "Save changes?" → Save on a new document while another file dialog is still open: +/// nothing can be asked, so nothing happens, and the status bar says why. +#[test] +fn save_changes_while_a_file_dialog_is_open_says_so() { + let (mut a, shown) = async_app(); + typed(&mut a); + a.open_dialog(); + assert_eq!(shown.borrow().len(), 1); + a.run("file.close", json!({})).unwrap(); + a.status_msg = None; + let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + assert_eq!(r["done"], false); + assert!(!a.quit_requested); + assert!(a.session.dirty && body_text(&a).contains(UNSAVED)); + let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(msg.contains("file dialog is already open"), "the status bar says why: {msg:?}"); +} + +/// "Save changes?" → Save on a new document: the held-back Close runs only once the later +/// Save As has actually saved; a cancelled Save As cancels it. +#[test] +fn save_changes_waits_for_the_save_as_answer() { + let dir = scratch("prompt"); + let path = dir.join("novel.docx"); + let (mut a, shown) = async_app(); + typed(&mut a); + a.run("file.close", json!({})).unwrap(); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + answer(&shown, None); + a.poll_file_dialog(); + assert!(body_text(&a).contains(UNSAVED), "cancelled Save As keeps the document"); + assert!(a.session.dirty); + + a.run("file.close", json!({})).unwrap(); + let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + assert_eq!(r["pending"], "saveAs"); + a.poll_file_dialog(); + assert!(a.has_document(), "nothing closes before the save"); + answer(&shown, Some(&path.to_string_lossy())); + a.poll_file_dialog(); + assert!(!a.has_document(), "closed once saved"); + assert!(saved_text(&path).contains(UNSAVED)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Desktop (#240 on #94): Use an Existing List… asks the host's file dialog for a recipient +/// list and, once the answer arrives, loads it as the mail-merge recipients, not as a document. +#[test] +fn a_recipient_list_picked_later_loads_recipients() { + let dir = scratch("recipients"); + let csv = dir.join("people.csv"); + std::fs::write(&csv, "First Name,City\nAda,London\nAlan,Wilmslow\n").unwrap(); + let (mut a, shown) = async_app(); + typed(&mut a); + a.run("ui.openRecipientList", json!({})).unwrap(); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "recipients".into() }); + assert!(a.file_dialog_open()); + answer(&shown, Some(&csv.to_string_lossy())); + a.poll_file_dialog().unwrap().unwrap(); + assert_eq!(a.session.merge.headers, vec!["First Name", "City"]); + assert_eq!(a.session.merge.rows.len(), 2); + assert!(body_text(&a).contains(UNSAVED), "the document stays open"); + // Cancelling loads nothing. + a.run("ui.openRecipientList", json!({})).unwrap(); + answer(&shown, None); + assert_eq!(a.poll_file_dialog().unwrap().unwrap(), json!({"cancelled": true})); + assert_eq!(a.session.merge.rows.len(), 2); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #2640 (desktop): Manage Sources › Import… hands the picked Word bibliography file +/// (`Sources.xml`, `b:Sources`) to `references.sources`, and the status says how many +/// sources joined the document. Cancelling imports nothing, and importing the same file +/// twice adds nothing the second time. +#[test] +fn an_imported_sources_file_adds_its_sources() { + let dir = scratch("sources"); + let xml = dir.join("Sources.xml"); + std::fs::write( + &xml, + r#" + Riv21BookShared Spaces2021 +"#, + ) + .unwrap(); + let (mut a, _) = async_app(); + a.finish_pick(AfterPick::Sources, Some(xml.to_string_lossy().to_string())).unwrap(); + assert_eq!(a.session.doc.sources.len(), 1); + assert_eq!(a.session.doc.sources[0].tag, "Riv21"); + assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("Imported 1 source")), "{:?}", a.status_msg); + assert_eq!(a.finish_pick(AfterPick::Sources, None).unwrap(), json!({"cancelled": true})); + a.finish_pick(AfterPick::Sources, Some(xml.to_string_lossy().to_string())).unwrap(); + assert_eq!(a.session.doc.sources.len(), 1, "the tag is already here"); + assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m == "No new sources in that file"), "{:?}", a.status_msg); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A workbook with several sheets (#334) shows Select Table; its answer loads that sheet. +#[test] +fn a_workbook_with_several_sheets_asks_which_one() { + use std::io::Write; + let dir = scratch("workbook"); + let book = dir.join("people.ods"); + let table = |name: &str, cells: &[&str]| { + let cells: String = cells + .iter() + .map(|c| { + format!(r#"{c}"#) + }) + .collect(); + format!(r#"{cells}"#) + }; + let content = format!( + r#"{}{}"#, + table("Notes", &["n"]), + table("Guests", &["Name", "Ada", "Alan"]) + ); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, body) in [("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", content.as_str())] { + z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(body.as_bytes()).unwrap(); + } + std::fs::write(&book, z.finish().unwrap().into_inner()).unwrap(); + let (mut a, shown) = async_app(); + a.run("ui.openRecipientList", json!({})).unwrap(); + answer(&shown, Some(&book.to_string_lossy())); + a.poll_file_dialog().unwrap().unwrap(); + let Some(crate::dialogs::Dialog::SelectTable { sheets, params, headers: true, .. }) = &a.dialog else { panic!("Select Table expected") }; + assert_eq!(sheets, &["Notes", "Guests"]); + assert!(a.session.merge.headers.is_empty(), "nothing loads before a sheet is chosen"); + let mut p = params.clone(); + p["sheet"] = json!(2); + a.load_recipients(p).unwrap(); + assert_eq!(a.session.merge.headers, vec!["Name"]); + assert_eq!(a.session.merge.rows.len(), 2); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn load_system_fonts_runs_hook_when_present() { + use std::cell::Cell; + let called = Rc::new(Cell::new(false)); + let called2 = called.clone(); + let services = Services { load_system_fonts: Some(Box::new(move || called2.set(true))), ..Default::default() }; + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services); + a.run("font.loadSystem", json!({})).unwrap(); + assert!(called.get()); +} + +#[test] +fn load_system_fonts_without_hook_reports_status() { + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("font.loadSystem", json!({})).unwrap(); + assert_eq!(a.status_msg.as_ref().map(|(s, _)| s.as_str()), Some("All system fonts are already available.")); +} + +/// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it +/// is a font the database doesn't have. +fn renamed_font(file: &str, original: &str, family: &str) -> Vec { + assert_eq!(family.len(), original.len()); + let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] { + let mut i = 0; + while let Some(at) = data.get(i..).and_then(|d| d.windows(from.len()).position(|w| w == from)) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +fn font_families(a: &mut WordApp) -> Vec { + let list = a.run("font.list", json!({"all": true})).unwrap(); + list["families"].as_array().unwrap().iter().filter_map(|f| f.as_str().map(str::to_string)).collect() +} + +/// #1789: Add Font asks for several files, and every one picked is added; an unreadable file +/// is reported and the rest still go in. +#[test] +fn add_font_imports_every_selected_file() { + const ALPHA: &str = "Mfpick Alpha 1"; + const BRAVO: &str = "Mfpick Bravo 2"; + let dir = scratch("fonts"); + let alpha = dir.join("MfpickAlpha1-Regular.ttf"); + let junk = dir.join("Junk.ttf"); + let bravo = dir.join("MfpickBravo2-Regular.ttf"); + std::fs::write(&alpha, renamed_font("JetBrainsMono-Regular.ttf", "JetBrains Mono", ALPHA)).unwrap(); + std::fs::write(&junk, b"this is not a font").unwrap(); + std::fs::write(&bravo, renamed_font("JetBrainsMono-Regular.ttf", "JetBrains Mono", BRAVO)).unwrap(); + + let (mut a, shown) = async_app(); + a.pick_font(); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::OpenMany { purpose: "font".into() }, "Add Font is multi-select"); + let paths: Vec = [&alpha, &junk, &bravo].iter().map(|p| p.to_string_lossy().into_owned()).collect(); + answer_all(&shown, &paths.iter().map(String::as_str).collect::>()); + let r = a.poll_file_dialog().unwrap().unwrap(); + assert_eq!(r["added"], 2); + assert_eq!(r["failed"].as_array().map(Vec::len), Some(1)); + let families = font_families(&mut a); + assert!(families.iter().any(|f| f == ALPHA) && families.iter().any(|f| f == BRAVO), "both valid fonts were added"); + let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert_eq!(msg, "Added 2 fonts; 1 couldn't be read: Junk.ttf"); + for f in [ALPHA, BRAVO] { + a.run("font.remove", json!({"family": f})).unwrap(); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Only Add Font is multi-select; a cancelled pick adds nothing. +#[test] +fn only_add_font_asks_for_several_files() { + let (mut a, shown) = async_app(); + a.open_dialog(); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() }); + answer(&shown, None); + a.poll_file_dialog(); + a.pick_picture(); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "picture".into() }); + answer(&shown, None); + a.poll_file_dialog(); + a.pick_font(); + assert!(matches!(shown.borrow()[0].0, FileDialogRequest::OpenMany { .. })); + answer_all(&shown, &[]); + assert_eq!(a.poll_file_dialog().unwrap().unwrap(), json!({"cancelled": true})); + assert!(!a.file_dialog_open()); +} + +/// A hostile selection is bounded: only the first MAX_FONTS_PER_PICK are tried. +#[test] +fn a_huge_font_selection_is_capped() { + let (mut a, _) = async_app(); + let paths: Vec = (0..MAX_FONTS_PER_PICK + 50).map(|i| format!("/nonexistent/f{i}.ttf")).collect(); + let r = a.add_fonts(paths).unwrap(); + assert_eq!(r["added"], 0); + assert_eq!(r["failed"].as_array().map(Vec::len), Some(MAX_FONTS_PER_PICK)); + assert_eq!(r["skipped"], 50); + let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert_eq!(msg, "Added 0 fonts; 2000 couldn't be read: f0.ttf, f1.ttf, f2.ttf and 1997 more; 50 skipped (limit 2000)"); +} + +/// Past three failures the status line names three and counts the rest. +#[test] +fn many_failed_fonts_end_with_a_count() { + let (mut a, _) = async_app(); + let paths: Vec = (0..5).map(|i| format!("/nonexistent/f{i}.ttf")).collect(); + a.add_fonts(paths).unwrap(); + let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert_eq!(msg, "Added 0 fonts; 5 couldn't be read: f0.ttf, f1.ttf, f2.ttf and 2 more"); +}