2592 lines
120 KiB
Rust
2592 lines
120 KiB
Rust
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 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 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<String>, wordcraft_doc::Document)> {
|
||
let mut out = Vec::new();
|
||
while s.can_redo() {
|
||
run(s, "edit.redo", json!({}));
|
||
}
|
||
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;
|
||
}
|
||
run(s, "edit.undo", json!({}));
|
||
}
|
||
}
|
||
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 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 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 = "<p>plain <b>strong</b></p>";
|
||
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 <b> 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": "<b>hi</b> there</p>"}));
|
||
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": "<ul><li>one</li><li>two</li></ul>"}));
|
||
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<String> = (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<TextDirection> {
|
||
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());
|
||
}
|
||
}
|
||
|
||
#[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<u32>) -> Option<String> {
|
||
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<String>)> = 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<String> {
|
||
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<u32> = 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": "<data/>", "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("<data/>"));
|
||
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: `<img src="logo.png">` 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#"<p><img src="img/my%20logo.png" alt="Logo"></p><p><img src="secret.txt" alt="T"></p><p><img src="http://example.com/x.png" alt="Web"></p>"#;
|
||
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<u8> {
|
||
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#"<p><img src="../outside.png" alt="Up"></p><p><img src="img/../../outside.png" alt="Sneak"></p><p><img src="img/../inside.png" alt="In"></p>"#;
|
||
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#"<p><img src="link.png" alt="Link"></p>"#).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#"<p><img src="pipe.png" alt="Pipe"></p>"#).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<String> {
|
||
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}");
|
||
}
|
||
}
|
||
|
||
#[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.autocorrect_on = 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.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");
|
||
}
|
||
|
||
#[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:?}");
|
||
}
|
||
|
||
/// 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<String> = 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<String> = 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");
|
||
}
|
||
|
||
/// #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");
|
||
}
|