// Reuse our hand-written Czech proof fixture; its install helper resolves this parent import.
use wordcraft_proof::cs::install_dictionary;
#[path = "../../proof/src/cs/test_dict.rs"]
mod czech_test_dict;
use serde_json::json;
use wordcraft_doc::props::BorderStyle;
use wordcraft_doc::{Pos, StoryRef};
use crate::{Session, cmd};
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}");
}
#[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"));
}
#[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).
let reg = cmd::registry();
let id = |k: &str| reg.by_shortcut(k).map(|c| c.id);
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"));
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"));
}
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 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");
}
/// 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());
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 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": ""}));
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("\nBáo cáo
tháng 9
",
"\nBá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 { 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 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);
}
/// 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() {
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() {
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 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),
] {
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());
assert_eq!(file("pl"), Some(json!(dir.join("pl_PL.dic").to_string_lossy())));
assert_eq!(file("pt"), Some(json!(dir.join("pt_BR.dic").to_string_lossy())));
assert_eq!(file("id"), Some(json!(dir.join("id_ID.dic").to_string_lossy())));
// 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() {
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");
}
/// 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.
fn italian_test_dictionary() {
let aff = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n";
let dic = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n";
wordcraft_proof::it::install_dictionary(aff, 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);
}
/// 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");
}
/// #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 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");
// 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");
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());
}
/// #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);
}
/// Text typed right after a footnote or endnote number is plain body text, not another raised number.
#[test]
fn typing_after_a_note_number_is_not_superscript() {
for (cmd_id, style) in [("references.footnote", "FootnoteReference"), ("references.endnote", "EndnoteReference")] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "One"}));
run(&mut s, cmd_id, json!({"text": "Note"}));
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
assert_eq!(s.doc.para_at(&s.sel.focus).and_then(|p| p.props_at(p.len()).style.clone()).as_deref(), Some(style));
assert_eq!(s.typing_props().style, None, "{cmd_id}");
run(&mut s, "text.insert", json!({"text": " after"}));
let end = s.doc.end_of(StoryRef::Body);
assert_eq!(s.doc.para_at(&end).map(|p| p.props_of_char(p.len() - 1).style.clone()), Some(None), "{cmd_id}");
}
}
/// Gap 19, review: text inserted by one author and deleted by another keeps both marks through
/// an ODT save, so Reject All (undo the deletion, then the insertion) and Accept All both remove it.
#[test]
fn odt_inserted_then_deleted_text_round_trips() {
use std::io::Write;
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#;
let info = |who: &str| format!("{who}2024-03-01T10:00:00");
let content = format!(
r#"{}{}goneKeep this."#,
info("Alice"),
info("Bob")
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
}
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
let doc = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap();
for cmd in ["review.rejectAll", "review.acceptAll"] {
let mut s = Session::new(doc.clone());
run(&mut s, cmd, json!({}));
assert_eq!(text(&s), "Keep this.", "{cmd}");
}
}
/// Gap 19: an ODT's tracked insertion and multi-paragraph deletion come in as revisions that
/// Reject All turns back into the original and Accept All into the edited text.
#[test]
fn odt_tracked_changes_accept_and_reject_all() {
use std::io::Write;
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#;
let info = |who: &str| format!("{who}2024-03-01T10:00:00");
let content = format!(
r#"{}{}tailheadFirst rest added.End."#,
info("Alice"),
info("Bob")
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
}
let bytes = z.finish().unwrap().into_inner();
let doc = crate::io::open_bytes("t.odt", &bytes).unwrap();
let mut sr = Session::new(doc.clone());
run(&mut sr, "review.rejectAll", json!({}));
assert_eq!(text(&sr), "First tail\nhead rest .\nEnd.");
assert!(sr.doc.revisions.is_empty() || run(&mut sr, "review.changes", json!({})).as_array().unwrap().is_empty());
let mut sa = Session::new(doc);
run(&mut sa, "review.acceptAll", json!({}));
assert_eq!(text(&sa), "First rest added.\nEnd.");
assert!(run(&mut sa, "review.changes", json!({})).as_array().unwrap().is_empty());
}
/// Gap 19, review 2: a paragraph break deleted inside an insertion range is not itself
/// inserted, so Reject All keeps the two paragraphs (after an ODT save, too).
#[test]
fn odt_deleted_mark_inside_an_insertion_survives_reject_all() {
use std::io::Write;
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#;
let info = |who: &str| format!("{who}2024-03-01T10:00:00");
let content = format!(
r#"{}{}One AC two"#,
info("Alice"),
info("Bob")
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
}
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
let saved = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap();
for d in [doc, saved] {
let mut s = Session::new(d);
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(text(&s), "One \n two");
}
}
/// #1320: `layout.verticalAlign` sets the section's vertical alignment, Justified included
/// ("justify", or "both"/"justified"); a value it doesn't know is Top, as before. Section JSON
/// for `layout.pageSetup` keeps its names.
#[test]
fn vertical_align_command_sets_justified() {
use wordcraft_doc::section::PageVAlign;
let mut s = s();
let va = |s: &Session| crate::cmd::page::sect(s).valign;
for (value, want) in [
("justify", PageVAlign::Justify),
("center", PageVAlign::Center),
("both", PageVAlign::Justify),
("bottom", PageVAlign::Bottom),
("justified", PageVAlign::Justify),
("top", PageVAlign::Top),
("sideways", PageVAlign::Top),
] {
run(&mut s, "layout.verticalAlign", json!({"value": value}));
assert_eq!(va(&s), want, "{value}");
}
run(&mut s, "layout.verticalAlign", json!({"value": "justify"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(va(&s), PageVAlign::Top, "one Undo step");
let spec = s.registry.get("layout.verticalAlign").unwrap();
assert!(spec.params.contains("justify"), "documents its values: {}", spec.params);
// Section JSON: today's names still read, and Justified is "justify".
let mut props = serde_json::to_value(crate::cmd::page::sect(&s)).unwrap();
assert_eq!(props["valign"], json!("top"));
for (name, want) in [("center", PageVAlign::Center), ("bottom", PageVAlign::Bottom), ("justify", PageVAlign::Justify), ("top", PageVAlign::Top)] {
props["valign"] = json!(name);
run(&mut s, "layout.pageSetup", json!({"section": props.clone()}));
assert_eq!(va(&s), want, "{name}");
assert_eq!(serde_json::to_value(crate::cmd::page::sect(&s)).unwrap()["valign"], json!(name));
}
}
/// #1876 review: only Web Layout wraps to the window's `web_width`. Draft and Outline keep a fixed
/// 800 pt column, also after a visit to a wide Web Layout, and the window width is never serialized.
#[test]
fn draft_keeps_its_column_after_web_layout() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Draft text wraps in a fixed column. ".repeat(80)}));
run(&mut s, "view.draft", json!({}));
let before = s.layout();
assert_eq!(before.pages[0].w, 800.0);
run(&mut s, "view.webLayout", json!({}));
s.view.web_width = 1400.0;
assert_eq!(s.layout().pages[0].w, 1400.0, "Web Layout follows the window");
run(&mut s, "view.draft", json!({}));
assert_eq!(s.layout().pages[0].w, 800.0, "Draft never sees Web Layout's window width");
run(&mut s, "view.outline", json!({}));
assert_eq!(s.layout().pages[0].w, 800.0, "nor does Outline");
let serialized = serde_json::to_value(&s.view).unwrap();
assert!(serialized.get("webWidth").is_none(), "a window width is not view state to keep");
let restored: crate::ViewState = serde_json::from_value(serialized).unwrap();
assert_eq!(restored.web_width, 800.0);
}
/// Insert › Quick Parts and AutoText list, insert and save the document's own building blocks
/// (its glossary, kept in the file); saving with `"in": "document"` is undoable.
#[test]
fn quick_parts_use_the_documents_building_blocks() {
use wordcraft_doc::{BuildingBlock, CharProps, Paragraph, para_block};
let entry = |name: &str, gallery: &str, text: &str| BuildingBlock {
name: name.into(),
gallery: gallery.into(),
category: "General".into(),
blocks: vec![para_block(Paragraph::with_text(text, CharProps::default()))],
..Default::default()
};
let mut d = wordcraft_doc::Document::new();
d.glossary = vec![entry("Signature", "docParts", "Kind regards, Ann"), entry("Disclaimer", "autoTxt", "No warranty.")];
let bytes = wordcraft_docx::write(&d).unwrap();
let mut s = Session::new(crate::io::open_bytes("glossary.docx", &bytes).unwrap());
let listed = run(&mut s, "insert.quickParts", json!({}));
assert_eq!(listed["document"], json!(["Signature"]), "{listed}");
assert_eq!(listed["parts"], json!(["Signature"]), "{listed}");
assert_eq!(run(&mut s, "insert.autoText", json!({}))["document"], json!(["Disclaimer"]));
run(&mut s, "insert.quickParts", json!({"insert": "Signature"}));
assert!(text(&s).contains("Kind regards, Ann"), "{}", text(&s));
run(&mut s, "insert.autoText", json!({"insert": "Disclaimer"}));
assert!(text(&s).contains("No warranty."), "{}", text(&s));
// Saved into the document: undoable, and kept in its file.
run(&mut s, "select.text", json!({"text": "Kind regards"}));
s.dirty = false;
let listed = run(&mut s, "insert.quickParts", json!({"save": "Closing", "in": "document"}));
assert_eq!(listed["document"], json!(["Signature", "Closing"]), "{listed}");
assert!(s.building_blocks.is_empty(), "not in the session's store");
assert!(s.dirty);
run(&mut s, "edit.undo", json!({}));
assert!(!s.doc.glossary.iter().any(|e| e.name == "Closing"));
run(&mut s, "edit.redo", json!({}));
let back = crate::io::open_bytes("glossary.docx", &wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let closing = back.glossary.iter().find(|e| e.name == "Closing").expect("saved entry");
assert_eq!(closing.gallery, "docParts");
assert_eq!(closing.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.as_str()).collect::(), "Kind regards");
assert_eq!(back.glossary.len(), 3);
// Without "in", saving stays in the session; deleting removes the name where it lives.
run(&mut s, "insert.quickParts", json!({"save": "Scratch"}));
assert!(s.building_blocks.contains_key("Scratch") && !s.doc.glossary.iter().any(|e| e.name == "Scratch"));
run(&mut s, "insert.quickParts", json!({"delete": "Scratch"}));
run(&mut s, "insert.quickParts", json!({"delete": "Closing"}));
assert!(s.building_blocks.is_empty());
assert_eq!(s.doc.glossary.iter().map(|e| e.name.as_str()).collect::>(), ["Signature", "Disclaimer"]);
assert!(s.run("insert.quickParts", &json!({"save": " ", "in": "document"})).is_err());
assert!(s.run("insert.quickParts", &json!({"insert": "Closing"})).is_err());
}
#[test]
fn toc_collects_cell_headings() {
use std::sync::Arc;
use wordcraft_doc::{Block, CharProps, InlineObject, Paragraph, Path, Table, para_block};
let mut earlier = Table::new(1, 1, 180.0);
earlier.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Earlier", CharProps::default()).styled("Heading1"))];
let mut nested = Table::new(1, 1, 180.0);
nested.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Nested", CharProps::default()).styled("Heading3"))];
let mut later = Paragraph::with_text("Later", CharProps::default());
later.props.outline_level = Some(1);
let mut table = Table::new(1, 1, 180.0);
table.rows[0].cells[0].blocks =
vec![para_block(later), Arc::new(Block::Table(nested)), para_block(Paragraph::with_text("Unlisted", CharProps::default()))];
let mut toc = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
toc.insert_object(
toc.len(),
InlineObject::Field {
instr: r#"TOC \o "1-3" \h \u"#.into(),
result: String::new(),
locked: false,
dirty: true,
code: false,
form: String::new(),
},
&CharProps::default(),
)
.unwrap();
let mut d = wordcraft_doc::Document::new();
d.body = vec![
Arc::new(Block::Table(earlier)),
para_block(toc),
para_block(Paragraph::with_text("\u{000C}", CharProps::default())),
Arc::new(Block::Table(table)),
];
let mut s = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
for command in ["references.updateFields", "references.updateToc"] {
run(&mut s, command, json!({}));
let entries: Vec<_> = s
.doc
.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
.cloned()
.collect();
assert_eq!(entries.len(), 3, "{command}: {}", text(&s));
let layout = s.layout();
let bookmarks = s.doc.bookmarks();
let mut pages = Vec::new();
for (p, title) in entries.iter().zip(["Earlier", "Later", "Nested"]) {
let value = p.plain_text();
assert!(value.starts_with(title), "{value}");
let shown: u32 = value.rsplit_once('\t').unwrap().1.parse().unwrap();
let name = p.props_of_char(0).link.as_ref().unwrap().strip_prefix('#').unwrap();
let (_, target) = bookmarks.iter().find(|(n, _)| n == name).unwrap();
assert!(target.path.depth() > 0);
let paragraph = s.doc.para(StoryRef::Body, &target.path).unwrap();
assert_eq!(paragraph.plain_text(), title);
let caret = layout.caret(target).unwrap();
assert_eq!(shown, layout.pages[caret.page].number, "{title}");
pages.push(caret.page);
}
assert!(pages[1] > pages[0]);
assert_eq!(bookmarks.iter().filter(|(n, _)| n.starts_with("_Toc")).count(), 3);
// The block-index-only link API must not misidentify a cell paragraph as a body block.
let targets = s.run("link.targets", &json!({})).unwrap();
assert_eq!(targets["headings"].as_array().unwrap().len(), 0);
assert!(s.doc.para(StoryRef::Body, &Path::top(1)).is_some());
}
}
#[test]
fn toc_keeps_direct_number_tabs() {
use wordcraft_doc::props::{CharProps, NumRef, TabAlign, TabLeader, TabStop};
let left = TabStop { pos: 24.0, align: TabAlign::Left, leader: TabLeader::None };
let right = TabStop { pos: 179.5, align: TabAlign::Right, leader: TabLeader::Dot };
let mut d = wordcraft_doc::Document::from_text("Contents\n\n1.\tAlpha\t9\nAlpha\nBeta");
d.last_section.page_w = 220.0;
d.last_section.margin_left = 20.0;
d.last_section.margin_right = 20.0;
d.styles.get_mut("TOC2").unwrap().para.tabs = Some(vec![right]);
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
{
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
p.props.style = Some("TOCHeading".into());
p.insert_object(
p.len(),
wordcraft_doc::InlineObject::Field {
instr: r#"TOC \o "1-2""#.into(),
result: String::new(),
locked: false,
dirty: true,
code: false,
form: String::new(),
},
&CharProps::default(),
)
.unwrap();
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap();
p.props.style = Some("TOC2".into());
p.props.tabs = Some(vec![right]);
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(2)).unwrap();
p.props.style = Some("TOC2".into());
p.props.tabs = Some(vec![left, right]);
}
for i in [3, 4] {
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
p.props.style = Some("Heading2".into());
p.props.numbering = Some(NumRef { num, level: 0 });
}
let bytes = wordcraft_docx::write(&d).unwrap();
let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap());
for command in ["references.updateFields", "references.updateToc", "references.toc"] {
run(&mut s, command, json!({}));
let layout = s.layout();
let mut entries = 0;
for (i, b) in s.doc.body.iter().enumerate() {
let Some(p) = b.as_para().filter(|p| p.props.style.as_deref() == Some("TOC2")) else { continue };
entries += 1;
assert!(p.props.tabs.as_ref().is_some_and(|tabs| tabs.contains(&left)), "{command}: {:?}", p.props.tabs);
let title = p.text.find('\t').unwrap() + 1;
let page = p.text.rfind('\t').unwrap() + 1;
let start = layout.caret(&Pos::body(i, title)).unwrap();
let end = layout.caret(&Pos::body(i, page)).unwrap();
assert_eq!((start.page, start.top), (end.page, end.top), "{command}: {:?}", p.plain_text());
assert!((start.x - 44.0).abs() < 1.0, "{command}: {}", start.x);
assert!(end.x > 180.0, "{command}: {}", end.x);
}
assert_eq!(entries, 2);
}
}
/// #2601: Insert › Drop Cap's menu and Drop Cap Options. Dropped and In margin take the
/// documented defaults (3 lines, distance 0, the text's font); the dialog's values (lines,
/// distance, font) are kept and clamped; None takes the drop cap away with its font; each is one
/// undo step and the caret stays where it was. The command is off where Word disables it: an
/// empty paragraph, a table cell, a paragraph that starts with a space.
#[test]
fn drop_cap_options_set_position_lines_distance_and_font() {
let props = |s: &Session| s.doc.body.first().and_then(|b| b.as_para()).map(|p| p.props.clone()).unwrap_or_default();
let cap = |s: &Session| {
let p = props(s);
(p.drop_cap, p.drop_cap_margin, p.drop_cap_distance, p.drop_cap_font)
};
let mut s = s();
assert!(s.run("insert.dropCap", &json!({})).is_err(), "off in an empty paragraph");
run(&mut s, "text.insert", json!({"text": "Once upon a time"}));
let caret = s.sel.clone();
run(&mut s, "insert.dropCap", json!({"position": "margin"}));
assert_eq!(cap(&s), (Some(3), Some(true), None, None));
assert_eq!(s.sel, caret, "the caret stays");
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.2, "font": "Liberation Mono"}));
assert_eq!(cap(&s), (Some(5), None, Some(7.2), Some("Liberation Mono".to_string())));
run(&mut s, "edit.undo", json!({}));
assert_eq!(cap(&s), (Some(3), Some(true), None, None), "one undo step");
run(&mut s, "insert.dropCap", json!({"lines": 99, "distance": 1e30, "font": " "}));
assert_eq!(cap(&s), (Some(10), None, Some(wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE), None), "clamped");
run(&mut s, "insert.dropCap", json!({"lines": -4, "distance": -3, "position": "sideways"}));
assert_eq!(cap(&s), (Some(1), None, None, None), "junk falls back to the nearest valid value");
run(&mut s, "insert.dropCap", json!({"font": "Liberation Mono", "distance": 3}));
run(&mut s, "insert.dropCap", json!({"position": "none"}));
assert_eq!(cap(&s), (None, None, None, None), "None takes the letter's font back too");
assert!(props(&s).is_empty());
run(&mut s, "insert.dropCap", json!({"lines": 2}));
run(&mut s, "insert.dropCap", json!({"lines": 0}));
assert_eq!(cap(&s), (None, None, None, None), "0 lines is None, as before");
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": " leading space"}));
assert!(s.run("insert.dropCap", &json!({})).is_err(), "off when the paragraph starts with a space");
let mut s = self::s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "Cell"}));
assert!(s.sel.focus.path.cell().is_some());
assert!(s.run("insert.dropCap", &json!({})).is_err(), "off in a table cell");
}
#[test]
fn rtf_list_round_trip_keeps_text() {
for cmd in ["para.bullets", "para.numbering", "para.multilevel", "para.defineBullet"] {
let mut s = Session::new(wordcraft_doc::Document::from_text("first\nsecond"));
run(&mut s, "select.all", json!({}));
run(&mut s, cmd, json!({}));
let bytes = crate::io::save_bytes("x.rtf", &s.doc).unwrap();
let d = crate::io::open_bytes("x.rtf", &bytes).unwrap();
assert_eq!(d.plain_text(StoryRef::Body), "first\nsecond", "{cmd}");
}
}
|