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 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"));
if cfg!(target_os = "macos") {
assert_eq!(id("Mod+Shift+G"), Some("edit.findPrevious"));
} else {
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("Mod+PageUp"), Some("edit.findPrevious"));
}
#[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 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");
}
#[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), "");
}
#[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");
}
fn bold_at(s: &Session, block: usize, off: usize) -> bool {
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false)
}
#[test]
fn paste_special_lists_formats_and_pastes_text_without_formatting() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Bold words"}));
run(&mut s, "select.text", json!({"text": "Bold"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "edit.copy", json!({}));
// No format: the formats are listed (and a front end is asked to show the dialog).
let r = run(&mut s, "edit.pasteSpecial", json!({}));
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["formatted", "text"]);
assert_eq!(s.ui_requests.last().unwrap()["open"], "pasteSpecial");
run(&mut s, "caret.docEnd", json!({}));
let r = run(&mut s, "edit.pasteSpecial", json!({"as": "text"}));
assert_eq!(r["pastedAs"], "text");
assert_eq!(text(&s), "Bold wordsBold");
assert!(bold_at(&s, 0, 0));
assert!(!bold_at(&s, 0, 11), "unformatted text drops the bold");
// Formatted keeps it.
run(&mut s, "edit.pasteSpecial", json!({"as": "formatted"}));
assert_eq!(text(&s), "Bold wordsBoldBold");
assert!(bold_at(&s, 0, 15));
// Text that someone else copied since: our rich copy is no longer offered.
let r = run(&mut s, "edit.pasteSpecial", json!({"text": "elsewhere"}));
assert_eq!(r["formats"].as_array().unwrap().len(), 1);
assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err());
}
#[test]
fn paste_special_html_and_rtf_keep_bold() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "x"}));
run(&mut s, "caret.docEnd", json!({}));
let html = "plain strong
";
let r = run(&mut s, "edit.pasteSpecial", json!({"html": html, "text": "plain strong"}));
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["html", "text"]);
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": html}));
assert_eq!(text(&s), "xplain strong");
assert!(!bold_at(&s, 0, 2));
assert!(bold_at(&s, 0, 8), "the run stays bold");
// HTML source copied as text can be pasted as HTML too.
run(&mut s, "document.setText", json!({"text": ""}));
run(&mut s, "edit.pasteSpecial", json!({"as": "html", "text": "hi there
"}));
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"));
}
#[test]
fn clipboard_pane_collects_copies_and_pastes_one_undoably() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "one two three"}));
for (a, b) in [(0, 3), (4, 7), (8, 13)] {
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": a}, "focus": {"block": 0, "off": b}}));
run(&mut s, "edit.copy", json!({}));
}
// Copying the same text again adds nothing.
run(&mut s, "edit.copy", json!({}));
let items = run(&mut s, "edit.clipboardItems", json!({}));
let previews: Vec<&str> = items.as_array().unwrap().iter().map(|i| i["preview"].as_str().unwrap()).collect();
assert_eq!(previews, ["three", "two", "one"], "newest first");
assert_eq!(run(&mut s, "edit.clipboardPane", json!({}))["value"], true);
assert!(s.view.clipboard_pane);
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "edit.pasteClipboardItem", json!({"index": 1}));
assert_eq!(text(&s), "one two threetwo");
assert_eq!(s.clipboard_text, "three", "the clipboard itself is unchanged");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "one two three");
run(&mut s, "edit.pasteAllClipboard", json!({}));
assert_eq!(text(&s), "one two threeonetwothree", "Paste All goes in copy order");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "one two three", "Paste All is one undo step");
let left = run(&mut s, "edit.deleteClipboardItem", json!({"index": 0}));
assert_eq!(left[0]["preview"], "two");
run(&mut s, "edit.clearClipboard", json!({}));
assert!(s.clip_history.is_empty());
assert!(s.run("edit.pasteClipboardItem", &json!({"index": 0})).is_err());
}
#[test]
fn clipboard_pane_is_capped_and_rejects_bad_indexes() {
let mut s = s();
let words: Vec = (0..30).map(|i| format!("w{i}")).collect();
run(&mut s, "document.setText", json!({"text": words.join(" ")}));
// Cutting each word in turn leaves the spaces: word `off` starts at `off`.
for (off, w) in words.iter().enumerate() {
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": off}, "focus": {"block": 0, "off": off + w.len()}}));
run(&mut s, "edit.cut", json!({}));
}
let items = run(&mut s, "edit.clipboardItems", json!({}));
let items = items.as_array().unwrap();
assert_eq!(items.len(), crate::cmd::edit::CLIP_MAX_ITEMS);
assert_eq!(items[0]["preview"], "w29");
assert_eq!(items[23]["preview"], "w6", "the oldest drop out");
let before = text(&s);
for bad in [json!({"index": 24}), json!({"index": -1}), json!({"index": 1e300}), json!({"index": "x"}), json!({})] {
assert!(s.run("edit.pasteClipboardItem", &bad).is_err(), "{bad}");
assert!(s.run("edit.deleteClipboardItem", &bad).is_err(), "{bad}");
}
assert_eq!(text(&s), before);
assert_eq!(s.clip_history.len(), 24);
// A huge copy still reaches the clipboard but is not collected.
run(&mut s, "document.setText", json!({"text": "x".repeat(3 << 20)}));
run(&mut s, "select.all", json!({}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard_text.len(), 3 << 20);
assert_eq!(s.clip_history.items()[0].text, "w29");
}
#[test]
fn tables_commands() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 3}));
assert!(s.sel.focus.path.cell().is_some());
run(&mut s, "text.insert", json!({"text": "A1"}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "B1"}));
run(&mut s, "table.insertRowBelow", json!({}));
run(&mut s, "table.insertColumnRight", json!({}));
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.rows.len(), 3);
assert_eq!(t.cols(), 4);
run(&mut s, "table.deleteTable", json!({}));
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()));
assert!(s.run("table.merge", &json!({})).is_err());
}
/// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a
/// long sentence widens (wrapping within the page), and the widths are written to the grid.
#[test]
fn autofit_contents_sizes_columns_to_their_text() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 3}));
let grid = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().grid.clone();
let before = grid(&s);
run(&mut s, "text.insert", json!({"text": "A"}));
run(&mut s, "text.tab", json!({}));
run(
&mut s,
"text.insert",
json!({"text": "This cell holds a long sentence that would wrap many times in a third of the page width, so it should get most of the room."}),
);
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "B"}));
run(&mut s, "table.autofit", json!({"mode": "contents"}));
let after = grid(&s);
let tw = cmd::page::sect(&s).text_width();
assert!(after[0] < before[0] / 2.0, "short column narrows: {before:?} → {after:?}");
assert!(after[2] < before[2] / 2.0, "short column narrows: {before:?} → {after:?}");
assert!(after[1] > before[1] * 1.5, "long column widens: {before:?} → {after:?}");
let total: f32 = after.iter().sum();
assert!(total <= tw + 0.5 && total > tw - 1.0, "the long text fills the page width: {total} vs {tw}");
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
assert!(t.props.width_pct.is_none() && !t.props.fixed);
assert_eq!(t.rows[0].cells[1].props.width, Some(after[1]), "cell widths follow the grid");
// Short text only: every column gets just what its text needs.
s.doc = wordcraft_doc::Document::new();
s.sel = crate::Selection::caret(Pos::body(0, 0));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
run(&mut s, "text.insert", json!({"text": "Name"}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "Quantity"}));
run(&mut s, "table.autofit", json!({"mode": "contents"}));
let g = grid(&s);
assert!(g[0] < g[1] && g[1] < 100.0, "{g:?}");
// The layout uses the new widths.
let w = s.layout().pages[0].items.iter().find_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, row: 0, cell: 0, .. } => Some(rect.w),
_ => None,
});
assert!(w.is_some_and(|w| (w - g[0]).abs() < 0.5), "{w:?} vs {g:?}");
}
/// The Height and Width boxes (Table Layout › Cell Size) run `table.rowHeight` and
/// `table.columnWidth`; each edit is one Undo.
#[test]
fn cell_size_boxes_set_row_height_and_column_width() {
use wordcraft_doc::props::HeightRule;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
run(&mut s, "table.autofit", json!({"mode": "window"}));
let t0 = s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
run(&mut s, "table.columnWidth", json!({"width": 100.0}));
run(&mut s, "table.rowHeight", json!({"height": 30.0, "rule": "exact"}));
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.grid[0], 100.0);
assert_eq!(t.rows[0].cells[0].props.width, Some(100.0));
assert!(t.props.width_pct.is_none(), "the column isn't scaled back to the window");
assert_eq!((t.rows[0].props.height, t.rows[0].props.height_rule), (Some(30.0), HeightRule::Exact));
let rect = s.layout().pages[0].items.iter().find_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, row: 0, cell: 0, .. } => Some(*rect),
_ => None,
});
assert!(rect.is_some_and(|r| (r.w - 100.0).abs() < 0.5 && (r.h - 30.0).abs() < 0.5), "{rect:?}");
assert!(s.run("table.rowHeight", &json!({"height": 30.0, "rule": "sideways"})).is_err());
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap(), &t0);
}
/// Table Properties (#44): table, row, column and cell settings land as one undo step, and
/// without settings the command reports the current ones (the dialog's source).
#[test]
fn table_properties_apply_in_one_step() {
use wordcraft_doc::props::{Align, HeightRule, VAlign};
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let t0 = s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
run(
&mut s,
"table.properties",
json!({"align": "center", "indent": 18.0, "width": 300.0, "rowHeight": 40.0, "rowHeightRule": "exact", "allowBreak": false,
"headerRow": true, "columnWidth": 120.0, "cellWidth": 110.0, "valign": "bottom"}),
);
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!((t.props.align, t.props.indent, t.props.width), (Some(Align::Center), Some(18.0), Some(300.0)));
let row = &t.rows[0];
assert_eq!((row.props.height, row.props.height_rule, row.props.cant_split, row.props.header), (Some(40.0), HeightRule::Exact, true, true));
assert_eq!(t.grid[0], 120.0);
assert_eq!((row.cells[0].props.width, row.cells[0].props.valign), (Some(110.0), VAlign::Bottom));
let applied = t.clone();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap(), &t0, "one undo step per apply");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap(), &applied);
let state = run(&mut s, "table.properties", json!({}));
assert_eq!(state["align"], "center");
assert_eq!(state["row"]["heightRule"], "exact");
assert_eq!(state["columnWidth"], 120.0);
assert_eq!(state["cell"]["valign"], "bottom");
// Clearing the row height makes it automatic again; bad values are refused.
run(&mut s, "table.properties", json!({"rowHeight": null}));
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap().rows[0].props.height_rule, HeightRule::Auto);
assert!(s.run("table.properties", &json!({"valign": "middle"})).is_err());
}
/// Table Layout › Text Direction (#226) cycles the selected cells' text through horizontal,
/// top-to-bottom and bottom-to-top, and the layout turns the text.
#[test]
fn table_text_direction_cycles_and_turns_the_text() {
use wordcraft_doc::props::TextDirection;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 3}));
run(&mut s, "text.insert", json!({"text": "Turned"}));
let dirs = |s: &Session| -> Vec {
s.doc.body.iter().find_map(|b| b.as_table()).unwrap().rows[0].cells.iter().map(|c| c.props.text_direction).collect()
};
let turned = |s: &mut Session| {
s.layout().pages[0].items.iter().find_map(|it| match it {
wordcraft_layout::Placed::Lines { para, turn, .. } if para.lines.first().is_some_and(|l| l.stop > 0) => Some(*turn),
_ => None,
})
};
assert_eq!(turned(&mut s), Some(TextDirection::Horizontal));
run(&mut s, "table.textDirection", json!({}));
assert_eq!(dirs(&s), [TextDirection::Down, TextDirection::Horizontal, TextDirection::Horizontal]);
assert_eq!(turned(&mut s), Some(TextDirection::Down), "the text is drawn turned");
run(&mut s, "table.textDirection", json!({}));
assert_eq!(dirs(&s)[0], TextDirection::Up);
assert_eq!(turned(&mut s), Some(TextDirection::Up));
run(&mut s, "table.textDirection", json!({}));
assert_eq!(dirs(&s)[0], TextDirection::Horizontal);
// Every selected cell gets the caret cell's next direction (the issue's selection spans cells).
let a = s.sel.focus.clone();
let mut b = a.clone();
if let Some(last) = b.path.0.get_mut(2) {
*last = 1;
}
b.off = 0;
s.sel = crate::Selection { anchor: a, focus: b };
run(&mut s, "table.textDirection", json!({}));
assert_eq!(dirs(&s), [TextDirection::Down, TextDirection::Down, TextDirection::Horizontal]);
run(&mut s, "table.textDirection", json!({"value": "up"}));
assert_eq!(dirs(&s), [TextDirection::Up, TextDirection::Up, TextDirection::Horizontal]);
assert!(s.run("table.textDirection", &json!({"value": "sideways"})).is_err());
run(&mut s, "edit.undo", json!({}));
assert_eq!(dirs(&s), [TextDirection::Down, TextDirection::Down, TextDirection::Horizontal]);
// Outside a table it is disabled.
s.sel = crate::Selection::caret(Pos::body(0, 0));
if s.sel.focus.path.cell().is_none() {
assert!(s.run("table.textDirection", &json!({})).is_err());
}
}
/// Right-to-left tables and sections (#362): `table.rtl` and `layout.sectionRtl` set or toggle
/// the direction, one Undo step each, and the layout mirrors the table.
#[test]
fn table_and_section_direction_commands() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 3}));
let rtl = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().props.bidi_visual;
let first_cell_x = |s: &mut Session| {
s.layout().pages[0].items.iter().find_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, cell: 0, .. } => Some(rect.x),
_ => None,
})
};
let ltr_x = first_cell_x(&mut s).unwrap();
run(&mut s, "table.rtl", json!({}));
assert!(rtl(&s));
assert!(first_cell_x(&mut s).unwrap() > ltr_x + 100.0, "the first column moves to the right");
run(&mut s, "table.rtl", json!({"value": true}));
assert!(rtl(&s), "an explicit value sets rather than toggles");
run(&mut s, "table.properties", json!({"rtl": false}));
assert!(!rtl(&s));
run(&mut s, "edit.undo", json!({}));
assert!(rtl(&s));
assert_eq!(s.run("table.properties", &json!({})).unwrap()["bidiVisual"], json!(true));
run(&mut s, "layout.sectionRtl", json!({}));
assert!(crate::cmd::page::sect(&s).rtl);
run(&mut s, "layout.sectionRtl", json!({"value": false}));
assert!(!crate::cmd::page::sect(&s).rtl);
run(&mut s, "edit.undo", json!({}));
assert!(crate::cmd::page::sect(&s).rtl);
// Outside a table `table.rtl` is disabled.
s.sel = crate::Selection::caret(Pos::body(0, 0));
if s.sel.focus.path.cell().is_none() {
assert!(s.run("table.rtl", &json!({})).is_err());
}
}
#[test]
fn table_border_presets_mask_the_sides_they_clear() {
// On a default (TableGrid) table the "outside"/"inside" presets must write nil over the
// sides they clear; without it those sides inherit the style's grid (#143) and every
// preset renders like "all".
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
// Distinct vertical/horizontal rule positions on page 0.
let rule_pos = |s: &mut Session| {
let (mut vx, mut hy) = (Vec::new(), Vec::new());
for it in &s.layout().pages[0].items {
if let crate::layout::Placed::Rule { x0, y0, x1, y1, .. } = it {
if (x0 - x1).abs() < 1e-3 && !vx.contains(x0) {
vx.push(*x0);
}
if (y0 - y1).abs() < 1e-3 && !hy.contains(y0) {
hy.push(*y0);
}
}
}
(vx, hy)
};
// Style default: a full 2x2 grid — 3 vertical + 3 horizontal lines.
let (vx, hy) = rule_pos(&mut s);
assert_eq!((vx.len(), hy.len()), (3, 3), "baseline grid: {vx:?} {hy:?}");
run(&mut s, "table.borders", json!({"kind": "outside"}));
let (vx, hy) = rule_pos(&mut s);
assert_eq!((vx.len(), hy.len()), (2, 2), "outside keeps only the frame: {vx:?} {hy:?}");
let b = s.doc.body.iter().find_map(|x| x.as_table()).unwrap().props.borders.unwrap();
assert!(b.between.is_some_and(|x| x.style == BorderStyle::None));
assert!(b.inside_v.is_some_and(|x| x.style == BorderStyle::None));
run(&mut s, "table.borders", json!({"kind": "inside"}));
let (vx, hy) = rule_pos(&mut s);
assert_eq!((vx.len(), hy.len()), (1, 1), "inside keeps only the inner rules: {vx:?} {hy:?}");
let b = s.doc.body.iter().find_map(|x| x.as_table()).unwrap().props.borders.unwrap();
for side in [b.top, b.bottom, b.left, b.right] {
assert!(side.is_some_and(|x| x.style == BorderStyle::None));
}
}
#[test]
fn page_setup_and_breaks() {
let mut s = s();
run(&mut s, "layout.orientation", json!({"value": "landscape"}));
assert!(s.doc.last_section.landscape);
run(&mut s, "layout.size", json!({"name": "A4"}));
assert!((s.doc.last_section.page_w - 841.89).abs() < 0.1);
run(&mut s, "layout.margins", json!({"preset": "narrow"}));
assert_eq!(s.doc.last_section.margin_left, 36.0);
run(&mut s, "text.insert", json!({"text": "one"}));
run(&mut s, "layout.break", json!({"kind": "nextPage"}));
run(&mut s, "text.insert", json!({"text": "two"}));
assert_eq!(s.doc.sections().len(), 2);
assert_eq!(s.layout().pages.len(), 2);
assert!(s.run("layout.margins", &json!({"left": 1000.0})).is_err());
}
#[test]
fn track_changes_and_accept() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "original"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "text.insert", json!({"text": " added"}));
run(&mut s, "select.text", json!({"text": "orig"}));
s.author = "Someone Else".into();
run(&mut s, "text.delete", json!({}));
// Deleted text stays (marked) until accepted.
assert_eq!(text(&s), "original added");
let ch = run(&mut s, "review.changes", json!({}));
assert_eq!(ch.as_array().unwrap().len(), 2);
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(text(&s), "inal added");
}
/// The author of the revision `rid` points to.
fn author_of(s: &Session, rid: Option) -> Option {
rid.and_then(|r| s.doc.revisions.get(r as usize)).map(|r| r.author.clone())
}
#[test]
fn tracked_delete_keeps_another_authors_deletion() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "alpha beta gamma"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
s.author = "Ana".into();
run(&mut s, "select.text", json!({"text": "beta"}));
run(&mut s, "text.delete", json!({}));
// A second author deletes the whole line, including the text Ana already deleted.
s.author = "Ben".into();
run(&mut s, "select.text", json!({"text": "alpha beta gamma"}));
run(&mut s, "text.delete", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let by: Vec<(String, Option)> = p.run_ranges().map(|(r, c)| (p.text[r].to_string(), author_of(&s, c.del))).collect();
assert!(by.iter().any(|(t, a)| t == "beta" && a.as_deref() == Some("Ana")), "{by:?}");
assert!(by.iter().any(|(t, a)| t.contains("alpha") && a.as_deref() == Some("Ben")), "{by:?}");
assert!(by.iter().any(|(t, a)| t.contains("gamma") && a.as_deref() == Some("Ben")), "{by:?}");
}
#[test]
fn tracked_split_gives_the_new_paragraph_mark_to_its_author() {
// Ben presses Enter at the end of Ana's insertion, and in the middle of it.
for off in [None, Some(8)] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "alpha"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
s.author = "Ana".into();
run(&mut s, "text.insert", json!({"text": " beta"}));
s.author = "Ben".into();
if let Some(off) = off {
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [0], "off": off}}));
}
run(&mut s, "text.newParagraph", json!({}));
let head = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
let tail = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap();
assert_eq!(author_of(&s, head.mark.ins).as_deref(), Some("Ben"), "split at {off:?}: the new paragraph mark is Ben's");
// The second paragraph ends with the original mark, which nobody inserted (not Ana).
assert_eq!(tail.mark.ins, None, "split at {off:?}");
assert_eq!(tail.mark.del, None, "split at {off:?}");
}
}
fn body_texts(s: &Session) -> Vec {
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect()
}
/// Issue #229's steps: a paragraph split while tracking changes.
fn tracked_split(text: &str) -> Session {
let mut s = s();
run(&mut s, "text.insert", json!({"text": text}));
run(&mut s, "file.setAuthor", json!({"name": "Owned Bob"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 12}}));
run(&mut s, "text.newParagraph", json!({}));
s
}
#[test]
fn reject_all_removes_a_tracked_paragraph_break() {
// Issue #229: Reject All left the paragraph break typed with Track Changes on.
for (text, head) in [("Owned ALPHA Owned BETA", "Owned ALPHA "), ("Owned GAMMA Owned DELTA", "Owned GAMMA ")] {
let mut s = tracked_split(text);
assert_eq!(body_texts(&s), [head, &text[12..]]);
// The break is listed as a change, by its author.
let ch = run(&mut s, "review.changes", json!({}));
assert_eq!(ch.as_array().map(Vec::len), Some(1), "{ch}");
assert_eq!((ch[0]["kind"].as_str(), ch[0]["author"].as_str()), (Some("insert"), Some("Owned Bob")));
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(body_texts(&s), [text]);
assert!(s.doc.revisions.is_empty());
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!((p.mark.ins, p.mark.del), (None, None));
// Undo brings the tracked split back; redo rejects it again.
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_texts(&s), [head, &text[12..]]);
assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.ins.is_some());
run(&mut s, "edit.redo", json!({}));
assert_eq!(body_texts(&s), [text]);
}
// Split → Undo → Redo → Reject All (the issue's other variant).
let mut s = tracked_split("Owned ALPHA Owned BETA");
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_texts(&s), ["Owned ALPHA Owned BETA"]);
run(&mut s, "edit.redo", json!({}));
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(body_texts(&s), ["Owned ALPHA Owned BETA"]);
}
#[test]
fn accept_all_keeps_a_tracked_paragraph_break() {
let mut s = tracked_split("Owned ALPHA Owned BETA");
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(body_texts(&s), ["Owned ALPHA ", "Owned BETA"]);
assert!(s.doc.revisions.is_empty());
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.mark.ins.is_none() && p.mark.del.is_none()));
assert_eq!(run(&mut s, "review.changes", json!({})), json!([]));
}
#[test]
fn reject_one_paragraph_break_and_rejecting_a_typed_paragraph() {
// Reject with the caret on the break (Next Change selects it), or at the end of its paragraph.
let mut s = tracked_split("Owned ALPHA Owned BETA");
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
run(&mut s, "review.nextChange", json!({}));
run(&mut s, "review.reject", json!({}));
assert_eq!(body_texts(&s), ["Owned ALPHA Owned BETA"]);
let mut s = tracked_split("Owned ALPHA Owned BETA");
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 12}}));
run(&mut s, "review.accept", json!({}));
assert_eq!(body_texts(&s), ["Owned ALPHA ", "Owned BETA"]);
assert_eq!(run(&mut s, "review.changes", json!({})), json!([]));
// Enter at the end of a heading, then a typed paragraph: rejecting everything restores
// the heading alone, with its style.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "Title"}));
run(&mut s, "para.style", json!({"style": "Heading1"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Body"}));
run(&mut s, "text.newParagraph", json!({}));
assert_eq!(body_texts(&s), ["Title", "Body", ""]);
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(body_texts(&s), ["Title"]);
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.style.as_deref(), Some("Heading1"));
}
#[test]
fn tracked_backspace_and_delete_mark_a_paragraph_break_deleted() {
let two = || {
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"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
s.author = "Ana".into();
s
};
// Backspace at the start of "Two": the mark above is deleted, the caret moves before it.
for accept in [false, true] {
let mut s = two();
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(body_texts(&s), ["One", "Two"], "a tracked deletion stays in the text");
assert_eq!(s.sel.focus, Pos::body(0, 3));
assert_eq!(author_of(&s, s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.del).as_deref(), Some("Ana"));
let ch = run(&mut s, "review.changes", json!({}));
assert_eq!(ch[0]["kind"], "delete", "{ch}");
if accept {
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(body_texts(&s), ["OneTwo"]);
} else {
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(body_texts(&s), ["One", "Two"]);
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.del, None);
}
}
// Delete at the end of "One": the same, and the caret steps over the deleted mark.
let mut s = two();
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 3}}));
run(&mut s, "text.delete", json!({}));
assert_eq!(body_texts(&s), ["One", "Two"]);
assert_eq!(s.sel.focus, Pos::body(1, 0));
assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.del.is_some());
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(body_texts(&s), ["OneTwo"]);
// A break the same author inserted is simply removed again.
let mut s = two();
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 3}}));
run(&mut s, "text.newParagraph", json!({}));
assert_eq!(body_texts(&s), ["One", "Two", ""]);
run(&mut s, "text.backspace", json!({}));
assert_eq!(body_texts(&s), ["One", "Two"]);
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.mark.ins.is_none() && p.mark.del.is_none()));
}
#[test]
fn set_author_names_tracked_changes_headlessly() {
// Issue #90: a script or agent labels its own edits without any dialog.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "original"}));
let r = run(&mut s, "file.setAuthor", json!({"name": " Claude (copyedit)\n"}));
assert_eq!(r["name"], "Claude (copyedit)");
assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs");
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "text.insert", json!({"text": " added"}));
run(&mut s, "select.text", json!({"text": "orig"}));
run(&mut s, "text.delete", json!({}));
let ch = run(&mut s, "review.changes", json!({}));
let ch = ch.as_array().unwrap();
assert_eq!(ch.len(), 2);
assert!(ch.iter().all(|c| c["author"] == "Claude (copyedit)"), "{ch:?}");
// Comments use the same name.
run(&mut s, "select.text", json!({"text": "added"}));
run(&mut s, "review.newComment", json!({"text": "Tightened."}));
assert!(s.doc.comments.values().all(|c| c.author == "Claude (copyedit)" && c.initials == "C("));
}
#[test]
fn set_author_refuses_blank_names_and_clamps_long_ones() {
let mut s = s();
for bad in [json!(null), json!({}), json!({"name": 5}), json!({"name": ""}), json!({"name": " \t\n "}), json!({"name": "\u{0}\u{7}"})] {
assert!(s.run("file.setAuthor", &bad).is_err(), "{bad} should be refused");
assert_eq!(s.author, "WordCraft User", "a refused name leaves the author alone");
}
run(&mut s, "file.setAuthor", json!({"name": "Ada\u{0}\u{1b} Lovelace"}));
assert_eq!(s.author, "Ada Lovelace", "control characters can't reach the saved XML");
run(&mut s, "file.setAuthor", json!({"name": "é".repeat(100_000)}));
assert_eq!(s.author.chars().count(), cmd::file::MAX_AUTHOR_CHARS);
}
#[test]
fn track_changes_and_set_author_list_their_params() {
// Scripts that only read the command list must find the explicit, idempotent form.
let reg = cmd::registry();
assert!(reg.get("review.trackChanges").unwrap().params.contains(r#""value"?: bool"#));
assert!(reg.get("file.setAuthor").unwrap().params.contains(r#""name": string"#));
let mut s = s();
for _ in 0..2 {
assert_eq!(run(&mut s, "review.trackChanges", json!({"value": true}))["value"], true);
}
}
#[test]
fn no_markup_view_lays_out_the_final_text() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "original"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "select.text", json!({"text": "orig"}));
run(&mut s, "text.delete", json!({}));
let gap = |s: &mut Session| {
let l = s.layout();
let x = |off| l.caret(&Pos::body(0, off)).map(|c| c.x).unwrap_or(f32::NAN);
x(4) - x(0)
};
assert!(gap(&mut s) > 5.0, "markup shows the deletion");
run(&mut s, "review.markup", json!({"value": "noMarkup"}));
assert!(gap(&mut s).abs() < 0.01, "No Markup leaves it out");
run(&mut s, "review.showMarkup", json!({"value": true}));
assert!(gap(&mut s) > 5.0);
run(&mut s, "review.showMarkup", json!({"value": false}));
assert!(gap(&mut s).abs() < 0.01);
}
#[test]
fn replace_all_is_tracked() {
let mut s = s();
let original = "We walked towards the light, then towards home.";
run(&mut s, "document.setText", json!({"text": original}));
run(&mut s, "review.trackChanges", json!({"value": true}));
let r = run(&mut s, "edit.replaceAll", json!({"text": "towards", "with": "toward", "matchCase": true}));
assert_eq!(r["replaced"], 2);
let ch = run(&mut s, "review.changes", json!({}));
assert!(!ch.as_array().unwrap().is_empty(), "replace all must leave tracked revisions");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(text(&s), original);
}
#[test]
fn comments() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Some text here"}));
run(&mut s, "select.text", json!({"text": "text"}));
let r = run(&mut s, "review.newComment", json!({"text": "Nice"}));
let id = r["id"].as_u64().unwrap();
let l = run(&mut s, "review.comments", json!({}));
assert_eq!(l[0]["text"], "Nice");
assert_eq!(text(&s), "Some text here");
// Editing the text is one undo step; replies hang off their comment.
run(&mut s, "review.editComment", json!({"id": id, "text": "Nicer\nTwo lines"}));
assert_eq!(run(&mut s, "review.comments", json!({}))[0]["text"], "Nicer\nTwo lines");
assert!(s.undo());
assert_eq!(run(&mut s, "review.comments", json!({}))[0]["text"], "Nice");
assert!(s.run("review.editComment", &json!({"id": 999, "text": "x"})).is_err());
let r = run(&mut s, "review.reply", json!({"id": id, "text": "Agreed"}));
assert_eq!(s.doc.comments.get(&(r["id"].as_u64().unwrap() as u32)).unwrap().parent, Some(id as u32));
run(&mut s, "review.deleteComment", json!({"id": id}));
assert!(s.doc.comments.is_empty());
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.len(), 0);
}
#[test]
fn toc_and_fields() {
let mut s = Session::new(crate::sample::report());
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "references.toc", json!({}));
let t = text(&s);
assert!(t.contains("Contents"), "{t}");
assert!(t.contains("Summary\t1"), "{t}");
run(&mut s, "references.updateToc", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Summary\t").count(), 1, "one TOC entry after update");
}
#[test]
fn toc_page_numbers_follow_headings() {
let mut s = s();
run(&mut s, "references.toc", json!({}));
run(&mut s, "caret.docEnd", json!({}));
for name in ["Alpha", "Bravo", "Charlie"] {
run(&mut s, "text.pageBreak", json!({}));
run(&mut s, "text.insert", json!({"text": name}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "references.updateToc", json!({}));
let t = text(&s);
for (name, page) in [("Alpha", 2), ("Bravo", 3), ("Charlie", 4)] {
assert!(t.contains(&format!("{name}\t{page}")), "{name} should be on page {page}: {t}");
}
}
/// A heading above the TOC keeps its index when the entries go in below it, so its page is read
/// where it is, not `entries` blocks further down.
#[test]
fn toc_page_number_for_heading_above_toc() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Preface"}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.pageBreak", json!({}));
run(&mut s, "references.toc", json!({}));
run(&mut s, "caret.docEnd", json!({}));
for name in ["Alpha", "Bravo"] {
run(&mut s, "text.pageBreak", json!({}));
run(&mut s, "text.insert", json!({"text": name}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "references.updateToc", json!({}));
let t = text(&s);
for (name, page) in [("Preface", 1), ("Alpha", 3), ("Bravo", 4)] {
assert!(t.contains(&format!("{name}\t{page}")), "{name} should be on page {page}: {t:?}");
}
}
#[test]
fn failed_command_leaves_document_unchanged() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "keep"}));
let before = text(&s);
assert!(s.run("format.size", &json!({"size": -3})).is_err());
assert!(s.run("no.such", &json!({})).is_err());
assert!(s.run("text.insert", &json!({})).is_err());
assert_eq!(text(&s), before);
}
#[test]
fn hostile_params_never_panic() {
let reg = cmd::registry();
let junk = [
json!(null),
json!({}),
json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}),
json!({"value": -1e308, "rows": 1e9}),
];
for spec in reg.all() {
// These reach outside the session (files). Read Aloud (`review.readAloud`, `readAloud.*`)
// is fuzzed too: under `cfg(test)` its backend is the silent `Hold`, asserted below.
if spec.id.starts_with("file.") || spec.id == "insert.picture" || spec.id == "insert.textFromFile" {
continue;
}
for j in &junk {
let mut s = Session::new(crate::sample::sample_document());
assert_ne!(s.read_aloud.backend, crate::speech::Backend::System, "tests must never start real speech");
let _ = s.run(spec.id, j);
s.clamp_selection();
let _ = s.layout();
}
}
}
#[test]
fn inspect_reports_structure() {
let mut s = Session::new(crate::sample::sample_document());
let r = run(&mut s, "document.inspect", json!({}));
assert!(r["pages"].as_u64().unwrap() >= 1);
assert!(r["blocks"].as_array().unwrap().iter().any(|b| b["type"] == "table"));
let f = run(&mut s, "format.state", json!({}));
assert_eq!(f["styleName"], "Title");
}
#[test]
fn caret_navigation() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "first line\nsecond line"}));
run(&mut s, "caret.down", json!({}));
assert_eq!(s.sel.focus.path.last(), 1);
run(&mut s, "caret.end", json!({}));
assert_eq!(s.sel.focus.off, 11);
run(&mut s, "caret.up", json!({"extend": true}));
assert_eq!(s.sel.focus.path.last(), 0);
assert!(!s.sel.is_collapsed());
run(&mut s, "caret.wordLeft", json!({}));
run(&mut s, "caret.docEnd", json!({}));
assert_eq!(s.sel.focus, Pos::body(1, 11));
run(&mut s, "caret.left", json!({}));
assert_eq!(s.sel.focus.off, 10);
}
#[test]
fn text_box_insert_puts_the_caret_inside() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Before "}));
let id = run(&mut s, "insert.textBox", json!({}))["story"].as_u64().unwrap() as u32;
let boxed = StoryRef::Part(id);
assert_eq!(s.sel.focus.story, boxed);
run(&mut s, "text.insert", json!({"text": "Hello"}));
assert_eq!(s.doc.plain_text(boxed), "Hello");
assert!(!text(&s).contains("Hello"), "{}", text(&s));
// Leaving the box lands just after it in the body.
let anchor = s.doc.text_box_anchor(id).unwrap();
assert_eq!(anchor, Pos::body(0, "Before \u{FFFC}".len()));
run(&mut s, "caret.set", json!({"pos": anchor}));
run(&mut s, "text.insert", json!({"text": " after"}));
assert_eq!(s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).map(|p| p.text.as_str()), Some("Before \u{FFFC} after"));
// Undo goes back through the typing to before the box, in the body.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.sel.focus.story, StoryRef::Body);
assert_eq!(text(&s), "Before ");
// Given text: the caret ends after it.
let id = run(&mut s, "insert.textBox", json!({"text": "Note"}))["story"].as_u64().unwrap() as u32;
assert_eq!(s.sel.focus, Pos { story: StoryRef::Part(id), path: wordcraft_doc::Path::top(0), off: 4 });
}
#[test]
fn text_box_in_a_table_cell_shows_its_text() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
assert!(s.sel.focus.path.0.len() > 1);
let id = run(&mut s, "insert.textBox", json!({}))["story"].as_u64().unwrap() as u32;
// The caret goes into the box, and typing shows there (it has a caret position).
assert_eq!(s.sel.focus.story, StoryRef::Part(id));
run(&mut s, "text.insert", json!({"text": "In a cell"}));
let caret = s.layout().caret(&s.sel.focus).expect("laid out");
let cell = s.layout().find_object(0, |o| o.text_box == Some(id)).unwrap();
assert!(cell.rect.contains(wordcraft_geom::Point::new(caret.x, caret.top)), "{caret:?} in {:?}", cell.rect);
// No text box inside a text box.
assert!(s.run("insert.textBox", &json!({})).is_err());
}
#[test]
fn copy_paste_text_box_makes_an_independent_copy() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "A"}));
let id = run(&mut s, "insert.textBox", json!({"text": "Original"}))["story"].as_u64().unwrap() as u32;
// Select "A" and the box in the body, copy, paste at the end.
run(&mut s, "select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, 1 + 3)}));
run(&mut s, "edit.copy", json!({}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 4)}));
let parts = s.doc.parts.len();
run(&mut s, "edit.paste", json!({}));
assert_eq!(s.doc.parts.len(), parts + 1);
let stories: Vec = s
.doc
.para(StoryRef::Body, &wordcraft_doc::Path::top(0))
.unwrap()
.objects
.iter()
.filter_map(|o| if let wordcraft_doc::InlineObject::Shape { story, .. } = o { *story } else { None })
.collect();
assert_eq!(stories.len(), 2);
assert_eq!(stories[0], id);
let copy = stories[1];
assert_ne!(copy, id);
// Typing in the copy leaves the original alone.
run(&mut s, "caret.set", json!({"pos": Pos { story: StoryRef::Part(copy), path: wordcraft_doc::Path::top(0), off: 0 }}));
run(&mut s, "text.insert", json!({"text": "Copy of "}));
assert_eq!(s.doc.plain_text(StoryRef::Part(copy)), "Copy of Original");
assert_eq!(s.doc.plain_text(StoryRef::Part(id)), "Original");
// Undo the typing and the paste: the copy's story goes too.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.parts.len(), parts);
}
#[test]
fn word_count_includes_text_boxes_by_default() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "one two "}));
run(&mut s, "insert.textBox", json!({"text": "three four five"}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)}));
assert_eq!(s.word_count(), 5);
let v = run(&mut s, "review.wordCount", json!({}));
assert_eq!((v["words"].as_u64(), v["includeTextBoxes"].as_bool()), (Some(5), Some(true)));
assert_eq!(v["paragraphs"].as_u64(), Some(2));
// Turned off: body only, and it sticks.
let v = run(&mut s, "review.wordCount", json!({"includeTextBoxes": false}));
assert_eq!(v["words"].as_u64(), Some(2));
assert_eq!(s.word_count(), 2);
assert_eq!(run(&mut s, "review.wordCount", json!({}))["words"].as_u64(), Some(2));
// Deleting the box drops its words (its story stays behind, unused).
run(&mut s, "review.wordCount", json!({"includeTextBoxes": true}));
run(&mut s, "select.range", json!({"anchor": Pos::body(0, 8), "focus": Pos::body(0, 8 + 3)}));
run(&mut s, "text.delete", json!({}));
assert_eq!(s.word_count(), 2);
}
/// The laid-out area of the only text box.
fn box_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
let l = s.layout();
l.find_object(0, |o| o.text_box.is_some()).unwrap()
}
fn select_box(s: &mut Session) {
let o = box_area(s);
let end = Pos { off: o.off + 3, ..o.pos() };
run(s, "select.range", json!({"anchor": o.pos(), "focus": end}));
}
#[test]
fn arrange_bounds_resizes_and_moves_objects() {
use wordcraft_doc::para::{Anchor, Wrap};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Some text "}));
run(&mut s, "insert.textBox", json!({"text": "Box"}));
select_box(&mut s);
// Resize: stays inline, keeps a text box's minimum.
run(&mut s, "arrange.bounds", json!({"width": 200, "height": 5}));
let o = box_area(&mut s);
assert_eq!((o.rect.w, o.rect.h, o.wrap), (200.0, 18.0, Wrap::Inline));
assert!(s.run("arrange.nudge", &json!({"dx": 5})).is_err(), "inline objects don't nudge");
// Move: floats exactly where it was dropped, still selected.
let r = run(&mut s, "arrange.bounds", json!({"page": 0, "x": 300, "y": 400}));
assert_eq!(r["object"]["float"]["wrap"], "square");
let o = box_area(&mut s);
assert_eq!((o.page, o.rect.x, o.rect.y), (0, 300.0, 400.0));
assert_eq!(s.doc.plain_text(StoryRef::Part(o.text_box.unwrap())), "Box");
// Moving it again anchors it to the paragraph under its top edge, relative to that
// paragraph (it moves with the text), landing exactly where dropped.
let before = box_area(&mut s).rect;
select_box(&mut s);
run(&mut s, "arrange.bounds", json!({"x": before.x + 30.0, "y": before.y - 5.0}));
let o = box_area(&mut s);
assert!((o.rect.x - before.x - 30.0).abs() < 0.01 && (o.rect.y - before.y + 5.0).abs() < 0.01, "{:?} → {:?}", before, o.rect);
let p = s.doc.para(StoryRef::Body, &o.path).unwrap();
let Some(wordcraft_doc::InlineObject::Shape { float, .. }) = p.object_at(o.off) else { panic!() };
assert_eq!((float.h_rel, float.v_rel), (Anchor::Column, Anchor::Paragraph));
assert!((float.x - (o.rect.x - o.origin.x)).abs() < 0.01 && (float.y - (o.rect.y - o.origin.y)).abs() < 0.01);
// Nudge.
run(&mut s, "arrange.nudge", json!({"dx": 6, "dy": -1}));
let n = box_area(&mut s).rect;
assert!((n.x - o.rect.x - 6.0).abs() < 0.01 && (n.y - o.rect.y + 1.0).abs() < 0.01);
// Hostile values stay on the page.
run(&mut s, "arrange.bounds", json!({"x": -1e9, "y": 1e9, "width": 1e9}));
let o = box_area(&mut s);
assert!(o.rect.right() >= 12.0 && o.rect.y <= 792.0 && o.rect.w <= 4000.0, "{:?}", o.rect);
// One undo per change.
run(&mut s, "edit.undo", json!({}));
assert_eq!(box_area(&mut s).rect, n);
}
#[test]
fn nudging_an_aligned_object_moves_it_from_where_it_is() {
use wordcraft_doc::para::{Anchor, FloatAlign};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Some text "}));
run(&mut s, "insert.textBox", json!({"text": "Box"}));
select_box(&mut s);
run(&mut s, "arrange.position", json!({"preset": "topLeft"}));
// As a .docx can place it: centred on the page, at the bottom margin.
let o = box_area(&mut s);
let p = s.doc.para_mut(StoryRef::Body, &o.path).unwrap();
if let Some(wordcraft_doc::InlineObject::Shape { float, .. }) = p.object_at_mut(o.off) {
(float.h_rel, float.h_align, float.v_rel, float.v_align) =
(Anchor::Page, Some(FloatAlign::Center), Anchor::BottomMargin, Some(FloatAlign::Start));
}
p.touch();
s.touch();
let before = box_area(&mut s).rect;
select_box(&mut s);
run(&mut s, "arrange.nudge", json!({"dx": 6, "dy": -2}));
let o = box_area(&mut s);
assert!((o.rect.x - before.x - 6.0).abs() < 0.01 && (o.rect.y - before.y + 2.0).abs() < 0.01, "{before:?} → {:?}", o.rect);
let p = s.doc.para(StoryRef::Body, &o.path).unwrap();
let Some(wordcraft_doc::InlineObject::Shape { float, .. }) = p.object_at(o.off) else { panic!() };
assert_eq!((float.h_align, float.v_align), (None, None));
// Dragging it clears alignment too, and it lands where dropped.
select_box(&mut s);
run(&mut s, "arrange.bounds", json!({"x": 100, "y": 150}));
let o = box_area(&mut s);
assert_eq!((o.rect.x, o.rect.y), (100.0, 150.0));
}
#[test]
fn arrange_bounds_moves_an_object_to_another_page() {
let mut s = s();
for i in 0..60 {
run(&mut s, "text.insert", json!({"text": format!("Paragraph {i} of filler text.")}));
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)}));
run(&mut s, "insert.textBox", json!({"text": "Traveller"}));
select_box(&mut s);
assert!(s.layout().pages.len() >= 2);
run(&mut s, "arrange.bounds", json!({"page": 1, "x": 100, "y": 200}));
let o = box_area(&mut s);
assert_eq!((o.page, o.rect.x, o.rect.y), (1, 100.0, 200.0));
assert!(o.path.0[0] > 0, "anchored to text on page 2: {:?}", o.path);
assert_eq!(s.doc.plain_text(StoryRef::Part(o.text_box.unwrap())), "Traveller");
// The selection follows the object.
assert_eq!(s.sel.anchor, o.pos());
assert!(s.run("arrange.bounds", &json!({"page": 99, "x": 1, "y": 1})).is_err());
}
#[test]
fn moved_text_box_makes_the_text_it_lands_on_wrap() {
let mut s = s();
for i in 0..6 {
run(
&mut s,
"text.insert",
json!({"text": format!("Paragraph {i}: the quick brown fox jumps over the lazy dog, again and again, across the page.")}),
);
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "caret.set", json!({"pos": Pos::body(4, 0)}));
run(&mut s, "insert.textBox", json!({"text": "Box"}));
select_box(&mut s);
// Drop it over paragraph 1, right half of the page: well above its own paragraph (4).
let l = s.layout();
let p1_top = wordcraft_layout::hit::page_lines(&l.pages[0], StoryRef::Body).iter().find(|ln| ln.path.0 == [1] && ln.li == 0).unwrap().top;
run(&mut s, "arrange.bounds", json!({"x": 300, "y": p1_top + 2.0}));
let o = box_area(&mut s);
assert_eq!(o.path.0, vec![1], "anchored to the paragraph it was dropped on");
assert_eq!((o.rect.x, o.rect.y), (300.0, p1_top + 2.0));
// Lines beside the box stop short of it; lines below it use the full width again.
let l = s.layout();
let lines = wordcraft_layout::hit::page_lines(&l.pages[0], StoryRef::Body);
let beside: Vec<_> = lines.iter().filter(|ln| ln.bottom > o.rect.y && ln.top < o.rect.bottom()).collect();
assert!(!beside.is_empty());
assert!(beside.iter().all(|ln| ln.right <= o.rect.x + 0.5 || ln.left >= o.rect.right() - 0.5), "text overlaps the box");
assert!(lines.iter().any(|ln| ln.top > o.rect.bottom() + 10.0 && ln.right > o.rect.right()));
// Its old paragraph no longer holds it.
assert!(s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(4)).unwrap().objects.is_empty());
}
#[test]
fn deleted_text_box_takes_its_text_with_it_until_undo() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "A"}));
let id = run(&mut s, "insert.textBox", json!({"text": "Inside"}))["story"].as_u64().unwrap() as u32;
let box_sel = json!({"anchor": Pos::body(0, 1), "focus": Pos::body(0, 1 + 3)});
run(&mut s, "select.range", box_sel.clone());
run(&mut s, "text.delete", json!({}));
assert!(!s.doc.parts.contains_key(&id), "the deleted box's text is gone");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Part(id)), "Inside", "undo brings it back");
// Cut and paste: one box, one story, same text.
run(&mut s, "select.range", box_sel);
run(&mut s, "edit.cut", json!({}));
assert!(s.doc.parts.is_empty());
run(&mut s, "edit.paste", json!({}));
assert_eq!(s.doc.parts.len(), 1);
let (_, part) = s.doc.parts.iter().next().unwrap();
assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox);
assert_eq!(s.doc.word_count_including_notes(), 2);
}
#[test]
fn word_count_with_an_object_selected_counts_the_document() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "one two "}));
run(&mut s, "insert.textBox", json!({"text": "three"}));
run(&mut s, "select.range", json!({"anchor": Pos::body(0, 8), "focus": Pos::body(0, 8 + 3)}));
assert_eq!(run(&mut s, "review.wordCount", json!({}))["words"].as_u64(), Some(3));
// A real text selection still counts just itself.
run(&mut s, "select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, 3)}));
assert_eq!(run(&mut s, "review.wordCount", json!({}))["words"].as_u64(), Some(1));
}
#[test]
fn custom_properties_set_read_remove_and_undo() {
let mut s = s();
let r = run(&mut s, "file.properties", json!({"custom": {"ZOTERO_PREF_1": "", "Status": "draft"}}));
let custom = r["custom"].as_array().cloned().unwrap_or_default();
assert!(custom.iter().any(|p| p["name"] == "Status" && p["value"] == "draft" && p["kind"] == "lpwstr"));
assert_eq!(custom.len(), 2);
assert_eq!(s.doc.custom_prop("zotero_pref_1"), Some(""));
let r = run(&mut s, "file.properties", json!({"custom": {"status": null}}));
assert_eq!(r["custom"].as_array().map(|a| a.len()), Some(1));
assert!(s.run("file.properties", &json!({"custom": "x"})).is_err());
assert!(s.run("file.properties", &json!({"custom": {"": "x"}})).is_err());
assert!(s.run("file.properties", &json!({"custom": {"n": 3}})).is_err());
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.custom_prop("Status"), Some("draft"));
}
/// `document_id` tells an edit from a replacement (the UI drops a "Save changes?" prompt
/// about a document that has been replaced).
#[test]
fn document_id_changes_only_when_the_document_is_replaced() {
let mut s = s();
let first = s.document_id();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "format.bold", json!({}));
assert_eq!(s.document_id(), first);
run(&mut s, "file.new", json!({"template": "letter"}));
let second = s.document_id();
assert_ne!(second, first);
run(&mut s, "file.new", json!({}));
assert_ne!(s.document_id(), second);
}
/// Envelopes, Labels and Finish & Merge make a new, untitled document, as Word does, and like New
/// its undo history starts afresh. Undo/Redo across the swap put the wrong document under a file
/// (Undo, Save As, Redo, Save wrote the envelope over the saved file), so it isn't offered; the UI
/// asks to save the replaced document first.
#[test]
fn mailings_results_are_new_untitled_documents() {
let dir = std::env::temp_dir().join(format!("wordcraft-engine-mailings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("letter.docx");
let saved_as = dir.join("saved-as.docx");
let on_file = |p: &std::path::Path| crate::io::open_path(p).unwrap().plain_text(StoryRef::Body);
for id in ["mailings.envelopes", "mailings.labels", "mailings.finish"] {
let mut s = s();
run(&mut s, "mailings.recipients", json!({"csv": "First Name\nAda\nAlan"}));
run(&mut s, "text.insert", json!({"text": "Dear "}));
run(&mut s, "mailings.insertField", json!({"field": "First Name"}));
run(&mut s, "file.save", json!({"path": original.to_string_lossy()}));
let on_disk = std::fs::read(&original).unwrap();
run(&mut s, "text.insert", json!({"text": ", unsaved"}));
let before = text(&s);
let document = s.document_id();
run(&mut s, id, json!({}));
let result = text(&s);
assert_ne!(result, before, "{id}: the result replaced the document");
assert_ne!(s.document_id(), document, "{id}: a different document");
assert_eq!(s.path, None, "{id}: the result is untitled");
assert_eq!(run(&mut s, "file.save", json!({}))["saved"], false, "{id}: Save asks where (Save As)");
assert_eq!(std::fs::read(&original).unwrap(), on_disk, "{id}: the original file is untouched");
// Undo doesn't bring the old document back under the new one's identity; Redo can't
// put the result back under a file saved in between.
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), result, "{id}: history starts afresh, like New");
run(&mut s, "file.saveAs", json!({"path": saved_as.to_string_lossy()}));
let written = on_file(&saved_as);
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), result, "{id}: nothing to redo");
run(&mut s, "file.save", json!({}));
assert_eq!(on_file(&saved_as), written, "{id}: the saved file still holds what was saved");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn html_pictures_load_relative_to_the_file() {
// Issue #98: `
` beside an HTML file is embedded when it is opened.
let dir = std::env::temp_dir().join(format!("wordcraft-html-img-{}", std::process::id()));
std::fs::create_dir_all(dir.join("img")).unwrap();
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 2, image::Rgba([10, 20, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
std::fs::write(dir.join("img/my logo.png"), &png).unwrap();
std::fs::write(dir.join("secret.txt"), b"not a picture").unwrap();
let html = r#"


"#;
std::fs::write(dir.join("page.html"), html).unwrap();
let doc = crate::io::open_path(&dir.join("page.html"));
let _ = std::fs::remove_dir_all(&dir);
let doc = doc.unwrap();
assert_eq!(doc.media.len(), 1);
let text = doc.plain_text(StoryRef::Body);
assert!(!text.contains("Logo") && text.contains('T') && text.contains("Web"), "{text:?}");
}
/// A fresh, empty scratch folder for one test.
#[cfg(not(target_arch = "wasm32"))]
fn scratch_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("wordcraft-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[cfg(not(target_arch = "wasm32"))]
fn tiny_png() -> Vec {
let mut png = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
png
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn html_pictures_outside_the_folder_are_not_loaded() {
// #98 review: `..` must not reach files outside the HTML file's folder.
let root = scratch_dir("html-img-escape");
std::fs::create_dir_all(root.join("site/img")).unwrap();
std::fs::write(root.join("outside.png"), tiny_png()).unwrap();
std::fs::write(root.join("site/inside.png"), tiny_png()).unwrap();
let html = r#"


"#;
std::fs::write(root.join("site/page.html"), html).unwrap();
let doc = crate::io::open_path(&root.join("site/page.html"));
let _ = std::fs::remove_dir_all(&root);
let doc = doc.unwrap();
assert_eq!(doc.media.len(), 1, "only the picture inside the folder loads");
let text = doc.plain_text(StoryRef::Body);
assert!(text.contains("Up") && text.contains("Sneak") && !text.contains("In"), "{text:?}");
}
#[cfg(unix)]
#[test]
fn html_pictures_through_a_symlink_out_of_the_folder_are_not_loaded() {
// #98 review: a symlink inside the folder must not lead outside it.
let root = scratch_dir("html-img-symlink");
std::fs::create_dir_all(root.join("site")).unwrap();
std::fs::write(root.join("outside.png"), tiny_png()).unwrap();
std::os::unix::fs::symlink(root.join("outside.png"), root.join("site/link.png")).unwrap();
std::fs::write(root.join("site/page.html"), r#"
"#).unwrap();
let doc = crate::io::open_path(&root.join("site/page.html"));
let _ = std::fs::remove_dir_all(&root);
let doc = doc.unwrap();
assert!(doc.media.is_empty());
assert!(doc.plain_text(StoryRef::Body).contains("Link"));
}
#[cfg(unix)]
#[test]
fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() {
// #98 review: opening a FIFO blocks until a writer appears; it must never be opened.
let dir = scratch_dir("html-img-fifo");
let fifo = dir.join("pipe.png");
let made = std::process::Command::new("mkfifo").arg(&fifo).status();
if !made.is_ok_and(|s| s.success()) {
let _ = std::fs::remove_dir_all(&dir);
eprintln!("mkfifo unavailable; skipping");
return;
}
std::fs::write(dir.join("page.html"), r#"
"#).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let page = dir.join("page.html");
std::thread::spawn(move || {
let _ = tx.send(crate::io::open_path(&page));
});
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
if got.is_err() {
// Unblock the stuck reader so the thread ends, then fail.
let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
}
let _ = std::fs::remove_dir_all(&dir);
let doc = got.expect("opening the HTML file blocked on a named pipe").unwrap();
assert!(doc.media.is_empty());
assert!(doc.plain_text(StoryRef::Body).contains("Pipe"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn html_picture_size_limit_holds_whatever_length_the_file_reports() {
use crate::io::LocalImages;
let dir = scratch_dir("html-img-limit");
std::fs::write(dir.join("ten.bin"), [7u8; 10]).unwrap();
std::fs::write(dir.join("eleven.bin"), [7u8; 11]).unwrap();
let images = LocalImages::with_limits(&dir, 10, 1000);
let ten = images.load("ten.bin").map(|d| d.len());
let eleven = images.load("eleven.bin");
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(ten, Some(10));
assert!(eleven.is_none());
#[cfg(unix)]
{
// A device reports a length of 0 and never ends: it is not a regular file, and the read is
// bounded anyway.
let dev = LocalImages::with_limits(std::path::Path::new("/dev"), 1 << 20, 1 << 20);
assert!(dev.load("zero").is_none());
assert!(dev.load("null").is_none());
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn html_pictures_share_one_read_and_a_per_document_budget() {
// #98 review: a picture referenced many times is read once, and every reference counts
// against the document's budget so a large picture can't be multiplied without bound.
use crate::io::LocalImages;
let dir = scratch_dir("html-img-budget");
let png = tiny_png();
let n = png.len() as u64;
std::fs::write(dir.join("a.png"), &png).unwrap();
let images = LocalImages::with_limits(&dir, 1 << 20, 3 * n);
let first = images.load("a.png").unwrap();
std::fs::write(dir.join("a.png"), b"changed on disk").unwrap();
let second = images.load("./a.png").unwrap();
assert!(std::sync::Arc::ptr_eq(&first, &second), "the second reference reuses the first read");
assert_eq!(*second, png);
assert!(images.load("a.png").is_some());
assert!(images.load("a.png").is_none(), "budget spent");
// Files over the per-picture limit still cost what was read.
std::fs::write(dir.join("big.bin"), vec![1u8; 100]).unwrap();
std::fs::write(dir.join("small.bin"), [1u8; 5]).unwrap();
let images = LocalImages::with_limits(&dir, 10, 25);
assert!(images.load("big.bin").is_none());
assert!(images.load("big.bin").is_none());
let small = images.load("small.bin");
let _ = std::fs::remove_dir_all(&dir);
assert!(small.is_none(), "two oversized reads used up the budget");
}
fn para_style(s: &Session, i: usize) -> Option {
s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.style.clone())
}
#[test]
fn styles_apply_by_name() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Title text"}));
run(&mut s, "styles.apply", json!({"style": "Heading 1"}));
assert_eq!(para_style(&s, 0).as_deref(), Some("Heading1"));
assert_eq!(s.undo_label(), Some("Apply Style"));
assert!(s.dirty);
}
#[test]
fn styles_apply_pane_is_not_an_edit() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
s.dirty = false;
let undo = s.undo_labels();
s.view.styles_pane = false;
assert_eq!(run(&mut s, "styles.apply", json!({})), json!({"pane": true}));
assert!(s.view.styles_pane);
// Shows, never toggles.
run(&mut s, "styles.apply", json!({}));
assert!(s.view.styles_pane);
assert_eq!(s.undo_labels(), undo);
assert!(!s.dirty);
// Works on a read-only document.
s.doc.settings.protection = Some("readOnly".into());
s.view.styles_pane = false;
run(&mut s, "styles.apply", json!({}));
assert!(s.view.styles_pane);
}
#[test]
fn styles_apply_rejects_bad_style_params() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
let undo = s.undo_labels();
let before = text(&s);
for bad in [json!(5), json!(null), json!([]), json!({}), json!(""), json!(" "), json!(true)] {
let r = s.run("styles.apply", &json!({"style": bad}));
assert!(matches!(r, Err(crate::CmdError::Params(_))), "{bad}: {r:?}");
}
assert_eq!(s.undo_labels(), undo);
assert_eq!(text(&s), before);
assert_eq!(para_style(&s, 0), None);
}
#[test]
fn styles_apply_unknown_style_changes_nothing() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
let undo = s.undo_labels();
let doc = s.doc.clone();
assert!(s.run("styles.apply", &json!({"style": "No Such Style"})).is_err());
assert_eq!(s.undo_labels(), undo);
assert_eq!(s.doc, doc);
assert!(!s.can_redo());
}
#[test]
fn styles_apply_undo_redo() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "styles.apply", json!({"style": "Heading 2"}));
assert_eq!(para_style(&s, 0).as_deref(), Some("Heading2"));
run(&mut s, "edit.undo", json!({}));
assert_eq!(para_style(&s, 0), None);
assert_eq!(text(&s), "Hello");
run(&mut s, "edit.redo", json!({}));
assert_eq!(para_style(&s, 0).as_deref(), Some("Heading2"));
}
#[test]
fn styles_apply_character_style() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "make this strong"}));
run(&mut s, "select.text", json!({"text": "this"}));
run(&mut s, "styles.apply", json!({"style": "Strong"}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props_of_char(6).style.as_deref(), Some("Strong"));
assert_eq!(p.props_of_char(0).style, None);
// A character style leaves the paragraph style alone.
assert_eq!(p.props.style, None);
}
#[test]
fn styles_apply_multi_paragraph_selection() {
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"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Three"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "styles.apply", json!({"style": "Quote"}));
for i in 0..3 {
assert_eq!(para_style(&s, i).as_deref(), Some("Quote"), "paragraph {i}");
}
// One undo step for the whole selection.
run(&mut s, "edit.undo", json!({}));
for i in 0..3 {
assert_eq!(para_style(&s, i), None, "paragraph {i}");
}
}
/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2
/// look like the selected text — its paragraph style and direct formatting — so every Heading 2
/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step.
#[test]
fn update_heading_to_match_selection_takes_the_selections_formatting() {
use wordcraft_doc::{Align, CharProps, ParaProps, Rgb, TextColor};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Heading text"}));
run(&mut s, "styles.apply", json!({"style": "Heading 2"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Body text"}));
run(&mut s, "styles.apply", json!({"style": "Normal"}));
run(&mut s, "select.text", json!({"text": "Body text"}));
run(&mut s, "format.size", json!({"size": 20}));
run(&mut s, "format.italic", json!({}));
run(&mut s, "format.color", json!({"color": "C00000"}));
run(&mut s, "para.alignCenter", json!({}));
let normal = s.doc.styles.resolve_char(Some("Normal"), &CharProps::default());
let before = s.undo_depth();
run(&mut s, "styles.updateToMatch", json!({"style": "Heading2"}));
let h2 = ParaProps { style: Some("Heading2".into()), ..Default::default() };
let c = s.doc.styles.resolve_char(Some("Heading2"), &CharProps::default());
assert_eq!(c.size, 20.0);
assert!(c.italic);
assert_eq!(c.color, TextColor::Rgb(Rgb(0xC0, 0, 0)));
assert_eq!(c.font, normal.font, "the heading font gives way to the selection's");
let p = s.doc.styles.resolve_para(&h2);
assert_eq!(p.align, Align::Center);
assert_eq!(p.outline_level, Some(1), "Heading 2 stays a level-2 heading");
// The selected paragraph now carries Heading 2, its direct formatting absorbed by the style.
assert_eq!(para_style(&s, 1).as_deref(), Some("Heading2"));
let body = s.doc.para_at(&Pos::body(1, 0)).unwrap();
assert!(body.props_of_char(0).formatting().is_empty(), "{:?}", body.props_of_char(0));
assert_eq!(body.props.align, None);
assert_eq!(s.undo_depth(), before + 1, "one undo step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.styles.resolve_char(Some("Heading2"), &CharProps::default()).size, 16.0);
assert_eq!(para_style(&s, 1).as_deref(), Some("Normal"));
}
#[test]
fn styles_apply_rejected_on_protected_document() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
s.doc.settings.protection = Some("readOnly".into());
let undo = s.undo_labels();
let r = s.run("styles.apply", &json!({"style": "Heading 1"}));
assert!(matches!(r, Err(crate::CmdError::Disabled(_))), "{r:?}");
assert_eq!(para_style(&s, 0), None);
assert_eq!(s.undo_labels(), undo);
}
fn column_doc() -> Session {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "abcdef\nabcdef\nabcdef"}));
s
}
fn block(s: &mut Session) {
let a = serde_json::to_value(Pos::body(0, 2)).unwrap();
let f = serde_json::to_value(Pos::body(2, 4)).unwrap();
let r = run(s, "select.column", json!({"anchor": a, "focus": f}));
assert_eq!(r["rows"], json!(["cd", "cd", "cd"]));
}
#[test]
fn column_selection_copies_the_block() {
let mut s = column_doc();
block(&mut s);
let r = run(&mut s, "edit.copy", json!({}));
assert_eq!(r["text"], "cd\ncd\ncd");
assert_eq!(s.clipboard.as_ref().map(|f| f.blocks.len()), Some(3));
// A caret move drops the block; pasting the copy gives three paragraphs.
run(&mut s, "caret.docEnd", json!({}));
assert!(s.column_segments().is_none());
s.prefs.autocorrect.enabled = false;
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(text(&s), "abcdef\nabcdef\nabcdef\ncd\ncd\ncd");
}
#[test]
fn column_selection_deletes_only_the_block() {
let mut s = column_doc();
block(&mut s);
run(&mut s, "text.delete", json!({}));
assert_eq!(text(&s), "abef\nabef\nabef");
assert_eq!(s.sel, crate::Selection::caret(Pos::body(0, 2)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "abcdef\nabcdef\nabcdef");
// Cut copies and deletes; typing replaces the block.
block(&mut s);
let r = run(&mut s, "edit.cut", json!({}));
assert_eq!(r["text"], "cd\ncd\ncd");
assert_eq!(text(&s), "abef\nabef\nabef");
run(&mut s, "edit.undo", json!({}));
block(&mut s);
run(&mut s, "text.insert", json!({"text": "X"}));
assert_eq!(text(&s), "abXef\nabef\nabef");
}
#[test]
fn column_selection_formats_each_row() {
let mut s = column_doc();
block(&mut s);
run(&mut s, "format.bold", json!({}));
for i in 0..3 {
let p = s.doc.para_at(&Pos::body(i, 0)).unwrap();
assert_ne!(p.props_of_char(1).bold, Some(true));
assert_eq!(p.props_of_char(2).bold, Some(true));
assert_eq!(p.props_of_char(3).bold, Some(true));
assert_ne!(p.props_of_char(4).bold, Some(true));
}
// The block survives formatting, and a second Bold turns it off again.
assert!(s.column_segments().is_some());
run(&mut s, "format.bold", json!({}));
assert_ne!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props_of_char(2).bold, Some(true));
run(&mut s, "format.changeCase", json!({"mode": "upper"}));
assert_eq!(text(&s), "abCDef\nabCDef\nabCDef");
}
#[test]
fn column_mode_extends_with_the_arrow_keys() {
let mut s = column_doc();
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}}));
let r = run(&mut s, "select.column", json!({}));
assert_eq!(r["columnMode"], true);
run(&mut s, "caret.right", json!({}));
run(&mut s, "caret.right", json!({}));
run(&mut s, "caret.down", json!({}));
let r = run(&mut s, "caret.down", json!({}));
assert_eq!(r["rows"], json!(["bc", "bc", "bc"]));
run(&mut s, "text.backspace", json!({}));
assert_eq!(text(&s), "adef\nadef\nadef");
assert!(!s.column_mode);
// Escape leaves the mode.
run(&mut s, "select.column", json!({}));
run(&mut s, "select.collapse", json!({}));
assert!(!s.column_mode);
}
#[test]
fn column_selection_from_points() {
let mut s = column_doc();
let l = s.layout();
let a = l.caret(&Pos::body(0, 1)).unwrap();
let b = l.caret(&Pos::body(1, 5)).unwrap();
let r = run(
&mut s,
"select.column",
json!({"from": {"page": a.page, "x": a.x, "y": a.top + 2.0}, "to": {"page": b.page, "x": b.x, "y": b.top + 2.0}}),
);
assert_eq!(r["rows"], json!(["bcde", "bcde"]));
for bad in [
json!({"from": {"page": 99, "x": 1, "y": 1}, "to": {"page": 0, "x": 1, "y": 1}}),
json!({"from": 1, "to": "x"}),
json!({"anchor": 1, "focus": {}}),
] {
assert!(s.run("select.column", &bad).is_err());
}
}
#[test]
fn style_inspector_reports_and_clears_levels() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Chapter one"}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.bold", json!({"value": true}));
run(&mut s, "para.alignCenter", json!({}));
let r = run(&mut s, "styles.inspect", json!({}));
assert_eq!(r["paragraph"]["style"], "Heading1");
assert_eq!(r["paragraph"]["styleName"], "Heading 1");
let pdirect = r["paragraph"]["direct"].as_array().unwrap();
assert!(pdirect.iter().any(|d| d["prop"] == "align" && d["value"] == "center"), "{pdirect:?}");
assert!(r["character"]["style"].is_null());
let cdirect = r["character"]["direct"].as_array().unwrap();
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true})]);
// Formatting that matches the style isn't a difference (Heading 1 is 20 pt).
run(&mut s, "format.size", json!({"size": 20}));
let r = run(&mut s, "styles.inspect", json!({}));
assert_eq!(r["character"]["direct"].as_array().unwrap().len(), 1, "{r}");
// Character style level.
run(&mut s, "format.charStyle", json!({"style": "Emphasis"}));
let r = run(&mut s, "styles.inspect", json!({}));
assert_eq!(r["character"]["style"], "Emphasis");
assert_eq!(r["character"]["styleName"], "Emphasis");
run(&mut s, "styles.inspectorClear", json!({"level": "characterStyle"}));
assert!(run(&mut s, "styles.inspect", json!({}))["character"]["style"].is_null());
// Clearing character formatting removes the bold and keeps the paragraph levels.
run(&mut s, "styles.inspectorClear", json!({"level": "characterFormatting"}));
let r = run(&mut s, "styles.inspect", json!({}));
assert!(r["character"]["direct"].as_array().unwrap().is_empty(), "{r}");
assert!(!run(&mut s, "format.state", json!({}))["bold"].as_bool().unwrap());
assert_eq!(r["paragraph"]["style"], "Heading1");
assert!(!r["paragraph"]["direct"].as_array().unwrap().is_empty());
run(&mut s, "styles.inspectorClear", json!({"level": "paragraphFormatting"}));
let r = run(&mut s, "styles.inspect", json!({}));
assert!(r["paragraph"]["direct"].as_array().unwrap().is_empty(), "{r}");
assert_eq!(r["paragraph"]["style"], "Heading1");
run(&mut s, "styles.inspectorClear", json!({"level": "paragraphStyle"}));
assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Normal");
// Each clear is one undo step.
run(&mut s, "edit.undo", json!({}));
assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Heading1");
assert_eq!(text(&s), "Chapter one");
}
#[test]
fn style_inspector_pane_and_hostile_params() {
let mut s = Session::new(crate::sample::sample_document());
let r = run(&mut s, "styles.inspector", json!({}));
assert_eq!(r["pane"], true);
assert!(s.view.style_inspector);
assert_eq!(r["paragraph"]["styleName"], "Title");
let r = run(&mut s, "styles.inspector", json!({"value": false}));
assert_eq!(r["pane"], false);
let before = text(&s);
for junk in [json!(null), json!({}), json!({"level": 5}), json!({"level": "everything"}), json!({"level": ""})] {
assert!(s.run("styles.inspectorClear", &junk).is_err(), "{junk}");
}
assert_eq!(text(&s), before);
assert!(s.run("styles.inspector", &json!({"value": "yes"})).is_ok());
// A selection reaching far outside the document.
s.sel.focus = Pos::body(9999, 99999);
s.sel.anchor = Pos::body(0, 0);
assert!(s.run("styles.inspect", &json!({})).is_ok());
assert!(s.run("styles.inspectorClear", &json!({"level": "characterFormatting"})).is_ok());
}
/// Home › Editing › Find › Advanced Find: every match, in the body or a selection, with Reading Highlight.
#[test]
fn advanced_find_lists_and_highlights_matches() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "cat dog cat"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Cat"}));
// From the UI it opens the dialog.
run(&mut s, "edit.advancedFind", json!({}));
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "find"})));
let r = run(&mut s, "edit.advancedFind", json!({"text": "cat", "highlight": true}));
assert_eq!(r["count"], 3);
assert_eq!(s.find_highlights().len(), 3);
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "matchCase": true}))["count"], 2);
// Highlights follow edits and the latest search.
assert_eq!(s.find_highlights().len(), 2);
run(&mut s, "text.insert", json!({"text": " cat"}));
assert_eq!(s.find_highlights().len(), 3);
// Only inside the selection.
run(&mut s, "select.text", json!({"text": "cat dog"}));
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "in": "selection"}))["count"], 1);
run(&mut s, "edit.advancedFind", json!({"highlight": false}));
assert!(s.find_highlights().is_empty());
// Hostile params.
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "elsewhere"})).is_err());
assert!(s.run("edit.advancedFind", &json!({"text": "(", "regex": true})).is_err());
run(&mut s, "select.all", json!({}));
run(&mut s, "text.delete", json!({}));
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "selection"})).is_err());
}
/// #146: a table style made by command styles the table at the caret (header row fill and bold
/// text, banded rows), can be modified, and saves as a table style the table refers to.
#[test]
fn custom_table_style_applies_modifies_and_saves() {
use wordcraft_doc::{Rgb, StyleKind, TextColor};
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
run(&mut s, "text.insert", json!({"text": "Head"}));
run(&mut s, "table.look", json!({"headerRow": true, "bandedRows": true}));
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
let r = run(
&mut s,
"table.newStyle",
json!({"name": "Thesis Table", "basedOn": "Table Grid",
"wholeTable": {"size": 10},
"headerRow": {"fill": "1F3864", "bold": true, "color": "FFFFFF", "borders": true},
"bandedRows": {"fill": "EEEEEE", "italic": true}}),
);
let id = r["id"].as_str().unwrap().to_string();
let table_style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
assert_eq!(table_style(&s).as_deref(), Some(id.as_str()), "applied to the current table");
assert!(s.run("table.newStyle", &json!({"name": "thesis table"})).is_err(), "names are unique");
assert!(s.run("table.newStyle", &json!({"name": "X", "basedOn": "Heading 1"})).is_err(), "bases are table styles");
assert!(s.run("table.style", &json!({"style": "Normal"})).is_err(), "only table styles apply to tables");
let fills = |s: &mut Session, c: Rgb| {
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
};
let head = |s: &mut Session| {
let mut p = tp.0.clone();
p.extend([0, 0, 0]);
s.layout().pages[0]
.items
.iter()
.find_map(
|i| if let crate::layout::Placed::Lines { path, para, .. } = i { (path.0 == p).then(|| para.styles[0].rc.clone()) } else { None },
)
.unwrap()
};
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 2, "header cells");
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 2, "first band");
let rc = head(&mut s);
assert!(rc.bold && rc.size == 10.0, "{rc:?}");
assert_eq!(rc.color, TextColor::Rgb(Rgb::WHITE));
// Modify the current table's style: the table follows.
run(&mut s, "table.modifyStyle", json!({"headerRow": {"fill": "C00000", "bold": false}, "bandSize": 2}));
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 0);
assert_eq!(fills(&mut s, Rgb(0xC0, 0, 0)), 2);
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 4, "two rows per band");
assert!(!head(&mut s).bold);
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let st = back.styles.get(&id).unwrap();
assert_eq!((st.kind, st.name.as_str(), st.based_on.as_deref()), (StyleKind::Table, "Thesis Table", Some("TableGrid")));
let parts = st.table.as_ref().unwrap();
assert_eq!((parts.header_fill, parts.band_fill, parts.band_size), (Some(Rgb(0xC0, 0, 0)), Some(Rgb(0xEE, 0xEE, 0xEE)), Some(2)));
assert_eq!((parts.header_chr.bold, parts.band_chr.italic), (Some(false), Some(true)));
assert!(parts.header_borders.is_some_and(|b| b.any_visible()));
let t = back.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle");
}
#[test]
fn page_and_table_gridlines_toggle_independently() {
// #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell
// outlines) are separate view switches; neither touches the document or the undo stack.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
let undo = s.undo_labels();
// Table cell outlines are on by default, as in Word; the page grid is off.
assert!(!s.view.gridlines && s.view.table_gridlines);
assert_eq!(run(&mut s, "table.viewGridlines", json!({"value": false}))["value"], false);
assert_eq!(run(&mut s, "view.gridlines", json!({}))["value"], true);
assert!(s.view.gridlines && !s.view.table_gridlines);
// Works with the caret outside a table.
assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], true);
assert!(s.view.gridlines && s.view.table_gridlines);
assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false);
assert!(!s.view.gridlines && s.view.table_gridlines);
assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false);
assert!(!s.view.gridlines);
assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], false);
assert!(!s.view.gridlines && !s.view.table_gridlines);
assert_eq!(text(&s), "Hello");
assert_eq!(s.undo_labels(), undo);
let state = run(&mut s, "view.state", json!({}));
assert_eq!(state["gridlines"], false);
assert_eq!(state["tableGridlines"], false);
}
/// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom
/// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck
/// short of the 10%–500% limits.
#[test]
fn zoom_in_and_out_step_from_current_zoom_and_leave_fit_modes() {
let mut s = s();
let pct = |s: &Session| (s.view.zoom * 100.0).round() as i32;
run(&mut s, "view.zoomIn", json!({}));
assert_eq!(pct(&s), 110);
run(&mut s, "view.zoomOut", json!({}));
run(&mut s, "view.zoomOut", json!({}));
assert_eq!(pct(&s), 90);
for fit in ["view.pageWidth", "view.onePage", "view.multiplePages"] {
run(&mut s, fit, json!({}));
assert!(!s.view.fit.is_empty());
// What the canvas reports while a fit mode shows the page at 163%.
s.view.zoom = 1.63;
run(&mut s, "view.zoomIn", json!({}));
assert_eq!((pct(&s), s.view.fit.as_str(), s.view.multi_page), (170, "", false), "{fit}");
run(&mut s, fit, json!({}));
s.view.zoom = 1.63;
run(&mut s, "view.zoomOut", json!({}));
assert_eq!((pct(&s), s.view.fit.as_str()), (150, ""), "{fit}");
}
let mut last = pct(&s);
for _ in 0..60 {
run(&mut s, "view.zoomIn", json!({}));
assert!(pct(&s) > last || pct(&s) == 500);
last = pct(&s);
}
assert_eq!(last, 500);
for _ in 0..60 {
run(&mut s, "view.zoomOut", json!({}));
assert!(pct(&s) < last || pct(&s) == 10);
last = pct(&s);
}
assert_eq!(last, 10);
run(&mut s, "view.zoom100", json!({}));
assert_eq!(pct(&s), 100);
}
/// Comments, revisions and dates read the real clock (#188: the web stamped 2026-01-01).
#[test]
fn timestamps_come_from_the_clock() {
let before = cmd::now_unix();
assert!(before > 1_760_000_000, "{before}");
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Dated"}));
run(&mut s, "select.text", json!({"text": "Dated"}));
run(&mut s, "review.newComment", json!({"text": "When?"}));
let date = run(&mut s, "review.comments", json!({}))[0]["date"].as_str().unwrap_or_default().to_string();
// Fixed-width ISO strings sort by time.
assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}");
}
#[test]
fn charts_and_diagrams_can_be_selected_and_deleted_but_not_moved() {
use std::sync::Arc;
use wordcraft_doc::para::InlineObject;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Before "}));
let chart = InlineObject::Graphic { w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(Default::default()) };
s.doc.insert_object(&Pos::body(0, 7), chart, &Default::default()).unwrap();
let end = 7 + wordcraft_doc::para::OBJ.len_utf8();
run(&mut s, "select.range", json!({"anchor": Pos::body(0, 7), "focus": Pos::body(0, end)}));
assert!(crate::cmd::objects::object_selection(&s).is_some(), "the chart is selected");
for (id, v) in [
("arrange.bounds", json!({"width": 50})),
("picture.size", json!({"width": 50})),
("arrange.wrap", json!({"wrap": "square"})),
("arrange.wrapText", json!({"side": "left"})),
("arrange.position", json!({"preset": "topLeft"})),
("arrange.align", json!({"value": "left"})),
("arrange.bringForward", json!({})),
] {
assert!(s.run(id, &v).is_err(), "{id} must not change a chart");
}
run(&mut s, "text.delete", json!({}));
assert!(s.doc.para_at(&Pos::body(0, 0)).is_some_and(|p| p.objects.is_empty()), "the chart is deleted");
}
/// The chart model of the first object in the body's first paragraph.
fn first_chart(doc: &wordcraft_doc::Document) -> Option {
use wordcraft_doc::para::InlineObject;
let p = doc.para_at(&Pos::body(0, 0))?;
p.object_offsets().into_iter().find_map(|off| match p.object_at(off) {
Some(InlineObject::Graphic { graphic, .. }) => graphic.chart.as_deref().cloned(),
_ => None,
})
}
#[test]
fn inserted_chart_is_saved_and_reopened_with_its_data() {
let mut s = s();
let data =
json!({"categories": ["Jan", "Feb", "Mar"], "series": [{"name": "Rain", "values": [12.5, null, 3]}, {"name": "Sun", "values": [4, 5, 6]}]});
let r = run(&mut s, "insert.chart", json!({"type": "line", "data": data, "title": "Weather", "legend": "right"}));
assert_eq!(r["chart"]["type"], "line");
let spec = first_chart(&s.doc).expect("a chart with a model");
assert_eq!(spec.series[0].values, vec![Some(12.5), None, Some(3.0)]);
assert!(crate::cmd::chart::selected_chart(&s).is_some(), "the new chart is selected");
let bytes = wordcraft_docx::write(&s.doc).unwrap();
assert!(bytes.windows(22).any(|w| w == b"word/charts/chart1.xml"), "a chart part is written");
let back = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(first_chart(&back), Some(spec), "the chart reads back as the same editable chart");
// Sample data when none is given.
let mut s2 = self::s();
run(&mut s2, "insert.chart", json!({"type": "pie"}));
assert!(first_chart(&s2.doc).is_some_and(|c| c.kind == wordcraft_doc::chart::ChartType::Pie && !c.series.is_empty()));
// No params: the picker, nothing inserted.
let mut s3 = self::s();
run(&mut s3, "insert.chart", json!({}));
assert!(s3.ui_requests.iter().any(|r| r["open"] == "insertChart"));
assert!(first_chart(&s3.doc).is_none());
}
#[test]
fn chart_edits_are_one_undo_step_each() {
use wordcraft_doc::chart::{ChartType, LegendPos};
let mut s = s();
run(&mut s, "insert.chart", json!({"type": "column"}));
let before = first_chart(&s.doc).unwrap();
run(&mut s, "chart.editData", json!({"data": {"categories": ["A", "B"], "series": [{"name": "X", "values": [1, 2]}]}}));
run(&mut s, "chart.type", json!({"type": "barStacked"}));
run(&mut s, "chart.title", json!({"text": "Totals"}));
run(&mut s, "chart.legend", json!({"pos": "none"}));
run(&mut s, "chart.dataLabels", json!({"on": true}));
let c = first_chart(&s.doc).unwrap();
assert_eq!((c.kind, c.title.as_deref(), c.legend, c.data_labels), (ChartType::BarStacked, Some("Totals"), None, true));
assert_eq!((c.categories.len(), c.series.len()), (2, 1));
let steps: [&dyn Fn(&wordcraft_doc::chart::ChartSpec) -> bool; 5] = [
&|c| !c.data_labels,
&|c| c.legend == Some(LegendPos::Bottom),
&|c| c.title.is_none(),
&|c| c.kind == ChartType::Column && c.series.len() == 1,
&|c| *c == before,
];
for (k, check) in steps.iter().enumerate() {
run(&mut s, "edit.undo", json!({}));
assert!(check(&first_chart(&s.doc).unwrap()), "undo step {k}");
}
// Resizing draws the chart again at its new size.
run(&mut s, "edit.redo", json!({}));
run(&mut s, "picture.size", json!({"width": 300, "height": 150, "lockAspect": false}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let Some(wordcraft_doc::para::InlineObject::Graphic { graphic, w, .. }) = p.object_offsets().into_iter().find_map(|o| p.object_at(o)) else {
panic!()
};
assert_eq!((*w, graphic.w, graphic.h), (300.0, 300.0, 150.0));
}
#[test]
fn hostile_chart_data_is_capped() {
use wordcraft_doc::chart::{MAX_CELLS, MAX_POINTS, MAX_SERIES, MAX_TEXT};
let mut s = s();
let values: Vec = (0..5000).map(|i| if i % 2 == 0 { json!("NaN") } else { json!(1e308) }).collect();
let series: Vec = (0..300).map(|i| json!({"name": "n".repeat(10_000), "values": values, "x": i})).collect();
let cats: Vec = (0..6000).map(|i| json!(i)).collect();
run(
&mut s,
"insert.chart",
json!({"type": "scatter", "data": {"categories": cats, "series": series}, "title": "\u{0}".repeat(9999), "width": 1e30}),
);
let c = first_chart(&s.doc).unwrap();
assert!(c.series.len() <= MAX_SERIES && c.points() <= MAX_POINTS && c.series.len() * c.points() <= MAX_CELLS);
assert!(c.series.iter().all(|x| x.name.chars().count() <= MAX_TEXT && x.values.len() == c.points()));
assert!(c.series[0].values.iter().all(|v| v.is_none_or(f64::is_finite)));
let _ = s.layout();
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert_eq!(first_chart(&back), Some(c));
for bad in [json!({"type": "radar"}), json!({"data": {"series": 5}}), json!({"data": {"series": []}})] {
assert!(s.run("insert.chart", &bad).is_err(), "{bad}");
}
// A chart without a model (from another program) is shown, not edited.
let mut s = self::s();
let foreign =
wordcraft_doc::para::InlineObject::Graphic { w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Default::default() };
s.doc.insert_object(&Pos::body(0, 0), foreign, &Default::default()).unwrap();
run(&mut s, "select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, wordcraft_doc::para::OBJ.len_utf8())}));
assert!(s.run("chart.type", &json!({"type": "pie"})).is_err());
}
#[test]
fn accessibility_reports_a_chart_without_alt_text() {
use std::sync::Arc;
use wordcraft_doc::para::InlineObject;
let mut s = s();
let chart = InlineObject::Graphic { w: 200.0, h: 100.0, alt: " ".into(), float: Default::default(), graphic: Arc::new(Default::default()) };
s.doc.insert_object(&Pos::body(0, 0), chart, &Default::default()).unwrap();
let v = run(&mut s, "file.accessibility", json!({}));
let issues = v["issues"].as_array().expect("issues");
assert!(issues.iter().any(|i| i["issue"] == "Chart or diagram has no alternative text"), "{issues:?}");
}
/// #320: the Tabs dialog's command sets and clears stops (inherited ones too), changes the
/// default interval, and is one undo step.
#[test]
fn para_tabs_set_clear_and_undo() {
use wordcraft_doc::props::{TabAlign, TabLeader};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Name\tPrice"}));
run(&mut s, "para.style", json!({"style": "Header"}));
let stops = |s: &Session| {
let p = s.doc.para_at(&s.sel.focus).map(|p| p.props.clone()).unwrap_or_default();
s.doc.styles.resolve_para(&p).tabs.iter().map(|t| (t.pos, t.align, t.leader)).collect::>()
};
assert_eq!(stops(&s).len(), 2, "Header's own stops");
run(
&mut s,
"para.tabs",
json!({"set": [{"pos": 100.0, "align": "decimal", "leader": "dot"}, {"pos": 300.0}], "clear": [234.0], "default": 18.0}),
);
assert_eq!(
stops(&s),
vec![(100.0, TabAlign::Decimal, TabLeader::Dot), (300.0, TabAlign::Left, TabLeader::None), (468.0, TabAlign::Right, TabLeader::None)]
);
assert_eq!(s.doc.settings.default_tab, 18.0);
// Setting a stop at an existing position replaces it.
run(&mut s, "para.tabs", json!({"set": [{"pos": 300.0, "align": "bar"}]}));
assert!(stops(&s).contains(&(300.0, TabAlign::Bar, TabLeader::None)));
run(&mut s, "para.tabs", json!({"clearAll": true}));
assert!(stops(&s).is_empty(), "Clear All removes inherited stops too");
run(&mut s, "edit.undo", json!({}));
assert_eq!(stops(&s).len(), 3);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(stops(&s).len(), 2);
assert_eq!(s.doc.settings.default_tab, 36.0, "one undo step restores stops and default together");
assert!(s.run("para.tabs", &json!({"set": [{"pos": f64::MAX}]})).is_err());
assert!(s.run("para.tabs", &json!({"clear": "x"})).is_err());
}
/// #320: changing only the default tab interval (a document setting) is its own undo step,
/// and redo puts it back.
#[test]
fn para_tabs_default_only_undoes() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "a\tb"}));
let before = s.doc.settings.default_tab;
run(&mut s, "para.tabs", json!({"default": 72.0}));
assert_eq!(s.doc.settings.default_tab, 72.0);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.settings.default_tab, before, "undo restores the default interval");
assert!(text(&s).contains("a\tb"), "only the setting was undone");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.settings.default_tab, 72.0, "redo sets it again");
}
/// #320: exact border sides with a style, for paragraphs and page borders.
#[test]
fn border_sides_apply_exactly() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Boxed"}));
let side = json!({"style": "double", "width": 1.5, "color": "C00000"});
run(&mut s, "para.borders", json!({"sides": {"top": side, "bottom": side, "left": side, "right": side}}));
let b = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.borders).unwrap();
for x in [b.top, b.bottom, b.left, b.right] {
let x = x.unwrap();
assert_eq!((x.style, x.width, x.color), (BorderStyle::Double, 1.5, Some(wordcraft_doc::props::Rgb(0xC0, 0, 0))));
}
assert!(b.between.is_none());
run(&mut s, "para.borders", json!({"sides": {}}));
assert!(!s.doc.para_at(&s.sel.focus).and_then(|p| p.props.borders).unwrap().any_visible());
run(&mut s, "design.pageBorders", json!({"sides": {"top": {"style": "dashed", "width": 3.0}}, "applyTo": "document"}));
let pb = s.doc.last_section.page_borders.unwrap();
assert_eq!(pb.top.map(|t| (t.style, t.width)), Some((BorderStyle::Dashed, 3.0)));
assert!(pb.left.is_none());
run(&mut s, "design.pageBorders", json!({"kind": "box", "style": "thick"}));
assert_eq!(s.doc.last_section.page_borders.unwrap().left.map(|t| t.style), Some(BorderStyle::Thick));
run(&mut s, "design.pageBorders", json!({"sides": {}}));
assert!(s.doc.last_section.page_borders.is_none());
}
/// Encrypt with Password (#55): a document saved with a password opens only with it, keeps it
/// for the next save, and saves unencrypted once the password is removed.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn password_protected_documents_round_trip() {
let dir = scratch_dir("password");
let path = dir.join("secret.docx");
let path_str = path.to_string_lossy().to_string();
let mut a = s();
run(&mut a, "document.setText", json!({"text": "Quarterly numbers"}));
let r = run(&mut a, "file.save", json!({"path": path_str, "password": "hunter2"}));
assert_eq!(r["encrypted"], true);
assert!(wordcraft_docx::is_encrypted(&std::fs::read(&path).unwrap()));
assert!(a.run("file.save", &json!({"path": dir.join("x.txt").to_string_lossy(), "password": "pw"})).is_err(), "only Word packages encrypt");
// No password, then a wrong one: clear errors, and the open document stays.
let mut t = s();
let e = t.run("file.open", &json!({"path": path_str})).unwrap_err().to_string();
assert!(crate::io::needs_password(&e), "{e}");
let e = t.run("file.open", &json!({"path": path_str, "password": "hunter3"})).unwrap_err().to_string();
assert!(crate::io::wrong_password(&e), "{e}");
assert!(t.path.is_none());
let r = run(&mut t, "file.open", json!({"path": path_str, "password": "hunter2"}));
assert_eq!(r["encrypted"], true);
assert_eq!(text(&t), "Quarterly numbers");
assert_eq!(run(&mut t, "file.info", json!({}))["encrypted"], true);
// Saving again keeps the password, as Word does.
run(&mut t, "text.insert", json!({"text": "Q3 "}));
run(&mut t, "file.save", json!({}));
let (doc, encrypted) = crate::io::open_path_with(&path, Some("hunter2")).unwrap();
assert!(encrypted && doc.plain_text(StoryRef::Body).contains("Q3"));
// Removing the password saves the document unencrypted.
assert!(t.run("file.encrypt", &json!({})).is_err());
assert!(t.run("file.encrypt", &json!({"password": "x".repeat(300)})).is_err());
t.dirty = false;
assert_eq!(run(&mut t, "file.encrypt", json!({"password": null}))["encrypted"], false);
assert!(t.dirty, "removing the password is a change to save");
run(&mut t, "file.save", json!({}));
assert!(crate::io::open_path(&path).unwrap().plain_text(StoryRef::Body).contains("Quarterly"));
// A password never shows in debug output.
assert_eq!(format!("{:?}", crate::io::Password::new("hunter2")), "Password(***)");
let _ = std::fs::remove_dir_all(&dir);
}
/// Shape Format › Shape Effects (#275): presets and custom values apply to the selected shape,
/// an omitted key is kept, hostile numbers are clamped and each change is one undo step.
#[test]
fn shape_effects_apply_and_undo() {
use wordcraft_doc::effects::{MAX_BLUR, MAX_DISTANCE, Shadow};
let mut s = s();
run(&mut s, "insert.shape", json!({"kind": "ellipse"}));
let effects = |s: &Session| match cmd::objects::selected(s) {
Some((_, wordcraft_doc::InlineObject::Shape { effects, float, .. })) => (effects, float.effect),
o => panic!("{o:?}"),
};
assert!(effects(&s).0.is_empty());
run(&mut s, "shape.effects", json!({"shadow": "offsetBottomRight", "glow": {"color": "FF0000", "size": 8}, "softEdge": 5}));
let (e, extent) = effects(&s);
assert_eq!(e.shadow, Shadow::preset("offsetBottomRight"));
assert_eq!(e.glow.map(|g| (g.color, g.size)), Some((wordcraft_doc::props::Rgb(255, 0, 0), 8.0)));
assert_eq!(e.soft_edge, Some(5.0));
assert!(extent.iter().all(|v| *v >= 8.0), "room for the glow: {extent:?}");
// Clearing the glow leaves the shadow and soft edges.
run(&mut s, "shape.effects", json!({"glow": null}));
let (e, _) = effects(&s);
assert!(e.glow.is_none() && e.shadow.is_some() && e.soft_edge == Some(5.0));
run(&mut s, "edit.undo", json!({}));
assert_eq!(effects(&s).0.glow.map(|g| g.size), Some(8.0));
// Hostile numbers.
run(&mut s, "shape.effects", json!({"shadow": {"blur": 1e30, "distance": -5, "angle": 1e20, "transparency": 400}, "softEdge": -3}));
let (e, _) = effects(&s);
let sh = e.shadow.unwrap();
assert!(sh.blur == MAX_BLUR && sh.distance == 0.0 && (0.0..360.0).contains(&sh.angle) && sh.transparency == 100.0, "{sh:?}");
assert!(e.soft_edge.is_none());
run(&mut s, "shape.effects", json!({"shadow": {"distance": 1e9}}));
assert_eq!(effects(&s).0.shadow.map(|s| s.distance), Some(MAX_DISTANCE));
assert!(s.run("shape.effects", &json!({"shadow": "perspective"})).is_err());
// Off a shape it's disabled.
run(&mut s, "select.collapse", json!({"end": true}));
run(&mut s, "text.insert", json!({"text": "x"}));
assert!(s.run("shape.effects", &json!({"shadow": null})).is_err());
}
#[test]
fn asian_typography_sets_flags_on_selected_paragraphs_and_undoes() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "一\n二\n三"}));
run(&mut s, "select.all", json!({}));
let r = run(&mut s, "para.asianTypography", json!({"kinsoku": false, "wordWrap": false, "topLinePunct": true}));
assert_eq!(r["asianTypography"]["kinsoku"], false);
assert_eq!(r["asianTypography"]["autoSpaceDE"], true, "a flag left out keeps its default");
for p in s.doc.body.iter().filter_map(|b| b.as_para()) {
assert_eq!(
(p.props.kinsoku, p.props.word_wrap, p.props.top_line_punct, p.props.overflow_punct),
(Some(false), Some(false), Some(true), None)
);
}
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.kinsoku.is_none() && p.props.word_wrap.is_none()));
// Without flags it only reports them.
let r = run(&mut s, "para.asianTypography", json!({}));
assert_eq!(r["asianTypography"]["kinsoku"], true);
}
#[test]
fn manage_styles_lists_paragraph_character_linked_and_table_styles() {
let mut s = s();
let list = run(&mut s, "styles.manage", json!({"sort": "alphabetical"}));
let list = list.as_array().unwrap();
let find = |name: &str| list.iter().find(|x| x["name"] == name).unwrap_or_else(|| panic!("{name} listed"));
assert_eq!(find("Normal")["type"], "paragraph");
assert_eq!(find("Normal")["builtIn"], true);
assert_eq!(find("Normal")["inGallery"], true);
assert_eq!(find("Heading 1")["type"], "linked");
assert_eq!(find("Heading 1")["basedOn"], "Normal");
assert!(find("Heading 1")["format"]["size"].is_number(), "description formatting");
assert_eq!(find("Table Grid")["type"], "table");
assert!(list.iter().any(|x| x["type"] == "character"));
// The character half of a linked pair is listed once, as the linked style.
assert!(!list.iter().any(|x| x["id"] == "Heading1Char"));
let names: Vec = list.iter().map(|x| x["name"].as_str().unwrap().to_lowercase()).collect();
assert!(names.windows(2).all(|w| w[0] <= w[1]), "alphabetical");
// Recommended order starts with Normal (priority 0); listing is not an edit.
let rec = run(&mut s, "styles.manage", json!({}));
assert_eq!(rec[0]["name"], "Normal");
assert!(s.run("styles.manage", &json!({"sort": "sideways"})).is_err());
assert!(!s.dirty);
}
#[test]
fn deleting_a_style_falls_back_to_its_base_and_undoes() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Styled"}));
run(&mut s, "styles.create", json!({"name": "Mine", "basedOn": "Heading 1"}));
run(&mut s, "styles.create", json!({"name": "Child", "basedOn": "Mine", "fromSelection": false}));
run(&mut s, "styles.apply", json!({"style": "Mine"}));
assert_eq!(para_style(&s, 0).as_deref(), Some("Mine"));
let r = run(&mut s, "styles.delete", json!({"style": "Mine"}));
assert_eq!(r["restyled"], 1);
assert!(s.doc.styles.get("Mine").is_none());
assert_eq!(para_style(&s, 0).as_deref(), Some("Heading1"), "falls back to its base");
assert_eq!(s.doc.styles.get("Child").and_then(|c| c.based_on.clone()).as_deref(), Some("Heading1"), "rebased");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.styles.get("Mine").is_some());
assert_eq!(para_style(&s, 0).as_deref(), Some("Mine"));
// Built-in styles are refused, unknown ones are errors.
assert!(s.run("styles.delete", &json!({"style": "Heading 1"})).is_err());
assert!(s.run("styles.delete", &json!({"style": "No Such Style"})).is_err());
assert!(s.doc.styles.get("Heading1").is_some());
}
/// Manage Styles' Delete on a table style is Table Design's Delete Table Style: its tables take
/// Table Grid, styles based on it keep their look, and Word's built-in table styles are refused.
#[test]
fn deleting_a_table_style_from_manage_styles_uses_table_style_deletion() {
use wordcraft_doc::Rgb;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false}));
let r = run(&mut s, "styles.delete", json!({"style": "Base"}));
assert_eq!((r["deleted"][0].as_str(), r["restyled"].as_u64()), (Some("Base"), Some(1)));
assert_eq!(s.doc.table(StoryRef::Body, &tp).unwrap().props.style.as_deref(), Some("TableGrid"));
assert_eq!(s.doc.styles.table_style("Child").unwrap().parts.header_fill, Some(Rgb(0xC0, 0, 0)), "the child keeps its look");
for st in s.doc.styles.styles.iter_mut() {
st.builtin = false;
}
assert!(s.run("styles.delete", &json!({"style": "Table Grid"})).is_err());
let list = run(&mut s, "styles.manage", json!({}));
let grid = list.as_array().unwrap().iter().find(|x| x["name"] == "Table Grid").unwrap();
assert_eq!(grid["builtIn"], true);
}
#[test]
fn style_visibility_round_trips_through_docx() {
let mut s = s();
run(&mut s, "styles.create", json!({"name": "Mine", "fromSelection": false}));
run(&mut s, "styles.setVisibility", json!({"style": "Heading 1", "gallery": false}));
run(&mut s, "styles.setVisibility", json!({"style": "Mine", "hidden": true}));
run(&mut s, "styles.setVisibility", json!({"style": "Heading 4", "gallery": true}));
assert!(s.run("styles.setVisibility", &json!({"style": "Mine"})).is_err());
let gallery: Vec = s.doc.styles.gallery().iter().map(|x| x.id.clone()).collect();
assert!(!gallery.contains(&"Heading1".to_string()) && !gallery.contains(&"Mine".to_string()) && gallery.contains(&"Heading4".to_string()));
let bytes = crate::io::save_bytes("v.docx", &s.doc).unwrap();
let doc = crate::io::open_bytes("v.docx", &bytes).unwrap();
let st = |id: &str| doc.styles.get(id).cloned().unwrap_or_else(|| panic!("{id} kept"));
assert!(!st("Heading1").quick && !st("Heading1").hidden);
assert!(st("Mine").hidden && !st("Mine").builtin);
assert!(st("Heading4").quick);
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.styles.get("Heading4").is_some_and(|h| !h.quick), "undoable");
}
/// #548: `.xml` saves a Word XML Document (Flat OPC) and opens it again; other XML is refused.
#[test]
fn word_xml_saves_and_opens_through_io() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Kept in one XML file"}));
let bytes = crate::io::save_bytes("notes.xml", &s.doc).unwrap();
assert_eq!(wordcraft_docx::sniff_xml(&bytes), wordcraft_docx::XmlKind::FlatOpc);
let doc = crate::io::open_bytes("notes.XML", &bytes).unwrap();
assert_eq!(doc.body, crate::io::open_bytes("n.docx", &crate::io::save_bytes("n.docx", &s.doc).unwrap()).unwrap().body);
let err = crate::io::open_bytes("feed.xml", b"").unwrap_err();
assert!(err.contains("not a Word XML document"), "{err}");
}
/// #146: the first/last column, total row and banded column regions apply in layout (later
/// regions win: the total row's fill beats the first column's) and round-trip through .docx.
#[test]
fn custom_table_style_column_and_total_regions() {
use wordcraft_doc::Rgb;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 3, "cols": 4}));
run(
&mut s,
"table.look",
json!({"headerRow": false, "bandedRows": false, "firstColumn": true, "lastColumn": true, "totalRow": true, "bandedColumns": true}),
);
let r = run(
&mut s,
"table.newStyle",
json!({"name": "Ledger",
"firstColumn": {"fill": "112233", "bold": true},
"lastColumn": {"fill": "445566"},
"lastRow": {"fill": "778899", "borders": true},
"bandedColumns": {"fill": "ABCDEF"}}),
);
let id = r["id"].as_str().unwrap().to_string();
let fills = |s: &mut Session, c: Rgb| {
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
};
// 3 rows x 4 columns: the total row takes all 4 of its cells, the first and last columns the
// two cells above it, and the column band (column 2 of the inner columns 2 and 3) one each.
assert_eq!(fills(&mut s, Rgb(0x77, 0x88, 0x99)), 4, "total row");
assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "first column");
assert_eq!(fills(&mut s, Rgb(0x44, 0x55, 0x66)), 2, "last column");
assert_eq!(fills(&mut s, Rgb(0xAB, 0xCD, 0xEF)), 2, "first column band");
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let p = back.styles.get(&id).unwrap().table.clone().unwrap();
assert_eq!(
(p.first_col_fill, p.first_col_chr.bold, p.last_col_fill, p.total_fill, p.col_band_fill),
(Some(Rgb(0x11, 0x22, 0x33)), Some(true), Some(Rgb(0x44, 0x55, 0x66)), Some(Rgb(0x77, 0x88, 0x99)), Some(Rgb(0xAB, 0xCD, 0xEF)))
);
assert!(p.total_borders.is_some_and(|b| b.any_visible()));
}
/// #146: deleting a custom table style re-bases the styles based on it (keeping their look),
/// puts its tables on Table Grid, refuses built-in styles, and undoes.
#[test]
fn delete_table_style_falls_back_and_undoes() {
use wordcraft_doc::Rgb;
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false, "wholeTable": {"bold": true}}));
let style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
assert_eq!(style(&s).as_deref(), Some("Base"));
assert!(s.run("table.deleteStyle", &json!({"style": "Table Grid"})).is_err(), "built-in");
assert!(s.run("table.deleteStyle", &json!({"style": "Normal Table"})).is_err(), "built-in");
let r = run(&mut s, "table.deleteStyle", json!({}));
assert_eq!((r["deleted"].as_str(), r["tables"].as_u64()), (Some("Base"), Some(1)));
assert!(s.doc.styles.get("Base").is_none());
assert_eq!(style(&s).as_deref(), Some("TableGrid"), "tables fall back to Table Grid");
let child = s.doc.styles.table_style("Child").unwrap();
assert_eq!((child.parts.header_fill, child.chr.bold), (Some(Rgb(0xC0, 0, 0)), Some(true)), "the child keeps its look");
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("TableGrid"));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.styles.get("Base").is_some());
assert_eq!(style(&s).as_deref(), Some("Base"));
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("Base"));
}
/// #146: a .docx may mark Word's built-in table styles as custom; they still can't be deleted, so
/// no table is left pointing at a style that's gone.
#[test]
fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
for st in s.doc.styles.styles.iter_mut() {
st.builtin = false;
}
for name in ["Table Grid", "Normal Table", "TableGrid"] {
assert!(s.run("table.deleteStyle", &json!({"style": name})).is_err(), "{name}");
}
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
let id = s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone().unwrap();
assert!(s.doc.styles.get(&id).is_some(), "the table's style still exists");
}
/// Delete Cells can leave a row shorter than the table grid; Delete Columns then removed that row's
/// only cell and every row with it, leaving a table block with no rows.
#[test]
fn deleting_the_last_cell_column_never_leaves_a_table_without_rows() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
run(&mut s, "table.deleteCells", json!({}));
run(&mut s, "table.deleteColumn", json!({}));
for b in s.doc.body.iter() {
if let Some(t) = b.as_table() {
assert!(!t.rows.is_empty(), "table without rows");
}
}
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()), "an emptied table is removed");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.body.iter().any(|b| b.as_table().is_some_and(|t| !t.rows.is_empty())));
}
#[test]
fn center_after_select_paragraph_leaves_the_next_paragraph_alone() {
// #418: Select Paragraph ends at the next paragraph's start; that paragraph isn't selected.
use wordcraft_doc::Align;
let align = |s: &Session, i: usize| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.align);
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Alpha\rBeta", "raw": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 2}}));
run(&mut s, "select.paragraph", json!({}));
assert_eq!(s.selected_text(), "Alpha\n");
run(&mut s, "para.alignCenter", json!({}));
assert_eq!((align(&s, 0), align(&s, 1)), (Some(Align::Center), None));
// Reversed, too; a range into Beta centres both; a caret at Beta's start centres Beta.
for (a, b, want) in [((1, 0), (0, 0), (Some(Align::Center), None)), ((0, 0), (1, 1), (Some(Align::Center), Some(Align::Center)))] {
let mut s = crate::Session::new(wordcraft_doc::Document::new());
run(&mut s, "text.insert", json!({"text": "Alpha\rBeta", "raw": true}));
run(&mut s, "caret.set", json!({"pos": {"block": a.0, "off": a.1}}));
run(&mut s, "caret.set", json!({"pos": {"block": b.0, "off": b.1}, "extend": true}));
run(&mut s, "para.align", json!({"value": "center"}));
assert_eq!((align(&s, 0), align(&s, 1)), want, "{a:?}..{b:?}");
}
let mut s = crate::Session::new(wordcraft_doc::Document::new());
run(&mut s, "text.insert", json!({"text": "Alpha\rBeta", "raw": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
run(&mut s, "para.alignCenter", json!({}));
assert_eq!((align(&s, 0), align(&s, 1)), (None, Some(Align::Center)));
}
#[test]
fn change_case_keeps_the_selection_when_lengths_change() {
// #420: İ lowers to i + U+0307 (one byte more); the selection still covers the result.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "aİxZ", "raw": true}));
run(&mut s, "select.text", json!({"text": "İx"}));
run(&mut s, "format.changeCase", json!({"mode": "lower"}));
assert_eq!(text(&s), "ai\u{307}xZ");
assert_eq!(s.selected_text(), "i\u{307}x");
// ı uppers to I (one byte less); the trailing Z stays out of the selection.
let mut s = crate::Session::new(wordcraft_doc::Document::new());
run(&mut s, "text.insert", json!({"text": "ıxZ", "raw": true}));
run(&mut s, "select.text", json!({"text": "ıx"}));
run(&mut s, "format.changeCase", json!({"mode": "upper"}));
assert_eq!(text(&s), "IXZ");
assert_eq!(s.selected_text(), "IX");
}
#[test]
fn change_case_keeps_each_runs_formatting_when_lengths_change() {
// #421: lowercasing a plain İ and a bold x keeps x bold (and the i̇ plain).
let mut s = s();
run(&mut s, "text.insert", json!({"text": "İx", "raw": true}));
run(&mut s, "select.text", json!({"text": "x"}));
run(&mut s, "format.bold", json!({"value": true}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.changeCase", json!({"mode": "lower"}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, "i\u{307}x");
assert_ne!(p.props_of_char(0).bold, Some(true));
assert_ne!(p.props_of_char(1).bold, Some(true));
assert_eq!(p.props_of_char(3).bold, Some(true));
// Typing over the converted selection replaces all of it.
run(&mut s, "text.insert", json!({"text": "Q", "raw": true}));
assert_eq!(text(&s), "Q");
}
#[test]
fn document_grid_sets_lines_and_characters_in_one_undo_step() {
// #391: Layout › Page Setup › Document Grid.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "日本語"}));
let undo = s.undo_labels().len();
let r = run(&mut s, "layout.documentGrid", json!({"type": "linesAndChars", "linesPerPage": 40, "charsPerLine": 30}));
assert_eq!(r["type"], "linesAndChars");
assert_eq!(r["linesPerPage"], 40);
assert_eq!(r["charsPerLine"], 30);
let g = crate::cmd::page::sect(&s).doc_grid.unwrap();
assert!((g.line_pitch - 648.0 / 40.0).abs() < 1e-3, "{g:?}");
assert_eq!(s.undo_labels().len(), undo + 1);
// Hostile values are clamped, not trusted.
let r = run(&mut s, "layout.documentGrid", json!({"linePitch": -5, "charSpace": 1e15}));
assert_eq!(r["linePitch"], 1.0);
assert!(r["charPitch"].as_f64().unwrap() <= 1584.0);
assert!(s.run("layout.documentGrid", &json!({"type": "diagonal"})).is_err());
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(crate::cmd::page::sect(&s).doc_grid, None);
}
#[test]
fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours() {
use wordcraft_doc::para::InlineObject;
let mut s = s();
// Without an id: the library is listed and a front end is asked for its picker.
let r = run(&mut s, "insert.icon", json!({"query": "envelope"}));
assert_eq!(r["icons"][0]["id"], "mail");
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "icons"})));
let all = run(&mut s, "insert.icon", json!({}));
assert!(all["icons"].as_array().unwrap().len() >= 40);
assert!(s.run("insert.icon", &json!({"id": "no-such-icon"})).is_err());
assert!(s.run("insert.icon", &json!({"id": "heart", "color": "red"})).is_err());
let r = run(&mut s, "insert.icon", json!({"id": "heart", "color": "C00000", "size": 48}));
assert_eq!((r["width"].as_f64(), r["height"].as_f64(), r["icon"].as_str()), (Some(48.0), Some(48.0), Some("heart")));
let Some((_, InlineObject::Image { media, w, h, alt, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") };
assert_eq!((w, h, alt.as_str()), (48.0, 48.0, "Heart"));
let png = image::load_from_memory(&s.doc.media[&media]).unwrap().to_rgba8();
assert_eq!(png.dimensions(), (200, 200), "about 300 pixels per inch");
let inked: Vec<_> = png.pixels().filter(|p| p.0[3] == 255).collect();
assert!(!inked.is_empty() && inked.iter().all(|p| p.0[..3] == [0xC0, 0, 0]));
assert!(png.get_pixel(0, 0).0[3] == 0, "transparent background");
// It is an ordinary picture: it saves to .docx.
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert_eq!(back.media.len(), 1);
// Icon Color repaints it, keeping the transparency.
run(&mut s, "picture.iconColor", json!({"color": "1F4E79"}));
let Some((_, InlineObject::Image { media: m2, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") };
let png2 = image::load_from_memory(&s.doc.media[&m2]).unwrap().to_rgba8();
assert!(png2.pixels().zip(png.pixels()).all(|(a, b)| a.0[3] == b.0[3] && (a.0[3] == 0 || a.0[..3] == [0x1F, 0x4E, 0x79])));
assert!(s.run("picture.iconColor", &json!({})).is_err());
}
/// Print Markup (#589): `file.printMarkup` or a command's `markup` puts the comments into PDFs
/// (balloons beside the scaled page) and at the end of HTML, Markdown and text copies.
#[test]
fn print_markup_exports_comments() {
let mut s = Session::new(wordcraft_doc::Document::from_text("The quarterly figures look strong."));
s.author = "Ada Reviewer".into();
s.run("select.text", &json!({"text": "figures"})).unwrap();
let id = s.run("review.newComment", &json!({"text": "Check the totals"})).unwrap()["id"].as_u64().unwrap();
s.run("review.reply", &json!({"id": id, "text": "Totals checked"})).unwrap();
let opts = |s: &Session, v| cmd::file::export_options(s, &v);
assert!(!opts(&s, json!({})).markup, "off by default");
let pdf = |s: &Session, v| crate::io::save_bytes_opts("x.pdf", &s.doc, &opts(s, v)).unwrap();
let html = |s: &Session, v| String::from_utf8(crate::io::save_bytes_opts("x.html", &s.doc, &opts(s, v)).unwrap()).unwrap();
let txt = |s: &Session, v| String::from_utf8(crate::io::save_bytes_opts("x.txt", &s.doc, &opts(s, v)).unwrap()).unwrap();
assert!(!html(&s, json!({})).contains("Check the totals"));
assert!(!txt(&s, json!({})).contains("Check the totals"));
let plain = pdf(&s, json!({}));
// The setting turns it on for every copy; a command's `markup` overrides it.
assert_eq!(s.run("file.printMarkup", &json!({})).unwrap()["value"], true);
let h = html(&s, json!({}));
assert!(
h.contains("