// A copy of craft-proof's hand-written Czech fixture (`crates/proof/src/cs/test_dict.rs` in
// craft-libs, which a dependency's tests can't share); its install helper resolves this parent import.
use wordcraft_proof::cs::install_dictionary;
mod czech_test_dict;
use serde_json::json;
use wordcraft_doc::props::BorderStyle;
use wordcraft_doc::{Pos, StoryRef};
use crate::{Session, cmd};
mod bidi;
mod documents;
mod font_sizes;
mod fonts;
mod ime_noop;
mod revision_checks;
mod typing;
mod wordart;
/// True, after saying so on stderr, when this build lacks the Language data the proofing tests
/// need (craft-proof built without `CRAFT_I18N_DIR`). The probes are craft-proof's public
/// `english_source` (the embedded English dictionary) and its American/British variant table
/// (`colour` → `color`): a craft-i18n checkout without some of the files embeds only the rest,
/// so both are asked. Without them English still spells from craft-proof's built-in word list,
/// but there are no hyphenation patterns and no variant table, so the tests that check those
/// return early, as craft-proof's own do; with the data (CI, releases) they all run.
pub(crate) fn no_language_data(test: &str) -> bool {
let missing = wordcraft_proof::english_source(wordcraft_proof::ProofLang::En) != Some(wordcraft_proof::EnglishSource::Embedded)
|| wordcraft_proof::en_variants::preferred("colour", wordcraft_proof::ProofLang::En).as_deref() != Some("color");
if missing {
eprintln!("{test}: skipped, needs the Language data (set CRAFT_I18N_DIR to a craft-i18n checkout)");
}
missing
}
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn text(s: &Session) -> String {
s.doc.plain_text(StoryRef::Body)
}
#[test]
fn doc_extension_dispatches_to_docbin() {
// Garbage bytes with a .doc name must produce an error through the Word 97-2003
// reader — never a panic and never a silently empty document.
for name in ["x.doc", "x.dot"] {
assert!(crate::io::open_bytes(name, &[0u8; 64]).is_err(), "{name} should fail");
}
}
#[test]
fn encrypted_doc_asks_for_a_password_and_rejects_a_wrong_one() {
// #1079: a Word 97-2003 file with the encryption flag and an RC4 header whose verifier does
// not match any password asks for one, and a given password is reported as wrong.
use std::io::Write;
let mut fib = vec![0u8; 0x100];
fib[0..2].copy_from_slice(&0xA5ECu16.to_le_bytes());
fib[0x0A..0x0C].copy_from_slice(&0x0100u16.to_le_bytes());
let mut header = vec![1u8, 0, 1, 0];
header.extend_from_slice(&[0x5A; 48]);
let mut comp = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap();
for (name, data) in [("WordDocument", &fib), ("0Table", &header)] {
comp.create_stream(format!("/{name}")).unwrap().write_all(data).unwrap();
}
let bytes = comp.into_inner().into_inner();
for name in ["x.doc", "x.dot"] {
let e = crate::io::open_bytes_with(name, &bytes, None).unwrap_err();
assert!(crate::io::needs_password(&e), "{e}");
let e = crate::io::open_bytes_with(name, &bytes, Some("nope")).unwrap_err();
assert!(crate::io::wrong_password(&e), "{e}");
}
// Opened by content (no extension) it asks the same way.
let e = crate::io::open_bytes_with("noext", &bytes, None).unwrap_err();
assert!(crate::io::needs_password(&e), "{e}");
}
/// Gap 9: AES-encrypted Word files — Standard encryption (Word 2007) and agile packages — open
/// with the right password whatever Word extension they carry, and ask for one or refuse a wrong
/// one the same way.
#[test]
fn aes_encrypted_packages_open_under_any_word_extension() {
let mut doc = wordcraft_doc::Document::from_text("Old secret minutes");
doc.core.title = "Minutes".into();
let pkg = wordcraft_docx::write(&doc).unwrap();
let standard = wordcraft_docx::encrypt_standard_for_tests(&pkg, "letmein", 128).unwrap();
let agile = wordcraft_docx::encrypt_with_spin_count(&pkg, "letmein", 8).unwrap();
for (kind, bytes) in [("standard", &standard), ("agile", &agile)] {
for name in ["old.doc", "old.docx", "old.dot", "noext"] {
let (d, encrypted) = crate::io::open_bytes_with(name, bytes, Some("letmein")).unwrap_or_else(|e| panic!("{kind} {name}: {e}"));
assert!(encrypted, "{kind} {name}");
assert!(d.plain_text(StoryRef::Body).contains("Old secret minutes"), "{kind} {name}");
assert_eq!(d.core.title, "Minutes");
let e = crate::io::open_bytes_with(name, bytes, None).unwrap_err();
assert!(crate::io::needs_password(&e), "{kind} {name}: {e}");
let e = crate::io::open_bytes_with(name, bytes, Some("letmeout")).unwrap_err();
assert!(crate::io::wrong_password(&e), "{kind} {name}: {e}");
}
}
}
#[test]
fn txt_open_decodes_windows_1252_and_unmarked_utf16() {
// #433: the TXT open path sniffs encodings like the formats importer.
let open = |b: &[u8]| crate::io::open_bytes("x.txt", b).unwrap().plain_text(StoryRef::Body);
assert_eq!(open(b"\x80"), "€");
assert_eq!(open(b"A\0B\0"), "AB");
assert_eq!(open(b"\0A\0B"), "AB");
assert_eq!(open("€".as_bytes()), "€");
assert_eq!(open(b"\xFF\xFEA\0B\0"), "AB");
assert_eq!(open(b"A\0B"), "AB", "odd byte count stays safe");
assert_eq!(crate::io::decode_text(b"caf\xE9 \x80"), "café €");
}
#[test]
fn txt_save_writes_list_labels() {
// #434: list paragraphs keep their bullet or number and a tab in plain text.
let doc = crate::io::open_bytes("l.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
assert_eq!(crate::io::save_bytes("l.txt", &doc).unwrap(), "•\talpha\r\n•\tbeta\r\n1.\tfirst\r\n2.\tsecond".as_bytes());
let plain = crate::io::open_bytes("p.md", b"alpha\n\nbeta\n").unwrap();
assert_eq!(crate::io::save_bytes("p.txt", &plain).unwrap(), b"alpha\r\nbeta");
}
#[test]
fn every_command_has_unique_id() {
let reg = cmd::registry();
let mut ids: Vec<&str> = reg.all().iter().map(|c| c.id).collect();
let n = ids.len();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), n, "duplicate command ids");
assert!(n > 150, "{n} commands");
}
#[test]
fn every_shortcut_runs_one_command() {
// `by_shortcut` takes the first match, so a shared key silently shadows the later command (#186).
let reg = cmd::registry();
let mut seen = std::collections::HashMap::new();
for c in reg.all() {
for k in c.shortcut.split(" / ").filter(|k| !k.is_empty()) {
if let Some(other) = seen.insert(k.to_ascii_lowercase(), c.id) {
panic!("{k} is bound to both {other} and {}", c.id);
}
}
}
assert_eq!(reg.by_shortcut("Shift+F3").map(|c| c.id), Some("format.changeCase"));
}
/// The command a shortcut runs.
fn id(k: &str) -> Option<&'static str> {
cmd::registry().by_shortcut(k).map(|c| c.id)
}
#[test]
fn word_default_shortcuts_resolve() {
// #330: Word's documented default shortcuts reach the matching commands, with Word for
// Mac's keys where macOS takes Word's (⌘Q quits, ⌘Space is Spotlight, ⌘⇧G is Find Previous).
assert_eq!(id("Mod+Alt+1"), Some("para.heading1"));
assert_eq!(id("Mod+Alt+P"), Some("view.printLayout"));
assert_eq!(id("Mod+Alt+O"), Some("view.outline"));
assert_eq!(id("Mod+Alt+N"), Some("view.draft"));
assert_eq!(id("Mod+Alt+S"), Some("view.split"));
assert_eq!(id("Mod+Alt+-"), Some("text.emDash"));
assert_eq!(id("Mod+Shift+F5"), Some("insert.bookmark"));
assert_eq!(id("Alt+F7"), Some("review.spelling"));
assert_eq!(id("Shift+F12"), Some("file.save"));
assert_eq!(id(cmd::mac_or("Ctrl+Q", "Mod+Q")), Some("para.reset"));
assert_eq!(id(cmd::mac_or("Ctrl+Space", "Mod+Space")), Some("format.resetChar"));
assert_eq!(id(cmd::mac_or("Ctrl+Shift+D", "Alt+Shift+D")), Some("insert.dateField"));
// Option+arrows move by word everywhere; ⌘←/⌘→ go to the line's ends on macOS (#954).
assert_eq!(id("Alt+Left"), Some("caret.wordLeft"));
assert_eq!(id("Alt+Right"), Some("caret.wordRight"));
assert_eq!(id("Mod+Left"), Some(cmd::mac_or("caret.home", "caret.wordLeft")));
assert_eq!(id("Mod+Right"), Some(cmd::mac_or("caret.end", "caret.wordRight")));
assert_eq!(id("Shift+F1"), Some("styles.inspector"));
assert_eq!(id("Alt+F8"), Some("tools.macros"));
assert_eq!(id("Mod+Shift+X"), Some("format.strikethrough"));
assert_eq!(id("Shift+F4"), Some("edit.findNext"));
assert_eq!(id("Mod+Insert"), Some("edit.copy"));
assert_eq!(id("Shift+Insert"), Some("edit.paste"));
assert_eq!(id("Mod+Alt+Z"), Some("edit.goBack"));
assert_eq!(id("Mod+Alt+F2"), Some("file.open"));
assert_eq!(id("Alt+Shift+F2"), Some("file.save"));
assert_eq!(id("Mod+Alt+I"), Some("file.print"));
assert_eq!(id("Alt+Shift+F1"), Some("fields.previous"));
}
#[test]
fn word_default_shortcuts_resolve_per_platform() {
if cfg!(target_os = "macos") {
assert_eq!(id("Mod+Shift+G"), Some("edit.findPrevious"));
// Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E endnote (⌘⌥D hides the Dock), ⌘⌥A comment, ⌃H Replace.
assert_eq!(id("Mod+Shift+S"), Some("file.saveAs"));
assert_eq!(id("Mod+Alt+E"), Some("references.endnote"));
assert_eq!(id("Mod+Alt+D"), None);
assert_eq!(id("Mod+Alt+A"), Some("review.newComment"));
assert_eq!(id("Ctrl+H"), Some("edit.findReplace"));
assert_eq!(id("Mod+Alt+L"), Some("review.spelling"));
} else {
assert_eq!(id("Mod+Shift+S"), Some("styles.apply"));
assert_eq!(id("Mod+Alt+D"), Some("references.endnote"));
assert_eq!(id("Mod+Alt+A"), None);
assert_eq!(id("Mod+Shift+G"), Some("review.wordCount"));
assert_eq!(id("Mod+Alt+C"), Some("text.copyright"));
assert_eq!(id("Mod+Alt+H"), Some("format.highlight"));
assert_eq!(id("Alt+Shift+L"), Some("outline.firstLineOnly"));
assert_eq!(id("Alt+Shift+>"), Some("references.nextFootnote"));
assert_eq!(id("Alt+Shift+."), Some("references.nextFootnote"));
assert_eq!(id("Alt+Shift+R"), Some("layout.linkToPrevious"));
assert_eq!(id("Alt+F3"), Some("insert.autoText"));
assert_eq!(id("Alt+F2"), Some("file.saveAs"));
assert_eq!(id("Alt+F1"), Some("fields.next"));
}
}
#[test]
fn word_default_shortcuts_resolve_function_keys() {
assert_eq!(id("Mod+PageUp"), Some("edit.findPrevious"));
// The Spike: Word's Ctrl+F3 and Ctrl+Shift+F3 (⌘F3 and ⌘⇧F3 in Word for Mac).
assert_eq!(id("Mod+F3"), Some("edit.spike"));
assert_eq!(id("Mod+Shift+F3"), Some("edit.insertSpike"));
assert_eq!(id("F3"), Some("edit.findNext"));
assert_eq!(id("Shift+F3"), Some("format.changeCase"));
assert_eq!(id("F12"), Some("file.saveAs"));
assert_eq!(id("Mod+Alt+M"), Some("review.newComment"));
assert_eq!(id("Mod+H"), Some("edit.findReplace"));
}
#[test]
fn reset_shortcuts_remove_direct_formatting_but_keep_styles() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello world"}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "para.indent", json!({}));
run(&mut s, "para.alignCenter", json!({}));
run(&mut s, "para.reset", json!({}));
let props = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.clone();
assert_eq!(props.style.as_deref(), Some("Heading1"), "Ctrl+Q keeps the paragraph style");
assert_eq!((props.indent_left, props.align), (None, None), "Ctrl+Q drops direct paragraph formatting");
run(&mut s, "select.all", json!({}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "format.resetChar", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props_of_char(1).bold, None, "Ctrl+Space drops direct character formatting");
assert_eq!(p.props.style.as_deref(), Some("Heading1"), "Ctrl+Space leaves the paragraph alone");
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "text.emDash", json!({}));
assert_eq!(text(&s), "Hello world—");
}
#[test]
fn custom_dictionary_words_persist_through_their_file() {
// #40: adding a word through a command writes it to the session's file, and loading
// that file makes the word known (only the desktop app wires this up).
let dir = std::env::temp_dir().join(format!("wc-engdict-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("custom-dictionary.txt");
let _ = std::fs::remove_file(&path);
let mut s = s();
s.user_dict_path = Some(path.clone());
run(&mut s, "review.addToDictionary", json!({"word": "zxqengpersist"}));
let saved = std::fs::read_to_string(&path).unwrap_or_default();
assert!(saved.lines().any(|l| l == "zxqengpersist"), "the word reaches the file: {saved:?}");
run(&mut s, "file.options", json!({"removeWords": ["zxqengpersist"]}));
let saved = std::fs::read_to_string(&path).unwrap_or_default();
assert!(!saved.lines().any(|l| l == "zxqengpersist"), "removal reaches the file too: {saved:?}");
// Ignore All is for this session only, as in Word: the word is accepted, never saved.
assert!(!wordcraft_proof::is_correct("zxqengignored"));
run(&mut s, "review.ignoreAll", json!({"word": "zxqengignored"}));
assert!(wordcraft_proof::is_correct("zxqengignored"));
assert!(!wordcraft_proof::user_dictionary().contains(&"zxqengignored".to_string()));
let saved = std::fs::read_to_string(&path).unwrap_or_default();
assert!(!saved.contains("zxqengignored"), "Ignore All doesn't reach the file: {saved:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots() {
// #3737 (findings 34 and WC-35): the custom dictionary file and the folder settings are
// held to the automation roots like any other path.
let base = scratch_dir("dict-roots");
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap();
let mut s = s();
let outside = base.join("outside/custom-dictionary.txt");
s.user_dict_path = Some(outside.clone());
let confined = |s: &mut Session, cmd: &str, v: serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(cmd, &v));
assert!(confined(&mut s, "review.addToDictionary", json!({"word": "zxqengconfined"})).is_ok());
assert!(wordcraft_proof::user_dictionary().contains(&"zxqengconfined".to_string()), "kept in memory");
assert!(!outside.exists(), "nothing written outside the write root");
for key in ["dictionariesFolder", "userTemplatesFolder"] {
assert!(confined(&mut s, "file.options", json!({key: base.join("outside").to_string_lossy()})).is_err(), "{key}");
}
assert_eq!((s.prefs.dictionaries_folder.as_deref(), s.prefs.user_templates_folder.as_deref()), (None, None));
// A folder inside the read root is still taken.
let inside = base.join("in");
assert!(confined(&mut s, "file.options", json!({"userTemplatesFolder": inside.to_string_lossy()})).is_ok());
let _ = std::fs::remove_dir_all(&base);
assert_eq!(s.prefs.user_templates_folder.as_deref(), Some(inside.to_string_lossy().as_ref()));
}
#[test]
fn add_to_dictionary_takes_only_words_the_file_keeps() {
// #40: the same rule as loading the dictionary file, so nothing added is dropped on the next load.
let mut s = s();
for bad in ["two words", "tab\tword", "bell\u{7}", &"x".repeat(65)] {
assert!(s.run("review.addToDictionary", &json!({"word": bad})).is_err(), "{bad:?}");
assert!(!wordcraft_proof::user_dictionary().iter().any(|w| w == &bad.to_lowercase()));
}
run(&mut s, "review.addToDictionary", json!({"word": " zxqengtrimmed "}));
assert!(wordcraft_proof::user_dictionary().contains(&"zxqengtrimmed".to_string()));
}
#[test]
fn non_english_runs_are_not_proofed() {
// #100: runs tagged with a proofing language that has no dictionary are skipped
// by both proofing paths; English still is proofed.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "ąžuolas labas"}));
let before = run(&mut s, "review.issues", json!({}));
assert!(!before.as_array().map(|a| a.is_empty()).unwrap_or(true), "default language flags the words: {before}");
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "lt-LT"}));
let after = run(&mut s, "review.issues", json!({}));
assert_eq!(after, json!([]), "Lithuanian runs are skipped: {after}");
// English typos are still caught.
let mut e = Session::new(wordcraft_doc::Document::new());
run(&mut e, "text.insert", json!({"text": "Hello helo world"}));
let en = run(&mut e, "review.issues", json!({}));
assert_eq!(en.as_array().map(|a| a.len()).unwrap_or(0), 1, "English typo still flagged: {en}");
}
#[test]
fn typing_enter_undo() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "text.insert", json!({"text": " world"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Second"}));
assert_eq!(text(&s), "Hello world\nSecond");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "Hello world\n");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "Hello world");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "");
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), "Hello world");
}
/// A command that changes nothing is not an edit (#23): no undo step, no unsaved changes
/// and no relayout.
#[test]
fn a_command_that_changes_nothing_is_not_an_edit() {
let mut s = s();
let rev = s.rev();
// Backspace at the start of the document: there is nothing to delete.
run(&mut s, "text.backspace", json!({}));
assert_eq!(s.undo_depth(), 0, "no undo step");
assert!(!s.dirty, "no unsaved changes");
assert_eq!(s.rev(), rev, "no relayout");
// Typing nothing is the same.
run(&mut s, "text.insert", json!({"text": ""}));
assert_eq!(s.undo_depth(), 0, "no undo step");
assert!(!s.dirty, "no unsaved changes");
// A command that changes something is an edit.
run(&mut s, "text.insert", json!({"text": "Hello"}));
assert_eq!(s.undo_depth(), 1, "one undo step");
assert!(s.dirty, "the document is edited");
// Setting the alignment a paragraph already has changes nothing.
let depth = s.undo_depth();
run(&mut s, "para.align", json!({"value": "center"}));
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
let depth = s.undo_depth();
run(&mut s, "para.align", json!({"value": "center"}));
assert_eq!(s.undo_depth(), depth, "no undo step");
// A command that changes nothing leaves the unsaved-changes flag and the layout revision as
// they were, even when it calls `touch` itself: a saved document is not marked edited for
// nothing, and its layout is not laid out again.
let mut clean = Session::new(wordcraft_doc::Document::new());
run(&mut clean, "para.align", json!({"value": "left"}));
clean.dirty = false;
let rev = clean.rev();
run(&mut clean, "para.align", json!({"value": "left"}));
assert!(!clean.dirty, "no unsaved changes");
assert_eq!(clean.rev(), rev, "no relayout");
assert_eq!(clean.undo_depth(), 1, "only the first align is a step");
}
/// Five hundred commands that change nothing used to fill the whole undo stack and push the
/// user's own edits out of it, so they could never be undone again (#23).
#[test]
fn no_op_commands_do_not_evict_the_undo_history() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "caret.docStart", json!({}));
for _ in 0..600 {
run(&mut s, "text.backspace", json!({}));
}
assert_eq!(s.undo_depth(), 1, "the typing step is still there");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "", "the typed text is still undoable");
}
/// A command that changes nothing does not throw the redo stack away (#23).
#[test]
fn a_no_op_command_keeps_the_redo_stack() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "edit.undo", json!({}));
assert!(s.can_redo());
run(&mut s, "text.backspace", json!({}));
assert!(s.can_redo(), "the redo stack survives a command that changed nothing");
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), "Hello");
}
/// Undo and Redo move between steps even when a step's document is the one showing: they are
/// never taken back as commands that changed nothing, so Undo can't get stuck on such a step.
#[test]
fn undo_moves_past_a_step_that_changed_nothing() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
s.checkpoint("Nothing");
let depth = s.undo_depth();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.undo_depth(), depth - 1);
assert!(s.can_redo());
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.undo_depth(), depth);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "");
}
/// Commands that run as one undo step (`join_next_undo`: a dialog's OK, the frames of a drag):
/// a group whose first command changes nothing has no step to merge into, so the next command
/// in it starts the group's step instead of merging into an older edit (#23 follow-up).
#[test]
fn a_joined_command_starts_its_group_when_the_first_changed_nothing() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
let (depth, margins) = (s.undo_depth(), (s.doc.last_section.margin_left, s.doc.last_section.margin_top));
// Portrait on a portrait page changes nothing.
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
assert_eq!(s.undo_depth(), depth, "the first command of the group is no edit");
// The next command of the group starts the step the group needs.
s.join_next_undo();
run(&mut s, "layout.margins", json!({"left": 100.0}));
assert_eq!(s.doc.last_section.margin_left, 100.0);
assert_eq!(s.undo_depth(), depth + 1, "the group's one step");
// A command that changes nothing in the middle of the group leaves that step alone, and
// the next one merges into it.
s.join_next_undo();
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
s.join_next_undo();
run(&mut s, "layout.margins", json!({"top": 60.0}));
assert_eq!(s.undo_depth(), depth + 1, "still the group's one step");
// Undo takes the whole group back and no more: the typing is still there.
run(&mut s, "edit.undo", json!({}));
assert_eq!((s.doc.last_section.margin_left, s.doc.last_section.margin_top), margins, "the margins the group started from");
assert_eq!(text(&s), "Hello");
}
/// Bold with nothing selected arms the formatting for the next word: the document is the
/// same, so only the caret's formatting changed — and that is an edit (#23).
#[test]
fn a_command_that_only_arms_the_caret_formatting_is_an_edit() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "caret.docStart", json!({}));
let depth = s.undo_depth();
run(&mut s, "format.bold", json!({}));
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
// Undo takes the armed formatting back, as Word does (docs/typing-parity.md).
run(&mut s, "edit.undo", json!({}));
run(&mut s, "text.insert", json!({"text": "x"}));
assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(0).bold), Some(None));
}
/// The Undo button's list (#701): several steps undone at once, newest first.
#[test]
fn undo_to_takes_several_steps_back_at_once() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "World"}));
let h = run(&mut s, "edit.undoHistory", json!({}));
assert_eq!(h["undo"], json!(["Typing", "New Paragraph", "Bold", "Typing"]));
assert_eq!(h["depth"], 4);
assert_eq!(s.undo_history(), ["Typing", "New Paragraph", "Bold", "Typing"]);
// Three steps in one command: World, the paragraph and the bold go; Hello stays.
let r = run(&mut s, "edit.undoTo", json!({"count": 3}));
assert_eq!(r["undone"], 3);
assert_eq!(r["depth"], 1);
assert_eq!(text(&s), "Hello");
assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(1).bold), Some(None));
assert_eq!(s.undo_history(), ["Typing"]);
// Each undone step is one redo step.
run(&mut s, "edit.redo", json!({}));
run(&mut s, "edit.redo", json!({}));
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), "Hello\nWorld");
// More than the depth undoes everything.
let r = run(&mut s, "edit.undoTo", json!({"count": 1e300}));
assert_eq!(r["undone"], 4);
assert_eq!(text(&s), "");
assert!(!s.can_undo());
assert!(s.run("edit.undoTo", &json!({"count": 1})).is_err(), "nothing to undo");
}
#[test]
fn undo_to_rejects_bad_counts_and_leaves_the_document_alone() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "text.newParagraph", json!({}));
let before = (s.doc.clone(), s.undo_depth());
for junk in
[json!({}), json!(null), json!({"count": 0}), json!({"count": -3}), json!({"count": "2"}), json!({"count": [2]}), json!({"count": -1e308})]
{
assert!(s.run("edit.undoTo", &junk).is_err(), "{junk}");
assert_eq!((s.doc.clone(), s.undo_depth()), before, "{junk}");
}
// A fraction counts whole steps.
let r = run(&mut s, "edit.undoTo", json!({"count": 1.9}));
assert_eq!(r["undone"], 1);
assert_eq!(text(&s), "Hello");
}
#[test]
fn undo_history_is_capped() {
let mut s = s();
for i in 0..(crate::UNDO_LIST_MAX + 20) {
run(&mut s, "para.indents", json!({"left": i as f64}));
}
assert_eq!(s.undo_history().len(), crate::UNDO_LIST_MAX);
assert_eq!(run(&mut s, "edit.undoHistory", json!({}))["undo"].as_array().map(Vec::len), Some(crate::UNDO_LIST_MAX));
}
#[test]
fn joined_commands_are_one_undo_step() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "para.indents", json!({"left": 9.0}));
run(&mut s, "para.indents", json!({"left": 18.0}));
for x in [27.0, 36.0] {
s.join_next_undo();
run(&mut s, "para.indents", json!({"left": x}));
}
let indent = |s: &Session| s.doc.para_at(&s.sel.focus).and_then(|p| p.props.indent_left);
assert_eq!(indent(&s), Some(36.0));
// One undo reverts the whole second drag, not just its last frame.
run(&mut s, "edit.undo", json!({}));
assert_eq!(indent(&s), Some(9.0));
run(&mut s, "edit.undo", json!({}));
assert_eq!(indent(&s), None);
assert_eq!(text(&s), "Hello");
run(&mut s, "edit.redo", json!({}));
run(&mut s, "edit.redo", json!({}));
assert_eq!(indent(&s), Some(36.0));
}
#[test]
fn restore_takes_a_command_back_exactly() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "alpha beta"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "gamma"}));
run(&mut s, "edit.undo", json!({}));
assert!(s.can_redo());
let (doc, sel, labels, depth, rev) = (s.doc.clone(), s.sel.clone(), s.undo_labels(), s.undo_depth(), s.rev());
s.dirty = false;
let snap = s.edit_snapshot();
assert_eq!(snap.doc(), &doc);
assert_eq!(snap.undo_depth(), depth);
assert!(!snap.dirty());
run(&mut s, "select.all", json!({}));
run(&mut s, "format.bold", json!({}));
assert_ne!(s.doc, doc);
assert_eq!(s.undo_depth(), depth + 1);
assert!(!s.can_redo(), "a new command clears redo");
s.restore(snap);
assert_eq!(s.doc, doc);
assert_eq!(s.sel, sel);
assert_eq!(s.undo_labels(), labels, "the command's undo step is gone");
assert!(s.can_redo(), "redo is back");
assert!(!s.dirty);
assert!(s.rev() > rev, "the layout is recomputed");
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), "alpha beta\ngamma");
}
#[test]
fn restore_is_exact_at_the_undo_limit() {
// 510 steps, each with its own indent: the stack holds the newest 500.
let build = || {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "alpha"}));
for i in 0..510 {
run(&mut s, "para.indents", json!({"left": f64::from(i)}));
}
s
};
// What undo walks through: (label, indent) for every step, newest first.
let walk = |s: &mut Session| {
let mut steps = Vec::new();
while let Some(label) = s.undo_label().map(str::to_string) {
s.undo();
steps.push((label, s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.indent_left)));
}
steps
};
let expected = walk(&mut build());
assert_eq!(expected.len(), 500);
// One command, and several (each pushes the oldest step out of the full stack).
for n in [1, 2, 4] {
let mut s = build();
let snap = s.edit_snapshot();
for _ in 0..n {
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
}
assert_eq!(s.undo_label(), Some("Table"));
assert_eq!(s.undo_depth(), 500);
s.restore(snap);
assert_eq!(walk(&mut s), expected, "{n} command(s): the oldest steps are back, the commands' steps are gone");
}
// A command that fails at the limit puts its eviction back with the stacks, so a later
// restore doesn't bring back a step that never left.
let mut s = build();
let snap = s.edit_snapshot();
assert!(s.run("para.align", &json!({"value": "bogus"})).is_err());
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
s.restore(snap);
assert_eq!(walk(&mut s), expected, "a failed command between snapshot and restore");
// An automatic change recorded after the fact (`push_undo`) at the limit counts its eviction.
let mut s = build();
let snap = s.edit_snapshot();
let (doc, sel) = (s.doc.clone(), s.sel.clone());
s.push_undo("AutoCorrect", doc, sel);
s.restore(snap);
assert_eq!(walk(&mut s), expected, "push_undo between snapshot and restore");
}
#[test]
fn restore_keeps_the_open_undo_step() {
// Typing in progress: text typed after the restore joins the same undo step.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "alpha"}));
let depth = s.undo_depth();
let snap = s.edit_snapshot();
run(&mut s, "para.alignCenter", json!({}));
s.restore(snap);
run(&mut s, "text.insert", json!({"text": " beta"}));
assert_eq!(s.undo_depth(), depth, "still one typing step");
// A drag in progress (`join_next_undo`): its next frame joins the same undo step.
run(&mut s, "para.indents", json!({"left": 9.0}));
s.join_next_undo();
let depth = s.undo_depth();
let snap = s.edit_snapshot();
run(&mut s, "para.indents", json!({"left": 18.0}));
s.restore(snap);
run(&mut s, "para.indents", json!({"left": 27.0}));
assert_eq!(s.undo_depth(), depth, "still one drag step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.indent_left), None);
}
#[test]
fn failed_command_keeps_undo_and_redo() {
// A command that fails after its undo checkpoint leaves the history exactly as it was:
// the redo stack survives, and no empty undo step is added.
for (id, params) in [("text.insert", json!({})), ("para.align", json!({"value": "bogus"}))] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "edit.undo", json!({}));
assert!(s.can_redo());
assert!(s.run(id, ¶ms).is_err(), "{id} should fail");
assert!(s.can_redo(), "{id}: redo lost");
assert!(!s.can_undo(), "{id}: failed command left an undo step");
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), "Hello", "{id}");
}
// At the undo limit, the oldest step is not evicted by a command that fails.
let mut s = s();
let steps = 600;
for i in 1..=steps {
run(&mut s, "para.indents", json!({"left": i as f32}));
}
let kept = s.undo_labels().len();
assert!(kept < steps, "the history should be at its limit");
assert!(s.run("para.align", &json!({"value": "bogus"})).is_err());
assert_eq!(s.undo_labels().len(), kept);
while s.can_undo() {
run(&mut s, "edit.undo", json!({}));
}
let indent = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.indent_left);
assert_eq!(indent, Some((steps - kept) as f32));
}
#[test]
fn failed_command_restores_history_changed_by_nested_commands() {
// `file.inspect` runs `review.deleteComment` (allowed in a comments-only document, and it
// checkpoints), then `review.acceptAll` (refused). The refusal must leave the undo and
// redo stacks exactly as they were, including at and next to the history limit, where the
// outer and nested checkpoints each evict the oldest step.
fn timeline(s: &mut Session) -> Vec<(Vec, wordcraft_doc::Document)> {
let mut out = Vec::new();
// Straight through the history (the commands won't undo past Restrict Editing).
while s.can_redo() {
s.redo();
}
loop {
// Comments are stamped with the time to the second, and the two sessions compared
// are built a moment apart, so leave the stamp out of the comparison.
let mut doc = s.doc.clone();
doc.comments.values_mut().for_each(|c| {
c.date.clear();
c.date_utc = None;
});
out.push((s.undo_labels(), doc));
if !s.can_undo() {
return out;
}
s.undo();
}
}
let kept = {
let mut s = s();
for i in 1..=600 {
run(&mut s, "para.indents", json!({"left": i as f32}));
}
s.undo_labels().len()
};
// Below the limit there is room for a redo step too; at the limit an Undo would leave one free.
for (len, redo) in [(kept - 1, Some("New Comment")), (kept, None)] {
let session = || {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "note"}));
for i in 1..=len + usize::from(redo.is_some()) - 4 {
run(&mut s, "para.indents", json!({"left": i as f32}));
}
run(&mut s, "review.restrict", json!({"mode": "comments"}));
run(&mut s, "review.newComment", json!({"text": "one"}));
run(&mut s, "review.newComment", json!({"text": "two"}));
if redo.is_some() {
run(&mut s, "edit.undo", json!({}));
}
assert_eq!(s.undo_labels().len(), len);
assert_eq!(s.redo_label(), redo);
s
};
let mut failed = session();
let err = failed.run("file.inspect", &json!({"remove": ["comments", "revisions"]}));
assert!(matches!(err, Err(crate::CmdError::Disabled(_))), "{len}: {err:?}");
assert_eq!(failed.undo_labels().len(), len, "{len}: undo steps");
assert_eq!(failed.redo_label(), redo, "{len}: redo step");
assert!(timeline(&mut failed) == timeline(&mut session()), "{len}: undo history changed");
}
}
#[test]
fn backspace_and_delete() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "abc"}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(text(&s), "ab");
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "cd"}));
run(&mut s, "caret.home", json!({}));
run(&mut s, "text.backspace", json!({}));
// Enter capitalised the first word, as Word's AutoCorrect does.
assert_eq!(text(&s), "Abcd");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "text.delete", json!({}));
assert_eq!(text(&s), "bcd");
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "text.deleteWordBack", json!({}));
assert_eq!(text(&s), "");
}
/// Backspace at the start of the paragraph after a table nested in a cell goes into the nested
/// table's last cell, not into the last cell of the table around it.
#[test]
fn backspace_after_a_nested_table_stays_in_its_cell() {
let mut s = s();
run(&mut s, "table.quick", json!({"kind": "matrix"}));
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 0}}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let outer = s.doc.body.get(1).and_then(|b| b.as_table()).expect("the outer table");
let blocks = &outer.rows[1].cells[1].blocks;
let nested = blocks.iter().position(|b| b.as_table().is_some()).expect("a nested table");
assert!(nested + 1 < blocks.len(), "a paragraph follows the nested table");
let after = nested as u32 + 1;
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, after], "off": 0}}));
run(&mut s, "text.backspace", json!({}));
// The nested table's last cell: row 1, cell 1, its first paragraph.
assert_eq!(s.sel.focus.path.0, vec![1, 1, 1, after - 1, 1, 1, 0]);
}
#[test]
fn bold_toggles_selection_and_caret_word() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "make this bold"}));
run(&mut s, "select.text", json!({"text": "this"}));
run(&mut s, "format.bold", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props_of_char(5).bold, Some(true));
assert_eq!(p.props_of_char(0).bold, None);
run(&mut s, "format.bold", json!({}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).bold, Some(false));
// Caret inside a word formats the word.
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [0], "off": 11}}));
run(&mut s, "format.italic", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props_of_char(10).italic, Some(true));
assert_eq!(p.props_of_char(13).italic, Some(true));
assert_eq!(p.props_of_char(9).italic, None);
}
#[test]
fn border_toggles_and_saves() {
use wordcraft_doc::props::Border;
let border = |s: &Session| s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).border;
// Saved .docx carries the border iff it is on (read back: the reader maps only `w:bdr` to `border`).
let docx_has_bdr = |s: &Session| {
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
back.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).border.is_some()
};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "make this boxed"}));
run(&mut s, "select.text", json!({"text": "this"}));
run(&mut s, "format.border", json!({}));
assert_eq!(border(&s), Some(Border::single(0.5)));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).border, None);
assert!(docx_has_bdr(&s));
run(&mut s, "format.border", json!({}));
assert_eq!(border(&s), None);
run(&mut s, "format.border", json!({"value": true}));
run(&mut s, "format.border", json!({"value": true}));
assert_eq!(border(&s), Some(Border::single(0.5)));
run(&mut s, "format.border", json!({"value": false}));
assert_eq!(border(&s), None);
assert!(!docx_has_bdr(&s));
}
#[test]
fn pending_format_applies_to_typing() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "a "}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "text.insert", json!({"text": "b"}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props_of_char(2).bold, Some(true));
assert_eq!(p.props_of_char(0).bold, None);
}
#[test]
fn styles_and_lists() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Title"}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "text.newParagraph", json!({}));
// Heading's next style is Normal.
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props.style.as_deref(), Some("Normal"));
run(&mut s, "para.bullets", json!({}));
run(&mut s, "text.insert", json!({"text": "one"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "two"}));
let n1 = s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.unwrap();
let n2 = s.doc.para_at(&Pos::body(2, 0)).unwrap().props.numbering.unwrap();
assert_eq!(n1.num, n2.num);
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.newParagraph", json!({})); // empty item ends the list
assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_none_or(|n| n.num == 0));
// "1. " autoformat.
run(&mut s, "text.insert", json!({"text": "1."}));
run(&mut s, "text.insert", json!({"text": " "}));
assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_some_and(|n| n.num != 0));
}
#[test]
fn applying_a_heading_clears_list_numbering() {
// Applying a heading (or Title/Normal) to a numbered paragraph removes its direct list
// numbering. A dead `&& id == "Normal"` term used to let only Normal clear it, so headings
// kept the numbering.
for style_cmd in ["para.heading1", "para.heading2", "para.normal"] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "item"}));
run(&mut s, "para.bullets", json!({}));
assert!(
s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0),
"{style_cmd}: expected list numbering before applying the style"
);
run(&mut s, style_cmd, json!({}));
assert!(
s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_none_or(|n| n.num == 0),
"{style_cmd}: should clear direct list numbering"
);
}
}
#[test]
fn find_replace() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "cat dog cat\nCat bird"}));
let r = run(&mut s, "edit.find", json!({"text": "cat"}));
assert_eq!(r["count"], 3);
let r = run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": "fox", "matchCase": true}));
assert_eq!(r["replaced"], 2);
assert_eq!(text(&s), "fox dog fox\nCat bird");
let r = run(&mut s, "edit.find", json!({"text": "\\b\\w{3}\\b", "regex": true, "matchCase": false}));
assert_eq!(r["count"], 4);
}
#[test]
fn find_replace_special_codes() {
// ^p finds paragraph marks across paragraphs; Replace All with ^p splits paragraphs and is one undo step.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "one\ntwo\nthree"}));
let paras = |s: &Session| s.doc.para_paths(StoryRef::Body).len();
assert_eq!(run(&mut s, "edit.find", json!({"text": "^p"}))["count"], 2);
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "o^pt", "with": "O+T"}))["replaced"], 1);
assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwO+Three", 2));
run(&mut s, "edit.undo", json!({}));
assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwo\nthree", 3));
run(&mut s, "document.setText", json!({"text": "a, b, c"}));
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": ", ", "with": "^p"}))["replaced"], 2);
assert_eq!((text(&s).as_str(), paras(&s)), ("a\nb\nc", 3));
run(&mut s, "edit.undo", json!({}));
assert_eq!((text(&s).as_str(), paras(&s)), ("a, b, c", 1));
// ^t, ^# and ^& (the found text), ^^ is a caret.
run(&mut s, "document.setText", json!({"text": "x\ty 12 a^b"}));
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^t", "with": " "}))["replaced"], 1);
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^#", "with": "<^&>"}))["replaced"], 2);
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^^", "with": "-"}))["replaced"], 1);
assert_eq!(text(&s), "x y <1><2> a-b");
// `special: false` reads the text literally.
assert_eq!(run(&mut s, "edit.find", json!({"text": "^#", "special": false}))["count"], 0);
}
#[test]
fn find_replace_wildcards() {
let mut s = s();
// Groups and back-references swap words; Use wildcards matches case.
run(&mut s, "document.setText", json!({"text": "Smith, John and Doe, Jane; smith, x"}));
let r = run(&mut s, "edit.replaceAll", json!({"text": "([A-Z][a-z]@), ([A-Z][a-z]@)", "with": "\\2 \\1", "wildcards": true}));
assert_eq!(r["replaced"], 2);
assert_eq!(text(&s), "John Smith and Jane Doe; smith, x");
// < and > anchor to word starts and ends; * is the shortest run; {n} counts.
run(&mut s, "document.setText", json!({"text": "cat concat cats scatter"}));
assert_eq!(run(&mut s, "edit.find", json!({"text": "", "wildcards": true}))["count"], 2);
assert_eq!(run(&mut s, "edit.find", json!({"text": "", "wildcards": true}))["count"], 2);
assert_eq!(run(&mut s, "edit.find", json!({"text": "t{2}", "wildcards": true}))["count"], 1);
assert_eq!(run(&mut s, "edit.find", json!({"text": "[!c]at", "wildcards": true}))["count"], 0);
assert_eq!(run(&mut s, "edit.find", json!({"text": "[!s ]cat", "wildcards": true}))["count"], 1);
// Options of the normal search: prefix, suffix, ignore punctuation.
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "wildcards": false, "matchPrefix": true}))["count"], 2);
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "matchPrefix": false, "matchSuffix": true}))["count"], 2);
run(&mut s, "document.setText", json!({"text": "e.g. eg"}));
assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2);
}
#[test]
fn regex_replace_expands_groups() {
// #4177: with Regular expressions on, Replace with expands capture groups.
let rep = |text: &str, q: &str, with: &str| rep_mode(text, q, with, json!({"regex": true}));
let names = "Smith, John and Doe, Jane";
// The issue's cases: `\n` and `$n` swap words; `${name}` names a group.
assert_eq!(rep(names, r"(\w+), (\w+)", r"\2 \1"), "John Smith and Jane Doe");
assert_eq!(rep(names, r"(\w+), (\w+)", "$2 $1"), "John Smith and Jane Doe");
assert_eq!(rep("2024-05", r"(?\d{4})-", "${y}/"), "2024/05");
// Braced numbers, `$0` / `${0}` (whole match), `$$` (a dollar sign).
assert_eq!(rep("ab", "(a)(b)", "${2}${1}"), "ba");
assert_eq!(rep("ab", "a", "[$0|${0}]"), "[a|a]b");
assert_eq!(rep("5", r"(\d)", "$$$1"), "$5");
// `$name` takes the longest run of letters, digits and `_` (the regex crate's rule).
assert_eq!(rep("ab", "(?a)", "$x_"), "b");
assert_eq!(rep("ab", "(?a)", "${x}_"), "a_b");
assert_eq!(rep("ab", "(a)", "$1a"), "b");
// Missing, unknown and out-of-range groups expand to nothing.
assert_eq!(rep("ab", "(a)", "<$2>"), "<>b");
assert_eq!(rep("ab", "(a)", r"<\9>"), "<>b");
assert_eq!(rep("ab", "(a)", "<${nope}>"), "<>b");
assert_eq!(rep("ab", "(a)", "<$99999999999999999999999>"), "<>b");
assert_eq!(rep("ab", "(a)|(z)", "<$2>"), "<>b");
// A `$` that starts no group, and an unclosed brace, stay as written.
assert_eq!(rep("ab", "a", "$ ${x"), "$ ${xb");
// `\t` a tab, `\\` a backslash; other escapes stay as written.
assert_eq!(rep("ab", "a", r"\t\\\q"), "\t\\\\qb");
// Word's `^` codes still work.
assert_eq!(rep("ab", "a", "^&^t^&"), "a\tab");
// `\$` and `\^` keep the character as typed (CodeRabbit on #4177).
assert_eq!(rep("ab", "(a)", r"\$1\^p"), "$1^pb");
// `special: false` turns off the `^` codes only; groups still expand (CodeRabbit on #4177).
assert_eq!(rep_mode("ab", "(a)", "$1^t", json!({"regex": true, "special": false})), "a^tb");
// `\n` is a paragraph mark: it splits the paragraph, as `^p` does, in one undo step.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "one, two"}));
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": r"(\w+), (\w+)", "with": r"$1\n$2", "regex": true}))["replaced"], 1);
assert_eq!(text(&s), "one\ntwo");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "one, two");
run(&mut s, "document.setText", json!({"text": "a-b"}));
run(&mut s, "edit.replaceAll", json!({"text": "-", "with": "^p", "regex": true}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
// Plain and wildcard Replace are unchanged: `$1` is literal there.
assert_eq!(rep_mode("ab", "a", "$1", json!({})), "$1b");
assert_eq!(rep_mode("ab", "(a)", "$1\\1", json!({"wildcards": true})), "$1ab");
// Hostile Replace text never panics.
run(&mut s, "document.setText", json!({"text": "ab ab"}));
for with in ["$", "${", "${}", "${0", "$\u{e9}", "\\", "${\u{1F600}}", "$18446744073709551616", "${-1}", "$$$", "\\0\\n\\", "^"] {
let _ = s.run("edit.replaceAll", &json!({"text": "(a)(?b)?", "with": with, "regex": true}));
}
}
/// The body text after Replace All of `q` with `with` in `text`, matching case, with `extra` options.
fn rep_mode(text: &str, q: &str, with: &str, extra: serde_json::Value) -> String {
let mut s = s();
run(&mut s, "document.setText", json!({"text": text}));
let mut v = json!({"text": q, "with": with, "matchCase": true});
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
run(&mut s, "edit.replaceAll", v);
self::text(&s)
}
#[test]
fn hostile_find_patterns_are_bounded() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": &"a".repeat(5000)}));
// Patterns that backtrack catastrophically elsewhere finish quickly (linear-time engine).
let t = std::time::Instant::now();
for q in ["(a@)@b", "(*)@b", "(a{1,1000}){1,1000}", "(((((a*)*)*)*)*)b"] {
let _ = s.run("edit.find", &json!({"text": q, "wildcards": true}));
}
assert!(t.elapsed().as_secs() < 20);
// Bad and oversized patterns are errors, not crashes.
for q in ["[z-a]", "a{5", "(ab", "\\1", "x{2000}", "[", "@"] {
assert!(s.run("edit.find", &json!({"text": q, "wildcards": true})).is_err(), "{q}");
}
assert!(s.run("edit.find", &json!({"text": "x".repeat(2000), "wildcards": false})).is_err());
for q in ["^", "^p^", "^u", "^99999999", "^0", "a^"] {
let _ = s.run("edit.replaceAll", &json!({"text": q, "with": "^&^c\\9^p^", "wildcards": false}));
}
}
#[test]
fn replace_under_track_changes_skips_deleted_text() {
// Replace marks the match deleted and leaves it in the text; the next
// step must not find it again, or Replace never advances.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "one cat two cat"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "caret.docStart", json!({}));
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 2);
let mut remaining = Vec::new();
for _ in 0..4 {
remaining.push(run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"}))["remaining"].clone());
}
assert_eq!(remaining, [json!(1), json!(0), json!(0), json!(0)]);
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 0);
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(text(&s), "one dog two dog");
// A match that runs across tracked-deleted text is not in the document.
let mut s = self::s();
run(&mut s, "document.setText", json!({"text": "cat"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 1}, "focus": {"block": 0, "off": 2}}));
run(&mut s, "text.delete", json!({}));
assert_eq!(text(&s), "cat", "the deletion is tracked, not applied");
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 0);
assert_eq!(run(&mut s, "edit.find", json!({"text": "t"}))["count"], 1);
// Without Track Changes, Replace steps through every match as before.
let mut s = self::s();
run(&mut s, "document.setText", json!({"text": "one cat two cat"}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "edit.find", json!({"text": "cat"}));
run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"}));
let r = run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"}));
assert_eq!(r["remaining"], 0);
assert_eq!(text(&s), "one dog two dog");
}
#[test]
fn find_reads_the_text_around_tracked_deletions() {
// `edits` are (start, end) byte ranges deleted with Track Changes on, last first.
fn tracked(text: &str, edits: &[(usize, usize)]) -> Session {
let mut s = self::s();
run(&mut s, "document.setText", json!({"text": text}));
run(&mut s, "review.trackChanges", json!({"value": true}));
for (a, b) in edits {
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": a}, "focus": {"block": 0, "off": b}}));
run(&mut s, "text.delete", json!({}));
}
assert_eq!(text, self::text(&s), "the deletions are tracked, not applied");
s
}
let offs = |r: &serde_json::Value| -> Vec<(u64, u64)> {
r["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect()
};
// "aaa" with the first "a" deleted reads "aa": the rejected raw match 0..2 must not hide
// the live one at 1..3.
let mut s = tracked("aaa", &[(0, 1)]);
assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "aa"}))), [(1, 3)]);
assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "^a", "regex": true}))), [(1, 2)]);
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "aa", "with": "b"}))["replaced"], 1);
// Whole words follow the live text: deleting the space joins "cat" and "fish"...
let mut s = tracked("cat fish", &[(3, 4)]);
assert_eq!(run(&mut s, "edit.find", json!({"text": "fish", "wholeWord": true}))["count"], 0);
assert_eq!(run(&mut s, "edit.find", json!({"text": "catfish", "wholeWord": true}))["count"], 0, "a match may not span a deletion");
assert_eq!(run(&mut s, "edit.find", json!({"text": "fish", "wholeWord": false}))["count"], 1);
// ...and deleting the "X" leaves "fish" a word of its own.
let mut s = tracked("cat Xfish", &[(4, 5)]);
assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "FISH", "wholeWord": true}))), [(5, 9)]);
assert_eq!(run(&mut s, "edit.find", json!({"text": "FISH", "wholeWord": true, "matchCase": true}))["count"], 0);
// Replace and the Find Next/Previous steps agree with Find.
let mut s = tracked("aaa aaa", &[(4, 5), (0, 1)]);
run(&mut s, "caret.docStart", json!({}));
assert_eq!(run(&mut s, "edit.find", json!({"text": "aa"}))["count"], 2);
assert_eq!(run(&mut s, "edit.findNext", json!({}))["index"], 1);
assert_eq!(run(&mut s, "edit.findPrevious", json!({}))["index"], 0);
let r = run(&mut s, "edit.replace", json!({"text": "aa", "with": "b"}));
assert_eq!(r["remaining"], 1);
}
#[test]
fn find_next_with_options_visits_every_match() {
// The Find dialog sends its options with each Find Next (#468): every click is one step.
let opts = json!({"text": "cat", "with": "", "matchCase": false, "wholeWord": false, "regex": false});
let starts = |text: &str, clicks: usize, cmd: &str| {
let mut s = s();
run(&mut s, "document.setText", json!({"text": text}));
run(&mut s, "caret.docStart", json!({}));
(0..clicks).map(|_| run(&mut s, cmd, opts.clone())["index"].as_u64().unwrap()).collect::>()
};
assert_eq!(starts("cat cat cat cat", 5, "edit.findNext"), [0, 1, 2, 3, 0]);
assert_eq!(starts("cat cat", 3, "edit.findNext"), [0, 1, 0]);
assert_eq!(starts("cat cat cat", 4, "edit.findPrevious"), [2, 1, 0, 2]);
// A new query takes effect at once, without a separate `edit.find`.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "cat dog cat dog"}));
run(&mut s, "caret.docStart", json!({}));
assert_eq!(run(&mut s, "edit.findNext", opts.clone())["index"], 0);
let r = run(&mut s, "edit.findNext", json!({"text": "dog"}));
assert_eq!((r["index"].clone(), r["count"].clone()), (json!(0), json!(2)));
assert_eq!(s.sel.ordered().0.off, 4);
}
#[test]
fn clipboard_round_trip() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "alpha beta\ngamma"}));
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 1, "off": 2}}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard_text, "beta\nga");
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(text(&s), "alpha beta\ngammabeta\nga");
run(&mut s, "select.all", json!({}));
run(&mut s, "edit.cut", json!({}));
assert_eq!(text(&s), "");
run(&mut s, "edit.paste", json!({"text": "plain\ntext"}));
assert_eq!(text(&s), "plain\ntext");
}
/// Equations in the paragraphs of a session's body, as linear text.
fn equations(s: &Session) -> Vec {
let mut out = Vec::new();
for b in &s.doc.body {
if let wordcraft_doc::Block::Para(p) = &**b {
for o in &p.objects {
if let wordcraft_doc::para::InlineObject::Equation { linear, .. } = o {
out.push(linear.clone());
}
}
}
}
out
}
#[test]
fn copying_an_equation_puts_mathml_on_the_clipboard() {
// #816: other programs get the equation as MathML in HTML, beside the linear text.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Speed "}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.equation", json!({"linear": "y=2x_s", "edit": false}));
run(&mut s, "select.all", json!({}));
let r = run(&mut s, "edit.copy", json!({}));
let html = r["html"].as_str().unwrap_or_default().to_string();
assert!(html.contains("
"}));
assert_eq!(text(&s), "hi there");
assert!(bold_at(&s, 0, 0) && !bold_at(&s, 0, 4));
// RTF.
run(&mut s, "document.setText", json!({"text": ""}));
run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": r"{\rtf1\ansi {\b bold}\b0 then}"}));
assert!(text(&s).starts_with("bold"), "{}", text(&s));
assert!(bold_at(&s, 0, 0));
// Asking for a format the clipboard lacks is an error, not a panic.
assert!(s.run("edit.pasteSpecial", &json!({"as": "rtf", "text": "plain"})).is_err());
assert!(s.run("edit.pasteSpecial", &json!({"as": "bogus", "text": "plain"})).is_err());
let huge = "a".repeat(crate::cmd::paste::MAX_PASTE_BYTES + 1);
assert!(s.run("edit.pasteSpecial", &json!({"as": "text", "text": huge})).is_err());
}
#[test]
fn paste_special_respects_track_changes_and_brings_lists() {
let mut s = s();
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": "
one
two
"}));
assert!(text(&s).starts_with("one\ntwo"), "{}", text(&s));
let ch = run(&mut s, "review.changes", json!({}));
assert!(!ch.as_array().unwrap().is_empty(), "the paste is a tracked insertion");
let first = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
let n = first.props.numbering.expect("list kept");
assert!(s.doc.numbering.nums.iter().any(|x| x.id == n.num), "the list definition came along");
run(&mut s, "review.acceptAll", json!({}));
assert!(text(&s).starts_with("one\ntwo"));
}
/// Character formatting at a body offset of the first paragraph.
fn props_at(s: &Session, off: usize) -> wordcraft_doc::CharProps {
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off };
s.doc.para_at(&pos).map(|p| p.props_of_char(off).clone()).unwrap_or_default()
}
/// Paste of another program's formatted copy keeps its formatting (#687); Merge Formatting keeps
/// only its emphasis, in the destination's font; Keep Text Only drops it all.
#[test]
fn paste_keeps_source_formatting_from_other_programs() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "x"}));
run(&mut s, "caret.docEnd", json!({}));
let html = "
red bold
";
let clip = json!({"text": "red bold", "html": html});
run(&mut s, "edit.paste", clip.clone());
assert_eq!(text(&s), "xred bold");
assert_eq!(props_at(&s, 1).font.as_deref(), Some("Courier New"));
assert!(props_at(&s, 1).color.is_some());
assert!(!bold_at(&s, 0, 1) && bold_at(&s, 0, 5));
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "x");
run(&mut s, "edit.pasteMerge", clip.clone());
assert_eq!(text(&s), "xred bold");
assert_eq!(props_at(&s, 1).font, None, "Merge Formatting takes the destination's font");
assert_eq!(props_at(&s, 1).color, None);
assert!(bold_at(&s, 0, 5), "and keeps the emphasis");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.pasteText", clip);
assert_eq!(text(&s), "xred bold");
assert!(!bold_at(&s, 0, 5));
// Windows' "HTML Format" carries a header with the fragment's byte offsets.
run(&mut s, "document.setText", json!({"text": ""}));
let body = "win";
let head = "Version:0.9\r\nStartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:SSSSSSSSSS\r\nEndFragment:EEEEEEEEEE\r\n";
let start = head.len() + body.find("").unwrap();
let end = head.len() + body.find("\n
Báo cáo
tháng 9
",
"\n
Báo cáo tháng 9
",
];
for (i, page) in pages.iter().enumerate() {
for name in ["report.doc", "report.docx", "report.rtf"] {
let doc = crate::io::open_bytes(name, page.as_bytes()).unwrap_or_else(|e| panic!("page {i} as {name}: {e}"));
let t = text(&doc);
assert!(t.contains("Báo cáo") && t.contains("tháng 9") && !t.contains('<'), "page {i} as {name}: {t:?}");
}
}
// A web page in an unknown extension is still plain text, other XML still no Word document.
assert!(text(&crate::io::open_bytes("page.log", pages[1].as_bytes()).unwrap()).contains(""));
assert!(crate::io::open_bytes("note.doc", b"hi").is_err());
// Nor is XML whose root only starts like `html`.
for xml in [&b"text"[..], b"text"] {
assert!(crate::io::open_bytes("other.doc", xml).is_err(), "{:?}", String::from_utf8_lossy(xml));
}
}
/// #569: a playlist (.m3u / .m3u8) — or any file with an extension WordCraft doesn't know —
/// opens as plain text with its encoding detected, like Word's text import; a binary file is a
/// clean error, never garbage or a panic.
#[test]
fn unknown_text_extensions_open_as_text() {
let lines = |doc: &wordcraft_doc::Document| doc.plain_text(StoryRef::Body);
let m3u8 = "#EXTM3U\r\n#EXTINF:215,Björk — Jóga\r\nmusic/Jóga.mp3\r\n#EXTINF:-1,Радио\r\nhttp://example.org/live\r\n";
let doc = crate::io::open_bytes("Mix.M3U8", m3u8.as_bytes()).unwrap();
assert!(
lines(&doc).starts_with("#EXTM3U\n#EXTINF:215,Björk — Jóga\nmusic/Jóga.mp3\n#EXTINF:-1,Радио\nhttp://example.org/live"),
"{:?}",
lines(&doc)
);
assert!(!lines(&doc).contains('\r'));
// With a BOM, which must not show up as a character.
let bom = [&[0xEF, 0xBB, 0xBF][..], m3u8.as_bytes()].concat();
assert!(lines(&crate::io::open_bytes("a.m3u8", &bom).unwrap()).starts_with("#EXTM3U\n"));
// A plain .m3u in Windows-1252 (é = E9, — = 97).
let cp1252 = b"#EXTM3U\r\n#EXTINF:100,Caf\xE9 \x97 Live\r\nC:\\Music\\caf\xE9.mp3\r\n";
assert_eq!(lines(&crate::io::open_bytes("old.m3u", cp1252).unwrap()), "#EXTM3U\n#EXTINF:100,Café — Live\nC:\\Music\\café.mp3\n");
// Other text files, a file without an extension, a dot in a folder name only.
for name in ["server.log", "settings.ini", "subs.srt", "README", "/home/a.b/README", "C:\\v1.2\\notes"] {
assert_eq!(lines(&crate::io::open_bytes(name, b"one\ntwo").unwrap()), "one\ntwo", "{name}");
}
// Binary files: an error naming the file, no panic.
for (name, bytes) in
[("song.mp3", &b"ID3\x04\0\0\0\0\x01\x02\x03"[..]), ("pic.dat", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"), ("blob", &[0u8, 1, 2, 3, 0, 255])]
{
let err = crate::io::open_bytes(name, bytes).unwrap_err();
assert!(err.contains(name) && err.contains("not a document"), "{name}: {err}");
}
assert!(crate::io::open_bytes("x.pdf", b"%PDF-1.7\n1 0 obj\n").is_err());
// A Word document or RTF file with the wrong extension opens by what it holds.
let docx = crate::io::save_bytes("x.docx", &crate::sample::letter()).unwrap();
assert_eq!(lines(&crate::io::open_bytes("letter.bak", &docx).unwrap()), lines(&crate::io::open_bytes("x.docx", &docx).unwrap()));
let rtf = crate::io::open_bytes("note.tmp", br"{\rtf1\ansi Hello {\b bold}\par}").unwrap();
assert!(lines(&rtf).starts_with("Hello bold"), "{:?}", lines(&rtf));
// Through file.open, both from disk and with the bytes (the web's picker and drops).
let dir = std::env::temp_dir().join(format!("wordcraft-569-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("road trip.m3u");
std::fs::write(&path, cp1252).unwrap();
let mut s = s();
let r = run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
assert_eq!(r["paragraphs"], 4);
assert!(text(&s).contains("Café — Live"));
let data = crate::cmd::insert::base64_encode(m3u8.as_bytes());
run(&mut s, "file.open", json!({"path": "Mix.m3u8", "data": data}));
assert!(text(&s).contains("Радио"));
let bad = crate::cmd::insert::base64_encode(b"\0\x01\x02binary\0");
assert!(s.run("file.open", &json!({"path": "x.bin", "data": bad})).is_err());
assert!(text(&s).contains("Радио"), "a failed open leaves the document alone");
let _ = std::fs::remove_dir_all(&dir);
}
/// #1384: File Explorer's New › Microsoft Word Document makes an empty, zero-byte .docx. It opens
/// as a new blank document on the Normal template, and Save writes it back to that file instead
/// of asking for a name.
#[test]
fn an_empty_file_opens_as_a_blank_document_saved_back_to_it() {
let dir = std::env::temp_dir().join(format!("wordcraft-1384-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("New Microsoft Word Document.docx");
std::fs::write(&path, b"").unwrap();
let mut s = s();
run(&mut s, "file.options", json!({"region": "it-IT"}));
s.set_editing_language(Some("it-IT"));
run(&mut s, "text.insert", json!({"text": "An untitled draft"}));
let r = run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
assert_eq!((r["paragraphs"].as_u64(), r["words"].as_u64()), (Some(1), Some(0)), "{r}");
assert_eq!(text(&s), "");
assert_eq!(s.path.as_deref(), Some(path.as_path()));
assert!(!s.dirty);
let sp = &s.doc.last_section;
assert_eq!((sp.page_w, sp.page_h), (595.28, 841.89), "the region's paper, as File › New gives");
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("it-IT"), "in the editing language");
run(&mut s, "text.insert", json!({"text": "Ciao"}));
let r = run(&mut s, "file.save", json!({}));
assert_eq!(r["saved"], true, "{r}");
assert!(!s.ui_requests.iter().any(|q| q["open"] == "saveAs"), "no Save As");
let saved = crate::io::open_path(&path).unwrap();
assert_eq!(saved.plain_text(StoryRef::Body), "Ciao");
// The same from the browser's picker (the web), which hands over the bytes.
run(&mut s, "file.open", json!({"path": "Vuoto.docx", "data": ""}));
assert_eq!((text(&s), s.doc.last_section.page_w), (String::new(), 595.28));
let _ = std::fs::remove_dir_all(&dir);
}
/// #549: after a double-click, dragging extends the selection by whole words; after a
/// triple-click by whole paragraphs; after Ctrl/⌘+click by whole sentences. Forward drags keep
/// the first unit's start as the anchor, backward drags its end.
#[test]
fn drag_after_multi_click_extends_by_units() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Alpha beta gamma. Delta epsilon.\rSecond para here.\rThird one."}));
let drag = |s: &mut Session, unit: &str, start: Pos, end: Pos, pos: Pos| {
run(s, "select.dragUnit", json!({"unit": unit, "start": start, "end": end, "pos": pos}));
(s.sel.anchor.clone(), s.sel.focus.clone())
};
// Double-click "beta" (6..11 with its trailing space).
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 7)}));
run(&mut s, "select.word", json!({}));
let (a, b) = s.sel.ordered();
assert_eq!((a.off, b.off), (6, 11));
// Forward into the middle of "gamma": the whole word joins.
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 13)), (Pos::body(0, 6), Pos::body(0, 16)));
// Back into "Alpha": anchored at the end of "beta ", focus at the start of "Alpha".
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 2)), (Pos::body(0, 11), Pos::body(0, 0)));
// Back inside the first word: just that word.
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 8)), (Pos::body(0, 6), Pos::body(0, 11)));
// Into the next paragraph: whole word there.
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(1, 8)), (Pos::body(0, 6), Pos::body(1, 12)));
// Ctrl+click a sentence, drag into the next sentence.
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 3)}));
run(&mut s, "select.sentence", json!({}));
let (a, b) = s.sel.ordered();
let (_, f) = drag(&mut s, "sentence", a, b, Pos::body(0, 20));
assert_eq!(f.off, 32, "the second sentence is selected to its end");
// Triple-click the second paragraph, drag up into the first and down into the third.
run(&mut s, "caret.set", json!({"pos": Pos::body(1, 3)}));
run(&mut s, "select.paragraph", json!({}));
let (a, b) = s.sel.ordered();
assert_eq!((a.clone(), b.clone()), (Pos::body(1, 0), Pos::body(2, 0)));
assert_eq!(drag(&mut s, "paragraph", a.clone(), b.clone(), Pos::body(0, 5)), (Pos::body(2, 0), Pos::body(0, 0)));
assert_eq!(drag(&mut s, "paragraph", a.clone(), b.clone(), Pos::body(2, 2)), (Pos::body(1, 0), Pos::body(2, 10)));
// Junk never panics and leaves the document alone.
let before = text(&s);
for v in [
json!({}),
json!({"unit": "page", "start": Pos::body(0, 0), "end": Pos::body(0, 1), "pos": Pos::body(0, 1)}),
json!({"unit": "word", "start": Pos::body(99, 999), "end": Pos::body(0, 0), "pos": Pos::body(77, usize::MAX)}),
json!({"unit": "word", "start": {"block": 0, "off": 9_999}, "end": {"block": 0, "off": 1}, "pos": {"block": 5}}),
] {
let _ = s.run("select.dragUnit", &v);
}
assert_eq!(text(&s), before);
}
/// #856: a click in the selection bar selects the whole line (its trailing space; the
/// paragraph mark on the last line); dragging extends by lines; triple-click takes the document.
#[test]
fn selection_bar_selects_and_drags_by_lines() {
let mut s = s();
let long = "The quick brown fox jumps over the lazy dog. ".repeat(8);
run(&mut s, "text.insert", json!({"text": format!("{}\rShort one.\rLast.", long.trim_end())}));
let l = s.layout();
let c = l.caret(&Pos::body(0, 0)).unwrap();
let line2 = l.selection_bar_at(c.page, 20.0, c.top + c.height * 1.5).unwrap();
assert!(line2.off > 0);
run(&mut s, "select.line", json!({"pos": line2.clone()}));
let (a, b) = s.sel.ordered();
assert_eq!(a, line2);
assert_eq!(b.path, wordcraft_doc::Path::top(0));
assert!(b.off > a.off && b.off < long.trim_end().len());
assert_eq!(s.doc.para_at(&b).unwrap().text.as_bytes()[b.off - 1], b' ', "the trailing space is in");
// The last line of a paragraph takes its mark.
run(&mut s, "select.line", json!({"pos": Pos::body(1, 3)}));
assert_eq!(s.sel.ordered(), (Pos::body(1, 0), Pos::body(2, 0)));
// Drag from the "Short one." line down to "Last." and back up into the first paragraph.
let (a, b) = s.sel.ordered();
run(&mut s, "select.dragUnit", json!({"unit": "line", "start": a.clone(), "end": b.clone(), "pos": Pos::body(2, 0)}));
assert_eq!((s.sel.anchor.clone(), s.sel.focus.clone()), (Pos::body(1, 0), Pos::body(2, 5)));
run(&mut s, "select.dragUnit", json!({"unit": "line", "start": a.clone(), "end": b.clone(), "pos": line2.clone()}));
assert_eq!((s.sel.anchor.clone(), s.sel.focus.clone()), (Pos::body(2, 0), line2));
run(&mut s, "select.dragUnit", json!({"unit": "document", "start": a.clone(), "end": b.clone(), "pos": Pos::body(0, 0)}));
assert_eq!(s.sel.ordered(), (Pos::body(0, 0), Pos::body(2, 5)));
// Junk positions never panic.
let _ = s.run("select.line", &json!({"pos": {"block": 99, "off": usize::MAX}}));
let _ = s.run("select.line", &json!({"pos": 5}));
}
/// Home › Font › Enclose Characters (#297): the selection becomes an `EQ \o\ac` field, saved the
/// way Word stores it (the style in the code's run sizes) and read back; null takes the text out
/// again, and each step undoes.
#[test]
fn enclose_characters_make_an_eq_field_and_come_back_out() {
use wordcraft_doc::InlineObject;
let field = |s: &Session| {
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
p.objects.iter().find_map(|o| match o {
InlineObject::Field { instr, result, .. } => Some((instr.clone(), result.clone())),
_ => None,
})
};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "No 12 and 字 here"}));
run(&mut s, "select.text", json!({"text": "No 12"}));
assert!(s.run("format.enclose", &json!({"shape": "circle"})).is_err(), "five characters don't fit");
run(&mut s, "select.text", json!({"text": "字"}));
assert!(s.run("format.enclose", &json!({})).is_err(), "a script must say which shape");
run(&mut s, "format.enclose", json!({"shape": "circle"}));
assert_eq!(field(&s), Some(("eq \\o\\ac(○,字)".to_string(), "字".to_string())));
assert_eq!(text(&s), "No 12 and 字 here", "the field's text reads as the character");
run(&mut s, "select.text", json!({"text": "12"}));
run(&mut s, "format.enclose", json!({"shape": "diamond", "style": "enlarge"}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let codes: Vec =
p.objects.iter().filter_map(|o| if let InlineObject::Field { instr, .. } = o { Some(instr.clone()) } else { None }).collect();
assert_eq!(codes, ["eq \\o\\ac(◇,12) \\* enlarge", "eq \\o\\ac(○,字)"]);
// Word's structure in the .docx: no result, the symbol and the text in runs of their own
// sizes; reading it back gives the same fields, styles included.
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let back = wordcraft_docx::read(&bytes).unwrap();
let bp = back.para_at(&Pos::body(0, 0)).unwrap();
let back_codes: Vec<(String, String)> = bp
.objects
.iter()
.filter_map(|o| if let InlineObject::Field { instr, result, .. } = o { Some((instr.clone(), result.clone())) } else { None })
.collect();
assert_eq!(back_codes, [("eq \\o\\ac(◇,12) \\* enlarge".to_string(), "12".to_string()), ("eq \\o\\ac(○,字)".to_string(), "字".to_string())]);
// Change the shape of the enclosed characters at the caret, then remove it.
let e = s.doc.para_at(&Pos::body(0, 0)).unwrap().text.find('\u{FFFC}').unwrap();
s.sel = crate::Selection::caret(Pos::body(0, e + 3));
run(&mut s, "format.enclose", json!({"shape": "square"}));
assert!(field(&s).unwrap().0.starts_with("eq \\o\\ac(□,12) \\* enlarge"), "the style stays");
run(&mut s, "format.enclose", json!({"shape": null}));
assert_eq!(text(&s), "No 12 and 字 here");
assert_eq!(s.selected_text(), "12");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(field(&s).unwrap().0.starts_with("eq \\o\\ac(◇,12)"));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(field(&s), None, "back to plain text");
assert!(s.run("format.enclose", &json!({"shape": null})).is_err(), "nothing to remove");
// Text given (the dialog's Text box) goes in at a caret.
run(&mut s, "caret.docEnd", json!({}));
assert!(s.run("format.enclose", &json!({"shape": "triangle", "text": "ABC"})).is_err());
run(&mut s, "format.enclose", json!({"shape": "triangle", "text": "A"}));
assert_eq!(text(&s), "No 12 and 字 hereA");
assert_eq!(field(&s), Some(("eq \\o\\ac(△,A)".to_string(), "A".to_string())));
}
#[test]
fn list_buttons_follow_the_paragraphs_own_level() {
use crate::cmd::para::{ListButtons, list_buttons};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "item"}));
let state = |s: &Session| {
let rp = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props));
list_buttons(&s.doc, rp.and_then(|r| r.numbering))
};
assert_eq!(state(&s), ListButtons::default(), "plain text: no list button on");
run(&mut s, "para.bullets", json!({}));
assert_eq!(state(&s), ListButtons { bullets: true, numbering: false, multilevel: false });
// A bulleted list whose second level is numbered: at level 2 the paragraph is numbered.
let num = s.doc.para_at(&s.sel.focus).unwrap().props.numbering.unwrap().num;
let numbered = wordcraft_doc::numbering::Level { text: "%2.".into(), ..Default::default() };
s.doc.numbering.nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered));
run(&mut s, "para.listLevel", json!({"level": 1}));
assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false });
run(&mut s, "para.numbering", json!({}));
assert_eq!(state(&s), ListButtons::default(), "Numbering toggles off the paragraph's effective numbered level");
run(&mut s, "para.numbering", json!({}));
run(&mut s, "para.listLevel", json!({"level": 1}));
assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false }, "1. a. i. is not multilevel");
// Legal numbering (1. 1.1. 1.1.1.) numbers levels together, even on its first level. (Its
// first level reads like "1." numbering, which the button would toggle off, so start plain.)
run(&mut s, "para.numbering", json!({"off": true}));
run(&mut s, "para.multilevel", json!({"kind": "legal"}));
run(&mut s, "para.listLevel", json!({"level": 0}));
assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: true });
run(&mut s, "para.listLevel", json!({"level": 2}));
assert!(state(&s).multilevel);
run(&mut s, "para.numbering", json!({"off": true}));
assert_eq!(state(&s), ListButtons::default());
}
#[test]
fn replace_all_splits_paragraphs_at_a_paragraph_mark_in_the_replacement() {
// Issue #423: untracked Replace All left `\r` in the paragraph instead of splitting it.
for with in ["A\rB", "A\u{2029}B"] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "cat cat"}));
run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": with}));
assert_eq!(body_texts(&s), ["A", "B A", "B"], "{with:?}");
}
// A line feed stays a line break.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "cat"}));
run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": "A\nB"}));
assert_eq!(body_texts(&s), ["A\nB"]);
}
/// A one-row table converted from tab-separated `text`.
fn table_from(text: &str) -> Session {
let mut s = s();
run(&mut s, "document.setText", json!({"text": text}));
run(&mut s, "select.all", json!({}));
run(&mut s, "table.fromText", json!({}));
s
}
fn table_texts(s: &Session) -> Vec> {
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect()).collect()).collect()
}
#[test]
fn convert_text_to_table_refuses_to_drop_content() {
// Issue #1318: a 64th field and a table between the selected paragraphs were silently lost.
{
let wide = (0..64).map(|i| i.to_string()).collect::>().join("\t");
let mut s = s();
run(&mut s, "document.setText", json!({"text": wide}));
run(&mut s, "select.all", json!({}));
let depth = s.undo_depth();
assert!(s.run("table.fromText", &json!({})).is_err());
assert_eq!(s.doc.body.len(), 1);
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()));
assert_eq!(s.undo_depth(), depth);
}
let mut s = s();
run(&mut s, "document.setText", json!({"text": "x\ny\nz"}));
let at = |i: u32| json!({"story": "body", "path": [i], "off": 0});
run(&mut s, "select.range", json!({"anchor": at(1), "focus": at(1)}));
run(&mut s, "table.fromText", json!({}));
assert!(s.doc.body.get(1).and_then(|b| b.as_table()).is_some());
run(&mut s, "select.range", json!({"anchor": at(0), "focus": at(2)}));
let depth = s.undo_depth();
assert!(s.run("table.fromText", &json!({})).is_err());
assert_eq!(s.doc.body.len(), 3);
assert!(s.doc.body.get(1).and_then(|b| b.as_table()).is_some());
assert_eq!(s.undo_depth(), depth);
}
#[test]
fn convert_text_to_table_keeps_inline_content() {
// Issue #3751: the cells were rebuilt from plain text, losing formatting, pictures, links
// and tracked deletions.
use wordcraft_doc::para::InlineObject;
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Name\t\tLink\tOld"}));
{
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
p.format(0, 4, &|c| c.bold = Some(true)).unwrap();
p.format(6, 10, &|c| c.link = Some("https://example.com".into())).unwrap();
p.format(11, 14, &|c| c.del = Some(1)).unwrap();
let img =
InlineObject::Image { media: "pic".into(), w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
p.insert_object(5, img, &Default::default()).unwrap();
}
run(&mut s, "select.all", json!({}));
run(&mut s, "table.fromText", json!({}));
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
let cell = |c: usize| t.rows[0].cells[c].blocks.iter().find_map(|b| b.as_para()).expect("a paragraph");
assert_eq!(cell(0).text, "Name");
assert_eq!(cell(0).props_of_char(0).bold, Some(true), "cell 1 lost bold");
assert!(matches!(cell(1).objects.as_slice(), [InlineObject::Image { media, .. }] if media == "pic"), "cell 2 lost its picture");
assert_eq!(cell(2).text, "Link");
assert_eq!(cell(2).props_of_char(0).link.as_deref(), Some("https://example.com"), "cell 3 lost its link");
assert_eq!(cell(3).text, "Old");
assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion");
}
/// A table `cols` wide holding `cells` row by row, typed in with Tab; the caret ends in the
/// last cell.
fn typed_table(cols: usize, cells: &[&str]) -> Session {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": cells.len() / cols, "cols": cols}));
for (k, c) in cells.iter().enumerate() {
if k > 0 {
run(&mut s, "text.tab", json!({}));
}
if !c.is_empty() {
run(&mut s, "text.insert", json!({"text": c}));
}
}
s
}
/// The body as `T` for a table and the text of each paragraph.
fn body_outline(s: &Session) -> Vec {
s.doc.body.iter().map(|b| b.as_para().map_or_else(|| "T".to_string(), |p| p.text.clone())).collect()
}
fn cell_at(table: u32, r: u32, c: u32) -> serde_json::Value {
json!({"story": "body", "path": [table, r, c, 0], "off": 0})
}
/// Convert to Text sets a row's cells apart with a tab, a comma, another character or
/// paragraph marks; rows are paragraphs.
#[test]
fn convert_to_text_takes_each_separator() {
let cases = [
(json!({}), vec!["a\tb", "c\td"]),
(json!({"separator": "tab"}), vec!["a\tb", "c\td"]),
(json!({"separator": "comma"}), vec!["a,b", "c,d"]),
(json!({"separator": "other", "char": ";"}), vec!["a;b", "c;d"]),
(json!({"separator": "paragraph"}), vec!["a", "b", "c", "d"]),
];
for (params, want) in cases {
let mut s = typed_table(2, &["a", "b", "c", "d"]);
run(&mut s, "table.toText", params.clone());
let got = body_outline(&s);
assert_eq!(got.get(..want.len()), Some(&want.iter().map(|x| x.to_string()).collect::>()[..]), "{params}: {got:?}");
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()), "{params}");
}
// U+FFFC marks an object in a paragraph's text, so it cannot be the separator.
let obj = wordcraft_doc::para::OBJ.to_string();
for bad in [
json!({"separator": "other"}),
json!({"separator": "other", "char": "\n"}),
json!({"separator": "other", "char": obj}),
json!({"separator": "pipe"}),
] {
let mut s = typed_table(2, &["a", "b", "c", "d"]);
let depth = s.undo_depth();
assert!(s.run("table.toText", &bad).is_err(), "{bad}");
assert!(s.doc.body.iter().any(|b| b.as_table().is_some()), "{bad}: the table stays");
assert_eq!(s.undo_depth(), depth);
}
}
/// With rows selected, only those become text; the rows above and below stay tables, and a
/// vertical merge cut by the selection starts again below it.
#[test]
fn convert_to_text_converts_the_selected_rows() {
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
// Column 1 merged down rows 1 to 3.
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 3, 1)}));
run(&mut s, "table.merge", json!({}));
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
// The merged cell holds d, f and h, one paragraph each.
assert_eq!(body_outline(&s).get(at..at + 5), Some(&["T", "c\td", "f", "h", "T"].map(String::from)[..]), "{:?}", body_outline(&s));
let head = s.doc.body[at].as_table().unwrap();
let tail = s.doc.body[at + 4].as_table().unwrap();
assert_eq!((head.rows.len(), tail.rows.len()), (1, 2));
use wordcraft_doc::props::VMerge;
let merges: Vec = tail.rows.iter().map(|r| r.cells.last().unwrap().props.vmerge).collect();
assert_eq!(merges, [VMerge::Restart, VMerge::Continue], "the merge starts again in the first row left");
assert_eq!(s.sel.focus.path.0, [t + 1], "the caret goes to the converted text");
// A merge that went on into the converted rows ends in the last row above them.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
run(&mut s, "select.range", json!({"anchor": cell_at(t, 0, 0), "focus": cell_at(t, 1, 0)}));
run(&mut s, "table.merge", json!({}));
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 2, 1)}));
run(&mut s, "table.toText", json!({}));
let head = s.doc.body[t as usize].as_table().unwrap();
assert_eq!(head.rows.len(), 1);
assert_eq!(head.rows[0].cells[0].props.vmerge, VMerge::None, "no merge goes on below the table");
// A floating table keeps its place in the part above; the part below goes in the text flow.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
let float = wordcraft_doc::props::TableFloat { x: 100.0, y: 200.0, overlap: true, ..Default::default() };
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().props.float = Some(float);
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
assert_eq!(s.doc.body[at].as_table().unwrap().props.float, Some(float));
assert_eq!(s.doc.body[at + 2].as_table().unwrap().props.float, None, "the part below doesn't sit on the part above");
// The caret alone in a cell converts every row, as before.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
run(&mut s, "table.toText", json!({}));
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()));
}
/// A content control around the table keeps both its ends when the first rows, the last rows
/// or every row become text.
#[test]
fn convert_to_text_keeps_a_content_control_around_the_table() {
use wordcraft_doc::{ContentControl, ControlWrap, InlineObject};
let has = |p: &wordcraft_doc::Paragraph, start: bool| {
p.objects.iter().any(|o| if start { matches!(o, InlineObject::ControlStart { .. }) } else { matches!(o, InlineObject::ControlEnd) })
};
for rows in [Some((0, 0)), Some((2, 2)), None] {
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().controls =
ControlWrap { open: vec![ContentControl { tag: "wrap".into(), ..Default::default() }], close: 1 };
assert!(!s.doc.has_unbalanced_controls());
match rows {
Some((r0, r1)) => run(&mut s, "select.range", json!({"anchor": cell_at(t, r0, 0), "focus": cell_at(t, r1, 1)})),
None => run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 0)})),
};
run(&mut s, "table.toText", json!({}));
assert!(!s.doc.has_unbalanced_controls(), "{rows:?}: {:?}", body_outline(&s));
let at = t as usize;
if rows != Some((2, 2)) {
assert!(s.doc.body[at].as_para().is_some_and(|p| has(p, true)), "{rows:?}: the control starts on the text");
}
if rows != Some((0, 0)) {
let last = s.doc.body.iter().skip(at).rfind(|b| b.as_para().is_some_and(|p| !p.text.is_empty()) || b.as_table().is_some()).unwrap();
assert!(last.as_para().is_some_and(|p| has(p, false)), "{rows:?}: the control ends on the text");
}
}
// A control around rows 0 to 3, row 1 converted: the table above closes it, the table below
// opens it again.
let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
{
let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap();
table.rows[0].controls.open = vec![ContentControl { id: Some(7), tag: "rows".into(), ..Default::default() }];
table.rows[3].controls.close = 1;
}
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)}));
run(&mut s, "table.toText", json!({}));
let at = t as usize;
let head = s.doc.body[at].as_table().unwrap();
let tail = s.doc.body[at + 2].as_table().unwrap();
assert_eq!((head.rows.len(), head.rows[0].controls.close), (1, 1), "the table above closes the control");
assert_eq!(tail.rows[0].controls.open.iter().map(|c| c.tag.as_str()).collect::>(), ["rows"], "the table below opens it");
assert_eq!((head.rows[0].controls.open[0].id, tail.rows[0].controls.open[0].id), (Some(7), None), "one w:id in the file");
assert_eq!(tail.rows[1].controls.close, 1);
assert!(!s.doc.has_unbalanced_controls());
}
/// The text keeps its formatting, and a table nested in a cell stays a table.
#[test]
fn convert_to_text_keeps_formatting_and_nested_tables() {
let mut s = typed_table(2, &["Name", "X"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
let first = json!({"story": "body", "path": [t, 0, 0, 0], "off": 0});
let end = json!({"story": "body", "path": [t, 0, 0, 0], "off": 4});
run(&mut s, "select.range", json!({"anchor": first, "focus": end}));
run(&mut s, "format.bold", json!({}));
// A table nested in the second cell, after its text.
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 1, 0]), off: 1 });
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "Inner"}));
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 0, 0]), off: 0 });
run(&mut s, "table.toText", json!({}));
let at = t as usize;
let line = s.doc.body[at].as_para().unwrap();
assert_eq!(line.text, "Name\tX");
assert_eq!(line.props_of_char(0).bold, Some(true), "the text lost its bold");
assert_ne!(line.props_of_char(4).bold, Some(true), "the tab takes no formatting");
let inner = s.doc.body.iter().skip(at + 1).find_map(|b| b.as_table()).expect("the nested table stays a table");
assert_eq!(table_cell_text(inner), "Inner");
// A selected row whose first cell starts with a nested table: the caret goes to the row's
// text, not back into the table above.
let mut s = typed_table(2, &["A", "B", "", "D"]);
let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 0]), off: 0 });
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
let row = &s.doc.body[t as usize].as_table().unwrap().rows[1];
assert!(row.cells[0].blocks.first().is_some_and(|b| b.as_table().is_some()), "the cell starts with the nested table");
run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 1, 1)}));
s.sel.anchor = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 1]), off: 0 };
run(&mut s, "table.toText", json!({}));
let at = t as usize;
assert_eq!(body_outline(&s).get(at..at + 3), Some(&["T", "T", "\tD"].map(String::from)[..]), "{:?}", body_outline(&s));
assert_eq!(s.sel.focus.path.0, [t + 2], "the caret is on the row's text");
}
fn table_cell_text(t: &wordcraft_doc::Table) -> String {
t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect()
}
#[test]
fn delete_columns_deletes_every_selected_column() {
// Issue #431: only the selection's focus column went.
let cell = |r: u32, c: u32, off: usize| json!({"story": "body", "path": [0, r, c, 0], "off": off});
for (anchor, focus) in [(cell(0, 0, 0), cell(1, 1, 1)), (cell(1, 1, 1), cell(0, 0, 0))] {
let mut s = table_from("Left\tMiddle\tRight\nA\tB\tC");
run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus}));
run(&mut s, "table.deleteColumn", json!({}));
assert_eq!(table_texts(&s), [["Right"], ["C"]]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(table_texts(&s), [["Left", "Middle", "Right"], ["A", "B", "C"]]);
}
// A caret deletes its own column only.
let mut s = table_from("Left\tMiddle\tRight");
run(&mut s, "caret.set", json!({"pos": cell(0, 1, 0)}));
run(&mut s, "table.deleteColumn", json!({}));
assert_eq!(table_texts(&s), [["Left", "Right"]]);
}
#[test]
fn split_cells_leaves_the_other_rows_alone() {
// Issue #432: splitting a cell inserted a grid column with a new cell in every row.
let spans = |s: &Session| -> Vec> {
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
t.rows.iter().map(|r| r.cells.iter().map(wordcraft_doc::Cell::span).collect()).collect()
};
let mut s = table_from("Left\nA");
let width: f32 = s.doc.body.iter().find_map(|b| b.as_table()).unwrap().total_width();
run(&mut s, "table.split", json!({"columns": 2}));
assert_eq!(table_texts(&s), [vec!["Left", ""], vec!["A"]]);
assert_eq!(spans(&s), [vec![1, 1], vec![2]]);
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
assert!((t.total_width() - width).abs() < 0.1, "{:?}", t.grid);
assert!((t.grid[0] - width / 2.0).abs() < 0.1, "{:?}", t.grid);
let mut s = table_from("Left\tMiddle\tRight\nA\tB\tC");
run(&mut s, "table.split", json!({"columns": 2}));
assert_eq!(table_texts(&s), [vec!["Left", "", "Middle", "Right"], vec!["A", "B", "C"]]);
assert_eq!(spans(&s), [vec![1, 1, 1, 1], vec![2, 1, 1]]);
}
fn table_spans(s: &Session) -> Vec> {
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
t.rows.iter().map(|r| r.cells.iter().map(wordcraft_doc::Cell::span).collect()).collect()
}
fn table_vmerge(s: &Session) -> Vec> {
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
t.rows.iter().map(|r| r.cells.iter().map(|c| c.props.vmerge).collect()).collect()
}
/// #2592: Split Cells into columns and rows. A one-row cell gets new rows under it, the row's
/// other cells merging down over them; one undo puts the table back.
#[test]
fn split_cells_into_columns_and_rows() {
use wordcraft_doc::props::VMerge::{Continue, None as No, Restart};
let mut s = table_from("A\tB\nC\tD");
let depth = s.undo_depth();
run(&mut s, "table.split", json!({"columns": 2, "rows": 2}));
assert_eq!(s.undo_depth(), depth + 1);
assert_eq!(table_texts(&s), [vec!["A", "", "B"], vec!["", "", ""], vec!["C", "D"]]);
assert_eq!(table_spans(&s), [vec![1, 1, 1], vec![1, 1, 1], vec![2, 1]]);
assert_eq!(table_vmerge(&s), [vec![No, No, Restart], vec![No, No, Continue], vec![No, No]]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(table_texts(&s), [["A", "B"], ["C", "D"]]);
assert_eq!(table_spans(&s), [[1, 1], [1, 1]]);
// Rows only.
let mut s = table_from("A\tB");
run(&mut s, "table.split", json!({"columns": 1, "rows": 3}));
assert_eq!(table_texts(&s), [vec!["A", "B"], vec!["", ""], vec!["", ""]]);
assert_eq!(table_vmerge(&s), [[No, Restart], [No, Continue], [No, Continue]]);
}
/// #2592: Merge cells before split merges the selection, then splits the merged cell; without
/// it every selected cell is split on its own.
#[test]
fn split_cells_merge_before_split() {
use wordcraft_doc::props::VMerge::None as No;
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
let mut s = table_from("A\tB\nC\tD");
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)}));
let depth = s.undo_depth();
run(&mut s, "table.split", json!({"columns": 2, "rows": 2}));
assert_eq!(s.undo_depth(), depth + 1, "merge and split are one step");
assert_eq!(table_spans(&s), [[1, 1], [1, 1]]);
assert_eq!(table_vmerge(&s), [[No, No], [No, No]]);
assert_eq!(table_texts(&s), [["ABCD", ""], ["", ""]]);
let mut s = table_from("A\tB\nC\tD");
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)}));
run(&mut s, "table.split", json!({"columns": 2, "mergeFirst": false}));
assert_eq!(table_texts(&s), [["A", "", "B", ""], ["C", "", "D", ""]]);
assert_eq!(table_spans(&s), [[1, 1, 1, 1], [1, 1, 1, 1]]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(table_texts(&s), [["A", "B"], ["C", "D"]]);
// As in Word: merged cells split into at most as many rows as were selected, and cells split
// one by one keep their rows.
let mut s = table_from("A\tB\nC\tD");
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)}));
let depth = s.undo_depth();
assert!(s.run("table.split", &json!({"columns": 1, "rows": 3})).is_err(), "rows 1 to 2 only");
assert!(s.run("table.split", &json!({"columns": 2, "rows": 2, "mergeFirst": false})).is_err());
assert_eq!(s.undo_depth(), depth);
assert_eq!(table_texts(&s), [["A", "B"], ["C", "D"]]);
run(&mut s, "table.split", json!({"columns": 1, "rows": 2}));
assert_eq!(table_texts(&s), [["AC", "B"], ["", "D"]]);
}
/// #2592: a vertically merged cell splits into rows that divide its height; other counts are
/// refused without changing anything.
#[test]
fn split_cells_of_a_vertically_merged_cell() {
use wordcraft_doc::props::VMerge::{Continue, None as No, Restart};
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
let merged = || {
let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH");
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(3, 0)}));
run(&mut s, "table.merge", json!({}));
s
};
let mut s = merged();
assert_eq!(table_vmerge(&s), [[Restart, No], [Continue, No], [Continue, No], [Continue, No]]);
run(&mut s, "table.split", json!({"columns": 2, "rows": 2}));
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap().rows.len(), 4, "no new rows");
assert_eq!(table_vmerge(&s), [[Restart, Restart, No], [Continue, Continue, No], [Restart, Restart, No], [Continue, Continue, No]]);
assert_eq!(table_spans(&s), [[1, 1, 1], [1, 1, 1], [1, 1, 1], [1, 1, 1]]);
// The caret in a continued cell splits the whole merged cell.
let mut s = merged();
run(&mut s, "caret.set", json!({"pos": cell(2, 0)}));
run(&mut s, "table.split", json!({"columns": 1, "rows": 4}));
assert_eq!(table_vmerge(&s), [[No, No], [No, No], [No, No], [No, No]]);
let mut s = merged();
let (depth, before) = (s.undo_depth(), table_vmerge(&s));
let e = s.run("table.split", &json!({"columns": 1, "rows": 3})).unwrap_err();
assert!(matches!(e, crate::CmdError::Params(_)), "{e:?}");
assert!(e.to_string().contains("The number must be a divisor of 4"), "3 doesn't divide 4: {e}");
assert_eq!((s.undo_depth(), table_vmerge(&s)), (depth, before));
// 1 × 1 on the 4-row cell changes nothing either.
let mut s = merged();
run(&mut s, "table.split", json!({"columns": 1, "rows": 1}));
assert_eq!(table_vmerge(&s), [[Restart, No], [Continue, No], [Continue, No], [Continue, No]]);
}
/// #2592 review: cells split one by one split a vertically merged cell once, not once per row it
/// covers, and every other selected cell in two.
#[test]
fn split_cells_one_by_one_with_a_merged_cell() {
use wordcraft_doc::props::VMerge::{Continue, None as No, Restart};
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
let merged = || {
let mut s = table_from("A\tB\nC\tD");
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)}));
run(&mut s, "table.merge", json!({}));
s
};
let mut s = merged();
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "table.split", json!({"columns": 2, "mergeFirst": false}));
assert_eq!(table_spans(&s), [[1, 1, 1, 1], [1, 1, 1, 1]]);
assert_eq!(table_texts(&s), [["AC", "", "B", ""], ["", "", "D", ""]]);
assert_eq!(table_vmerge(&s), [[Restart, Restart, No, No], [Continue, Continue, No, No]]);
// The merged cell alone is one cell.
let mut s = merged();
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)}));
run(&mut s, "table.split", json!({"columns": 2, "mergeFirst": false}));
assert_eq!(table_spans(&s), [[1, 1, 1], [1, 1, 1]]);
assert_eq!(table_vmerge(&s), [[Restart, Restart, No], [Continue, Continue, No]]);
}
/// #2592: Convert Text to Table separates at paragraphs, commas, tabs or another character,
/// takes a column count and an AutoFit behaviour, and undoes in one step.
#[test]
fn convert_text_to_table_dialog_options() {
let convert = |text: &str, params: serde_json::Value| {
let mut s = s();
run(&mut s, "document.setText", json!({"text": text}));
run(&mut s, "select.all", json!({}));
let depth = s.undo_depth();
run(&mut s, "table.fromText", params);
assert_eq!(s.undo_depth(), depth + 1);
s
};
let s1 = convert("a;b;c\nd;e", json!({"separator": "other", "char": ";"}));
assert_eq!(table_texts(&s1), [["a", "b", "c"], ["d", "e", ""]]);
let s1 = convert("a\nb\nc\nd", json!({"separator": "paragraph"}));
assert_eq!(table_texts(&s1), [["a"], ["b"], ["c"], ["d"]]);
let s1 = convert("a\nb\nc", json!({"separator": "paragraph", "columns": 2}));
assert_eq!(table_texts(&s1), [["a", "b"], ["c", ""]]);
// More columns than fields pads; fewer wraps a paragraph's fields onto more rows.
let s1 = convert("a\tb\tc\nd", json!({"columns": 4}));
assert_eq!(table_texts(&s1), [["a", "b", "c", ""], ["d", "", "", ""]]);
let s1 = convert("a\tb\tc\nd", json!({"columns": 2}));
assert_eq!(table_texts(&s1), [["a", "b"], ["c", ""], ["d", ""]]);
let mut s1 = convert("a,b\nc,d", json!({"separator": "comma", "autofit": "fixed"}));
let t = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
assert!(t(&s1).props.fixed);
run(&mut s1, "edit.undo", json!({}));
assert_eq!(body_texts(&s1), ["a,b", "c,d"]);
let s1 = convert("a,b\nc,d", json!({"separator": "comma", "autofit": "window"}));
assert_eq!((t(&s1).props.fixed, t(&s1).props.width_pct), (false, Some(100.0)));
let s1 = convert("a,b\nc,d", json!({"separator": "comma", "autofit": "contents"}));
let width = cmd::page::sect(&s1).text_width();
assert!(!t(&s1).props.fixed && t(&s1).total_width() < width / 2.0, "{:?}", t(&s1).grid);
// Junk is refused or clamped, never a panic.
let mut s1 = s();
run(&mut s1, "document.setText", json!({"text": "a-b"}));
run(&mut s1, "select.all", json!({}));
assert!(s1.run("table.fromText", &json!({"separator": "other"})).is_err(), "no character");
assert!(s1.run("table.fromText", &json!({"separator": "other", "char": ""})).is_err());
assert!(s1.run("table.fromText", &json!({"separator": "nonsense"})).is_err());
assert!(s1.run("table.fromText", &json!({"autofit": "nonsense"})).is_err());
assert_eq!(body_texts(&s1), ["a-b"]);
run(&mut s1, "table.fromText", json!({"separator": "other", "char": "-", "columns": 1_000_000_000_u64}));
assert_eq!(table_texts(&s1).first().map(Vec::len), Some(63));
}
/// #2592, as Word does: Initial column width Auto gives equal columns across the text width;
/// a width gives every column that preferred width (kept even when the table is drawn narrower
/// to fit the text width), with no table width; both leave the table resizing to its contents.
#[test]
fn convert_text_to_table_initial_column_width() {
let convert = |params: serde_json::Value| {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\tb\tc\nd\te\tf"}));
run(&mut s, "select.all", json!({}));
let r = s.run("table.fromText", ¶ms);
(s, r)
};
let t = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
let (s1, _) = convert(json!({"autofit": "initial"}));
let width = cmd::page::sect(&s1).text_width();
let tb = t(&s1);
assert!(!tb.props.fixed && tb.props.width.is_none());
assert!(tb.grid.iter().all(|&w| (w - width / 3.0).abs() < 0.1), "{:?}", tb.grid);
let (s0, _) = convert(json!({"autofit": "initial", "width": 0}));
assert_eq!(t(&s0).grid, tb.grid, "0 is Auto");
let (s1, _) = convert(json!({"autofit": "initial", "width": 72}));
let tb = t(&s1);
assert_eq!(tb.grid, [72.0, 72.0, 72.0]);
assert!(tb.rows.iter().flat_map(|r| &r.cells).all(|c| c.props.width == Some(72.0)));
assert!(!tb.props.fixed && tb.props.width.is_none());
// 5 in columns are stored as such but drawn within the text width.
let (mut s1, _) = convert(json!({"width": 360}));
assert_eq!(t(&s1).grid, [360.0, 360.0, 360.0]);
let drawn: f32 = s1.layout().pages[0]
.items
.iter()
.filter_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, row: 0, .. } => Some(rect.w),
_ => None,
})
.sum();
assert!(drawn > 0.0 && drawn <= width + 0.5, "{drawn} vs {width}");
// A width is the Initial column width only: AutoFit to contents or the window ignores it.
for mode in ["contents", "window"] {
let (with, _) = convert(json!({"autofit": mode, "width": 72}));
let (without, _) = convert(json!({ "autofit": mode }));
let cell_widths = |s: &Session| t(s).rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.width)).collect::>();
assert_eq!((t(&with).grid, cell_widths(&with)), (t(&without).grid, cell_widths(&without)), "{mode}");
}
// More than 22 in, or less than nothing, is refused without converting.
for bad in [json!(1585), json!(-1), json!("wide")] {
let (s1, r) = convert(json!({ "width": bad }));
assert!(r.is_err(), "{bad}");
assert!(s1.doc.body.iter().all(|b| b.as_table().is_none()));
}
}
/// #2592, as Word does: a vertically merged cell splits into 1 to its height rows; its height
/// cuts it into one cell per row, 1 × 1 changes nothing, a number that doesn't divide the height or more than it is refused with Word's messages and
/// nothing changes, also when neighbours are merged with it first.
#[test]
fn split_cells_of_a_merged_cell_follow_word() {
use wordcraft_doc::props::VMerge::{Continue, None as No, Restart};
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
let merged = || {
let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH");
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 0)}));
run(&mut s, "table.merge", json!({}));
s
};
for params in [json!({"columns": 1, "rows": 3}), json!({"columns": 1, "rows": 4}), json!({"columns": 2, "rows": 3})] {
let mut s = merged();
let (depth, before) = (s.undo_depth(), table_vmerge(&s));
let e = s.run("table.split", ¶ms).unwrap_err().to_string();
assert!(e.contains("The number must be between 1 and 2"), "{e}");
assert_eq!((s.undo_depth(), table_vmerge(&s)), (depth, before));
}
// 1 × 1 on a merged cell is a no-op (Word, 2 to 5 rows).
let mut s = merged();
let before = table_vmerge(&s);
run(&mut s, "table.split", json!({"columns": 1, "rows": 1}));
assert_eq!(table_vmerge(&s), before);
{
let params = json!({"columns": 1, "rows": 2});
let mut s = merged();
run(&mut s, "table.split", params.clone());
assert_eq!(table_vmerge(&s), [[No, No], [No, No], [No, No], [No, No]], "{params}");
assert_eq!(table_texts(&s)[0][0], "AC");
assert_eq!(table_texts(&s)[1][0], "");
}
// A 3-row cell: 2 rows is not a divisor (Word's alert, nothing changes); 1 × 1 is a no-op; 3 rows gives one cell per row.
let tall = || {
let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH");
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(2, 0)}));
run(&mut s, "table.merge", json!({}));
s
};
for params in [json!({"columns": 1, "rows": 2}), json!({"columns": 2, "rows": 2})] {
let mut s = tall();
let (depth, before) = (s.undo_depth(), table_vmerge(&s));
let e = s.run("table.split", ¶ms).unwrap_err();
assert!(matches!(e, crate::CmdError::Params(_)), "{e:?}");
assert!(e.to_string().contains("The number must be a divisor of 3"), "{e}");
assert_eq!((s.undo_depth(), table_vmerge(&s)), (depth, before));
}
let mut s = tall();
run(&mut s, "table.split", json!({"columns": 1, "rows": 1}));
assert_eq!(table_vmerge(&s), [[Restart, No], [Continue, No], [Continue, No], [No, No]]);
assert_eq!(table_texts(&s)[0][0], "ACE");
let mut s = tall();
run(&mut s, "table.split", json!({"columns": 1, "rows": 3}));
assert_eq!(table_vmerge(&s), [[No, No], [No, No], [No, No], [No, No]]);
// Merge cells before split, then 1 × 1: one cell, as before (merging through Split Cells).
let mut s = table_from("A\tB\nC\tD");
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)}));
run(&mut s, "table.split", json!({"columns": 1, "rows": 1}));
assert_eq!(table_spans(&s), [[2], [2]]);
assert_eq!(table_vmerge(&s), [[wordcraft_doc::props::VMerge::Restart], [wordcraft_doc::props::VMerge::Continue]]);
// The merged cell and its neighbours, merged first: the limit is the selection's height.
let mut s = merged();
run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(1, 1)}));
let e = s.run("table.split", &json!({"columns": 1, "rows": 3})).unwrap_err().to_string();
assert!(e.contains("The number must be between 1 and 2"), "{e}");
}
/// #2592, as Word does: a cell merged across columns splits into equal parts inside its old
/// width; the other rows' cells keep their widths.
#[test]
fn split_cells_across_columns_into_equal_parts() {
let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0});
let widths = |s: &Session| -> Vec> {
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
(0..t.rows.len())
.map(|r| {
let row = &t.rows[r];
(0..row.cells.len()).map(|c| (0..row.cells[c].span()).map(|k| t.grid[t.grid_col(r, c) + k]).sum()).collect()
})
.collect()
};
for (across, parts) in [(2, 3), (3, 2), (2, 2)] {
let mut s = table_from("A\tB\tC\nD\tE\tF");
// Unequal columns, as in a real document.
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![0])).unwrap().grid = vec![100.0, 50.0, 150.0];
run(&mut s, "select.range", json!({"anchor": cell(1, 0), "focus": cell(1, across - 1)}));
run(&mut s, "table.merge", json!({}));
let (top, old) = (widths(&s)[0].clone(), widths(&s)[1][0]);
run(&mut s, "table.split", json!({"columns": parts, "rows": 1}));
let w = widths(&s);
let new: Vec = w[1].iter().take(parts as usize).copied().collect();
assert!(new.iter().all(|x| (x - old / parts as f32).abs() < 0.1), "{across}->{parts}: {w:?}");
let mut top_lines: Vec = Vec::new();
let mut x = 0.0;
for v in &w[0] {
x += v;
top_lines.push(x);
}
let mut expect = Vec::new();
let mut x = 0.0;
for v in &top {
x += v;
expect.push(x);
}
assert_eq!(top_lines.len(), expect.len(), "{across}->{parts}: {w:?}");
assert!(top_lines.iter().zip(&expect).all(|(a, b)| (a - b).abs() < 0.1), "{across}->{parts}: {w:?}");
}
}
/// #385: note options for the whole document or one section, and converting notes, which undoes
/// in one step.
#[test]
fn note_options_per_section_and_convert_notes() {
use wordcraft_doc::PartKind;
use wordcraft_doc::para::{InlineObject, NoteKind};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "One"}));
let a = run(&mut s, "references.footnote", json!({"text": "First", "mark": "*"}))["id"].as_u64().unwrap() as u32;
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "layout.break", json!({"kind": "nextPage"}));
run(&mut s, "text.insert", json!({"text": "Two"}));
let b = run(&mut s, "references.footnote", json!({"text": "Second"}))["id"].as_u64().unwrap() as u32;
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
// The second section restarts at 3 in letters; the first keeps the document's options.
let got = run(&mut s, "references.noteOptions", json!({"numFmt": "lowerLetter", "numStart": 3, "numRestart": "eachSect", "scope": "section"}));
assert_eq!((got["footnote"]["numFmt"].as_str(), got["footnote"]["numStart"].as_u64()), (Some("lowerLetter"), Some(3)));
let sections = s.doc.sections();
assert!(sections[0].1.footnote_pr.is_empty());
assert_eq!(sections[1].1.footnote_pr.num_start, Some(3));
// Hostile or wrong values are refused or clamped.
assert!(s.run("references.noteOptions", &json!({"kind": "endnote", "pos": "pageBottom"})).is_err());
assert!(s.run("references.noteOptions", &json!({"kind": "endnote", "numRestart": "eachPage"})).is_err());
let got = run(&mut s, "references.noteOptions", json!({"kind": "endnote", "pos": "sectEnd", "numStart": -5}));
assert_eq!((got["endnote"]["pos"].as_str(), got["endnote"]["numStart"].as_u64()), (Some("sectEnd"), Some(1)));
// The whole document: the section's own start gives way.
run(&mut s, "references.noteOptions", json!({"numStart": 2}));
assert_eq!(s.doc.sections()[1].1.footnote_pr.num_start, None);
assert_eq!(s.doc.settings.footnote_pr.num_start, Some(2));
// Convert footnotes to endnotes: kinds, marks and styles; the custom mark stays. One undo.
let kinds = |s: &Session| -> Vec {
let mut v = Vec::new();
s.doc.objects_in_reading_order(&mut |o| {
if let InlineObject::NoteRef { kind, .. } = o {
v.push(*kind);
}
});
v
};
assert_eq!(run(&mut s, "references.convertNotes", json!({"to": "endnote"}))["converted"], 2);
assert_eq!(kinds(&s), vec![NoteKind::Endnote, NoteKind::Endnote]);
assert!([a, b].iter().all(|id| s.doc.parts[id].kind == PartKind::Endnote));
let note = s.doc.para(StoryRef::Part(a), &wordcraft_doc::Path::top(0)).unwrap();
assert_eq!(note.props.style.as_deref(), Some("EndnoteText"));
assert!(matches!(¬e.objects[0], InlineObject::NoteRef { kind: NoteKind::Endnote, custom, .. } if custom == "*"));
run(&mut s, "edit.undo", json!({}));
assert_eq!(kinds(&s), vec![NoteKind::Footnote, NoteKind::Footnote]);
assert_eq!(s.doc.parts[&b].kind, PartKind::Footnote);
assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err());
}
#[test]
fn next_footnote_skips_endnotes() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "A"}));
run(&mut s, "references.footnote", json!({"text": "Fn1"}));
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "text.insert", json!({"text": "B"}));
run(&mut s, "references.endnote", json!({"text": "En1"}));
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "text.insert", json!({"text": "C"}));
run(&mut s, "references.footnote", json!({"text": "Fn2"}));
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "references.nextFootnote", json!({}));
assert_eq!(s.sel.focus.off, 1);
run(&mut s, "references.nextFootnote", json!({}));
assert_eq!(s.sel.focus.off, 9);
}
/// Tests that install a test dictionary wait for the dictionaries-folder test, which changes the
/// folder and then reads what each language reports: an install in between reports no file.
static DICTIONARIES: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn dictionaries_turn() -> std::sync::MutexGuard<'static, ()> {
DICTIONARIES.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A tiny synthetic Polish Hunspell dictionary (written for these tests). Real dictionaries are
/// installed on the system, never bundled; every Polish test installs this same one.
fn polish_test_dictionary() {
let _turn = dictionaries_turn();
wordcraft_proof::pl::install_dictionary(POLISH_TEST_AFF, POLISH_TEST_DIC).unwrap();
}
const POLISH_TEST_AFF: &str = "SET UTF-8\nTRY aioeznrwcyszżtkpdmłjlubąęgh\nMAP 2\nMAP oó\nMAP zż\nREP 2\nREP ż rz\nREP rz ż\n";
const POLISH_TEST_DIC: &str = "12\npłynie\nrzeka\na\nto\nwiem\nże\ntak\nw\nogóle\nzażółć\ngęślą\njaźń\n";
/// A tiny synthetic Portuguese Hunspell dictionary (written for these tests). Real dictionaries
/// are installed on the system, never bundled; every Portuguese test installs this same one.
fn portuguese_test_dictionary() {
let _turn = dictionaries_turn();
wordcraft_proof::pt::install_dictionary(PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC).unwrap();
}
const PORTUGUESE_TEST_AFF: &str =
"SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD F\nTRY aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêô\nMAP 2\nMAP aáã\nMAP eé\nREP 1\nREP seje seja\n";
const PORTUGUESE_TEST_DIC: &str = "11\nsaudade\ncoração\ne\nele\ndisse\nnão\né\nque\nseja\nfrequencia/F\nmuito\n";
/// #1063: File › Options › Proofing › Dictionaries folder. A folder of Hunspell files is saved,
/// searched first and reported per language; a missing folder or a relative path is refused and
/// changes nothing. The folder holds the same test dictionaries the other tests install, so tests
/// running meanwhile see the same words; it stays set for the rest of the run for that reason.
#[test]
fn dictionaries_folder_option_is_saved_searched_and_reported() {
let _turn = dictionaries_turn();
let dir = std::env::temp_dir().join(format!("wc-engine-dictionaries-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (name, aff, dic) in [
("pl_PL", POLISH_TEST_AFF, POLISH_TEST_DIC),
("pt_BR", PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC),
("id_ID", INDONESIAN_TEST_AFF, INDONESIAN_TEST_DIC),
// The others the tests install, so reloading from the folder keeps their words.
("it_IT", ITALIAN_TEST_AFF, ITALIAN_TEST_DIC),
("es_ES", SPANISH_TEST_AFF, SPANISH_TEST_DIC),
("es_UY", SPANISH_TEST_AFF, URUGUAYAN_TEST_DIC),
// #3252: set_dictionaries_folder() now reloads Czech too (it used to leave its cache
// alone), so a run of this test overlapping czech_proofing_through_the_review_commands
// would otherwise force a reload that finds no cs_CZ here and empties it mid-test. Same
// fixture content either way keeps such a race harmless, as it already does for Polish.
("cs_CZ", czech_test_dict::AFF, czech_test_dict::DIC),
] {
std::fs::write(dir.join(format!("{name}.aff")), aff).unwrap();
std::fs::write(dir.join(format!("{name}.dic")), dic).unwrap();
}
let mut s = s();
let missing = dir.join("no-such-folder");
assert!(s.run("file.options", &json!({"dictionariesFolder": missing.to_string_lossy()})).is_err());
assert!(s.run("file.options", &json!({"dictionariesFolder": "relative/dictionaries"})).is_err());
assert!(s.run("file.options", &json!({"dictionariesFolder": 7})).is_err());
assert_eq!(s.prefs.dictionaries_folder, None, "a refused folder changes nothing");
let r = run(&mut s, "file.options", json!({"dictionariesFolder": dir.to_string_lossy()}));
assert_eq!(s.prefs.dictionaries_folder.as_deref(), Some(dir.to_string_lossy().as_ref()));
assert_eq!(r["dictionariesFolder"], json!(dir.to_string_lossy()));
let dicts = r["dictionaries"].as_array().unwrap();
let file = |lang: &str| dicts.iter().find(|d| d["language"] == lang).map(|d| d["file"].clone());
// Each language reports the folder's file, which is searched first.
let reported = |got: Option<&serde_json::Value>, name: &str| {
let want = dir.join(format!("{name}.dic"));
assert_eq!(got, Some(&json!(want.to_string_lossy())), "{name}: {dicts:?}");
assert_eq!(wordcraft_proof::hunspell::find(&[name]).map(|(_, dic)| dic), Some(want));
};
reported(file("pl").as_ref(), "pl_PL");
reported(file("pt").as_ref(), "pt_BR");
reported(file("id").as_ref(), "id_ID");
// Each Spanish variety reports its own file (Spain, Mexico, Latin America, Uruguay, in order).
let spanish: Vec = dicts.iter().filter(|d| d["language"] == "es").map(|d| d["file"].clone()).collect();
assert_eq!(spanish.len(), 4);
reported(spanish.first(), "es_ES");
reported(spanish.last(), "es_UY");
// Polish text is checked against the folder's dictionary.
run(&mut s, "document.setText", json!({"text": "rzeka płynie żeka"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "pl-PL"}));
let issues = run(&mut s, "review.issues", json!({}));
let words: Vec<&str> = issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str()).collect();
assert_eq!(words, ["żeka"], "{issues}");
// Saved with the settings.
let prefs = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(prefs["dictionariesFolder"], json!(dir.to_string_lossy()));
}
/// Review › Language and the proofing commands work in Portuguese end to end: the language makes
/// the document's words Portuguese, F7 and the issue list use the Portuguese dictionary and
/// grammar, suggestions come from it, and a suggestion fixes the text.
#[test]
fn portuguese_proofing_through_the_review_commands() {
portuguese_test_dictionary();
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Saudade coraçao, e ele ele disse."}));
// English by default: the Portuguese words are English misspellings.
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 5);
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "pt_br", "default": true}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pt-BR"), "the tag is normalized");
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pt-BR");
let issues = run(&mut s, "review.issues", json!({}));
let arr = issues.as_array().unwrap();
let has = |text: &str, kind: &str, sugg: &str| {
arr.iter()
.any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "pt" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg))
};
assert!(has("coraçao", "spelling", "coração"), "{issues}");
assert!(has("ele ele", "grammar", "ele"), "{issues}");
assert_eq!(arr.len(), 2, "{issues}");
assert!(arr.iter().any(|i| i["message"] == "Palavra repetida: \"ele\""), "{issues}");
// F7 from the start selects the first issue; Change applies a suggestion.
run(&mut s, "caret.docStart", json!({}));
let first = run(&mut s, "review.spelling", json!({}));
assert_eq!(first["text"], "coraçao");
run(&mut s, "review.applySuggestion", json!({"text": "coração"}));
assert!(text(&s).starts_with("Saudade coração,"), "{}", text(&s));
// Add to Dictionary works for Portuguese words too.
run(&mut s, "review.addToDictionary", json!({"word": "coraçao"}));
let left = run(&mut s, "review.issues", json!({}));
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
}
fn indonesian_test_dictionary() {
let _turn = dictionaries_turn();
wordcraft_proof::id::install_dictionary(INDONESIAN_TEST_AFF, INDONESIAN_TEST_DIC).unwrap();
}
// Hand-written synthetic fixture (no system dictionary data).
const INDONESIAN_TEST_AFF: &str = "SET UTF-8\nFLAG UTF-8\nTRY aniekrumgtslhbdopy\nSFX A Y 1\nSFX A 0 an .\n";
const INDONESIAN_TEST_DIC: &str = "8\nrumah\nsekolah\nanak\nbermain\ndi\nmakan/A\nsaya\nkami\n";
/// Indonesian proofing (#498) through the same commands as Review, F7, Change and Add to
/// Dictionary.
#[test]
fn indonesian_proofing_through_the_review_commands() {
indonesian_test_dictionary();
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Saya di sekolha, anak anak bermain."}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "id_id", "default": true}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("id-ID"));
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "id-ID");
let issues = run(&mut s, "review.issues", json!({}));
let arr = issues.as_array().unwrap();
assert_eq!(arr.len(), 2, "{issues}");
assert!(arr.iter().all(|i| i["lang"] == "id"), "{issues}");
assert!(
arr.iter().any(|i| i["text"] == "sekolha" && i["kind"] == "spelling" && i["suggestions"].as_array().unwrap().iter().any(|s| s == "sekolah")),
"{issues}"
);
assert!(arr.iter().any(|i| i["text"] == "anak anak" && i["kind"] == "grammar" && i["suggestions"][0] == "anak-anak"), "{issues}");
run(&mut s, "caret.docStart", json!({}));
assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "sekolha");
run(&mut s, "review.applySuggestion", json!({"text": "sekolah"}));
assert!(text(&s).starts_with("Saya di sekolah,"), "{}", text(&s));
run(&mut s, "select.text", json!({"text": "sekolah"}));
run(&mut s, "text.insert", json!({"text": "zqkataidengine"}));
assert_eq!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len(), 2);
run(&mut s, "review.addToDictionary", json!({"word": "zqkataidengine"}));
let left = run(&mut s, "review.issues", json!({}));
let left = left.as_array().unwrap();
assert_eq!(left.len(), 1);
assert_eq!(left[0]["kind"], "grammar");
assert_eq!(left[0]["lang"], "id");
}
/// The spelling issues of the document: each word with its first suggestion.
fn spelling_with_first_suggestion(s: &mut Session) -> Vec<(String, String)> {
let issues = run(s, "review.issues", json!({}));
issues
.as_array()
.unwrap()
.iter()
.filter(|i| i["kind"] == "spelling")
.map(|i| (i["text"].as_str().unwrap().to_string(), i["suggestions"][0].as_str().unwrap_or("").to_string()))
.collect()
}
fn pairs(v: &[(&str, &str)]) -> Vec<(String, String)> {
v.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect()
}
/// English (UK), (Canada) and (Australia) through Review › Language (#1281, #1045): each accepts
/// its own spellings and flags the other dialect's with its own form suggested first, F7 finds
/// it and Change fixes it.
#[test]
fn english_dialects_through_the_review_commands() {
if no_language_data("english_dialects_through_the_review_commands") {
return;
}
let mut s = s();
run(&mut s, "document.setText", json!({"text": "The colour of the center."}));
// US English by default: the British spelling is flagged, the American one isn't.
assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("colour", "color")]));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "en_gb", "default": true}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-GB"));
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "en-GB");
assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("center", "centre")]));
run(&mut s, "caret.docStart", json!({}));
assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "center");
run(&mut s, "review.applySuggestion", json!({"text": "centre"}));
assert_eq!(text(&s), "The colour of the centre.");
assert!(spelling_with_first_suggestion(&mut s).is_empty());
// Canada: British -our and -re, American -ize.
run(&mut s, "document.setText", json!({"text": "We organise the colour and analyze the centre."}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "en-CA", "default": true}));
assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("organise", "organize")]));
// Australia spells the British way: -ise is right, -yze isn't.
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "en-AU", "default": true}));
assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("analyze", "analyse")]));
// Add to Dictionary overrules the dialect.
run(&mut s, "review.addToDictionary", json!({"word": "analyze"}));
assert!(spelling_with_first_suggestion(&mut s).is_empty());
wordcraft_proof::remove_word("analyze");
}
/// A British document keeps its languages through .docx (`w:lang` in the defaults and on a run)
/// and is proofed the same way when it comes back.
#[test]
fn english_dialects_survive_a_docx_round_trip() {
if no_language_data("english_dialects_survive_a_docx_round_trip") {
return;
}
use std::io::Read;
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Colour and color. Centre and center."}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "en-GB", "default": true}));
run(&mut s, "select.text", json!({"text": "Centre and center."}));
run(&mut s, "review.language", json!({"lang": "en-US"}));
let before = spelling_with_first_suggestion(&mut s);
assert_eq!(before, pairs(&[("color", "colour"), ("Centre", "Center")]));
let bytes = crate::io::save_bytes("british.docx", &s.doc).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut part = |name: &str| {
let mut xml = String::new();
zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap();
xml
};
assert!(part("word/styles.xml").contains(r#"w:val="en-GB""#), "the default language is written");
assert!(part("word/document.xml").contains(r#"w:val="en-US""#), "the run's language is written");
let doc = crate::io::open_bytes("british.docx", &bytes).unwrap();
assert_eq!(doc.styles.default_chr.lang.as_deref(), Some("en-GB"));
let mut back = Session::new(doc);
assert_eq!(spelling_with_first_suggestion(&mut back), before);
}
/// An English interface writes new documents in the region's English (English (UK) on a British
/// system, #1045); another region's language leaves US English.
#[test]
fn new_documents_follow_the_regions_english() {
if no_language_data("new_documents_follow_the_regions_english") {
return;
}
let mut s = s();
s.system_region = wordcraft_geom::Region::from_tag("en_GB.UTF-8");
assert_eq!(s.region_english().as_deref(), Some("en-GB"));
let tag = s.region_english();
s.set_editing_language(tag.as_deref());
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-GB"), "the untouched document follows");
run(&mut s, "document.setText", json!({"text": "Colour, color."}));
assert_eq!(spelling_with_first_suggestion(&mut s), pairs(&[("color", "colour")]));
// The region chosen in Options wins over the system's.
run(&mut s, "file.options", json!({"region": "en-CA"}));
assert_eq!(s.region_english().as_deref(), Some("en-CA"));
for (tag, english) in [("pl-PL", None), ("en-US", None), ("en-AU", Some("en-AU")), ("auto", Some("en-GB"))] {
run(&mut s, "file.options", json!({"region": tag}));
assert_eq!(s.region_english().as_deref(), english, "{tag}");
}
s.system_region = wordcraft_geom::Region::from_tag("fr-FR");
assert_eq!(s.region_english(), None);
}
/// A Spanish interface writes new documents in the region's Spanish (#3360): Uruguay's on a
/// Uruguayan system, Mexico's on a Mexican one, Spain's elsewhere.
#[test]
fn new_documents_follow_the_regions_spanish() {
let mut s = s();
s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8");
assert_eq!(s.region_spanish(), "es-UY");
// The ISO 639-2 code is Spanish too (CodeRabbit review on #3562).
s.system_region = wordcraft_geom::Region::from_tag("spa_MX.UTF-8");
assert_eq!(s.region_spanish(), "es-MX");
s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8");
s.set_editing_language(Some(&s.region_spanish()));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-UY"), "the untouched document follows");
for (tag, spanish) in [("es-MX", "es-MX"), ("es-419", "es-419"), ("es-ES", "es-ES"), ("en-US", "es-ES"), ("fr-FR", "es-ES"), ("auto", "es-UY")] {
run(&mut s, "file.options", json!({"region": tag}));
assert_eq!(s.region_spanish(), spanish, "{tag}");
}
}
// Hand-written synthetic fixtures (no system dictionary data): Spain's has `vosotros` forms,
// Uruguay's has `vos` ones' verbs only (the forms come from the voseo rules).
const SPANISH_TEST_AFF: &str =
"SET UTF-8\nFLAG UTF-8\nTRY aeosrnidlctumpbgvyqhfzjñáéíóú\nMAP 2\nMAP eé\nMAP ií\nREP 2\nREP v b\nREP b v\nSFX S Y 1\nSFX S 0 s [aeiou]\n";
const SPANISH_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\nhabéis\nvenido\ndecir\n";
const URUGUAYAN_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\ntener\nvenir\ndecir\n";
fn spanish_test_dictionaries() {
use wordcraft_proof::ProofLang;
let _turn = dictionaries_turn();
for (lang, dic) in [(ProofLang::Es, SPANISH_TEST_DIC), (ProofLang::EsUy, URUGUAYAN_TEST_DIC)] {
wordcraft_proof::es::install_dictionary(lang, SPANISH_TEST_AFF, dic).unwrap();
}
}
/// #3553: Spanish proofing through the same commands as Review, F7, Change and Add to
/// Dictionary: `prueva` and `corector` are flagged with `prueba` and `corrector` offered.
#[test]
fn spanish_proofing_through_the_review_commands() {
spanish_test_dictionaries();
let mut s = s();
run(&mut s, "document.setText", json!({"text": "esto es una prueva del corector. Habéis venido?"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "es_es", "default": true}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-ES"));
let issues = run(&mut s, "review.issues", json!({}));
let arr = issues.as_array().unwrap();
assert!(arr.iter().all(|i| i["lang"] == "es"), "{issues}");
let has = |text: &str, kind: &str, sugg: &str| {
arr.iter().any(|i| i["text"] == text && i["kind"] == kind && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg))
};
assert!(has("prueva", "spelling", "prueba"), "{issues}");
assert!(has("corector", "spelling", "corrector"), "{issues}");
assert!(has("Habéis venido?", "grammar", "¿Habéis venido?"), "{issues}");
assert_eq!(arr.len(), 3, "{issues}");
assert!(arr.iter().any(|i| i["message"] == "Posible error ortográfico"), "{issues}");
run(&mut s, "caret.docStart", json!({}));
assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "prueva");
run(&mut s, "review.applySuggestion", json!({"text": "prueba"}));
assert!(text(&s).starts_with("esto es una prueba del"), "{}", text(&s));
run(&mut s, "review.addToDictionary", json!({"word": "corector"}));
let left = run(&mut s, "review.issues", json!({}));
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
}
/// #3562 review: Add to Dictionary and Ignore All get the issue's own text, decomposed (NFD) as
/// typed; the word is then accepted decomposed and composed, and its issue range was the typed
/// bytes.
#[test]
fn add_and_ignore_all_accept_both_normalization_forms() {
spanish_test_dictionaries();
let mut s = s();
let text = "esto zqanade\u{301}do y zqignora\u{301}do";
run(&mut s, "document.setText", json!({"text": text}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "es-ES"}));
let issues = run(&mut s, "review.issues", json!({}));
let words: Vec<(String, u64)> = issues
.as_array()
.unwrap()
.iter()
.filter(|i| i["kind"] == "spelling")
.map(|i| (i["text"].as_str().unwrap().to_string(), i["start"]["off"].as_u64().unwrap()))
.collect();
assert_eq!(words, [("zqanade\u{301}do".to_string(), 5), ("zqignora\u{301}do".to_string(), text.find("zqign").unwrap() as u64)], "{issues}");
run(&mut s, "review.addToDictionary", json!({"word": words[0].0}));
run(&mut s, "review.ignoreAll", json!({"word": words[1].0}));
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty());
// The composed spellings are the same words.
run(&mut s, "document.setText", json!({"text": "esto zqanadédo y zqignorádo"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "es-ES"}));
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty());
wordcraft_proof::remove_word("zqanadédo");
}
/// Uruguayan Spanish accepts the `vos` forms, imperatives included, that Spain's flags.
#[test]
fn uruguayan_spanish_accepts_vos_forms() {
spanish_test_dictionaries();
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Vení, decime qué tenés."}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "es-UY"}));
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "es-UY");
let spelling = |s: &mut Session| -> Vec {
let issues = run(s, "review.issues", json!({}));
issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str().map(String::from)).collect()
};
assert_eq!(spelling(&mut s), ["qué"], "only the word the fixture lacks");
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "es-ES"}));
assert_eq!(spelling(&mut s), ["Vení", "decime", "qué", "tenés"]);
}
/// Czech proofing through the same commands as Review, F7, Change and Add to Dictionary.
#[test]
fn czech_proofing_through_the_review_commands() {
czech_test_dict::install();
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Město pocitac, škola škola."}));
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 3);
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "cs_cz", "default": true}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("cs-CZ"));
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "cs-CZ");
let issues = run(&mut s, "review.issues", json!({}));
let arr = issues.as_array().unwrap();
assert_eq!(arr.len(), 2, "{issues}");
assert!(arr.iter().all(|i| i["lang"] == "cs"), "{issues}");
assert!(
arr.iter().any(|i| i["text"] == "pocitac" && i["kind"] == "spelling" && i["suggestions"].as_array().unwrap().iter().any(|s| s == "počítač")),
"{issues}"
);
assert!(arr.iter().any(|i| i["text"] == "škola škola" && i["kind"] == "grammar" && i["message"] == "Opakované slovo: „škola“"), "{issues}");
run(&mut s, "caret.docStart", json!({}));
assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "pocitac");
run(&mut s, "review.applySuggestion", json!({"text": "počítač"}));
assert!(text(&s).starts_with("Město počítač,"));
// Exercise dictionary addition on a misspelling still present in the document.
run(&mut s, "select.text", json!({"text": "počítač"}));
run(&mut s, "text.insert", json!({"text": "zqcsenginevlastni"}));
assert_eq!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len(), 2);
run(&mut s, "review.addToDictionary", json!({"word": "zqcsenginevlastni"}));
let left = run(&mut s, "review.issues", json!({}));
let left = left.as_array().unwrap();
assert_eq!(left.len(), 1);
assert_eq!(left[0]["kind"], "grammar");
assert_eq!(left[0]["lang"], "cs");
}
/// Review › Language and the proofing commands work in Polish end to end: the language makes
/// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar,
/// suggestions come from it, and a suggestion fixes the text.
const ITALIAN_TEST_AFF: &str = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n";
const ITALIAN_TEST_DIC: &str = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n";
fn italian_test_dictionary() {
let _turn = dictionaries_turn();
wordcraft_proof::it::install_dictionary(ITALIAN_TEST_AFF, ITALIAN_TEST_DIC).unwrap();
}
/// Review › Language and the proofing commands work in Italian end to end: the language makes
/// the document's words Italian, F7 and the issue list use the Italian dictionary and grammar,
/// suggestions come from it, and a suggestion fixes the text.
#[test]
fn italian_proofing_through_the_review_commands() {
italian_test_dictionary();
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Casa caffe, e roma roma disse."}));
// English by default: the Italian words are English misspellings.
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 3);
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "it_IT", "default": true}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("it-IT"), "the tag is normalized");
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "it-IT");
let issues = run(&mut s, "review.issues", json!({}));
let arr = issues.as_array().unwrap();
let has = |text: &str, kind: &str, sugg: &str| {
arr.iter()
.any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "it" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg))
};
assert!(has("caffe", "spelling", "caffè"), "{issues}");
assert!(has("roma roma", "grammar", "roma"), "{issues}");
assert_eq!(arr.len(), 2, "{issues}");
assert!(arr.iter().any(|i| i["message"] == "Parola ripetuta: \"roma\""), "{issues}");
// F7 from the start selects the first issue; Change applies a suggestion.
run(&mut s, "caret.docStart", json!({}));
let first = run(&mut s, "review.spelling", json!({}));
assert_eq!(first["text"], "caffe");
run(&mut s, "review.applySuggestion", json!({"text": "caffè"}));
assert!(text(&s).starts_with("Casa caffè,"), "{}", text(&s));
// Add to Dictionary works for Italian words too.
run(&mut s, "review.addToDictionary", json!({"word": "caffe"}));
let left = run(&mut s, "review.issues", json!({}));
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
}
#[test]
fn polish_proofing_through_the_review_commands() {
polish_test_dictionary();
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Płynie żeka, a wogóle to wiem że tak."}));
// English by default: the Polish words are English misspellings.
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 5);
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "pl_PL", "default": true}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the tag is normalized");
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pl-PL");
let issues = run(&mut s, "review.issues", json!({}));
let arr = issues.as_array().unwrap();
let has = |text: &str, kind: &str, sugg: &str| {
arr.iter()
.any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "pl" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg))
};
assert!(has("żeka", "spelling", "rzeka"), "{issues}");
assert!(has("wogóle", "spelling", "w ogóle"), "{issues}");
assert!(has(" że", "grammar", ", że"), "{issues}");
assert_eq!(arr.len(), 3, "{issues}");
assert!(arr.iter().any(|i| i["message"] == "Brak przecinka przed „że”"), "{issues}");
// F7 from the start selects the first issue; Change applies a suggestion.
run(&mut s, "caret.docStart", json!({}));
let first = run(&mut s, "review.spelling", json!({}));
assert_eq!(first["text"], "żeka");
run(&mut s, "review.applySuggestion", json!({"text": "rzeka"}));
assert!(text(&s).starts_with("Płynie rzeka,"), "{}", text(&s));
// Add to Dictionary works for Polish words too.
run(&mut s, "review.addToDictionary", json!({"word": "wogóle"}));
let left = run(&mut s, "review.issues", json!({}));
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
}
/// Review › Language and proofing commands work in Turkish end to end.
#[test]
fn turkish_proofing_through_the_review_commands() {
// Spelling and suggestions come from the installed Turkish Hunspell dictionary; machines
// without one (CI, most desktops) have nothing to check here.
if !crate::proof::tr::has_dictionary() {
return;
}
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Bana bazen gelenler oldu ise de. Sana olacaklardan sonra sorna"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "tr-TR", "default": true}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("tr-TR"));
let issues = run(&mut s, "review.issues", json!({}));
let arr = issues.as_array().unwrap();
// "sorna" must be flagged as spelling issue
let sorna_issue = arr.iter().find(|i| i["text"] == "sorna" && i["kind"] == "spelling");
assert!(sorna_issue.is_some(), "sorna must be flagged: {issues}");
// Check suggestions for sorna
let sugs: Vec = sorna_issue.unwrap()["suggestions"].as_array().unwrap().iter().filter_map(|x| x.as_str().map(str::to_string)).collect();
assert!(sugs.contains(&"sonra".to_string()), "sorna must suggest sonra: {sugs:?}");
// Add to Dictionary: adding "sorna" should remove its issue
run(&mut s, "review.addToDictionary", json!({"word": "sorna"}));
let issues_after = run(&mut s, "review.issues", json!({}));
let arr_after = issues_after.as_array().unwrap();
assert!(!arr_after.iter().any(|i| i["text"] == "sorna"), "sorna should no longer be flagged after adding to dictionary: {issues_after}");
}
/// `review.language`: tags are validated and normalized, `noProof` alone keeps the language,
/// no parameters keep the old meaning (US English), and `default` needs a language.
#[test]
fn review_language_validates_tags_and_marks_text_unchecked() {
polish_test_dictionary();
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Hello wrold"}));
run(&mut s, "select.all", json!({}));
assert!(s.run("review.language", &json!({"lang": "not a tag!"})).is_err());
assert!(s.run("review.language", &json!({"lang": ""})).is_err());
assert!(s.run("review.language", &json!({"default": true, "noProof": true})).is_err(), "default needs a language");
assert_eq!(text(&s), "Hello wrold", "a refused command changes nothing");
run(&mut s, "review.language", json!({"noProof": true}));
let st = run(&mut s, "format.state", json!({}));
assert_eq!((st["noProof"].clone(), st["lang"].clone()), (json!(true), json!("en-US")));
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
run(&mut s, "review.language", json!({"noProof": false}));
assert_eq!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len(), 1);
run(&mut s, "review.language", json!({"lang": "PL"}));
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pl");
run(&mut s, "review.language", json!({}));
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "en-US");
// A language without proofing tools: no marks.
run(&mut s, "review.language", json!({"lang": "fr-FR"}));
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
}
/// The editing language (set by the front end from the interface language): new blank
/// documents and opened plain text are written in it; the untouched first document switches,
/// a document with changes never does, and templates stay English.
#[test]
fn new_documents_follow_the_editing_language() {
polish_test_dictionary();
let mut s = s();
s.set_editing_language(Some("pl-PL"));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the untouched blank document switches");
assert!(!s.dirty && !s.can_undo(), "no change to save or undo");
run(&mut s, "text.insert", json!({"text": "Zażółć gęślą jaźń."}));
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
s.set_editing_language(None);
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "a document with changes keeps its language");
s.set_editing_language(Some("pl"));
run(&mut s, "file.new", json!({}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl"));
run(&mut s, "file.new", json!({"template": "letter"}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-US"), "templates are English text");
let data = crate::cmd::insert::base64_encode("Płynie rzeka.".as_bytes());
run(&mut s, "file.open", json!({"path": "notatka.txt", "data": data}));
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl"));
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
// Malformed tags fall back to US English.
s.set_editing_language(Some("not a tag"));
assert_eq!(s.editing_lang, None);
}
/// #2882: Set As Default survives preference serialization and overrides the interface language.
#[test]
fn default_proofing_language_survives_a_restart() {
let mut first = s();
first.set_editing_language(Some("en-US"));
run(&mut first, "review.language", json!({"lang": "FR_fr", "default": true}));
let saved = serde_json::to_vec(&first.prefs).unwrap();
run(&mut first, "review.language", json!({"lang": "en-GB"}));
run(&mut first, "edit.undo", json!({}));
run(&mut first, "file.new", json!({}));
assert_eq!(first.doc.styles.default_chr.lang.as_deref(), Some("fr-FR"));
let mut second = s();
second.prefs = serde_json::from_slice(&saved).unwrap();
second.set_editing_language(Some("pl-PL"));
assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR"));
assert!(!second.dirty && !second.can_undo());
run(&mut second, "file.new", json!({}));
assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR"));
let data = crate::cmd::insert::base64_encode(b"Bonjour");
run(&mut second, "file.open", json!({"path": "note.txt", "data": data}));
assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR"));
let mut english = wordcraft_doc::Document::from_text("English document");
english.styles.default_chr.lang = Some("en-GB".into());
let bytes = crate::io::save_bytes("english.docx", &english).unwrap();
let data = crate::cmd::insert::base64_encode(&bytes);
run(&mut second, "file.open", json!({"path": "english.docx", "data": data}));
second.set_editing_language(Some("ru-RU"));
assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("en-GB"));
run(&mut second, "file.new", json!({"template": "letter"}));
assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("en-US"));
}
/// Older or malformed settings retain the interface fallback and unrelated preferences.
#[test]
fn default_proofing_language_ignores_damaged_preferences() {
for value in [json!(null), json!("not a tag"), json!(17), json!([])] {
let mut session = s();
session.prefs = serde_json::from_value(json!({"defaultLanguage": value, "detectLanguage": false})).unwrap();
session.set_editing_language(Some("pl-PL"));
assert_eq!(session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"));
assert!(!session.prefs.detect_language);
}
let mut old = s();
old.prefs = serde_json::from_str("{}").unwrap();
old.set_editing_language(Some("ru-RU"));
assert_eq!(old.doc.styles.default_chr.lang.as_deref(), Some("ru-RU"));
}
/// #2882: a final protection rejection rolls back the saved default as well as the document.
#[test]
fn rejected_default_proofing_language_does_not_survive_a_restart() {
for saved in [None, Some("pl-PL")] {
let mut session = Session::new(wordcraft_doc::Document::from_text("Locked intro.\nEditable text.\nLocked end."));
session.prefs.default_language = saved.map(str::to_string);
session.set_editing_language(Some("en-US"));
run(&mut session, "select.text", json!({"text": "Editable text."}));
run(&mut session, "review.restrict", json!({"exception": "add"}));
run(&mut session, "review.restrict", json!({"mode": "readOnly"}));
run(&mut session, "select.text", json!({"text": "Editable"}));
// Selection-only language formatting is allowed inside the exception.
run(&mut session, "review.language", json!({"lang": "en-GB"}));
let before = session.doc.clone();
let prefs = serde_json::to_value(&session.prefs).unwrap();
let undo = session.undo_labels();
let error = session.run("review.language", &json!({"lang": "fr-FR", "default": true})).unwrap_err();
assert!(error.to_string().contains("only the editable regions"), "{error}");
assert_eq!(session.doc, before);
assert_eq!(session.undo_labels(), undo);
assert_eq!(serde_json::to_value(&session.prefs).unwrap(), prefs);
let mut restarted = s();
restarted.prefs = serde_json::from_value(prefs).unwrap();
restarted.set_editing_language(Some("en-US"));
run(&mut restarted, "file.new", json!({}));
assert_eq!(restarted.doc.styles.default_chr.lang.as_deref(), Some(saved.unwrap_or("en-US")));
}
}
/// A Polish document keeps its languages through .docx: the default (`w:docDefaults`) and an
/// English run (`w:lang`) come back, and so does the proofing.
#[test]
fn proofing_languages_survive_a_docx_round_trip() {
polish_test_dictionary();
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Płynie żeka. Hello wrold."}));
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
run(&mut s, "select.text", json!({"text": "Hello wrold."}));
run(&mut s, "review.language", json!({"lang": "en-GB"}));
let bytes = crate::io::save_bytes("polski.docx", &s.doc).unwrap();
let doc = crate::io::open_bytes("polski.docx", &bytes).unwrap();
assert_eq!(doc.styles.default_chr.lang.as_deref(), Some("pl-PL"));
let mut back = Session::new(doc);
let issues = run(&mut back, "review.issues", json!({}));
let found: Vec<(String, String)> =
issues.as_array().unwrap().iter().map(|i| (i["text"].as_str().unwrap().to_string(), i["lang"].as_str().unwrap().to_string())).collect();
assert_eq!(found, [("żeka".to_string(), "pl".to_string()), ("wrold".to_string(), "en".to_string())], "{issues}");
}
#[test]
fn hide_white_space_toggles_view_state_only() {
let mut s = s();
let before = s.doc.body.len();
assert!(!s.view.hide_white_space);
assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({}))["value"], true);
assert!(s.view.hide_white_space);
assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({"value": false}))["value"], false);
assert_eq!(s.doc.body.len(), before, "a view option never edits the document");
}
/// The first SmartArt (or other graphic) in the body, selected.
fn select_graphic(s: &mut Session) {
let (path, off) = (0..s.doc.body.len() as u32)
.find_map(|b| {
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![b]))?;
let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(wordcraft_doc::InlineObject::Graphic { .. })))?;
Some((wordcraft_doc::Path(vec![b]), off))
})
.expect("a graphic");
let at = Pos { story: StoryRef::Body, path, off };
let end = Pos { off: off + wordcraft_doc::para::OBJ.len_utf8(), ..at.clone() };
s.sel = crate::Selection { anchor: at, focus: end };
}
/// #497: Insert › SmartArt saves as diagram parts (data, layout, style, colours, drawing) and
/// reopens as the same editable graphic; an edited data part stays read-only.
#[test]
fn smart_art_round_trips_through_docx() {
use std::io::Read;
let mut s = s();
let r = run(
&mut s,
"insert.smartArt",
json!({"layout": "hierarchy", "items": ["Root", "\tLeft", {"text": "Right", "level": 1}], "colors": "colorful"}),
);
assert_eq!(r["smartArt"]["layout"], "hierarchy");
let spec = cmd::smart_art::selected_smart_art(&s).expect("selected after insert");
let bytes = crate::io::save_bytes("sa.docx", &s.doc).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
for part in ["data1", "layout1", "quickStyle1", "colors1", "drawing1"] {
assert!(zip.by_name(&format!("word/diagrams/{part}.xml")).is_ok(), "{part} written");
}
let mut ct = String::new();
zip.by_name("[Content_Types].xml").unwrap().read_to_string(&mut ct).unwrap();
assert!(ct.contains("diagramDrawing+xml") && ct.contains("diagramData+xml"), "{ct}");
let mut s2 = Session::new(crate::io::open_bytes("sa.docx", &bytes).unwrap());
select_graphic(&mut s2);
assert_eq!(cmd::smart_art::selected_smart_art(&s2).as_deref(), Some(&*spec), "reopens editable");
run(&mut s2, "smartArt.layout", json!({"layout": "venn"}));
// Saved again, and once more after reopening: still the same graphic.
let again = crate::io::save_bytes("sa.docx", &s2.doc).unwrap();
let mut s3 = Session::new(crate::io::open_bytes("sa.docx", &again).unwrap());
select_graphic(&mut s3);
assert_eq!(cmd::smart_art::selected_smart_art(&s3).map(|g| g.layout), Some(wordcraft_doc::smart_art::SmartArtLayout::Venn));
// A data part another program rewrote: shown, not editable.
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&again)).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
let name = f.name().to_string();
let mut buf = Vec::new();
f.read_to_end(&mut buf).unwrap();
if name.ends_with("data1.xml") {
buf = String::from_utf8(buf).unwrap().replace("", "").into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut out, &buf).unwrap();
}
let foreign = out.finish().unwrap().into_inner();
let mut s4 = Session::new(crate::io::open_bytes("sa.docx", &foreign).unwrap());
select_graphic(&mut s4);
assert!(cmd::smart_art::selected_smart_art(&s4).is_none());
assert!(s4.run("smartArt.layout", &json!({"layout": "cycle"})).is_err());
}
/// #497: item, layout and colour edits are one undo step each; promote and demote move an item
/// with the items under it.
#[test]
fn smart_art_edits_undo_one_step_each() {
let mut s = s();
run(&mut s, "insert.smartArt", json!({"layout": "process"}));
let items = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| (i.text.clone(), i.level)).collect::>();
let before = items(&s);
run(&mut s, "smartArt.items", json!({"items": ["One", "Two", "\tTwo a"]}));
assert_eq!(items(&s), [("One".to_string(), 0), ("Two".to_string(), 0), ("Two a".to_string(), 1)]);
run(&mut s, "smartArt.demote", json!({"index": 1}));
assert_eq!(items(&s)[1..], [("Two".to_string(), 1), ("Two a".to_string(), 2)]);
run(&mut s, "smartArt.promote", json!({"index": 1}));
run(&mut s, "smartArt.addShape", json!({"after": 0, "text": "New"}));
assert_eq!(items(&s)[1], ("New".to_string(), 0));
run(&mut s, "smartArt.colors", json!({"variant": "accent4"}));
run(&mut s, "smartArt.layout", json!({"layout": "pyramid"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap().layout, wordcraft_doc::smart_art::SmartArtLayout::Process);
for _ in 0..5 {
run(&mut s, "edit.undo", json!({}));
}
assert_eq!(items(&s), before);
assert!(s.run("smartArt.promote", &json!({"index": 0})).is_err(), "already at the top");
run(&mut s, "picture.size", json!({"width": 200, "height": 120, "lockAspect": false}));
}
/// #497: hostile SmartArt params are capped or refused, never a panic.
#[test]
fn smart_art_hostile_params_are_capped() {
use wordcraft_doc::smart_art::{MAX_ITEMS, MAX_LEVEL, MAX_TEXT};
let mut s = s();
let many: Vec = (0..MAX_ITEMS * 3).map(|i| json!({"text": "x".repeat(10_000), "level": i * 1000})).collect();
run(&mut s, "insert.smartArt", json!({"layout": "hierarchy", "items": many, "width": 1e30, "height": -5}));
let g = cmd::smart_art::selected_smart_art(&s).unwrap();
assert_eq!(g.items.len(), MAX_ITEMS);
assert!(g.items.iter().all(|i| i.text.chars().count() <= MAX_TEXT && i.level <= MAX_LEVEL));
for bad in [json!({"items": []}), json!({"items": "x"}), json!({"items": [5]}), json!({"layout": "spiral"}), json!({"layout": 3})] {
assert!(s.run("insert.smartArt", &bad).is_err(), "{bad}");
}
let junk = [json!(null), json!({}), json!({"index": -1, "after": 1e300, "items": [{"level": "deep"}], "variant": [], "layout": {}})];
for id in [
"smartArt.items",
"smartArt.addShape",
"smartArt.promote",
"smartArt.demote",
"smartArt.layout",
"smartArt.colors",
"smartArt.reset",
"smartArt.textPane",
] {
for j in &junk {
let _ = s.run(id, j);
}
}
for _ in 0..MAX_ITEMS {
let _ = s.run("smartArt.addShape", &json!({}));
}
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap().items.len(), MAX_ITEMS);
let _ = s.layout();
}
/// Outline view of: Heading 1 "One", body "a", Heading 2 "Two", body "b", Heading 1 "Three", body "c".
fn outline_doc() -> Session {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "One\na\nTwo\nb\nThree\nc"}));
for (i, style) in [(0, "Heading1"), (2, "Heading2"), (4, "Heading1")] {
s.sel = crate::Selection::caret(Pos::body(i, 0));
run(&mut s, "para.style", json!({"style": style}));
}
run(&mut s, "view.outline", json!({}));
s
}
fn outline_shown(s: &mut Session) -> Vec {
let st = run(s, "outline.state", json!({}));
st["shown"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
/// The body blocks the outline layout draws.
fn laid_out_blocks(s: &mut Session) -> Vec {
let l = s.layout();
let mut v: Vec = l.pages[0]
.items
.iter()
.filter_map(|it| match it {
wordcraft_layout::Placed::Lines { story: StoryRef::Body, path, .. } => path.0.first().copied(),
_ => None,
})
.collect();
v.dedup();
v
}
#[test]
fn outline_level_changes_are_undoable() {
let mut s = outline_doc();
let level = |s: &mut Session, i: usize| run(s, "outline.state", json!({}))["levels"][i].as_u64().unwrap();
s.sel = crate::Selection::caret(Pos::body(1, 0));
run(&mut s, "outline.level", json!({"level": 3}));
assert_eq!(level(&mut s, 1), 3);
run(&mut s, "edit.undo", json!({}));
assert_eq!(level(&mut s, 1), 10, "body text again");
s.sel = crate::Selection::caret(Pos::body(2, 0));
run(&mut s, "outline.promoteToHeading1", json!({}));
assert_eq!(level(&mut s, 2), 1);
run(&mut s, "outline.demoteToBody", json!({}));
assert_eq!(level(&mut s, 2), 10);
assert!(s.run("outline.level", &json!({"level": 12})).is_err());
assert!(s.run("outline.level", &json!({})).is_err());
// Every outline command survives junk parameters in Outline view.
for spec in cmd::registry().all().iter().filter(|c| c.id.starts_with("outline.")) {
for j in [json!(null), json!({"block": 99999, "level": "x", "value": 5}), json!({"block": -1, "level": 0})] {
let mut s = outline_doc();
let _ = s.run(spec.id, &j);
let _ = s.layout();
}
}
}
#[test]
fn outline_show_level_filters_paragraphs() {
let mut s = outline_doc();
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]);
assert_eq!(laid_out_blocks(&mut s), vec![0, 1, 2, 3, 4, 5]);
run(&mut s, "outline.showLevel", json!({"level": 1}));
assert_eq!(outline_shown(&mut s), vec![0, 4]);
assert_eq!(laid_out_blocks(&mut s), vec![0, 4]);
run(&mut s, "outline.showLevel2", json!({}));
assert_eq!(outline_shown(&mut s), vec![0, 2, 4]);
run(&mut s, "outline.showAll", json!({}));
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]);
// Word's keys, and only in Outline view.
let reg = cmd::registry();
assert_eq!(reg.by_shortcut("Alt+Shift+1").map(|c| c.id), Some("outline.showLevel1"));
assert_eq!(reg.by_shortcut("Alt+Shift+A").map(|c| c.id), Some("outline.showAll"));
assert_eq!(reg.by_shortcut("Alt+Shift+=").map(|c| c.id), Some("outline.expand"));
assert_eq!(reg.by_shortcut("Alt+Shift+-").map(|c| c.id), Some("outline.collapse"));
run(&mut s, "outline.close", json!({}));
assert!(s.run("outline.showLevel1", &json!({})).is_err());
}
#[test]
fn outline_collapse_hides_descendants_and_expand_restores() {
let mut s = outline_doc();
s.sel = crate::Selection::caret(Pos::body(0, 0));
// The deepest open level closes first, then the heading itself.
run(&mut s, "outline.collapse", json!({}));
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 4, 5]);
run(&mut s, "outline.collapse", json!({}));
assert_eq!(outline_shown(&mut s), vec![0, 4, 5]);
assert_eq!(laid_out_blocks(&mut s), vec![0, 4, 5]);
// A caret moving into the collapsed text goes on to the next shown paragraph.
run(&mut s, "caret.end", json!({}));
run(&mut s, "caret.right", json!({}));
assert_eq!(s.sel.focus, Pos::body(4, 0));
// Moving "Three" and "c" up passes "One" with everything collapsed under it, in one undo step.
s.sel = crate::Selection { anchor: Pos::body(4, 0), focus: Pos::body(5, 1) };
run(&mut s, "outline.moveUp", json!({}));
assert_eq!(text(&s), "Three\nc\nOne\na\nTwo\nb");
assert_eq!(outline_shown(&mut s), vec![0, 1, 2], "the collapsed heading moved and stays collapsed");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "One\na\nTwo\nb\nThree\nc");
assert_eq!(outline_shown(&mut s), vec![0, 4, 5]);
// Expand opens one level at a time; double-clicking the symbol (toggle) opens it all.
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "outline.expand", json!({}));
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 4, 5]);
run(&mut s, "outline.expand", json!({}));
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]);
run(&mut s, "outline.toggle", json!({"block": 0}));
assert_eq!(outline_shown(&mut s), vec![0, 4, 5]);
run(&mut s, "outline.toggle", json!({"block": 0}));
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]);
// Clicking a symbol selects the heading with what is under it.
run(&mut s, "outline.selectSubtree", json!({"block": 0}));
assert_eq!(s.selected_text(), "One\na\nTwo\nb");
}
/// #3621: with thousands of collapsed headings, an Enter above them (which shifts every index)
/// finds them again without scanning the document once per heading, and a caret move doesn't
/// redo the matching.
#[test]
fn outline_many_collapsed_headings_stay_fast_after_edits() {
const N: usize = 8000;
let text: Vec = (0..N).flat_map(|i| [format!("Heading {i}"), format!("body {i}")]).collect();
let mut d = wordcraft_doc::Document::from_text(&text.join("\n"));
for i in 0..N {
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(2 * i)).unwrap();
p.props.style = Some("Heading1".into());
}
let mut s = Session::new(d);
run(&mut s, "view.outline", json!({}));
run(&mut s, "outline.showLevel1", json!({}));
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "outline.collapse", json!({}));
let collapsed_texts =
|s: &Session| -> Vec { s.view.outline.collapsed.iter().map(|(i, _)| s.doc.body[*i].as_para().unwrap().text.clone()).collect() };
let before = collapsed_texts(&s);
assert_eq!(before.len(), N, "every heading is collapsed");
s.sel = crate::Selection::caret(Pos::body(1, 0));
for _ in 0..10 {
run(&mut s, "text.newParagraph", json!({}));
}
for _ in 0..20 {
run(&mut s, "caret.right", json!({}));
}
assert_eq!(collapsed_texts(&s), before, "the same headings stay collapsed");
}
/// #745: layout keeps a picture's size, so pictures read from HTML, Markdown, ODT or RTF at
/// their pixel size are fitted to the text width when the file is opened (and when pasted),
/// keeping their proportions; small pictures keep their size.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn imported_pictures_fit_the_text_width() {
let png = |w: u32, h: u32| {
let mut b = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png)
.unwrap();
cmd::insert::base64_encode(&b)
};
let sizes = |doc: &wordcraft_doc::Document| -> Vec<(f32, f32)> {
doc.body
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.filter_map(|o| match o {
wordcraft_doc::InlineObject::Image { w, h, .. } => Some((*w, *h)),
_ => None,
})
.collect()
};
let md = format!("\n\n\n", png(1200, 300), png(40, 20));
let doc = crate::io::open_bytes("p.md", md.as_bytes()).unwrap();
let max_w = crate::io::text_width(&doc);
let got = sizes(&doc);
assert_eq!(got.len(), 2, "{got:?}");
assert!((got[0].0 - max_w).abs() < 0.01, "wide picture fits: {got:?} (text width {max_w})");
assert!((got[0].1 - max_w / 4.0).abs() < 0.01, "and keeps its proportions: {got:?}");
assert!((got[1].0 - 30.0).abs() < 0.01 && (got[1].1 - 15.0).abs() < 0.01, "small picture keeps its size: {got:?}");
// Pasting HTML fits our text width too.
let mut s = Session::new(wordcraft_doc::Document::new());
let html = format!("
", png(1200, 300));
s.run("edit.pasteSpecial", &json!({"as": "html", "html": html})).unwrap();
let max_w = crate::io::text_width(&s.doc);
let got = sizes(&s.doc);
assert!(got.iter().any(|(w, _)| (w - max_w).abs() < 0.01), "pasted picture fits: {got:?}");
}
#[test]
fn format_font_sets_the_asian_and_latin_fonts_separately() {
// #505: Word's Font dialog sets an Asian text font and a Latin text font of their own.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Hello 你好"}));
run(&mut s, "select.all", json!({}));
let fonts = |s: &Session| {
let c = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0).clone();
(c.font, c.font_ea)
};
let before = fonts(&s);
run(&mut s, "format.font", json!({"name": "Georgia", "eastAsia": "SimSun"}));
assert_eq!(fonts(&s), (Some("Georgia".into()), Some("SimSun".into())));
assert_eq!(run(&mut s, "format.state", json!({}))["fontEastAsia"], "SimSun");
// The Asian font alone leaves the Latin font as it is.
run(&mut s, "format.font", json!({"eastAsia": "Noto Sans CJK SC"}));
assert_eq!(fonts(&s), (Some("Georgia".into()), Some("Noto Sans CJK SC".into())));
// Each change is one undo step.
run(&mut s, "edit.undo", json!({}));
assert_eq!(fonts(&s), (Some("Georgia".into()), Some("SimSun".into())));
run(&mut s, "edit.undo", json!({}));
assert_eq!(fonts(&s), before);
assert!(s.run("format.font", &json!({})).is_err() && s.run("format.font", &json!({"eastAsia": " "})).is_err());
}
/// The result of the formula field in cell `c` of row `r` of the first table, if there is one.
fn formula_result(s: &Session, r: usize, c: usize) -> Option {
let t = s.doc.body.iter().find_map(|b| b.as_table())?;
t.rows.get(r)?.cells.get(c)?.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).find_map(|o| match o {
wordcraft_doc::InlineObject::Field { instr, result, .. } if instr.starts_with('=') => Some(result.clone()),
_ => None,
})
}
/// A 4 × 4 matrix (values 1 to 9 under the header) with an empty fifth row; the caret in its
/// second cell.
fn formula_table() -> Session {
let mut s = s();
run(&mut s, "table.quick", json!({"kind": "matrix"}));
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 3, 1, 0], "off": 0}}));
run(&mut s, "table.insertRowBelow", json!({}));
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 4, 1, 0], "off": 0}}));
s
}
#[test]
fn table_formula_reads_cell_references_and_functions() {
let mut s = formula_table();
let r = run(&mut s, "table.formula", json!({"formula": "=B2+B3*2"}));
assert_eq!(r["result"], "9");
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("9"));
let mut s = formula_table();
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=IF(SUM(ABOVE)>10, 1, 0)"}))["result"], "1");
let mut s = formula_table();
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(B2:C3) \\# \"0.00\""}))["result"], "12.00");
}
#[test]
fn table_formula_with_an_unknown_function_is_an_error_and_inserts_nothing() {
let mut s = formula_table();
let err = s.run("table.formula", &json!({"formula": "=MEDIAN(ABOVE)"})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(err.contains("MEDIAN"), "{err}");
assert_eq!(formula_result(&s, 4, 1), None);
assert!(s.run("table.formula", &json!({"formula": "=SUM()"})).is_err(), "empty parentheses are a syntax error, as in Word");
}
#[test]
fn table_formula_bare_function_name_takes_its_direction_from_the_cell() {
let mut s = formula_table();
let r = run(&mut s, "table.formula", json!({"formula": "=SUM"}));
assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some("=SUM(ABOVE)"), Some("12")));
}
/// Update Field (F9) works a formula out again from the cells it reads.
#[test]
fn update_fields_recalculates_a_table_formula() {
let mut s = formula_table();
run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}));
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("12"));
// Row 1's 1 becomes 10.
run(
&mut s,
"select.range",
json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}),
);
run(&mut s, "text.insert", json!({"text": "10"}));
run(&mut s, "references.updateFields", json!({}));
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("21"));
}
/// A formula's `\*` switches format its result when the formula is inserted and when Update Field
/// works it out again.
#[test]
fn table_formula_results_take_the_format_switches() {
for (formula, inserted, updated) in [
(r"=SUM(ABOVE) \* CardText", "twelve", "twenty-one"),
(r"=SUM(ABOVE) \* CardText \* Caps", "Twelve", "Twenty-one"),
(r"=SUM(ABOVE) \* OrdText \* Upper", "TWELFTH", "TWENTY-FIRST"),
(r"=SUM(ABOVE) \* MERGEFORMAT", "12", "21"),
] {
let mut s = formula_table();
let r = run(&mut s, "table.formula", json!({"formula": formula}));
assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted)));
run(
&mut s,
"select.range",
json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}),
);
run(&mut s, "text.insert", json!({"text": "10"}));
run(&mut s, "references.updateFields", json!({}));
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some(updated), "{formula}");
}
}
#[test]
fn table_formula_results_take_dollar_text_and_hex() {
for (formula, inserted, updated) in [
(r"=SUM(ABOVE) \* DollarText", "twelve and 00/100", "twenty-one and 00/100"),
(r"=SUM(ABOVE) \* DollarText \* Upper", "TWELVE AND 00/100", "TWENTY-ONE AND 00/100"),
(r"=SUM(ABOVE) \* Hex", "C", "15"),
] {
let mut s = formula_table();
let r = run(&mut s, "table.formula", json!({"formula": formula}));
assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted)));
run(
&mut s,
"select.range",
json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}),
);
run(&mut s, "text.insert", json!({"text": "10"}));
run(&mut s, "references.updateFields", json!({}));
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some(updated), "{formula}");
}
}
/// A one-column table (no header row) holding `cells`, in a document whose default language
/// is `lang`; the caret ends in the last cell.
fn column_table(lang: &str, cells: &[&str]) -> Session {
let mut s = s();
run(&mut s, "review.language", json!({"lang": lang, "default": true}));
run(&mut s, "insert.table", json!({"rows": cells.len(), "cols": 1}));
for (k, c) in cells.iter().enumerate() {
if k > 0 {
run(&mut s, "text.tab", json!({}));
}
if !c.is_empty() {
run(&mut s, "text.insert", json!({"text": c}));
}
}
s
}
fn column_texts(s: &Session) -> Vec {
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::()).collect()
}
/// #836: table sort reads numbers in the text's language: `1,5` is one and a half in Polish.
#[test]
fn table_sort_reads_decimal_comma_in_polish() {
let mut s = column_table("pl-PL", &["12", "1,5", "2"]);
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
assert_eq!(column_texts(&s), ["1,5", "2", "12"]);
run(&mut s, "table.sort", json!({"column": 0, "header": false, "descending": true}));
assert_eq!(column_texts(&s), ["12", "2", "1,5"]);
// English keeps the comma as a thousands separator.
let mut s = column_table("en-US", &["1,250", "12.5", "2"]);
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
assert_eq!(column_texts(&s), ["2", "12.5", "1,250"]);
}
/// #836: table formulas read each cell in its language and write the result with its decimal
/// separator.
#[test]
fn table_formula_reads_and_writes_decimal_comma_in_polish() {
let mut s = column_table("pl-PL", &["3,50", "2,25", ""]);
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "5,75");
let mut s = column_table("pl-PL", &["1 250,00", "100", ""]);
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1350");
let mut s = column_table("pl-PL", &["1\u{a0}250,50 zł", "100", ""]);
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1350,50");
let mut s = column_table("en-US", &["$1,250.50", "3.25", ""]);
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1253.75");
}
/// #837: text sorts follow the language's alphabet: Polish `ł` after `l`, `ć` after `c`.
#[test]
fn table_and_paragraph_sort_use_polish_alphabet() {
let mut s = column_table("pl-PL", &["Zakopane", "Łódź", "Kraków"]);
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
assert_eq!(column_texts(&s), ["Kraków", "Łódź", "Zakopane"]);
let mut s = Session::new(wordcraft_doc::Document::from_text("zebra\nćma\ndom"));
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
run(&mut s, "select.all", json!({}));
run(&mut s, "para.sort", json!({}));
assert_eq!(text(&s), "ćma\ndom\nzebra");
// Swedish puts å ä ö after z; German sorts ä with a.
let mut s = column_table("sv-SE", &["ödla", "zebra", "åka", "apa"]);
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
assert_eq!(column_texts(&s), ["apa", "zebra", "åka", "ödla"]);
let mut s = column_table("de-DE", &["Zug", "Äpfel", "Bach"]);
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
assert_eq!(column_texts(&s), ["Äpfel", "Bach", "Zug"]);
}
/// #837: index entries and letter headings follow the language: `Łódź` files under its own
/// `Ł` heading between `K` and `Z` in Polish.
#[test]
fn index_sorts_polish_entries_and_headings() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Zakopane.\nŁódź.\nKraków."));
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
for w in ["Zakopane", "Łódź", "Kraków"] {
run(&mut s, "select.text", json!({"text": w}));
run(&mut s, "references.markEntry", json!({}));
}
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "references.index", json!({}));
let lines: Vec =
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some("ListEntry")).map(|p| p.plain_text()).collect();
let order: Vec<&str> = lines.iter().map(|l| l.split([',', '\t']).next().unwrap_or("")).collect();
let pos = |w: &str| order.iter().position(|x| *x == w).unwrap_or(usize::MAX);
assert!(pos("Kraków") < pos("Łódź") && pos("Łódź") < pos("Zakopane"), "{lines:?}");
}
/// Review of #844: a numbered column (`1.`, `2.`, `10.`) sorts as numbers; text with no language
/// at all follows the editing language (a Polish interface reads `3,50` as 3.5).
#[test]
fn ordinals_and_untagged_text_sort_and_sum_as_numbers() {
let mut t = column_table("pl-PL", &["10.", "2.", "1."]);
run(&mut t, "table.sort", json!({"column": 0, "header": false}));
assert_eq!(column_texts(&t), ["1.", "2.", "10."]);
let mut t = column_table("en-US", &["10", "(50)", "20"]);
run(&mut t, "table.sort", json!({"column": 0, "header": false}));
assert_eq!(column_texts(&t), ["(50)", "10", "20"]);
let mut s = s();
s.set_editing_language(Some("pl-PL"));
s.doc.styles.default_chr.lang = None;
run(&mut s, "insert.table", json!({"rows": 3, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "3,50"}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "2,25"}));
run(&mut s, "text.tab", json!({}));
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "5,75");
}
/// Review of #844: the bibliography lists Polish authors in the Polish alphabet.
#[test]
fn bibliography_sorts_polish_authors() {
let mut s = s();
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
for author in ["Mazur, Jan", "Łukasiewicz, Jan", "Lis, Anna"] {
run(&mut s, "references.citation", json!({"source": {"author": author, "title": format!("Książka {author}"), "year": "2020"}}));
run(&mut s, "text.insert", json!({"text": " "}));
}
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "references.bibliography", json!({}));
let all = text(&s);
// The list, not the citations before it.
let t = all.split_once("References").map_or(all.as_str(), |(_, list)| list);
let at = |name: &str| t.find(&format!("{name}, ")).unwrap_or_else(|| panic!("{name} in {all}"));
assert!(at("Lis") < at("Łukasiewicz") && at("Łukasiewicz") < at("Mazur"), "{t}");
}
/// A new blank document takes its paper from the region, and lengths are shown in the region's
/// unit with its decimal separator; an explicit Measurement units choice still wins (#838).
#[test]
fn new_documents_follow_the_region() {
// Poland: A4, centimetres, decimal comma.
let mut s = s();
run(&mut s, "file.options", json!({"region": "pl-PL"}));
run(&mut s, "file.new", json!({}));
let sp = &s.doc.last_section;
assert_eq!(wordcraft_geom::paper_name(sp.page_w, sp.page_h), Some("A4"));
assert_eq!(sp.margin_left, 72.0);
assert_eq!(s.units(), wordcraft_geom::Unit::Centimeters);
assert_eq!(s.format_length(sp.margin_left), "2,54 cm");
let o = run(&mut s, "file.options", json!({"pane": "advanced", "units": "auto"}));
assert_eq!((o["units"].as_str(), o["unitsFromRegion"].as_bool()), (Some("centimeters"), Some(true)));
assert_eq!((o["region"]["paper"].as_str(), o["region"]["decimal"].as_str()), (Some("A4"), Some(",")));
// The user's own choice wins over the region, and `auto` gives the region's back.
run(&mut s, "file.options", json!({"units": "inches"}));
assert_eq!(s.units(), wordcraft_geom::Unit::Inches);
assert_eq!(s.format_length(108.0), "1,5\"");
run(&mut s, "file.options", json!({"units": "auto"}));
assert_eq!(s.units(), wordcraft_geom::Unit::Centimeters);
// A chosen region is a saved preference over the system's; `auto` returns to the system's.
assert_eq!(s.prefs.region.as_deref(), Some("pl-PL"));
let saved: serde_json::Value = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(saved["region"], "pl-PL");
s.system_region = wordcraft_geom::Region::from_tag("ja-JP");
assert_eq!(s.region().territory, "PL", "the choice wins over the system");
let o = run(&mut s, "file.options", json!({"region": "auto"}));
assert_eq!((o["regionFromSystem"].as_bool(), o["region"]["territory"].as_str()), (Some(true), Some("JP")));
assert_eq!(s.prefs.region, None);
run(&mut s, "file.options", json!({"region": "de_AT.UTF-8@euro"}));
assert_eq!(s.prefs.region.as_deref(), Some("de-AT"), "kept as a short tag");
run(&mut s, "file.options", json!({"region": ""}));
assert_eq!(s.prefs.region, None);
// The United States: Letter and inches.
let mut us = Session::new(wordcraft_doc::Document::new());
run(&mut us, "file.options", json!({"region": "en-US"}));
run(&mut us, "file.new", json!({"template": "blank"}));
let sp = &us.doc.last_section;
assert_eq!((sp.page_w, sp.page_h), (612.0, 792.0));
assert_eq!(us.units(), wordcraft_geom::Unit::Inches);
assert_eq!(us.format_length(72.0), "1\"");
run(&mut us, "file.options", json!({"units": "centimeters"}));
assert_eq!(us.format_length(72.0), "2.54 cm");
// Templates keep their own size; a headless session is en-US; junk is refused or read as en-US.
let mut t = Session::new(wordcraft_doc::Document::new());
run(&mut t, "file.options", json!({"region": "de-DE"}));
run(&mut t, "file.new", json!({"template": "letter"}));
assert_eq!(t.doc.last_section.page_w, crate::sample::letter().last_section.page_w);
assert_eq!(Session::new(wordcraft_doc::Document::new()).blank_document().last_section.page_w, 612.0);
assert!(t.run("file.options", &json!({"region": 7})).is_err());
assert!(t.run("file.options", &json!({"units": "furlongs"})).is_err());
run(&mut t, "file.options", json!({"region": "\u{0}\u{FFFF}-zz-ZZZZZZZZ"}));
assert_eq!(t.region(), wordcraft_geom::Region::default());
assert_eq!(t.prefs.region.as_deref(), Some("en-US"), "junk is saved as en-US");
}
/// Automatic (HTML-style) paragraph spacing: `para.spacing` turns it on and off, and a number
/// replaces it, as typing one in Word's Paragraph dialog does; Ctrl+0 and Remove Space After work
/// on it too.
#[test]
fn spacing_commands_handle_automatic_spacing() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Pasted from the web"}));
let rp = |s: &Session| s.doc.styles.resolve_para(&s.doc.para_at(&s.sel.focus).unwrap().props);
run(&mut s, "para.spacing", json!({"beforeAuto": true, "afterAuto": true}));
assert_eq!((rp(&s).space_before, rp(&s).space_after, rp(&s).before_auto), (14.0, 14.0, true));
run(&mut s, "para.spacing", json!({"before": 6}));
assert_eq!((rp(&s).space_before, rp(&s).before_auto, rp(&s).after_auto), (6.0, false, true), "a number replaces Auto");
run(&mut s, "para.removeSpaceAfter", json!({}));
assert_eq!((rp(&s).space_after, rp(&s).after_auto), (0.0, false));
run(&mut s, "para.spacing", json!({"beforeAuto": true}));
run(&mut s, "para.addSpaceBefore", json!({}));
assert_eq!((rp(&s).space_before, rp(&s).before_auto), (0.0, false), "Ctrl+0 removes the automatic space");
assert!(s.run("para.spacing", &json!({"beforeAuto": "yes"})).is_ok(), "junk is ignored, never a panic");
}
/// #712, as in Word: Ctrl+F (`edit.find` with no text) opens the Navigation pane and asks the UI
/// to put the keyboard in its search box, not the dialog; Ctrl+H (`edit.replace` with nothing to
/// replace) opens Find and Replace on its Replace tab instead of failing; Go To moves by pages from
/// the caret's page (`pageBy`).
#[test]
fn find_replace_and_go_to_open_like_word() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "cat dog cat"}));
run(&mut s, "edit.find", json!({}));
assert!(s.view.nav_pane);
assert_eq!(s.ui_requests.last(), Some(&json!({"focus": "navSearch"})));
assert!(!s.ui_requests.iter().any(|r| r.get("open").is_some()), "no dialog: {:?}", s.ui_requests);
s.dirty = false;
run(&mut s, "edit.findReplace", json!({}));
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "replace"})));
assert!(!s.dirty, "opening the dialog changes nothing");
assert!(s.run("edit.findReplace", &json!({"tab": "nope"})).is_err());
let reg = s.registry.clone();
assert_eq!(s.keymap.resolve(®, "Mod+H").map(|c| c.id), Some("edit.findReplace"));
run(&mut s, "edit.goto", json!({}));
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "goto"})));
// Replace with a "with" and the last search still replaces, as before.
run(&mut s, "edit.find", json!({"text": "dog"}));
run(&mut s, "edit.replace", json!({"with": "cow"}));
assert_eq!(text(&s), "cat cow cat");
// A replace with no `text` and no earlier search is a mistake, not "0 matches" (#3271).
let mut s = super::tests::s();
for (id, params) in [("edit.replaceAll", json!({"find": "cat", "replace": "dog"})), ("edit.replace", json!({})), ("edit.replaceAll", json!({}))] {
let e = s.run(id, ¶ms).unwrap_err().to_string();
assert!(e.contains("`text` (string) is required"), "{id}: {e}");
}
run(&mut s, "edit.replaceAll", json!({"text": "", "with": "x"}));
// Five pages; Go To +2 / -1 from where the caret is, clamped to the document.
let mut s = super::tests::s();
for i in 0..5 {
if i > 0 {
run(&mut s, "insert.pageBreak", json!({}));
}
run(&mut s, "text.insert", json!({"text": format!("Page {}", i + 1)}));
}
let page = |s: &mut Session| s.layout().caret_on(&s.sel.focus, s.page_hint).unwrap().page + 1;
run(&mut s, "edit.goto", json!({"page": 1}));
assert_eq!(page(&mut s), 1);
run(&mut s, "edit.goto", json!({"pageBy": 2}));
assert_eq!(page(&mut s), 3);
run(&mut s, "edit.goto", json!({"pageBy": -1}));
assert_eq!(page(&mut s), 2);
run(&mut s, "edit.goto", json!({"pageBy": 99}));
assert_eq!(page(&mut s), 5, "past the end: the last page");
run(&mut s, "edit.goto", json!({"pageBy": -99}));
assert_eq!(page(&mut s), 1, "before the start: the first page");
assert!(s.run("edit.goto", &json!({"pageBy": i64::MIN})).is_ok(), "hostile numbers never panic");
}
/// #802: Insert › Picture takes Windows Metafiles (and EMF) at their own frame size, stored under
/// their own extension so a save writes them as WMF/EMF parts.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn insert_picture_takes_a_windows_metafile() {
use wordcraft_doc::para::InlineObject;
// One WMF record: size in words, function, parameters.
let rec = |func: u16, params: &[i16]| {
let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec();
v.extend(func.to_le_bytes());
params.iter().for_each(|p| v.extend(p.to_le_bytes()));
v
};
// A placeable WMF, 1440 × 720 units at 1440 per inch (72 × 36 points), drawing a rectangle.
let body = [rec(0x041B, &[720, 1440, 0, 0]), rec(0, &[])].concat();
let mut wmf = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
for v in [0i16, 0, 0, 1440, 720] {
wmf.extend(v.to_le_bytes());
}
wmf.extend(1440u16.to_le_bytes());
wmf.extend([0u8; 6]);
let words = ((wmf.len() + 18 + body.len()) / 2) as u32;
for v in [1u16, 9, 0x300] {
wmf.extend(v.to_le_bytes());
}
wmf.extend(words.to_le_bytes());
wmf.extend(0u16.to_le_bytes());
wmf.extend(5u32.to_le_bytes());
wmf.extend(0u16.to_le_bytes());
wmf.extend(body);
let dir = scratch_dir("insert-wmf");
let path = dir.join("logo.wmf");
std::fs::write(&path, &wmf).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
let r = run(&mut s, "insert.picture", json!({"path": path.to_string_lossy()}));
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(72.0), Some(36.0)), "{r}");
let key = r["media"].as_str().unwrap().to_string();
assert!(key.ends_with(".wmf"), "{key}");
assert_eq!(s.doc.media.get(&key).map(|b| b.len()), Some(wmf.len()));
let mut placed = false;
s.doc.objects_in_reading_order(&mut |o| placed |= matches!(o, InlineObject::Image { media, .. } if *media == key));
assert!(placed, "the picture is in the text");
// Neither a picture nor a metafile: the same error as before, naming the formats.
std::fs::write(&path, b"not a picture").unwrap();
let err = s.run("insert.picture", &json!({"path": path.to_string_lossy()})).unwrap_err();
assert!(err.to_string().contains("WMF, EMF"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Change Picture must keep the replacement's own format: the part's name and content type
/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word
/// and LibreOffice then show a broken picture).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn picture_change_keeps_the_replacement_format() {
let dir = scratch_dir("picture-change-format");
let first = dir.join("first.png");
std::fs::write(&first, tiny_png()).unwrap();
let mut jpeg = Vec::new();
image::RgbImage::from_pixel(2, 2, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
let second = dir.join("second.jpg");
std::fs::write(&second, &jpeg).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()}));
run(&mut s, "picture.change", json!({"path": second.to_string_lossy()}));
let mut media = String::new();
s.doc.objects_in_reading_order(&mut |o| {
if let wordcraft_doc::para::InlineObject::Image { media: m, .. } = o {
media = m.clone();
}
});
assert!(media.ends_with(".jpeg") || media.ends_with(".jpg"), "the JPEG keeps its format: {media}");
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert_eq!(back.media.get(&media).map(|b| b.as_slice()), Some(jpeg.as_slice()), "the saved part is the JPEG");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn json_open_repairs_hostile_run_lengths() {
use wordcraft_doc::{Block, Document};
// "héllo": é is two bytes (1..3); a run ending at 2 splits it, usize::MAX overflows a sum.
for lens in [vec![2, 4], vec![usize::MAX, usize::MAX], vec![1], vec![], vec![0, 99]] {
let mut d = Document::from_text("héllo");
if let Some(Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) {
p.runs = lens.iter().map(|&len| wordcraft_doc::para::Run { len, props: Default::default() }).collect();
}
let bytes = serde_json::to_vec(&d).unwrap();
let (doc, _) = crate::io::open_bytes_with("x.json", &bytes, None).unwrap();
let Some(Block::Para(p)) = doc.body.first().map(|b| &**b) else { panic!("no paragraph") };
let mut end = 0;
for (r, _) in p.run_ranges() {
assert!(p.text.is_char_boundary(r.end) && r.end > r.start, "{lens:?}");
end = r.end;
}
assert_eq!(end, p.text.len(), "{lens:?}");
}
}
#[test]
fn outline_aliases_add_one_undo_step() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "one"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "two"}));
for id in ["outline.promote", "outline.demote", "outline.moveUp", "outline.moveDown"] {
let (doc, sel, depth) = (s.doc.clone(), s.sel.clone(), s.undo_depth());
run(&mut s, id, json!({}));
assert_ne!(s.doc, doc, "{id} changed nothing");
assert_eq!(s.undo_depth(), depth + 1, "{id}");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc, doc, "{id}: one undo restores the document");
assert_eq!(s.sel, sel, "{id}: one undo restores the selection");
run(&mut s, "edit.redo", json!({}));
}
}
/// Insert › Link › Place in This Document (#1234): a link to a bookmark, a heading or the top of
/// the document, the list of those places, and following the link.
#[test]
fn links_to_places_in_this_document() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Intro"}));
run(&mut s, "para.style", json!({"style": "Heading1"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "para.style", json!({"style": "Normal"}));
run(&mut s, "text.insert", json!({"text": "Body text here"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Details"}));
run(&mut s, "para.style", json!({"style": "Heading2"}));
run(&mut s, "select.text", json!({"text": "Body"}));
run(&mut s, "insert.bookmark", json!({"name": "Mark1"}));
let links = |s: &Session| -> Vec<(String, String)> {
s.doc
.body
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.run_ranges().filter_map(|(r, c)| Some((c.link.clone()?, p.text.get(r)?.to_string()))).collect::>())
.collect()
};
// The places: the top, both headings with their levels, and the one visible bookmark.
let t = run(&mut s, "link.targets", json!({}));
assert_eq!(t["top"], true);
let heads: Vec<(u64, String)> =
t["headings"].as_array().unwrap().iter().map(|h| (h["level"].as_u64().unwrap(), h["text"].as_str().unwrap().to_string())).collect();
assert_eq!(heads, vec![(1, "Intro".to_string()), (2, "Details".to_string())]);
assert_eq!(t["bookmarks"], json!(["Mark1"]));
// A bookmark link on the selection.
run(&mut s, "select.text", json!({"text": "here"}));
run(&mut s, "insert.link", json!({"bookmark": "Mark1"}));
assert_eq!(links(&s), vec![("#Mark1".to_string(), "here".to_string())]);
// A heading link at a caret inserts the heading's text; it reuses the heading's hidden bookmark.
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
run(&mut s, "insert.link", json!({"heading": t["headings"][1]["block"]}));
let l = links(&s);
assert_eq!(l.len(), 2, "{l:?}");
let (hidden, shown) = l.iter().find(|(a, _)| a.starts_with("#_Toc")).cloned().unwrap();
assert_eq!(shown, "Details", "{l:?}");
let t2 = run(&mut s, "link.targets", json!({}));
assert_eq!(t2["bookmarks"], json!(["Mark1"]), "the hidden bookmark is not listed");
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
run(&mut s, "insert.link", json!({"heading": t["headings"][1]["block"], "text": "again"}));
assert!(links(&s).iter().all(|(a, _)| a == "#Mark1" || *a == hidden), "one bookmark for the heading: {:?}", links(&s));
// The top of the document, and following the links.
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
run(&mut s, "insert.link", json!({"top": true}));
assert!(links(&s).iter().any(|(a, t)| a == "#_top" && t == "Top of the Document"), "{:?}", links(&s));
run(&mut s, "edit.goto", json!({"bookmark": "Mark1"}));
assert_ne!(s.sel.focus, Pos::body(0, 0));
run(&mut s, "edit.goto", json!({"bookmark": "_top"}));
assert_eq!(s.sel.focus, Pos::body(0, 0));
// Refusals change nothing.
let rev = s.rev();
assert!(s.run("insert.link", &json!({"bookmark": "Nope"})).is_err());
assert!(s.run("insert.link", &json!({"heading": 1})).is_err(), "a body paragraph is not a heading");
assert_eq!(s.rev(), rev);
}
/// A DOCX built in code: a tracked deletion inside a field result, as Word writes when a
/// formula field's result is edited (#1037). The first field's result is split across the field
/// end; the second has its whole result deleted.
fn tracked_field_result_docx() -> Vec {
use std::io::Write;
const WHO: &str = r#"w:author="Tester" w:date="2026-10-12T00:00:00Z""#;
let field = |instr: &str, inside: &str, after: &str| {
format!(
r#" {instr} {inside}{after}"#
)
};
let del = |id: u32, t: &str| format!(r#"{t}"#);
let ins = |id: u32, t: &str| format!(r#"{t}"#);
let p1 = field("=SUM(ABOVE)", &del(1, "$"), &(del(2, "150.00") + &ins(3, "$140.00")));
let p2 = field("=2+3", &del(4, "5"), &ins(5, "five"));
let xml = format!(
r#"{p1}{p2}"#
);
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, value) in [
("word/document.xml", xml.as_str()),
(
"_rels/.rels",
r#""#,
),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(value.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn tracked_deletion_inside_a_field_result_is_a_revision_and_is_saved() {
let doc = wordcraft_docx::read(&tracked_field_result_docx()).unwrap();
// Revisions with one author and date are shared: one deletion, one insertion.
let kinds: Vec<_> = doc.revisions.iter().map(|r| r.kind).collect();
assert_eq!(kinds, [wordcraft_doc::RevisionKind::Delete, wordcraft_doc::RevisionKind::Insert]);
// The deleted `$` is still in the text, marked deleted (not ordinary result text).
let p = doc.body[0].as_para().unwrap();
assert_eq!(p.text.replace(wordcraft_doc::para::OBJ, ""), "$150.00$140.00");
// Saving keeps the deletion inside the result, with its text as delText.
let bytes = wordcraft_docx::write(&doc).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
let sep = xml.find(r#"w:fldCharType="separate""#).unwrap();
let end = xml.find(r#"w:fldCharType="end""#).unwrap();
let inside = &xml[sep..end];
assert!(inside.contains(" Vec {
body_texts(s).iter().map(|t| t.replace(wordcraft_doc::para::OBJ, "")).collect()
}
#[test]
fn accept_and_reject_all_handle_a_tracked_deletion_inside_a_field_result() {
let open = || Session::new(wordcraft_docx::read(&tracked_field_result_docx()).unwrap());
let mut s = open();
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(visible_texts(&s), ["$140.00", "five"]);
assert!(s.doc.revisions.is_empty());
let mut s = open();
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(visible_texts(&s), ["$150.00", "5"]);
}
#[test]
fn rename_document_preserves_saved_contents_and_rejects_collisions() {
let dir = std::env::temp_dir().join(format!("wordcraft-rename-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("original.docm");
let renamed = dir.join("Notes.docm");
let existing = dir.join("existing.docm");
std::fs::write(&original, b"original bytes").unwrap();
std::fs::write(&existing, b"keep these bytes").unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
s.path = Some(original.clone());
s.dirty = true;
assert!(s.run("file.rename", &json!({"name": "existing"})).is_err());
assert_eq!(s.path.as_ref(), Some(&original));
assert_eq!(std::fs::read(&existing).unwrap(), b"keep these bytes");
let roots = crate::file_access::AutomationRoots::new(Some(&dir), None).unwrap();
assert!(crate::file_access::confine(roots.as_ref(), || s.run("file.rename", &json!({"name": "Notes"}))).is_err());
assert!(original.exists());
assert!(!renamed.exists());
s.run("file.rename", &json!({"name": " Notes "})).unwrap();
assert_eq!(s.path.as_ref(), Some(&renamed));
assert!(s.dirty);
assert!(!original.exists());
assert_eq!(std::fs::read(&renamed).unwrap(), b"original bytes");
s.run("file.rename", &json!({"name": "Notes"})).unwrap();
s.run("file.rename", &json!({"name": "notes"})).unwrap();
let lower = dir.join("notes.docm");
assert_eq!(s.path.as_ref(), Some(&lower));
let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
assert!(names.contains(&"notes.docm".into()) && !names.contains(&"Notes.docm".into()), "{names:?}");
assert_eq!(std::fs::read(&lower).unwrap(), b"original bytes");
assert!(s.run("file.rename", &json!({"name": "existing"})).is_err());
assert_eq!(s.path.as_ref(), Some(&lower));
assert_eq!(std::fs::read(&existing).unwrap(), b"keep these bytes");
// A name given with the document's extension is the whole file name: an agent or a script
// passes one, and appending the extension again made "Report.docm.docm" (#2983).
s.run("file.rename", &json!({"name": "Report.docm"})).unwrap();
assert_eq!(s.path.as_ref(), Some(&dir.join("Report.docm")));
assert!(!dir.join("Report.docm.docm").exists());
// Another extension, or the extension alone, is a stem and still gets one.
s.run("file.rename", &json!({"name": "Report.2026"})).unwrap();
assert_eq!(s.path.as_ref(), Some(&dir.join("Report.2026.docm")));
s.run("file.rename", &json!({"name": ".docm"})).unwrap();
assert_eq!(s.path.as_ref(), Some(&dir.join(".docm.docm")));
assert_eq!(std::fs::read(dir.join(".docm.docm")).unwrap(), b"original bytes");
// A name whose bytes do not line up with the extension's length: comparing by slicing at
// `len - ext.len()` could land inside a character and panic (#2983 review).
s.run("file.rename", &json!({"name": "abécde"})).unwrap();
assert_eq!(s.path.as_ref(), Some(&dir.join("abécde.docm")));
s.run("file.rename", &json!({"name": "résumé.docm"})).unwrap();
assert_eq!(s.path.as_ref(), Some(&dir.join("résumé.docm")));
// Whatever its case; the file is named as typed, so the extension follows the name.
s.run("file.rename", &json!({"name": "Report.DOCM"})).unwrap();
assert_eq!(s.path.as_ref(), Some(&dir.join("Report.DOCM")));
assert!(!dir.join("Report.DOCM.docm").exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn rename_unsaved_document_names_the_file_not_the_document() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.doc.core.title = "Quarterly report".into();
for name in ["", " ", "../report", "a/b", "a\\b", "a\nb", "name."] {
assert!(s.run("file.rename", &json!({"name": name})).is_err(), "{name:?}");
assert!(s.file_name.is_none());
}
s.run("file.rename", &json!({"name": " Résumé notes "})).unwrap();
assert_eq!(s.file_name.as_deref(), Some("Résumé notes"));
assert_eq!(s.doc.core.title, "Quarterly report");
assert!(s.path.is_none());
assert!(!s.dirty);
assert!(!s.can_undo());
s.run("file.new", &json!({})).unwrap();
assert!(s.file_name.is_none());
s.run("file.rename", &json!({"name": "Draft"})).unwrap();
s.run("file.open", &json!({"path": "other.txt", "data": "aGk="})).unwrap();
assert!(s.file_name.is_none());
s.run("file.new", &json!({})).unwrap();
s.run("file.rename", &json!({"name": "Draft"})).unwrap();
s.run("review.compare", &json!({"text": "changed", "showIn": "new"})).unwrap();
assert!(s.file_name.is_none() && s.path.is_none());
}
/// A version snapshot belongs to the document it was taken from: opening another document (or
/// starting a new one) starts a new version history, so a restore can't put the previous
/// document's content into the open one (#1327 follow-up).
#[test]
fn a_version_snapshot_does_not_survive_a_document_switch() {
let mut s = Session::new(wordcraft_doc::Document::from_text("First document."));
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
assert_eq!(s.versions.len(), 1);
// A new document: the snapshot of the previous one is gone with it.
s.run("file.new", &json!({})).unwrap();
assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch");
assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable");
assert_ne!(s.doc.plain_text(StoryRef::Body), "First document.", "the old document's content came back");
// The same for an opened file.
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
s.run("file.open", &json!({"path": "other.txt", "data": "aGk="})).unwrap();
assert!(s.versions.is_empty(), "the snapshot of the previous document survived the switch");
assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable");
assert_eq!(s.doc.plain_text(StoryRef::Body), "hi");
// A snapshot of the open document still restores into it.
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
s.run("text.insert", &json!({"text": " there"})).unwrap();
s.run("file.versions", &json!({"restore": 0})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "hi");
}
/// A comparison that opens the original replaces the open document, so the version snapshots
/// of the document it replaces go with it: a restore must not put the previous document back
/// over the comparison result (#1327 follow-up).
#[test]
fn a_version_snapshot_does_not_survive_a_comparison() {
let dir = std::env::temp_dir().join(format!("wordcraft-compare-versions-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("original.docx");
let mut other = Session::new(wordcraft_doc::Document::from_text("Original document."));
other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap();
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
assert_eq!(s.versions.len(), 1);
s.run("review.compare", &json!({"original": original.to_string_lossy(), "text": "Revised document."})).unwrap();
assert!(s.versions.is_empty(), "the snapshot of the previous document survived the comparison");
assert!(s.run("file.versions", &json!({"restore": 0})).is_err(), "the old snapshot is still restorable");
let _ = std::fs::remove_dir_all(&dir);
}
/// Compare's new and revised results are untitled documents even without an `original` path.
#[test]
fn a_version_snapshot_does_not_survive_an_untitled_comparison() {
for show_in in ["new", "revised"] {
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
s.run("review.compare", &json!({"text": "Revised document.", "showIn": show_in})).unwrap();
assert!(s.versions.is_empty(), "old snapshots survived a {show_in} comparison");
assert!(s.run("file.versions", &json!({"restore": 0})).is_err());
assert!(s.path.is_none() && s.file_name.is_none());
}
}
/// A comparison with an `original` replaces the open document in place — the handler loads the
/// original into `s.doc` without `set_document` — so `document_id` does not move and two
/// identical comparisons produce identical documents. A comparison is an edit of its own all
/// the same: the `replaces_document` guard keeps its undo step (#4004 review).
#[test]
fn a_repeated_comparison_is_still_an_edit() {
let dir = std::env::temp_dir().join(format!("wordcraft-compare-twice-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("original.docx");
let mut other = Session::new(wordcraft_doc::Document::from_text("Original document."));
other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap();
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
let compare = json!({"original": original.to_string_lossy(), "text": "Revised document."});
s.run("review.compare", &compare).unwrap();
let depth = s.undo_depth();
assert!(s.dirty);
s.run("review.compare", &compare).unwrap();
assert_eq!(s.undo_depth(), depth + 1, "the second comparison is an edit of its own");
assert!(s.dirty, "the comparison result is unsaved");
let _ = std::fs::remove_dir_all(&dir);
}
/// A rejected comparison leaves the current document and its snapshots together.
#[test]
fn a_rejected_comparison_keeps_the_current_versions() {
use wordcraft_doc::{CharProps, InlineObject};
let mut s = Session::new(wordcraft_doc::Document::from_text("Editable. Locked."));
s.path = Some(std::path::PathBuf::from("original.docx"));
s.file_name = Some("original.docx".into());
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
let end = s.doc.end_of(StoryRef::Body);
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
let start = s.doc.start_of(StoryRef::Body);
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.doc.clone();
assert!(s.run("review.compare", &json!({"text": "Different document.", "showIn": "new"})).is_err());
assert_eq!(s.doc, before);
assert_eq!(s.versions.len(), 1, "a rejected comparison discarded the current document's snapshot");
assert_eq!(s.path.as_deref(), Some(std::path::Path::new("original.docx")));
assert_eq!(s.file_name.as_deref(), Some("original.docx"));
}
/// A mailing result opens beside a protected source document, which keeps its save target,
/// versions, password and text.
#[test]
fn a_mailing_result_beside_a_protected_document_keeps_its_file_identity() {
use wordcraft_doc::{CharProps, InlineObject};
let mut s = Session::new(wordcraft_doc::Document::from_text("Protected source."));
s.path = Some(std::path::PathBuf::from("source.docx"));
s.file_name = Some("source.docx".into());
s.run("file.versions", &json!({"save": "snapshot"})).unwrap();
let end = s.doc.end_of(StoryRef::Body);
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
let start = s.doc.start_of(StoryRef::Body);
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: s.author.clone() }, &CharProps::default()).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.doc.clone();
let before_id = s.document_id();
let before_dirty = s.dirty;
s.password = Some(crate::io::Password::new("synthetic file password"));
s.bib_style = "Chicago".into();
s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).unwrap();
assert_ne!(s.document_id(), before_id, "the envelope is a new document");
s.activate(before_id).unwrap();
assert_eq!(s.doc, before);
assert_eq!(s.versions.len(), 1);
assert_eq!(s.path.as_deref(), Some(std::path::Path::new("source.docx")));
assert_eq!(s.file_name.as_deref(), Some("source.docx"));
assert_eq!(s.document_id(), before_id);
assert_eq!(s.dirty, before_dirty);
assert_eq!(s.password.as_ref().map(crate::io::Password::as_str), Some("synthetic file password"));
assert_eq!(s.bib_style, "Chicago");
}
/// #1344: File › Info › Inspect Document finds hidden text by its effective formatting (direct,
/// character style, paragraph style) in every story — body, header, footer, footnote, comment,
/// text box — and Remove All deletes exactly that text, in one undo step, through a DOCX save.
#[test]
fn inspect_document_hidden_text_in_every_story_and_from_styles() {
use wordcraft_doc::props::{CharProps, ParaProps};
use wordcraft_doc::styles::{Style, StyleKind};
use wordcraft_doc::{Paragraph, PartKind, para_block};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Body words"}));
run(&mut s, "review.newComment", json!({"text": "c"}));
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "insert.textBox", json!({"text": "t"}));
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "references.footnote", json!({"text": "n"}));
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "references.endnote", json!({"text": "e"}));
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "insert.header", json!({"text": "h"}));
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "insert.footer", json!({"text": "f"}));
s.sel = crate::Selection::caret(Pos::body(0, 0));
let hidden = CharProps { hidden: Some(true), ..Default::default() };
s.doc.styles.upsert(Style { id: "HideC".into(), name: "Hide C".into(), kind: StyleKind::Character, chr: hidden.clone(), ..Default::default() });
s.doc.styles.upsert(Style {
id: "HideP".into(),
name: "Hide P".into(),
kind: StyleKind::Paragraph,
based_on: Some("Normal".into()),
chr: hidden.clone(),
..Default::default()
});
let plain = CharProps::default();
let blocks = |tag: &str| {
let mut a = Paragraph::with_text(&format!("keep-{tag}-1 "), plain.clone());
for (t, c) in [
(format!("hide-{tag}-direct"), hidden.clone()),
(format!(" keep-{tag}-2 "), plain.clone()),
(format!("hide-{tag}-cstyle"), CharProps { style: Some("HideC".into()), ..Default::default() }),
(format!(" keep-{tag}-3"), plain.clone()),
] {
let at = a.len();
a.insert_text(at, &t, &c).unwrap();
}
let mut b = Paragraph::with_text(&format!("hide-{tag}-pstyle"), plain.clone());
b.props = ParaProps { style: Some("HideP".into()), ..Default::default() };
// Shown again by direct formatting over the hidden paragraph style: it stays.
let mut c = Paragraph::with_text(&format!("keep-{tag}-4"), CharProps { hidden: Some(false), ..Default::default() });
c.props = ParaProps { style: Some("HideP".into()), ..Default::default() };
vec![para_block(a), para_block(b), para_block(c)]
};
let tag = |k: PartKind| match k {
PartKind::Header => "header",
PartKind::Footer => "footer",
PartKind::Footnote => "footnote",
PartKind::Endnote => "endnote",
PartKind::Comment => "comment",
PartKind::TextBox => "textBox",
};
s.doc.body.extend(blocks("body"));
// A table cell in the body, with the same pieces.
let mut table = wordcraft_doc::table::Table::new(1, 2, 200.0);
table.rows[0].cells[0].blocks = blocks("cell");
s.doc.body.push(std::sync::Arc::new(wordcraft_doc::Block::Table(table)));
s.doc.body.push(para_block(Paragraph::new()));
let mut tags = vec![("body", "body"), ("cell", "body")];
for part in s.doc.parts.values_mut() {
part.blocks.extend(blocks(tag(part.kind)));
tags.push((tag(part.kind), tag(part.kind)));
}
tags.sort();
assert_eq!(tags.iter().map(|t| t.0).collect::>(), ["body", "cell", "comment", "endnote", "footer", "footnote", "header", "textBox"]);
let stories = |d: &wordcraft_doc::Document| std::iter::once(StoryRef::Body).chain(d.parts.keys().map(|k| StoryRef::Part(*k))).collect::>();
let para_counts = |d: &wordcraft_doc::Document| stories(d).into_iter().map(|st| (st, d.para_paths(st).len())).collect::>();
let counts_before = para_counts(&s.doc);
let all_text = |d: &wordcraft_doc::Document| -> String {
std::iter::once(StoryRef::Body).chain(d.parts.keys().map(|k| StoryRef::Part(*k))).map(|st| d.plain_text(st)).collect::>().join("\n")
};
// Inspect reports every hidden piece, in its story.
let found = run(&mut s, "file.inspect", json!({}))["found"].clone();
assert_eq!(found["hiddenText"], true);
let items = found["hidden"].as_array().cloned().unwrap_or_default();
for (t, story) in &tags {
for how in ["direct", "cstyle", "pstyle"] {
let want = format!("hide-{t}-{how}");
assert!(
items.iter().any(|i| i["story"] == *story && i["text"].as_str().is_some_and(|x| x.contains(&want))),
"{want} not reported: {items:?}"
);
}
}
assert!(!items.iter().any(|i| i["text"].as_str().is_some_and(|x| x.contains("keep-"))), "visible text reported: {items:?}");
// Remove All: every hidden piece goes, every visible one stays, in one undo step.
let before = all_text(&s.doc);
let undo_len = s.undo_labels().len();
let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]}));
assert_eq!(out["residual"], json!([]), "{out}");
let after = all_text(&s.doc);
for (t, _) in &tags {
for n in 1..=4 {
assert!(after.contains(&format!("keep-{t}-{n}")), "keep-{t}-{n} lost:\n{after}");
}
}
assert!(!after.contains("hide-"), "hidden text left:\n{after}");
// The paragraphs hidden through their style went, marks and all (the body lost two: its own
// and the table cell's); the paragraph shown again by direct formatting stayed.
let expected: Vec<_> = counts_before.iter().map(|(st, n)| (*st, n - if *st == StoryRef::Body { 2 } else { 1 })).collect();
assert_eq!(para_counts(&s.doc), expected);
assert_eq!(s.undo_labels().len(), undo_len + 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(all_text(&s.doc), before, "one undo brings it all back");
run(&mut s, "edit.redo", json!({}));
assert_eq!(all_text(&s.doc), after);
assert_eq!(run(&mut s, "file.inspect", json!({}))["found"]["hiddenText"], false);
// Save and reopen: none of the hidden strings is in the file.
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let reopened = all_text(&back);
assert!(!reopened.contains("hide-"), "hidden text in the saved file:\n{reopened}");
assert!(reopened.contains("keep-header-4") && reopened.contains("keep-comment-3"), "{reopened}");
assert!(reopened.contains("keep-endnote-2") && reopened.contains("keep-cell-4"), "{reopened}");
}
fn hidden_props() -> wordcraft_doc::props::CharProps {
wordcraft_doc::props::CharProps { hidden: Some(true), ..Default::default() }
}
/// #1344: document defaults that hide text hide it everywhere they aren't overridden.
#[test]
fn inspect_document_removes_text_hidden_by_document_defaults() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Paragraph, para_block};
let mut s = s();
s.doc.styles.default_chr.hidden = Some(true);
s.doc.body = vec![
para_block(Paragraph::with_text("hide-default", CharProps::default())),
para_block(Paragraph::with_text("keep-default", CharProps { hidden: Some(false), ..Default::default() })),
];
let found = run(&mut s, "file.inspect", json!({}))["found"].clone();
assert!(found["hidden"].as_array().unwrap().iter().any(|i| i["text"] == "hide-default"), "{found}");
let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]}));
assert_eq!(out["residual"], json!([]), "{out}");
assert_eq!(text(&s), "keep-default");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1);
}
/// #1344: a hidden piece holding a content control's end keeps the marker (its start is outside).
#[test]
fn inspect_document_keeps_content_control_markers() {
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Paragraph, para_block};
let mut s = s();
let mut p = Paragraph::with_text("keep-a ", CharProps::default());
let plain = CharProps::default();
p.insert_object(p.len(), InlineObject::ControlStart { control: Box::default() }, &plain).unwrap();
p.insert_text(p.len(), "keep-cc ", &plain).unwrap();
p.insert_text(p.len(), "hide-cc", &hidden_props()).unwrap();
p.insert_object(p.len(), InlineObject::ControlEnd, &hidden_props()).unwrap();
p.insert_text(p.len(), "hide-after", &hidden_props()).unwrap();
p.insert_text(p.len(), " keep-b", &plain).unwrap();
s.doc.body = vec![para_block(p)];
let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]}));
assert_eq!(out["residual"], json!([]), "{out}");
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
assert_eq!(p.text.replace('\u{FFFC}', ""), "keep-a keep-cc keep-b");
let objs: Vec<_> = p.object_offsets().into_iter().filter_map(|o| p.object_at(o)).collect();
assert!(matches!(objs.as_slice(), [InlineObject::ControlStart { .. }, InlineObject::ControlEnd]), "{objs:?}");
}
/// #1344: text hidden by a table style isn't resolved by the inspector, so it says so: the table
/// is listed as unsupported and the removal's residual isn't empty.
#[test]
fn inspect_document_lists_tables_whose_style_hides_text() {
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts};
let mut s = s();
s.doc.styles.upsert(Style {
id: "HideHeader".into(),
name: "Hide Header".into(),
kind: StyleKind::Table,
table: Some(TableStyleParts { header_chr: hidden_props(), ..Default::default() }),
..Default::default()
});
let mut table = wordcraft_doc::table::Table::new(2, 2, 200.0);
table.props.style = Some("HideHeader".into());
s.doc.body.insert(0, std::sync::Arc::new(wordcraft_doc::Block::Table(table)));
let found = run(&mut s, "file.inspect", json!({}))["found"].clone();
let unsupported = found["unsupported"].as_array().cloned().unwrap_or_default();
assert!(unsupported.iter().any(|u| u["object"] == "table" && u["story"] == "body" && u["style"] == "HideHeader"), "{found}");
let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]}));
assert!(out["residual"].as_array().is_some_and(|r| r.iter().any(|u| u["object"] == "table")), "{out}");
}
/// #1344: removing a hidden footnote reference removes its note too, as deleting a reference does,
/// and reports the note's text.
#[test]
fn inspect_document_removes_the_note_of_a_hidden_reference() {
use wordcraft_doc::PartKind;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Body"}));
let id = run(&mut s, "references.footnote", json!({"text": "note-visible"}))["id"].as_u64().unwrap() as u32;
let path = wordcraft_doc::Path::top(0);
let p = s.doc.para_mut(StoryRef::Body, &path).unwrap();
let off = p.object_offsets()[0];
p.format(off, off + '\u{FFFC}'.len_utf8(), &|c| c.hidden = Some(true)).unwrap();
let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]}));
assert_eq!(out["residual"], json!([]), "{out}");
assert!(!s.doc.parts.contains_key(&id), "orphan note part left");
let notes = out["removedNotes"].as_array().cloned().unwrap_or_default();
assert!(notes.iter().any(|n| n["kind"] == "footnote" && n["text"].as_str().is_some_and(|t| t.contains("note-visible"))), "{out}");
assert_eq!(text(&s), "Body");
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert!(!back.parts.values().any(|p| p.kind == PartKind::Footnote), "note in the saved file");
assert_eq!(back.plain_text(StoryRef::Body), "Body");
}
/// #1344: many hidden pieces (index entries are hidden text) are removed quickly, and the
/// found list is capped.
#[test]
fn inspect_document_removes_thousands_of_hidden_pieces() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Paragraph, para_block};
let mut s = s();
s.doc.body = (0..2000)
.map(|i| {
let mut p = Paragraph::with_text(&format!("word{i} "), CharProps::default());
p.insert_text(p.len(), &format!("XE \"entry{i}\""), &hidden_props()).unwrap();
p.insert_text(p.len(), " more", &CharProps::default()).unwrap();
para_block(p)
})
.collect();
let t = std::time::Instant::now();
let found = run(&mut s, "file.inspect", json!({}))["found"].clone();
assert_eq!(found["hiddenCount"], 2000);
assert_eq!(found["hidden"].as_array().map(Vec::len), Some(1000));
assert_eq!(found["truncated"], true);
let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]}));
assert_eq!(out["residual"], json!([]));
assert!(!text(&s).contains("XE"));
assert!(text(&s).contains("word1999 more"));
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
}
/// #1344: a hidden paragraph between two tables stays (emptied, its mark still hidden), or the
/// tables would join into one when the file is reopened.
#[test]
fn inspect_document_keeps_a_hidden_paragraph_between_tables() {
use wordcraft_doc::{Block, Paragraph, para_block};
let mut s = s();
let table = || std::sync::Arc::new(Block::Table(wordcraft_doc::table::Table::new(1, 1, 100.0)));
let mut p = Paragraph::with_text("hide-between", hidden_props());
p.mark.hidden = Some(true);
s.doc.body = vec![table(), para_block(p), table(), para_block(Paragraph::with_text("keep-end", Default::default()))];
let out = run(&mut s, "file.inspect", json!({"remove": ["hidden"]}));
assert_eq!(out["residual"], json!([]), "{out}");
let kinds = |d: &wordcraft_doc::Document| d.body.iter().map(|b| matches!(**b, Block::Table(_))).collect::>();
assert_eq!(kinds(&s.doc), [true, false, true, false]);
let kept = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap();
assert!(kept.is_empty() && kept.mark.hidden == Some(true), "emptied, mark still hidden: {kept:?}");
assert!(!s.doc.plain_text(StoryRef::Body).contains("hide-"));
assert_eq!(run(&mut s, "file.inspect", json!({}))["found"]["hiddenText"], false);
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert_eq!(kinds(&back).iter().filter(|t| **t).count(), 2, "the tables stay apart");
}
/// #1381: the reporter's sample HTML (synthetic, 1.7 KB).
const SAMPLE_1381: &str = r##"Testo iniziale
Premessa
Il presente documento descrive l’organizzazione della struttura sanitaria di prova e le misure
adottate per la gestione del rischio clinico. I contenuti sono sintetici
e servono solo a verificare la resa della conversione.
Le attività sono svolte secondo le raccomandazioni ministeriali vigenti e sono oggetto
di revisione annuale da parte del comitato per la valutazione dei sinistri.
Identificazione dei rischi per reparto
Analisi degli eventi avversi e dei near miss
segnalazione spontanea
revisione delle cartelle cliniche
Piano annuale di miglioramento
Raccolta delle segnalazioni
Classificazione dell’evento
Azioni correttive
Indicatore
Valore atteso
Valore rilevato
Segnalazioni analizzate entro 30 giorni
≥ 90%
93%
Audit clinici svolti
4
5
Totale azioni correttive aperte
12
Fonte: elaborazione interna su dati sintetici.
"##;
/// The first table's grid width in points.
fn first_table_width(doc: &wordcraft_doc::Document) -> f32 {
doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.grid.iter().sum::()) } else { None }).unwrap()
}
#[test]
fn opened_html_text_and_markdown_get_the_region_paper() {
// #1381: a file that gives no page size opens on the region's paper, as a new blank document
// does (#838): A4 in Italy, Letter in the US. Tables fit that page.
let dir = std::env::temp_dir().join(format!("wordcraft-region-paper-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let html = dir.join("S05.html");
std::fs::write(&html, SAMPLE_1381).unwrap();
for (tag, size) in [("it-IT", (595.28, 841.89)), ("en-US", (612.0, 792.0))] {
let mut s = s();
s.system_region = wordcraft_geom::Region::from_tag(tag);
// By path (pictures load from beside the file) and by bytes (the web, MCP).
run(&mut s, "file.open", json!({"path": html.to_string_lossy()}));
let sp = s.doc.last_section.clone();
assert_eq!((sp.page_w, sp.page_h), size, "{tag}: HTML by path");
assert!(first_table_width(&s.doc) <= sp.text_width() + 0.5, "{tag}: the table fits the page");
for (name, body) in [("S05.html", SAMPLE_1381), ("notes.txt", "Hello"), ("notes.md", "# Title\n\n|a|b|\n|-|-|\n|1|2|\n")] {
let data = crate::cmd::insert::base64_encode(body.as_bytes());
run(&mut s, "file.open", json!({"path": name, "data": data}));
let sp = &s.doc.last_section;
assert_eq!((sp.page_w, sp.page_h), size, "{tag}: {name}");
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn opened_documents_with_their_own_page_size_keep_it() {
// #1381: only files without a page size take the region's paper.
for (w, h) in [(500.0, 700.0), (612.0, 792.0)] {
let mut doc = wordcraft_doc::Document::from_text("Own size");
doc.last_section.page_w = w;
doc.last_section.page_h = h;
for name in ["own.docx", "own.rtf", "own.odt"] {
let bytes = crate::io::save_bytes(name, &doc).unwrap();
let mut s = s();
s.system_region = wordcraft_geom::Region::from_tag("it-IT");
run(&mut s, "file.open", json!({"path": name, "data": crate::cmd::insert::base64_encode(&bytes)}));
let sp = &s.doc.last_section;
assert_eq!((sp.page_w.round(), sp.page_h.round()), (w, h), "{name}");
}
}
}
#[test]
fn html_converted_to_docx_keeps_bold_headers_and_right_aligned_cells() {
// #1381: `wordcraft-cli convert S05.html out.docx`: the
runs are bold and the
// `text-align:right` cells' paragraphs carry `w:jc w:val="right"`.
use std::io::Read;
let mut s = s();
run(&mut s, "file.open", json!({"path": "S05.html", "data": crate::cmd::insert::base64_encode(SAMPLE_1381.as_bytes())}));
let bytes = crate::io::save_bytes("out.docx", &s.doc).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
let cell = |text: &str| {
let at = xml.find(&format!(">{text}<")).unwrap_or_else(|| panic!("{text} in {xml}"));
let start = xml[..at].rfind("").or_else(|| xml[..at].rfind("").unwrap();
xml[start..end].to_string()
};
for head in ["Indicatore", "Valore atteso", "Valore rilevato"] {
assert!(cell(head).contains(""), "{head}: {}", cell(head));
}
for right in ["93%", "4", "5", "12"] {
assert!(cell(right).contains(r#""#), "{right}: {}", cell(right));
}
let left = cell("Audit clinici svolti");
assert!(!left.contains(r#""#) && !left.contains(""), "{left}");
}
#[test]
fn exported_page_images_draw_headers_at_full_strength() {
// file.exportPng (and `wordcraft-cli render`) drew headers and footers dimmed, as the screen
// does while the body is edited; Word's exported pages and WordCraft's PDF don't.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Body"}));
let mut hp = wordcraft_doc::Paragraph::with_text("HEADER", wordcraft_doc::props::CharProps { size: Some(28.0), ..Default::default() });
hp.props.align = Some(wordcraft_doc::props::Align::Left);
let id = s.doc.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]);
s.doc.last_section.headers.default = Some(id);
let dir = std::env::temp_dir().join(format!("wordcraft-png-header-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("p.png");
s.run("file.exportPng", &json!({"path": path.to_string_lossy(), "scale": 1.0})).unwrap();
let img = image::open(&path).unwrap().to_luma8();
// The darkest pixel in the top tenth of the page (the header) is black, not grey.
let top = img.height() / 10;
let darkest = (0..top).flat_map(|y| (0..img.width()).map(move |x| (x, y))).map(|(x, y)| img.get_pixel(x, y)[0]).min().unwrap();
assert!(darkest < 60, "header drawn dimmed: darkest pixel {darkest}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Shape Format › Shape Fill with transparency and gradients (#1500): each form sets the fill of
/// every selected shape in one undo step; a colour alone keeps the transparency; hostile values
/// are clamped; the old `{"color"}` form still works.
#[test]
fn shape_fill_transparency_and_gradients() {
use wordcraft_doc::fill::{Fill, GradientKind, PathShape};
use wordcraft_doc::props::Rgb;
let mut s = s();
run(&mut s, "insert.shape", json!({"kind": "rectangle"}));
run(&mut s, "select.collapse", json!({"end": true}));
run(&mut s, "insert.shape", json!({"kind": "ellipse"}));
run(&mut s, "select.objects", json!({"index": 0}));
run(&mut s, "select.addObject", json!({"index": 1}));
let fills = |s: &Session| -> Vec