mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
Keep inherited complex-script size, bold and italic independent from Latin properties and retain explicit values during DOCX round trips. Preserve ordinary formatting-command behavior and add regression coverage. Resolve eight existing Clippy warnings without changing behavior so all six required CI gates pass. Co-authored-by: ChatGPT <noreply@openai.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
4097 lines
200 KiB
Rust
4097 lines
200 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 txt_open_decodes_windows_1252_and_unmarked_utf16() {
|
||
// #433: the TXT open path sniffs encodings like the formats importer.
|
||
let open = |b: &[u8]| crate::io::open_bytes("x.txt", b).unwrap().plain_text(StoryRef::Body);
|
||
assert_eq!(open(b"\x80"), "€");
|
||
assert_eq!(open(b"A\0B\0"), "AB");
|
||
assert_eq!(open(b"\0A\0B"), "AB");
|
||
assert_eq!(open("€".as_bytes()), "€");
|
||
assert_eq!(open(b"\xFF\xFEA\0B\0"), "AB");
|
||
assert_eq!(open(b"A\0B"), "AB", "odd byte count stays safe");
|
||
assert_eq!(crate::io::decode_text(b"caf\xE9 \x80"), "café €");
|
||
}
|
||
|
||
#[test]
|
||
fn txt_save_writes_list_labels() {
|
||
// #434: list paragraphs keep their bullet or number and a tab in plain text.
|
||
let doc = crate::io::open_bytes("l.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
|
||
assert_eq!(crate::io::save_bytes("l.txt", &doc).unwrap(), "•\talpha\r\n•\tbeta\r\n1.\tfirst\r\n2.\tsecond".as_bytes());
|
||
let plain = crate::io::open_bytes("p.md", b"alpha\n\nbeta\n").unwrap();
|
||
assert_eq!(crate::io::save_bytes("p.txt", &plain).unwrap(), b"alpha\r\nbeta");
|
||
}
|
||
|
||
#[test]
|
||
fn every_command_has_unique_id() {
|
||
let reg = cmd::registry();
|
||
let mut ids: Vec<&str> = reg.all().iter().map(|c| c.id).collect();
|
||
let n = ids.len();
|
||
ids.sort();
|
||
ids.dedup();
|
||
assert_eq!(ids.len(), n, "duplicate command ids");
|
||
assert!(n > 150, "{n} commands");
|
||
}
|
||
|
||
#[test]
|
||
fn every_shortcut_runs_one_command() {
|
||
// `by_shortcut` takes the first match, so a shared key silently shadows the later command (#186).
|
||
let reg = cmd::registry();
|
||
let mut seen = std::collections::HashMap::new();
|
||
for c in reg.all() {
|
||
for k in c.shortcut.split(" / ").filter(|k| !k.is_empty()) {
|
||
if let Some(other) = seen.insert(k.to_ascii_lowercase(), c.id) {
|
||
panic!("{k} is bound to both {other} and {}", c.id);
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(reg.by_shortcut("Shift+F3").map(|c| c.id), Some("format.changeCase"));
|
||
}
|
||
|
||
#[test]
|
||
fn word_default_shortcuts_resolve() {
|
||
// #330: Word's documented default shortcuts reach the matching commands, with Word for
|
||
// Mac's keys where macOS takes Word's (⌘Q quits, ⌘Space is Spotlight, ⌘⇧G is Find Previous).
|
||
let reg = cmd::registry();
|
||
let id = |k: &str| reg.by_shortcut(k).map(|c| c.id);
|
||
assert_eq!(id("Mod+Alt+1"), Some("para.heading1"));
|
||
assert_eq!(id("Mod+Alt+P"), Some("view.printLayout"));
|
||
assert_eq!(id("Mod+Alt+O"), Some("view.outline"));
|
||
assert_eq!(id("Mod+Alt+N"), Some("view.draft"));
|
||
assert_eq!(id("Mod+Alt+S"), Some("view.split"));
|
||
assert_eq!(id("Mod+Alt+-"), Some("text.emDash"));
|
||
assert_eq!(id("Mod+Shift+F5"), Some("insert.bookmark"));
|
||
assert_eq!(id("Alt+F7"), Some("review.spelling"));
|
||
assert_eq!(id("Shift+F12"), Some("file.save"));
|
||
assert_eq!(id(cmd::mac_or("Ctrl+Q", "Mod+Q")), Some("para.reset"));
|
||
assert_eq!(id(cmd::mac_or("Ctrl+Space", "Mod+Space")), Some("format.resetChar"));
|
||
assert_eq!(id(cmd::mac_or("Ctrl+Shift+D", "Alt+Shift+D")), Some("insert.dateField"));
|
||
if cfg!(target_os = "macos") {
|
||
assert_eq!(id("Mod+Shift+G"), Some("edit.findPrevious"));
|
||
} else {
|
||
assert_eq!(id("Mod+Shift+G"), Some("review.wordCount"));
|
||
assert_eq!(id("Mod+Alt+C"), Some("text.copyright"));
|
||
assert_eq!(id("Mod+Alt+H"), Some("format.highlight"));
|
||
}
|
||
assert_eq!(id("Mod+PageUp"), Some("edit.findPrevious"));
|
||
}
|
||
|
||
#[test]
|
||
fn reset_shortcuts_remove_direct_formatting_but_keep_styles() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Hello world"}));
|
||
run(&mut s, "para.heading1", json!({}));
|
||
run(&mut s, "para.indent", json!({}));
|
||
run(&mut s, "para.alignCenter", json!({}));
|
||
run(&mut s, "para.reset", json!({}));
|
||
let props = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.clone();
|
||
assert_eq!(props.style.as_deref(), Some("Heading1"), "Ctrl+Q keeps the paragraph style");
|
||
assert_eq!((props.indent_left, props.align), (None, None), "Ctrl+Q drops direct paragraph formatting");
|
||
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "format.bold", json!({}));
|
||
run(&mut s, "format.resetChar", json!({}));
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
assert_eq!(p.props_of_char(1).bold, None, "Ctrl+Space drops direct character formatting");
|
||
assert_eq!(p.props.style.as_deref(), Some("Heading1"), "Ctrl+Space leaves the paragraph alone");
|
||
|
||
run(&mut s, "caret.docEnd", json!({}));
|
||
run(&mut s, "text.emDash", json!({}));
|
||
assert_eq!(text(&s), "Hello world—");
|
||
}
|
||
|
||
#[test]
|
||
fn typing_enter_undo() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||
run(&mut s, "text.insert", json!({"text": " world"}));
|
||
run(&mut s, "text.newParagraph", json!({}));
|
||
run(&mut s, "text.insert", json!({"text": "Second"}));
|
||
assert_eq!(text(&s), "Hello world\nSecond");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(text(&s), "Hello world\n");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(text(&s), "Hello world");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(text(&s), "");
|
||
run(&mut s, "edit.redo", json!({}));
|
||
assert_eq!(text(&s), "Hello world");
|
||
}
|
||
|
||
#[test]
|
||
fn joined_commands_are_one_undo_step() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||
run(&mut s, "para.indents", json!({"left": 9.0}));
|
||
run(&mut s, "para.indents", json!({"left": 18.0}));
|
||
for x in [27.0, 36.0] {
|
||
s.join_next_undo();
|
||
run(&mut s, "para.indents", json!({"left": x}));
|
||
}
|
||
let indent = |s: &Session| s.doc.para_at(&s.sel.focus).and_then(|p| p.props.indent_left);
|
||
assert_eq!(indent(&s), Some(36.0));
|
||
// One undo reverts the whole second drag, not just its last frame.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(indent(&s), Some(9.0));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(indent(&s), None);
|
||
assert_eq!(text(&s), "Hello");
|
||
run(&mut s, "edit.redo", json!({}));
|
||
run(&mut s, "edit.redo", json!({}));
|
||
assert_eq!(indent(&s), Some(36.0));
|
||
}
|
||
|
||
#[test]
|
||
fn restore_takes_a_command_back_exactly() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "alpha beta"}));
|
||
run(&mut s, "text.newParagraph", json!({}));
|
||
run(&mut s, "text.insert", json!({"text": "gamma"}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(s.can_redo());
|
||
let (doc, sel, labels, depth, rev) = (s.doc.clone(), s.sel.clone(), s.undo_labels(), s.undo_depth(), s.rev());
|
||
s.dirty = false;
|
||
let snap = s.edit_snapshot();
|
||
assert_eq!(snap.doc(), &doc);
|
||
assert_eq!(snap.undo_depth(), depth);
|
||
assert!(!snap.dirty());
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "format.bold", json!({}));
|
||
assert_ne!(s.doc, doc);
|
||
assert_eq!(s.undo_depth(), depth + 1);
|
||
assert!(!s.can_redo(), "a new command clears redo");
|
||
s.restore(snap);
|
||
assert_eq!(s.doc, doc);
|
||
assert_eq!(s.sel, sel);
|
||
assert_eq!(s.undo_labels(), labels, "the command's undo step is gone");
|
||
assert!(s.can_redo(), "redo is back");
|
||
assert!(!s.dirty);
|
||
assert!(s.rev() > rev, "the layout is recomputed");
|
||
run(&mut s, "edit.redo", json!({}));
|
||
assert_eq!(text(&s), "alpha beta\ngamma");
|
||
}
|
||
|
||
#[test]
|
||
fn restore_is_exact_at_the_undo_limit() {
|
||
// 510 steps, each with its own indent: the stack holds the newest 500.
|
||
let build = || {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "alpha"}));
|
||
for i in 0..510 {
|
||
run(&mut s, "para.indents", json!({"left": f64::from(i)}));
|
||
}
|
||
s
|
||
};
|
||
// What undo walks through: (label, indent) for every step, newest first.
|
||
let walk = |s: &mut Session| {
|
||
let mut steps = Vec::new();
|
||
while let Some(label) = s.undo_label().map(str::to_string) {
|
||
s.undo();
|
||
steps.push((label, s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.indent_left)));
|
||
}
|
||
steps
|
||
};
|
||
let expected = walk(&mut build());
|
||
assert_eq!(expected.len(), 500);
|
||
// One command, and several (each pushes the oldest step out of the full stack).
|
||
for n in [1, 2, 4] {
|
||
let mut s = build();
|
||
let snap = s.edit_snapshot();
|
||
for _ in 0..n {
|
||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||
}
|
||
assert_eq!(s.undo_label(), Some("Table"));
|
||
assert_eq!(s.undo_depth(), 500);
|
||
s.restore(snap);
|
||
assert_eq!(walk(&mut s), expected, "{n} command(s): the oldest steps are back, the commands' steps are gone");
|
||
}
|
||
// A command that fails at the limit puts its eviction back with the stacks, so a later
|
||
// restore doesn't bring back a step that never left.
|
||
let mut s = build();
|
||
let snap = s.edit_snapshot();
|
||
assert!(s.run("para.align", &json!({"value": "bogus"})).is_err());
|
||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||
s.restore(snap);
|
||
assert_eq!(walk(&mut s), expected, "a failed command between snapshot and restore");
|
||
// An automatic change recorded after the fact (`push_undo`) at the limit counts its eviction.
|
||
let mut s = build();
|
||
let snap = s.edit_snapshot();
|
||
let (doc, sel) = (s.doc.clone(), s.sel.clone());
|
||
s.push_undo("AutoCorrect", doc, sel);
|
||
s.restore(snap);
|
||
assert_eq!(walk(&mut s), expected, "push_undo between snapshot and restore");
|
||
}
|
||
|
||
#[test]
|
||
fn restore_keeps_the_open_undo_step() {
|
||
// Typing in progress: text typed after the restore joins the same undo step.
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "alpha"}));
|
||
let depth = s.undo_depth();
|
||
let snap = s.edit_snapshot();
|
||
run(&mut s, "para.alignCenter", json!({}));
|
||
s.restore(snap);
|
||
run(&mut s, "text.insert", json!({"text": " beta"}));
|
||
assert_eq!(s.undo_depth(), depth, "still one typing step");
|
||
// A drag in progress (`join_next_undo`): its next frame joins the same undo step.
|
||
run(&mut s, "para.indents", json!({"left": 9.0}));
|
||
s.join_next_undo();
|
||
let depth = s.undo_depth();
|
||
let snap = s.edit_snapshot();
|
||
run(&mut s, "para.indents", json!({"left": 18.0}));
|
||
s.restore(snap);
|
||
run(&mut s, "para.indents", json!({"left": 27.0}));
|
||
assert_eq!(s.undo_depth(), depth, "still one drag step");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.indent_left), None);
|
||
}
|
||
|
||
#[test]
|
||
fn failed_command_keeps_undo_and_redo() {
|
||
// A command that fails after its undo checkpoint leaves the history exactly as it was:
|
||
// the redo stack survives, and no empty undo step is added.
|
||
for (id, params) in [("text.insert", json!({})), ("para.align", json!({"value": "bogus"}))] {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(s.can_redo());
|
||
assert!(s.run(id, ¶ms).is_err(), "{id} should fail");
|
||
assert!(s.can_redo(), "{id}: redo lost");
|
||
assert!(!s.can_undo(), "{id}: failed command left an undo step");
|
||
run(&mut s, "edit.redo", json!({}));
|
||
assert_eq!(text(&s), "Hello", "{id}");
|
||
}
|
||
|
||
// At the undo limit, the oldest step is not evicted by a command that fails.
|
||
let mut s = s();
|
||
let steps = 600;
|
||
for i in 1..=steps {
|
||
run(&mut s, "para.indents", json!({"left": i as f32}));
|
||
}
|
||
let kept = s.undo_labels().len();
|
||
assert!(kept < steps, "the history should be at its limit");
|
||
assert!(s.run("para.align", &json!({"value": "bogus"})).is_err());
|
||
assert_eq!(s.undo_labels().len(), kept);
|
||
while s.can_undo() {
|
||
run(&mut s, "edit.undo", json!({}));
|
||
}
|
||
let indent = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.indent_left);
|
||
assert_eq!(indent, Some((steps - kept) as f32));
|
||
}
|
||
|
||
#[test]
|
||
fn failed_command_restores_history_changed_by_nested_commands() {
|
||
// `file.inspect` runs `review.deleteComment` (allowed in a comments-only document, and it
|
||
// checkpoints), then `review.acceptAll` (refused). The refusal must leave the undo and
|
||
// redo stacks exactly as they were, including at and next to the history limit, where the
|
||
// outer and nested checkpoints each evict the oldest step.
|
||
fn timeline(s: &mut Session) -> Vec<(Vec<String>, wordcraft_doc::Document)> {
|
||
let mut out = Vec::new();
|
||
// Straight through the history (the commands won't undo past Restrict Editing).
|
||
while s.can_redo() {
|
||
s.redo();
|
||
}
|
||
loop {
|
||
// Comments are stamped with the time to the second, and the two sessions compared
|
||
// are built a moment apart, so leave the stamp out of the comparison.
|
||
let mut doc = s.doc.clone();
|
||
doc.comments.values_mut().for_each(|c| c.date.clear());
|
||
out.push((s.undo_labels(), doc));
|
||
if !s.can_undo() {
|
||
return out;
|
||
}
|
||
s.undo();
|
||
}
|
||
}
|
||
let kept = {
|
||
let mut s = s();
|
||
for i in 1..=600 {
|
||
run(&mut s, "para.indents", json!({"left": i as f32}));
|
||
}
|
||
s.undo_labels().len()
|
||
};
|
||
// Below the limit there is room for a redo step too; at the limit an Undo would leave one free.
|
||
for (len, redo) in [(kept - 1, Some("New Comment")), (kept, None)] {
|
||
let session = || {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "note"}));
|
||
for i in 1..=len + usize::from(redo.is_some()) - 4 {
|
||
run(&mut s, "para.indents", json!({"left": i as f32}));
|
||
}
|
||
run(&mut s, "review.restrict", json!({"mode": "comments"}));
|
||
run(&mut s, "review.newComment", json!({"text": "one"}));
|
||
run(&mut s, "review.newComment", json!({"text": "two"}));
|
||
if redo.is_some() {
|
||
run(&mut s, "edit.undo", json!({}));
|
||
}
|
||
assert_eq!(s.undo_labels().len(), len);
|
||
assert_eq!(s.redo_label(), redo);
|
||
s
|
||
};
|
||
let mut failed = session();
|
||
let err = failed.run("file.inspect", &json!({"remove": ["comments", "revisions"]}));
|
||
assert!(matches!(err, Err(crate::CmdError::Disabled(_))), "{len}: {err:?}");
|
||
assert_eq!(failed.undo_labels().len(), len, "{len}: undo steps");
|
||
assert_eq!(failed.redo_label(), redo, "{len}: redo step");
|
||
assert!(timeline(&mut failed) == timeline(&mut session()), "{len}: undo history changed");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn backspace_and_delete() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "abc"}));
|
||
run(&mut s, "text.backspace", json!({}));
|
||
assert_eq!(text(&s), "ab");
|
||
run(&mut s, "text.newParagraph", json!({}));
|
||
run(&mut s, "text.insert", json!({"text": "cd"}));
|
||
run(&mut s, "caret.home", json!({}));
|
||
run(&mut s, "text.backspace", json!({}));
|
||
// Enter capitalised the first word, as Word's AutoCorrect does.
|
||
assert_eq!(text(&s), "Abcd");
|
||
run(&mut s, "caret.docStart", json!({}));
|
||
run(&mut s, "text.delete", json!({}));
|
||
assert_eq!(text(&s), "bcd");
|
||
run(&mut s, "caret.docEnd", json!({}));
|
||
run(&mut s, "text.deleteWordBack", json!({}));
|
||
assert_eq!(text(&s), "");
|
||
}
|
||
|
||
#[test]
|
||
fn bold_toggles_selection_and_caret_word() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "make this bold"}));
|
||
run(&mut s, "select.text", json!({"text": "this"}));
|
||
run(&mut s, "format.bold", json!({}));
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
assert_eq!(p.props_of_char(5).bold, Some(true));
|
||
assert_eq!(p.props_of_char(0).bold, None);
|
||
run(&mut s, "format.bold", json!({}));
|
||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).bold, Some(false));
|
||
// Caret inside a word formats the word.
|
||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [0], "off": 11}}));
|
||
run(&mut s, "format.italic", json!({}));
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
assert_eq!(p.props_of_char(10).italic, Some(true));
|
||
assert_eq!(p.props_of_char(13).italic, Some(true));
|
||
assert_eq!(p.props_of_char(9).italic, None);
|
||
}
|
||
|
||
#[test]
|
||
fn border_toggles_and_saves() {
|
||
use wordcraft_doc::props::Border;
|
||
let border = |s: &Session| s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).border;
|
||
// Saved .docx carries the border iff it is on (read back: the reader maps only `w:bdr` to `border`).
|
||
let docx_has_bdr = |s: &Session| {
|
||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||
back.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).border.is_some()
|
||
};
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "make this boxed"}));
|
||
run(&mut s, "select.text", json!({"text": "this"}));
|
||
run(&mut s, "format.border", json!({}));
|
||
assert_eq!(border(&s), Some(Border::single(0.5)));
|
||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).border, None);
|
||
assert!(docx_has_bdr(&s));
|
||
run(&mut s, "format.border", json!({}));
|
||
assert_eq!(border(&s), None);
|
||
run(&mut s, "format.border", json!({"value": true}));
|
||
run(&mut s, "format.border", json!({"value": true}));
|
||
assert_eq!(border(&s), Some(Border::single(0.5)));
|
||
run(&mut s, "format.border", json!({"value": false}));
|
||
assert_eq!(border(&s), None);
|
||
assert!(!docx_has_bdr(&s));
|
||
}
|
||
|
||
#[test]
|
||
fn pending_format_applies_to_typing() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "a "}));
|
||
run(&mut s, "format.bold", json!({}));
|
||
run(&mut s, "text.insert", json!({"text": "b"}));
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
assert_eq!(p.props_of_char(2).bold, Some(true));
|
||
assert_eq!(p.props_of_char(0).bold, None);
|
||
}
|
||
|
||
#[test]
|
||
fn styles_and_lists() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Title"}));
|
||
run(&mut s, "para.heading1", json!({}));
|
||
run(&mut s, "text.newParagraph", json!({}));
|
||
// Heading's next style is Normal.
|
||
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props.style.as_deref(), Some("Normal"));
|
||
run(&mut s, "para.bullets", json!({}));
|
||
run(&mut s, "text.insert", json!({"text": "one"}));
|
||
run(&mut s, "text.newParagraph", json!({}));
|
||
run(&mut s, "text.insert", json!({"text": "two"}));
|
||
let n1 = s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.unwrap();
|
||
let n2 = s.doc.para_at(&Pos::body(2, 0)).unwrap().props.numbering.unwrap();
|
||
assert_eq!(n1.num, n2.num);
|
||
run(&mut s, "text.newParagraph", json!({}));
|
||
run(&mut s, "text.newParagraph", json!({})); // empty item ends the list
|
||
assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_none_or(|n| n.num == 0));
|
||
// "1. " autoformat.
|
||
run(&mut s, "text.insert", json!({"text": "1."}));
|
||
run(&mut s, "text.insert", json!({"text": " "}));
|
||
assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_some_and(|n| n.num != 0));
|
||
}
|
||
|
||
#[test]
|
||
fn applying_a_heading_clears_list_numbering() {
|
||
// Applying a heading (or Title/Normal) to a numbered paragraph removes its direct list
|
||
// numbering. A dead `&& id == "Normal"` term used to let only Normal clear it, so headings
|
||
// kept the numbering.
|
||
for style_cmd in ["para.heading1", "para.heading2", "para.normal"] {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "item"}));
|
||
run(&mut s, "para.bullets", json!({}));
|
||
assert!(
|
||
s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0),
|
||
"{style_cmd}: expected list numbering before applying the style"
|
||
);
|
||
run(&mut s, style_cmd, json!({}));
|
||
assert!(
|
||
s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_none_or(|n| n.num == 0),
|
||
"{style_cmd}: should clear direct list numbering"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn find_replace() {
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "cat dog cat\nCat bird"}));
|
||
let r = run(&mut s, "edit.find", json!({"text": "cat"}));
|
||
assert_eq!(r["count"], 3);
|
||
let r = run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": "fox", "matchCase": true}));
|
||
assert_eq!(r["replaced"], 2);
|
||
assert_eq!(text(&s), "fox dog fox\nCat bird");
|
||
let r = run(&mut s, "edit.find", json!({"text": "\\b\\w{3}\\b", "regex": true, "matchCase": false}));
|
||
assert_eq!(r["count"], 4);
|
||
}
|
||
|
||
#[test]
|
||
fn find_replace_special_codes() {
|
||
// ^p finds paragraph marks across paragraphs; Replace All with ^p splits paragraphs and is one undo step.
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "one\ntwo\nthree"}));
|
||
let paras = |s: &Session| s.doc.para_paths(StoryRef::Body).len();
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "^p"}))["count"], 2);
|
||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "o^pt", "with": "O+T"}))["replaced"], 1);
|
||
assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwO+Three", 2));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwo\nthree", 3));
|
||
run(&mut s, "document.setText", json!({"text": "a, b, c"}));
|
||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": ", ", "with": "^p"}))["replaced"], 2);
|
||
assert_eq!((text(&s).as_str(), paras(&s)), ("a\nb\nc", 3));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!((text(&s).as_str(), paras(&s)), ("a, b, c", 1));
|
||
// ^t, ^# and ^& (the found text), ^^ is a caret.
|
||
run(&mut s, "document.setText", json!({"text": "x\ty 12 a^b"}));
|
||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^t", "with": " "}))["replaced"], 1);
|
||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^#", "with": "<^&>"}))["replaced"], 2);
|
||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^^", "with": "-"}))["replaced"], 1);
|
||
assert_eq!(text(&s), "x y <1><2> a-b");
|
||
// `special: false` reads the text literally.
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "^#", "special": false}))["count"], 0);
|
||
}
|
||
|
||
#[test]
|
||
fn find_replace_wildcards() {
|
||
let mut s = s();
|
||
// Groups and back-references swap words; Use wildcards matches case.
|
||
run(&mut s, "document.setText", json!({"text": "Smith, John and Doe, Jane; smith, x"}));
|
||
let r = run(&mut s, "edit.replaceAll", json!({"text": "([A-Z][a-z]@), ([A-Z][a-z]@)", "with": "\\2 \\1", "wildcards": true}));
|
||
assert_eq!(r["replaced"], 2);
|
||
assert_eq!(text(&s), "John Smith and Jane Doe; smith, x");
|
||
// < and > anchor to word starts and ends; * is the shortest run; {n} counts.
|
||
run(&mut s, "document.setText", json!({"text": "cat concat cats scatter"}));
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "<cat", "wildcards": true}))["count"], 2);
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat>", "wildcards": true}))["count"], 2);
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "<c*t>", "wildcards": true}))["count"], 2);
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "t{2}", "wildcards": true}))["count"], 1);
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "[!c]at", "wildcards": true}))["count"], 0);
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "[!s ]cat", "wildcards": true}))["count"], 1);
|
||
// Options of the normal search: prefix, suffix, ignore punctuation.
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "wildcards": false, "matchPrefix": true}))["count"], 2);
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "matchPrefix": false, "matchSuffix": true}))["count"], 2);
|
||
run(&mut s, "document.setText", json!({"text": "e.g. eg"}));
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2);
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_find_patterns_are_bounded() {
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": &"a".repeat(5000)}));
|
||
// Patterns that backtrack catastrophically elsewhere finish quickly (linear-time engine).
|
||
let t = std::time::Instant::now();
|
||
for q in ["(a@)@b", "(*)@b", "(a{1,1000}){1,1000}", "(((((a*)*)*)*)*)b"] {
|
||
let _ = s.run("edit.find", &json!({"text": q, "wildcards": true}));
|
||
}
|
||
assert!(t.elapsed().as_secs() < 20);
|
||
// Bad and oversized patterns are errors, not crashes.
|
||
for q in ["[z-a]", "a{5", "(ab", "\\1", "x{2000}", "[", "@"] {
|
||
assert!(s.run("edit.find", &json!({"text": q, "wildcards": true})).is_err(), "{q}");
|
||
}
|
||
assert!(s.run("edit.find", &json!({"text": "x".repeat(2000), "wildcards": false})).is_err());
|
||
for q in ["^", "^p^", "^u", "^99999999", "^0", "a^"] {
|
||
let _ = s.run("edit.replaceAll", &json!({"text": q, "with": "^&^c\\9^p^", "wildcards": false}));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn replace_under_track_changes_skips_deleted_text() {
|
||
// Replace marks the match deleted and leaves it in the text; the next
|
||
// step must not find it again, or Replace never advances.
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "one cat two cat"}));
|
||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||
run(&mut s, "caret.docStart", json!({}));
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 2);
|
||
let mut remaining = Vec::new();
|
||
for _ in 0..4 {
|
||
remaining.push(run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"}))["remaining"].clone());
|
||
}
|
||
assert_eq!(remaining, [json!(1), json!(0), json!(0), json!(0)]);
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 0);
|
||
run(&mut s, "review.acceptAll", json!({}));
|
||
assert_eq!(text(&s), "one dog two dog");
|
||
|
||
// A match that runs across tracked-deleted text is not in the document.
|
||
let mut s = self::s();
|
||
run(&mut s, "document.setText", json!({"text": "cat"}));
|
||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 1}, "focus": {"block": 0, "off": 2}}));
|
||
run(&mut s, "text.delete", json!({}));
|
||
assert_eq!(text(&s), "cat", "the deletion is tracked, not applied");
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 0);
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "t"}))["count"], 1);
|
||
|
||
// Without Track Changes, Replace steps through every match as before.
|
||
let mut s = self::s();
|
||
run(&mut s, "document.setText", json!({"text": "one cat two cat"}));
|
||
run(&mut s, "caret.docStart", json!({}));
|
||
run(&mut s, "edit.find", json!({"text": "cat"}));
|
||
run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"}));
|
||
let r = run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"}));
|
||
assert_eq!(r["remaining"], 0);
|
||
assert_eq!(text(&s), "one dog two dog");
|
||
}
|
||
|
||
#[test]
|
||
fn find_reads_the_text_around_tracked_deletions() {
|
||
// `edits` are (start, end) byte ranges deleted with Track Changes on, last first.
|
||
fn tracked(text: &str, edits: &[(usize, usize)]) -> Session {
|
||
let mut s = self::s();
|
||
run(&mut s, "document.setText", json!({"text": text}));
|
||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||
for (a, b) in edits {
|
||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": a}, "focus": {"block": 0, "off": b}}));
|
||
run(&mut s, "text.delete", json!({}));
|
||
}
|
||
assert_eq!(text, self::text(&s), "the deletions are tracked, not applied");
|
||
s
|
||
}
|
||
let offs = |r: &serde_json::Value| -> Vec<(u64, u64)> {
|
||
r["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect()
|
||
};
|
||
|
||
// "aaa" with the first "a" deleted reads "aa": the rejected raw match 0..2 must not hide
|
||
// the live one at 1..3.
|
||
let mut s = tracked("aaa", &[(0, 1)]);
|
||
assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "aa"}))), [(1, 3)]);
|
||
assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "^a", "regex": true}))), [(1, 2)]);
|
||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "aa", "with": "b"}))["replaced"], 1);
|
||
|
||
// Whole words follow the live text: deleting the space joins "cat" and "fish"...
|
||
let mut s = tracked("cat fish", &[(3, 4)]);
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "fish", "wholeWord": true}))["count"], 0);
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "catfish", "wholeWord": true}))["count"], 0, "a match may not span a deletion");
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "fish", "wholeWord": false}))["count"], 1);
|
||
// ...and deleting the "X" leaves "fish" a word of its own.
|
||
let mut s = tracked("cat Xfish", &[(4, 5)]);
|
||
assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "FISH", "wholeWord": true}))), [(5, 9)]);
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "FISH", "wholeWord": true, "matchCase": true}))["count"], 0);
|
||
|
||
// Replace and the Find Next/Previous steps agree with Find.
|
||
let mut s = tracked("aaa aaa", &[(4, 5), (0, 1)]);
|
||
run(&mut s, "caret.docStart", json!({}));
|
||
assert_eq!(run(&mut s, "edit.find", json!({"text": "aa"}))["count"], 2);
|
||
assert_eq!(run(&mut s, "edit.findNext", json!({}))["index"], 1);
|
||
assert_eq!(run(&mut s, "edit.findPrevious", json!({}))["index"], 0);
|
||
let r = run(&mut s, "edit.replace", json!({"text": "aa", "with": "b"}));
|
||
assert_eq!(r["remaining"], 1);
|
||
}
|
||
|
||
#[test]
|
||
fn find_next_with_options_visits_every_match() {
|
||
// The Find dialog sends its options with each Find Next (#468): every click is one step.
|
||
let opts = json!({"text": "cat", "with": "", "matchCase": false, "wholeWord": false, "regex": false});
|
||
let starts = |text: &str, clicks: usize, cmd: &str| {
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": text}));
|
||
run(&mut s, "caret.docStart", json!({}));
|
||
(0..clicks).map(|_| run(&mut s, cmd, opts.clone())["index"].as_u64().unwrap()).collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(starts("cat cat cat cat", 5, "edit.findNext"), [0, 1, 2, 3, 0]);
|
||
assert_eq!(starts("cat cat", 3, "edit.findNext"), [0, 1, 0]);
|
||
assert_eq!(starts("cat cat cat", 4, "edit.findPrevious"), [2, 1, 0, 2]);
|
||
|
||
// A new query takes effect at once, without a separate `edit.find`.
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "cat dog cat dog"}));
|
||
run(&mut s, "caret.docStart", json!({}));
|
||
assert_eq!(run(&mut s, "edit.findNext", opts.clone())["index"], 0);
|
||
let r = run(&mut s, "edit.findNext", json!({"text": "dog"}));
|
||
assert_eq!((r["index"].clone(), r["count"].clone()), (json!(0), json!(2)));
|
||
assert_eq!(s.sel.ordered().0.off, 4);
|
||
}
|
||
|
||
#[test]
|
||
fn clipboard_round_trip() {
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "alpha beta\ngamma"}));
|
||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 1, "off": 2}}));
|
||
run(&mut s, "edit.copy", json!({}));
|
||
assert_eq!(s.clipboard_text, "beta\nga");
|
||
run(&mut s, "caret.docEnd", json!({}));
|
||
run(&mut s, "edit.paste", json!({}));
|
||
assert_eq!(text(&s), "alpha beta\ngammabeta\nga");
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "edit.cut", json!({}));
|
||
assert_eq!(text(&s), "");
|
||
run(&mut s, "edit.paste", json!({"text": "plain\ntext"}));
|
||
assert_eq!(text(&s), "plain\ntext");
|
||
}
|
||
|
||
fn bold_at(s: &Session, block: usize, off: usize) -> bool {
|
||
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
|
||
s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false)
|
||
}
|
||
|
||
#[test]
|
||
fn paste_special_lists_formats_and_pastes_text_without_formatting() {
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "Bold words"}));
|
||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||
run(&mut s, "format.bold", json!({}));
|
||
run(&mut s, "edit.copy", json!({}));
|
||
// No format: the formats are listed (and a front end is asked to show the dialog).
|
||
let r = run(&mut s, "edit.pasteSpecial", json!({}));
|
||
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
|
||
assert_eq!(ids, ["formatted", "text"]);
|
||
assert_eq!(s.ui_requests.last().unwrap()["open"], "pasteSpecial");
|
||
run(&mut s, "caret.docEnd", json!({}));
|
||
let r = run(&mut s, "edit.pasteSpecial", json!({"as": "text"}));
|
||
assert_eq!(r["pastedAs"], "text");
|
||
assert_eq!(text(&s), "Bold wordsBold");
|
||
assert!(bold_at(&s, 0, 0));
|
||
assert!(!bold_at(&s, 0, 11), "unformatted text drops the bold");
|
||
// Formatted keeps it.
|
||
run(&mut s, "edit.pasteSpecial", json!({"as": "formatted"}));
|
||
assert_eq!(text(&s), "Bold wordsBoldBold");
|
||
assert!(bold_at(&s, 0, 15));
|
||
// Text that someone else copied since: our rich copy is no longer offered.
|
||
let r = run(&mut s, "edit.pasteSpecial", json!({"text": "elsewhere"}));
|
||
assert_eq!(r["formats"].as_array().unwrap().len(), 1);
|
||
assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn paste_special_html_and_rtf_keep_bold() {
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "x"}));
|
||
run(&mut s, "caret.docEnd", json!({}));
|
||
let html = "<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());
|
||
}
|
||
}
|
||
|
||
/// Right-to-left tables and sections (#362): `table.rtl` and `layout.sectionRtl` set or toggle
|
||
/// the direction, one Undo step each, and the layout mirrors the table.
|
||
#[test]
|
||
fn table_and_section_direction_commands() {
|
||
let mut s = s();
|
||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 3}));
|
||
let rtl = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().props.bidi_visual;
|
||
let first_cell_x = |s: &mut Session| {
|
||
s.layout().pages[0].items.iter().find_map(|it| match it {
|
||
wordcraft_layout::Placed::Cell { rect, cell: 0, .. } => Some(rect.x),
|
||
_ => None,
|
||
})
|
||
};
|
||
let ltr_x = first_cell_x(&mut s).unwrap();
|
||
run(&mut s, "table.rtl", json!({}));
|
||
assert!(rtl(&s));
|
||
assert!(first_cell_x(&mut s).unwrap() > ltr_x + 100.0, "the first column moves to the right");
|
||
run(&mut s, "table.rtl", json!({"value": true}));
|
||
assert!(rtl(&s), "an explicit value sets rather than toggles");
|
||
run(&mut s, "table.properties", json!({"rtl": false}));
|
||
assert!(!rtl(&s));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(rtl(&s));
|
||
assert_eq!(s.run("table.properties", &json!({})).unwrap()["bidiVisual"], json!(true));
|
||
run(&mut s, "layout.sectionRtl", json!({}));
|
||
assert!(crate::cmd::page::sect(&s).rtl);
|
||
run(&mut s, "layout.sectionRtl", json!({"value": false}));
|
||
assert!(!crate::cmd::page::sect(&s).rtl);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(crate::cmd::page::sect(&s).rtl);
|
||
// Outside a table `table.rtl` is disabled.
|
||
s.sel = crate::Selection::caret(Pos::body(0, 0));
|
||
if s.sel.focus.path.cell().is_none() {
|
||
assert!(s.run("table.rtl", &json!({})).is_err());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn table_border_presets_mask_the_sides_they_clear() {
|
||
// On a default (TableGrid) table the "outside"/"inside" presets must write nil over the
|
||
// sides they clear; without it those sides inherit the style's grid (#143) and every
|
||
// preset renders like "all".
|
||
let mut s = s();
|
||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||
// Distinct vertical/horizontal rule positions on page 0.
|
||
let rule_pos = |s: &mut Session| {
|
||
let (mut vx, mut hy) = (Vec::new(), Vec::new());
|
||
for it in &s.layout().pages[0].items {
|
||
if let crate::layout::Placed::Rule { x0, y0, x1, y1, .. } = it {
|
||
if (x0 - x1).abs() < 1e-3 && !vx.contains(x0) {
|
||
vx.push(*x0);
|
||
}
|
||
if (y0 - y1).abs() < 1e-3 && !hy.contains(y0) {
|
||
hy.push(*y0);
|
||
}
|
||
}
|
||
}
|
||
(vx, hy)
|
||
};
|
||
// Style default: a full 2x2 grid — 3 vertical + 3 horizontal lines.
|
||
let (vx, hy) = rule_pos(&mut s);
|
||
assert_eq!((vx.len(), hy.len()), (3, 3), "baseline grid: {vx:?} {hy:?}");
|
||
run(&mut s, "table.borders", json!({"kind": "outside"}));
|
||
let (vx, hy) = rule_pos(&mut s);
|
||
assert_eq!((vx.len(), hy.len()), (2, 2), "outside keeps only the frame: {vx:?} {hy:?}");
|
||
let b = s.doc.body.iter().find_map(|x| x.as_table()).unwrap().props.borders.unwrap();
|
||
assert!(b.between.is_some_and(|x| x.style == BorderStyle::None));
|
||
assert!(b.inside_v.is_some_and(|x| x.style == BorderStyle::None));
|
||
run(&mut s, "table.borders", json!({"kind": "inside"}));
|
||
let (vx, hy) = rule_pos(&mut s);
|
||
assert_eq!((vx.len(), hy.len()), (1, 1), "inside keeps only the inner rules: {vx:?} {hy:?}");
|
||
let b = s.doc.body.iter().find_map(|x| x.as_table()).unwrap().props.borders.unwrap();
|
||
for side in [b.top, b.bottom, b.left, b.right] {
|
||
assert!(side.is_some_and(|x| x.style == BorderStyle::None));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn page_setup_and_breaks() {
|
||
let mut s = s();
|
||
run(&mut s, "layout.orientation", json!({"value": "landscape"}));
|
||
assert!(s.doc.last_section.landscape);
|
||
run(&mut s, "layout.size", json!({"name": "A4"}));
|
||
assert!((s.doc.last_section.page_w - 841.89).abs() < 0.1);
|
||
run(&mut s, "layout.margins", json!({"preset": "narrow"}));
|
||
assert_eq!(s.doc.last_section.margin_left, 36.0);
|
||
run(&mut s, "text.insert", json!({"text": "one"}));
|
||
run(&mut s, "layout.break", json!({"kind": "nextPage"}));
|
||
run(&mut s, "text.insert", json!({"text": "two"}));
|
||
assert_eq!(s.doc.sections().len(), 2);
|
||
assert_eq!(s.layout().pages.len(), 2);
|
||
assert!(s.run("layout.margins", &json!({"left": 1000.0})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn track_changes_and_accept() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "original"}));
|
||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||
run(&mut s, "text.insert", json!({"text": " added"}));
|
||
run(&mut s, "select.text", json!({"text": "orig"}));
|
||
s.author = "Someone Else".into();
|
||
run(&mut s, "text.delete", json!({}));
|
||
// Deleted text stays (marked) until accepted.
|
||
assert_eq!(text(&s), "original added");
|
||
let ch = run(&mut s, "review.changes", json!({}));
|
||
assert_eq!(ch.as_array().unwrap().len(), 2);
|
||
run(&mut s, "review.acceptAll", json!({}));
|
||
assert_eq!(text(&s), "inal added");
|
||
}
|
||
|
||
/// The author of the revision `rid` points to.
|
||
fn author_of(s: &Session, rid: Option<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_refresh_after_save_and_reopen() {
|
||
for extension in ["docx", "json"] {
|
||
for (headings, levels) in [(&["Top"][..], 1), (&["Top", "Next"][..], 1), (&["Top"][..], 4)] {
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": headings.join("\n")}));
|
||
for i in 0..headings.len() {
|
||
run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)}));
|
||
run(&mut s, "styles.apply", json!({"style": "Heading 1"}));
|
||
}
|
||
// A level-two heading must stay out of a depth-one TOC after reopening.
|
||
if levels == 1 {
|
||
run(&mut s, "caret.docEnd", json!({}));
|
||
run(&mut s, "text.newParagraph", json!({}));
|
||
run(&mut s, "text.insert", json!({"text": "Detail"}));
|
||
run(&mut s, "styles.apply", json!({"style": "Heading 2"}));
|
||
}
|
||
run(&mut s, "caret.docStart", json!({}));
|
||
run(&mut s, "references.toc", json!({"levels": levels}));
|
||
run(&mut s, "references.updateToc", json!({}));
|
||
let expected = text(&s);
|
||
let name = format!("toc.{extension}");
|
||
let bytes = crate::io::save_bytes(&name, &s.doc).unwrap();
|
||
s = Session::new(crate::io::open_bytes(&name, &bytes).unwrap());
|
||
for command in ["references.updateToc", "references.updateToc", "references.updateFields"] {
|
||
run(&mut s, command, json!({}));
|
||
assert_eq!(text(&s), expected, "{extension}, levels={levels}, {command}");
|
||
}
|
||
let heading = s
|
||
.doc
|
||
.body
|
||
.iter()
|
||
.position(|b| b.as_para().is_some_and(|p| p.props.style.as_deref() == Some("Heading1") && p.plain_text() == "Top"))
|
||
.unwrap();
|
||
// Hyperlinked TOCs bookmark their headings; select the text after those markers.
|
||
let off = s.doc.body[heading].as_para().unwrap().text.find("Top").unwrap();
|
||
run(&mut s, "select.range", json!({"anchor": Pos::body(heading, off), "focus": Pos::body(heading, off + 3)}));
|
||
run(&mut s, "text.insert", json!({"text": "Revised"}));
|
||
let before_refresh = text(&s);
|
||
run(&mut s, "references.updateToc", json!({}));
|
||
let revised = text(&s);
|
||
assert_eq!(revised.matches("Revised\t").count(), 1, "{revised}");
|
||
assert!(!revised.contains("Top"), "{revised}");
|
||
assert_eq!(revised.matches("Next\t").count(), headings.len() - 1, "{revised}");
|
||
assert_eq!(revised.matches("Detail\t").count(), 0, "{revised}");
|
||
assert_eq!(revised.lines().filter(|line| *line == "Detail").count(), usize::from(levels == 1), "{revised}");
|
||
assert!(s.undo());
|
||
assert_eq!(text(&s), before_refresh);
|
||
run(&mut s, "references.updateToc", json!({}));
|
||
assert_eq!(text(&s), revised);
|
||
let bytes = crate::io::save_bytes(&name, &s.doc).unwrap();
|
||
s = Session::new(crate::io::open_bytes(&name, &bytes).unwrap());
|
||
run(&mut s, "references.updateFields", json!({}));
|
||
assert_eq!(text(&s), revised, "saved edited TOC, {extension}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A TOC imported as a range field (no TOC heading, so it isn't turned back into the engine's
|
||
/// TOC on open) is still found by Update Table, Update Field and Table of Contents: it is
|
||
/// refreshed in place, not duplicated, and the refresh is one undo step.
|
||
#[test]
|
||
fn imported_range_toc_is_refreshed_in_place() {
|
||
use wordcraft_doc::para::InlineObject;
|
||
use wordcraft_doc::props::CharProps;
|
||
for locked in [false, true] {
|
||
let mut d = wordcraft_doc::Document::from_text("Before.\nOld entry\t9\nAlpha\nBeta");
|
||
{
|
||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap();
|
||
let n = p.len();
|
||
p.insert_object(n, InlineObject::FieldEnd, &CharProps::default()).unwrap();
|
||
p.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-2" \h"#.into(), locked }, &CharProps::default()).unwrap();
|
||
}
|
||
let mut s = Session::new(d);
|
||
for (i, style) in [(2, "Heading 1"), (3, "Heading 2")] {
|
||
run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)}));
|
||
run(&mut s, "styles.apply", json!({"style": style}));
|
||
}
|
||
// Through DOCX, as an imported file is.
|
||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||
s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||
assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1, "read back as a range field");
|
||
let before = text(&s);
|
||
run(&mut s, "references.updateToc", json!({}));
|
||
let refreshed = text(&s);
|
||
if locked {
|
||
// Like Word, a locked field keeps its result.
|
||
assert_eq!(refreshed, before);
|
||
continue;
|
||
}
|
||
assert!(!refreshed.contains("Old entry"), "{refreshed}");
|
||
assert!(refreshed.starts_with("Before.\n"), "{refreshed}");
|
||
assert_eq!(refreshed.matches("Alpha\t").count(), 1, "{refreshed}");
|
||
assert_eq!(refreshed.matches("Beta\t").count(), 1, "{refreshed}");
|
||
assert!(s.doc.field_ranges(StoryRef::Body).is_empty(), "now the engine's TOC");
|
||
for command in ["references.updateToc", "references.updateFields", "references.toc"] {
|
||
run(&mut s, command, json!({}));
|
||
assert_eq!(text(&s), refreshed, "{command} refreshes the same TOC");
|
||
}
|
||
while s.undo() {}
|
||
assert_eq!(text(&s), before);
|
||
assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1, "undo brings the imported range back");
|
||
}
|
||
}
|
||
|
||
#[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}");
|
||
}
|
||
}
|
||
|
||
/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2
|
||
/// look like the selected text — its paragraph style and direct formatting — so every Heading 2
|
||
/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step.
|
||
#[test]
|
||
fn update_heading_to_match_selection_takes_the_selections_formatting() {
|
||
use wordcraft_doc::{Align, CharProps, ParaProps, Rgb, TextColor};
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Heading text"}));
|
||
run(&mut s, "styles.apply", json!({"style": "Heading 2"}));
|
||
run(&mut s, "text.newParagraph", json!({}));
|
||
run(&mut s, "text.insert", json!({"text": "Body text"}));
|
||
run(&mut s, "styles.apply", json!({"style": "Normal"}));
|
||
run(&mut s, "select.text", json!({"text": "Body text"}));
|
||
run(&mut s, "format.size", json!({"size": 20}));
|
||
run(&mut s, "format.italic", json!({}));
|
||
run(&mut s, "format.color", json!({"color": "C00000"}));
|
||
run(&mut s, "para.alignCenter", json!({}));
|
||
let normal = s.doc.styles.resolve_char(Some("Normal"), &CharProps::default());
|
||
let before = s.undo_depth();
|
||
|
||
run(&mut s, "styles.updateToMatch", json!({"style": "Heading2"}));
|
||
|
||
let h2 = ParaProps { style: Some("Heading2".into()), ..Default::default() };
|
||
let c = s.doc.styles.resolve_char(Some("Heading2"), &CharProps::default());
|
||
assert_eq!(c.size, 20.0);
|
||
assert!(c.italic);
|
||
assert_eq!(c.color, TextColor::Rgb(Rgb(0xC0, 0, 0)));
|
||
assert_eq!(c.font, normal.font, "the heading font gives way to the selection's");
|
||
let p = s.doc.styles.resolve_para(&h2);
|
||
assert_eq!(p.align, Align::Center);
|
||
assert_eq!(p.outline_level, Some(1), "Heading 2 stays a level-2 heading");
|
||
// The selected paragraph now carries Heading 2, its direct formatting absorbed by the style.
|
||
assert_eq!(para_style(&s, 1).as_deref(), Some("Heading2"));
|
||
let body = s.doc.para_at(&Pos::body(1, 0)).unwrap();
|
||
assert!(body.props_of_char(0).formatting().is_empty(), "{:?}", body.props_of_char(0));
|
||
assert_eq!(body.props.align, None);
|
||
assert_eq!(s.undo_depth(), before + 1, "one undo step");
|
||
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(s.doc.styles.resolve_char(Some("Heading2"), &CharProps::default()).size, 16.0);
|
||
assert_eq!(para_style(&s, 1).as_deref(), Some("Normal"));
|
||
}
|
||
|
||
#[test]
|
||
fn styles_apply_rejected_on_protected_document() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||
s.doc.settings.protection = Some("readOnly".into());
|
||
let undo = s.undo_labels();
|
||
let r = s.run("styles.apply", &json!({"style": "Heading 1"}));
|
||
assert!(matches!(r, Err(crate::CmdError::Disabled(_))), "{r:?}");
|
||
assert_eq!(para_style(&s, 0), None);
|
||
assert_eq!(s.undo_labels(), undo);
|
||
}
|
||
|
||
fn column_doc() -> Session {
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "abcdef\nabcdef\nabcdef"}));
|
||
s
|
||
}
|
||
|
||
fn block(s: &mut Session) {
|
||
let a = serde_json::to_value(Pos::body(0, 2)).unwrap();
|
||
let f = serde_json::to_value(Pos::body(2, 4)).unwrap();
|
||
let r = run(s, "select.column", json!({"anchor": a, "focus": f}));
|
||
assert_eq!(r["rows"], json!(["cd", "cd", "cd"]));
|
||
}
|
||
|
||
#[test]
|
||
fn column_selection_copies_the_block() {
|
||
let mut s = column_doc();
|
||
block(&mut s);
|
||
let r = run(&mut s, "edit.copy", json!({}));
|
||
assert_eq!(r["text"], "cd\ncd\ncd");
|
||
assert_eq!(s.clipboard.as_ref().map(|f| f.blocks.len()), Some(3));
|
||
// A caret move drops the block; pasting the copy gives three paragraphs.
|
||
run(&mut s, "caret.docEnd", json!({}));
|
||
assert!(s.column_segments().is_none());
|
||
s.prefs.autocorrect.enabled = false;
|
||
run(&mut s, "text.newParagraph", json!({}));
|
||
run(&mut s, "edit.paste", json!({}));
|
||
assert_eq!(text(&s), "abcdef\nabcdef\nabcdef\ncd\ncd\ncd");
|
||
}
|
||
|
||
#[test]
|
||
fn column_selection_deletes_only_the_block() {
|
||
let mut s = column_doc();
|
||
block(&mut s);
|
||
run(&mut s, "text.delete", json!({}));
|
||
assert_eq!(text(&s), "abef\nabef\nabef");
|
||
assert_eq!(s.sel, crate::Selection::caret(Pos::body(0, 2)));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(text(&s), "abcdef\nabcdef\nabcdef");
|
||
// Cut copies and deletes; typing replaces the block.
|
||
block(&mut s);
|
||
let r = run(&mut s, "edit.cut", json!({}));
|
||
assert_eq!(r["text"], "cd\ncd\ncd");
|
||
assert_eq!(text(&s), "abef\nabef\nabef");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
block(&mut s);
|
||
run(&mut s, "text.insert", json!({"text": "X"}));
|
||
assert_eq!(text(&s), "abXef\nabef\nabef");
|
||
}
|
||
|
||
#[test]
|
||
fn column_selection_formats_each_row() {
|
||
let mut s = column_doc();
|
||
block(&mut s);
|
||
run(&mut s, "format.bold", json!({}));
|
||
for i in 0..3 {
|
||
let p = s.doc.para_at(&Pos::body(i, 0)).unwrap();
|
||
assert_ne!(p.props_of_char(1).bold, Some(true));
|
||
assert_eq!(p.props_of_char(2).bold, Some(true));
|
||
assert_eq!(p.props_of_char(3).bold, Some(true));
|
||
assert_ne!(p.props_of_char(4).bold, Some(true));
|
||
}
|
||
// The block survives formatting, and a second Bold turns it off again.
|
||
assert!(s.column_segments().is_some());
|
||
run(&mut s, "format.bold", json!({}));
|
||
assert_ne!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props_of_char(2).bold, Some(true));
|
||
run(&mut s, "format.changeCase", json!({"mode": "upper"}));
|
||
assert_eq!(text(&s), "abCDef\nabCDef\nabCDef");
|
||
}
|
||
|
||
#[test]
|
||
fn column_mode_extends_with_the_arrow_keys() {
|
||
let mut s = column_doc();
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}}));
|
||
let r = run(&mut s, "select.column", json!({}));
|
||
assert_eq!(r["columnMode"], true);
|
||
run(&mut s, "caret.right", json!({}));
|
||
run(&mut s, "caret.right", json!({}));
|
||
run(&mut s, "caret.down", json!({}));
|
||
let r = run(&mut s, "caret.down", json!({}));
|
||
assert_eq!(r["rows"], json!(["bc", "bc", "bc"]));
|
||
run(&mut s, "text.backspace", json!({}));
|
||
assert_eq!(text(&s), "adef\nadef\nadef");
|
||
assert!(!s.column_mode);
|
||
// Escape leaves the mode.
|
||
run(&mut s, "select.column", json!({}));
|
||
run(&mut s, "select.collapse", json!({}));
|
||
assert!(!s.column_mode);
|
||
}
|
||
|
||
#[test]
|
||
fn column_selection_from_points() {
|
||
let mut s = column_doc();
|
||
let l = s.layout();
|
||
let a = l.caret(&Pos::body(0, 1)).unwrap();
|
||
let b = l.caret(&Pos::body(1, 5)).unwrap();
|
||
let r = run(
|
||
&mut s,
|
||
"select.column",
|
||
json!({"from": {"page": a.page, "x": a.x, "y": a.top + 2.0}, "to": {"page": b.page, "x": b.x, "y": b.top + 2.0}}),
|
||
);
|
||
assert_eq!(r["rows"], json!(["bcde", "bcde"]));
|
||
for bad in [
|
||
json!({"from": {"page": 99, "x": 1, "y": 1}, "to": {"page": 0, "x": 1, "y": 1}}),
|
||
json!({"from": 1, "to": "x"}),
|
||
json!({"anchor": 1, "focus": {}}),
|
||
] {
|
||
assert!(s.run("select.column", &bad).is_err());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn style_inspector_reports_and_clears_levels() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Chapter one"}));
|
||
run(&mut s, "para.heading1", json!({}));
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "format.bold", json!({"value": true}));
|
||
run(&mut s, "para.alignCenter", json!({}));
|
||
|
||
let r = run(&mut s, "styles.inspect", json!({}));
|
||
assert_eq!(r["paragraph"]["style"], "Heading1");
|
||
assert_eq!(r["paragraph"]["styleName"], "Heading 1");
|
||
let pdirect = r["paragraph"]["direct"].as_array().unwrap();
|
||
assert!(pdirect.iter().any(|d| d["prop"] == "align" && d["value"] == "center"), "{pdirect:?}");
|
||
assert!(r["character"]["style"].is_null());
|
||
let cdirect = r["character"]["direct"].as_array().unwrap();
|
||
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true}), json!({"prop": "boldCs", "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!({}));
|
||
let cdirect = r["character"]["direct"].as_array().unwrap();
|
||
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true}), json!({"prop": "boldCs", "value": true})]);
|
||
|
||
// Character style level.
|
||
run(&mut s, "format.charStyle", json!({"style": "Emphasis"}));
|
||
let r = run(&mut s, "styles.inspect", json!({}));
|
||
assert_eq!(r["character"]["style"], "Emphasis");
|
||
assert_eq!(r["character"]["styleName"], "Emphasis");
|
||
run(&mut s, "styles.inspectorClear", json!({"level": "characterStyle"}));
|
||
assert!(run(&mut s, "styles.inspect", json!({}))["character"]["style"].is_null());
|
||
|
||
// Clearing character formatting removes the bold and keeps the paragraph levels.
|
||
run(&mut s, "styles.inspectorClear", json!({"level": "characterFormatting"}));
|
||
let r = run(&mut s, "styles.inspect", json!({}));
|
||
assert!(r["character"]["direct"].as_array().unwrap().is_empty(), "{r}");
|
||
assert!(!run(&mut s, "format.state", json!({}))["bold"].as_bool().unwrap());
|
||
assert_eq!(r["paragraph"]["style"], "Heading1");
|
||
assert!(!r["paragraph"]["direct"].as_array().unwrap().is_empty());
|
||
|
||
run(&mut s, "styles.inspectorClear", json!({"level": "paragraphFormatting"}));
|
||
let r = run(&mut s, "styles.inspect", json!({}));
|
||
assert!(r["paragraph"]["direct"].as_array().unwrap().is_empty(), "{r}");
|
||
assert_eq!(r["paragraph"]["style"], "Heading1");
|
||
|
||
run(&mut s, "styles.inspectorClear", json!({"level": "paragraphStyle"}));
|
||
assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Normal");
|
||
|
||
// Each clear is one undo step.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(run(&mut s, "styles.inspect", json!({}))["paragraph"]["style"], "Heading1");
|
||
assert_eq!(text(&s), "Chapter one");
|
||
}
|
||
|
||
#[test]
|
||
fn style_inspector_pane_and_hostile_params() {
|
||
let mut s = Session::new(crate::sample::sample_document());
|
||
let r = run(&mut s, "styles.inspector", json!({}));
|
||
assert_eq!(r["pane"], true);
|
||
assert!(s.view.style_inspector);
|
||
assert_eq!(r["paragraph"]["styleName"], "Title");
|
||
let r = run(&mut s, "styles.inspector", json!({"value": false}));
|
||
assert_eq!(r["pane"], false);
|
||
let before = text(&s);
|
||
for junk in [json!(null), json!({}), json!({"level": 5}), json!({"level": "everything"}), json!({"level": ""})] {
|
||
assert!(s.run("styles.inspectorClear", &junk).is_err(), "{junk}");
|
||
}
|
||
assert_eq!(text(&s), before);
|
||
assert!(s.run("styles.inspector", &json!({"value": "yes"})).is_ok());
|
||
// A selection reaching far outside the document.
|
||
s.sel.focus = Pos::body(9999, 99999);
|
||
s.sel.anchor = Pos::body(0, 0);
|
||
assert!(s.run("styles.inspect", &json!({})).is_ok());
|
||
assert!(s.run("styles.inspectorClear", &json!({"level": "characterFormatting"})).is_ok());
|
||
}
|
||
|
||
/// Home › Editing › Find › Advanced Find: every match, in the body or a selection, with Reading Highlight.
|
||
#[test]
|
||
fn advanced_find_lists_and_highlights_matches() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "cat dog cat"}));
|
||
run(&mut s, "text.newParagraph", json!({}));
|
||
run(&mut s, "text.insert", json!({"text": "Cat"}));
|
||
// From the UI it opens the dialog.
|
||
run(&mut s, "edit.advancedFind", json!({}));
|
||
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "find"})));
|
||
let r = run(&mut s, "edit.advancedFind", json!({"text": "cat", "highlight": true}));
|
||
assert_eq!(r["count"], 3);
|
||
assert_eq!(s.find_highlights().len(), 3);
|
||
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "matchCase": true}))["count"], 2);
|
||
// Highlights follow edits and the latest search.
|
||
assert_eq!(s.find_highlights().len(), 2);
|
||
run(&mut s, "text.insert", json!({"text": " cat"}));
|
||
assert_eq!(s.find_highlights().len(), 3);
|
||
// Only inside the selection.
|
||
run(&mut s, "select.text", json!({"text": "cat dog"}));
|
||
assert_eq!(run(&mut s, "edit.advancedFind", json!({"text": "cat", "in": "selection"}))["count"], 1);
|
||
run(&mut s, "edit.advancedFind", json!({"highlight": false}));
|
||
assert!(s.find_highlights().is_empty());
|
||
// Hostile params.
|
||
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "elsewhere"})).is_err());
|
||
assert!(s.run("edit.advancedFind", &json!({"text": "(", "regex": true})).is_err());
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "text.delete", json!({}));
|
||
assert!(s.run("edit.advancedFind", &json!({"text": "x", "in": "selection"})).is_err());
|
||
}
|
||
|
||
/// #146: a table style made by command styles the table at the caret (header row fill and bold
|
||
/// text, banded rows), can be modified, and saves as a table style the table refers to.
|
||
#[test]
|
||
fn custom_table_style_applies_modifies_and_saves() {
|
||
use wordcraft_doc::{Rgb, StyleKind, TextColor};
|
||
let mut s = s();
|
||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
|
||
run(&mut s, "text.insert", json!({"text": "Head"}));
|
||
run(&mut s, "table.look", json!({"headerRow": true, "bandedRows": true}));
|
||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||
let r = run(
|
||
&mut s,
|
||
"table.newStyle",
|
||
json!({"name": "Thesis Table", "basedOn": "Table Grid",
|
||
"wholeTable": {"size": 10},
|
||
"headerRow": {"fill": "1F3864", "bold": true, "color": "FFFFFF", "borders": true},
|
||
"bandedRows": {"fill": "EEEEEE", "italic": true}}),
|
||
);
|
||
let id = r["id"].as_str().unwrap().to_string();
|
||
let table_style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
|
||
assert_eq!(table_style(&s).as_deref(), Some(id.as_str()), "applied to the current table");
|
||
assert!(s.run("table.newStyle", &json!({"name": "thesis table"})).is_err(), "names are unique");
|
||
assert!(s.run("table.newStyle", &json!({"name": "X", "basedOn": "Heading 1"})).is_err(), "bases are table styles");
|
||
assert!(s.run("table.style", &json!({"style": "Normal"})).is_err(), "only table styles apply to tables");
|
||
|
||
let fills = |s: &mut Session, c: Rgb| {
|
||
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
|
||
};
|
||
let head = |s: &mut Session| {
|
||
let mut p = tp.0.clone();
|
||
p.extend([0, 0, 0]);
|
||
s.layout().pages[0]
|
||
.items
|
||
.iter()
|
||
.find_map(
|
||
|i| if let crate::layout::Placed::Lines { path, para, .. } = i { (path.0 == p).then(|| para.styles[0].rc.clone()) } else { None },
|
||
)
|
||
.unwrap()
|
||
};
|
||
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 2, "header cells");
|
||
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 2, "first band");
|
||
let rc = head(&mut s);
|
||
assert!(rc.bold && rc.size == 10.0, "{rc:?}");
|
||
assert_eq!(rc.color, TextColor::Rgb(Rgb::WHITE));
|
||
|
||
// Modify the current table's style: the table follows.
|
||
run(&mut s, "table.modifyStyle", json!({"headerRow": {"fill": "C00000", "bold": false}, "bandSize": 2}));
|
||
assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 0);
|
||
assert_eq!(fills(&mut s, Rgb(0xC0, 0, 0)), 2);
|
||
assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 4, "two rows per band");
|
||
assert!(!head(&mut s).bold);
|
||
|
||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||
let st = back.styles.get(&id).unwrap();
|
||
assert_eq!((st.kind, st.name.as_str(), st.based_on.as_deref()), (StyleKind::Table, "Thesis Table", Some("TableGrid")));
|
||
let parts = st.table.as_ref().unwrap();
|
||
assert_eq!((parts.header_fill, parts.band_fill, parts.band_size), (Some(Rgb(0xC0, 0, 0)), Some(Rgb(0xEE, 0xEE, 0xEE)), Some(2)));
|
||
assert_eq!((parts.header_chr.bold, parts.band_chr.italic), (Some(false), Some(true)));
|
||
assert!(parts.header_borders.is_some_and(|b| b.any_visible()));
|
||
let t = back.body.iter().find_map(|b| b.as_table()).unwrap();
|
||
assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle");
|
||
}
|
||
|
||
#[test]
|
||
fn page_and_table_gridlines_toggle_independently() {
|
||
// #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell
|
||
// outlines) are separate view switches; neither touches the document or the undo stack.
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||
let undo = s.undo_labels();
|
||
// Table cell outlines are on by default, as in Word; the page grid is off.
|
||
assert!(!s.view.gridlines && s.view.table_gridlines);
|
||
assert_eq!(run(&mut s, "table.viewGridlines", json!({"value": false}))["value"], false);
|
||
|
||
assert_eq!(run(&mut s, "view.gridlines", json!({}))["value"], true);
|
||
assert!(s.view.gridlines && !s.view.table_gridlines);
|
||
|
||
// Works with the caret outside a table.
|
||
assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], true);
|
||
assert!(s.view.gridlines && s.view.table_gridlines);
|
||
|
||
assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false);
|
||
assert!(!s.view.gridlines && s.view.table_gridlines);
|
||
assert_eq!(run(&mut s, "view.gridlines", json!({"value": false}))["value"], false);
|
||
assert!(!s.view.gridlines);
|
||
|
||
assert_eq!(run(&mut s, "table.viewGridlines", json!({}))["value"], false);
|
||
assert!(!s.view.gridlines && !s.view.table_gridlines);
|
||
|
||
assert_eq!(text(&s), "Hello");
|
||
assert_eq!(s.undo_labels(), undo);
|
||
let state = run(&mut s, "view.state", json!({}));
|
||
assert_eq!(state["gridlines"], false);
|
||
assert_eq!(state["tableGridlines"], false);
|
||
}
|
||
|
||
/// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom
|
||
/// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck
|
||
/// short of the 10%–500% limits.
|
||
#[test]
|
||
fn zoom_in_and_out_step_from_current_zoom_and_leave_fit_modes() {
|
||
let mut s = s();
|
||
let pct = |s: &Session| (s.view.zoom * 100.0).round() as i32;
|
||
run(&mut s, "view.zoomIn", json!({}));
|
||
assert_eq!(pct(&s), 110);
|
||
run(&mut s, "view.zoomOut", json!({}));
|
||
run(&mut s, "view.zoomOut", json!({}));
|
||
assert_eq!(pct(&s), 90);
|
||
for fit in ["view.pageWidth", "view.onePage", "view.multiplePages"] {
|
||
run(&mut s, fit, json!({}));
|
||
assert!(!s.view.fit.is_empty());
|
||
// What the canvas reports while a fit mode shows the page at 163%.
|
||
s.view.zoom = 1.63;
|
||
run(&mut s, "view.zoomIn", json!({}));
|
||
assert_eq!((pct(&s), s.view.fit.as_str(), s.view.multi_page), (170, "", false), "{fit}");
|
||
run(&mut s, fit, json!({}));
|
||
s.view.zoom = 1.63;
|
||
run(&mut s, "view.zoomOut", json!({}));
|
||
assert_eq!((pct(&s), s.view.fit.as_str()), (150, ""), "{fit}");
|
||
}
|
||
let mut last = pct(&s);
|
||
for _ in 0..60 {
|
||
run(&mut s, "view.zoomIn", json!({}));
|
||
assert!(pct(&s) > last || pct(&s) == 500);
|
||
last = pct(&s);
|
||
}
|
||
assert_eq!(last, 500);
|
||
for _ in 0..60 {
|
||
run(&mut s, "view.zoomOut", json!({}));
|
||
assert!(pct(&s) < last || pct(&s) == 10);
|
||
last = pct(&s);
|
||
}
|
||
assert_eq!(last, 10);
|
||
run(&mut s, "view.zoom100", json!({}));
|
||
assert_eq!(pct(&s), 100);
|
||
}
|
||
|
||
/// Comments, revisions and dates read the real clock (#188: the web stamped 2026-01-01).
|
||
#[test]
|
||
fn timestamps_come_from_the_clock() {
|
||
let before = cmd::now_unix();
|
||
assert!(before > 1_760_000_000, "{before}");
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Dated"}));
|
||
run(&mut s, "select.text", json!({"text": "Dated"}));
|
||
run(&mut s, "review.newComment", json!({"text": "When?"}));
|
||
let date = run(&mut s, "review.comments", json!({}))[0]["date"].as_str().unwrap_or_default().to_string();
|
||
// Fixed-width ISO strings sort by time.
|
||
assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}");
|
||
}
|
||
|
||
#[test]
|
||
fn charts_and_diagrams_can_be_selected_and_deleted_but_not_moved() {
|
||
use std::sync::Arc;
|
||
use wordcraft_doc::para::InlineObject;
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Before "}));
|
||
let chart = InlineObject::Graphic { w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(Default::default()) };
|
||
s.doc.insert_object(&Pos::body(0, 7), chart, &Default::default()).unwrap();
|
||
let end = 7 + wordcraft_doc::para::OBJ.len_utf8();
|
||
run(&mut s, "select.range", json!({"anchor": Pos::body(0, 7), "focus": Pos::body(0, end)}));
|
||
assert!(crate::cmd::objects::object_selection(&s).is_some(), "the chart is selected");
|
||
for (id, v) in [
|
||
("arrange.bounds", json!({"width": 50})),
|
||
("picture.size", json!({"width": 50})),
|
||
("arrange.wrap", json!({"wrap": "square"})),
|
||
("arrange.wrapText", json!({"side": "left"})),
|
||
("arrange.position", json!({"preset": "topLeft"})),
|
||
("arrange.align", json!({"value": "left"})),
|
||
("arrange.bringForward", json!({})),
|
||
] {
|
||
assert!(s.run(id, &v).is_err(), "{id} must not change a chart");
|
||
}
|
||
run(&mut s, "text.delete", json!({}));
|
||
assert!(s.doc.para_at(&Pos::body(0, 0)).is_some_and(|p| p.objects.is_empty()), "the chart is deleted");
|
||
}
|
||
|
||
/// The chart model of the first object in the body's first paragraph.
|
||
fn first_chart(doc: &wordcraft_doc::Document) -> Option<wordcraft_doc::chart::ChartSpec> {
|
||
use wordcraft_doc::para::InlineObject;
|
||
let p = doc.para_at(&Pos::body(0, 0))?;
|
||
p.object_offsets().into_iter().find_map(|off| match p.object_at(off) {
|
||
Some(InlineObject::Graphic { graphic, .. }) => graphic.chart.as_deref().cloned(),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn inserted_chart_is_saved_and_reopened_with_its_data() {
|
||
let mut s = s();
|
||
let data =
|
||
json!({"categories": ["Jan", "Feb", "Mar"], "series": [{"name": "Rain", "values": [12.5, null, 3]}, {"name": "Sun", "values": [4, 5, 6]}]});
|
||
let r = run(&mut s, "insert.chart", json!({"type": "line", "data": data, "title": "Weather", "legend": "right"}));
|
||
assert_eq!(r["chart"]["type"], "line");
|
||
let spec = first_chart(&s.doc).expect("a chart with a model");
|
||
assert_eq!(spec.series[0].values, vec![Some(12.5), None, Some(3.0)]);
|
||
assert!(crate::cmd::chart::selected_chart(&s).is_some(), "the new chart is selected");
|
||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||
assert!(bytes.windows(22).any(|w| w == b"word/charts/chart1.xml"), "a chart part is written");
|
||
let back = wordcraft_docx::read(&bytes).unwrap();
|
||
assert_eq!(first_chart(&back), Some(spec), "the chart reads back as the same editable chart");
|
||
// Sample data when none is given.
|
||
let mut s2 = self::s();
|
||
run(&mut s2, "insert.chart", json!({"type": "pie"}));
|
||
assert!(first_chart(&s2.doc).is_some_and(|c| c.kind == wordcraft_doc::chart::ChartType::Pie && !c.series.is_empty()));
|
||
// No params: the picker, nothing inserted.
|
||
let mut s3 = self::s();
|
||
run(&mut s3, "insert.chart", json!({}));
|
||
assert!(s3.ui_requests.iter().any(|r| r["open"] == "insertChart"));
|
||
assert!(first_chart(&s3.doc).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn chart_edits_are_one_undo_step_each() {
|
||
use wordcraft_doc::chart::{ChartType, LegendPos};
|
||
let mut s = s();
|
||
run(&mut s, "insert.chart", json!({"type": "column"}));
|
||
let before = first_chart(&s.doc).unwrap();
|
||
run(&mut s, "chart.editData", json!({"data": {"categories": ["A", "B"], "series": [{"name": "X", "values": [1, 2]}]}}));
|
||
run(&mut s, "chart.type", json!({"type": "barStacked"}));
|
||
run(&mut s, "chart.title", json!({"text": "Totals"}));
|
||
run(&mut s, "chart.legend", json!({"pos": "none"}));
|
||
run(&mut s, "chart.dataLabels", json!({"on": true}));
|
||
let c = first_chart(&s.doc).unwrap();
|
||
assert_eq!((c.kind, c.title.as_deref(), c.legend, c.data_labels), (ChartType::BarStacked, Some("Totals"), None, true));
|
||
assert_eq!((c.categories.len(), c.series.len()), (2, 1));
|
||
let steps: [&dyn Fn(&wordcraft_doc::chart::ChartSpec) -> bool; 5] = [
|
||
&|c| !c.data_labels,
|
||
&|c| c.legend == Some(LegendPos::Bottom),
|
||
&|c| c.title.is_none(),
|
||
&|c| c.kind == ChartType::Column && c.series.len() == 1,
|
||
&|c| *c == before,
|
||
];
|
||
for (k, check) in steps.iter().enumerate() {
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(check(&first_chart(&s.doc).unwrap()), "undo step {k}");
|
||
}
|
||
// Resizing draws the chart again at its new size.
|
||
run(&mut s, "edit.redo", json!({}));
|
||
run(&mut s, "picture.size", json!({"width": 300, "height": 150, "lockAspect": false}));
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
let Some(wordcraft_doc::para::InlineObject::Graphic { graphic, w, .. }) = p.object_offsets().into_iter().find_map(|o| p.object_at(o)) else {
|
||
panic!()
|
||
};
|
||
assert_eq!((*w, graphic.w, graphic.h), (300.0, 300.0, 150.0));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_chart_data_is_capped() {
|
||
use wordcraft_doc::chart::{MAX_CELLS, MAX_POINTS, MAX_SERIES, MAX_TEXT};
|
||
let mut s = s();
|
||
let values: Vec<serde_json::Value> = (0..5000).map(|i| if i % 2 == 0 { json!("NaN") } else { json!(1e308) }).collect();
|
||
let series: Vec<serde_json::Value> = (0..300).map(|i| json!({"name": "n".repeat(10_000), "values": values, "x": i})).collect();
|
||
let cats: Vec<serde_json::Value> = (0..6000).map(|i| json!(i)).collect();
|
||
run(
|
||
&mut s,
|
||
"insert.chart",
|
||
json!({"type": "scatter", "data": {"categories": cats, "series": series}, "title": "\u{0}".repeat(9999), "width": 1e30}),
|
||
);
|
||
let c = first_chart(&s.doc).unwrap();
|
||
assert!(c.series.len() <= MAX_SERIES && c.points() <= MAX_POINTS && c.series.len() * c.points() <= MAX_CELLS);
|
||
assert!(c.series.iter().all(|x| x.name.chars().count() <= MAX_TEXT && x.values.len() == c.points()));
|
||
assert!(c.series[0].values.iter().all(|v| v.is_none_or(f64::is_finite)));
|
||
let _ = s.layout();
|
||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||
assert_eq!(first_chart(&back), Some(c));
|
||
for bad in [json!({"type": "radar"}), json!({"data": {"series": 5}}), json!({"data": {"series": []}})] {
|
||
assert!(s.run("insert.chart", &bad).is_err(), "{bad}");
|
||
}
|
||
// A chart without a model (from another program) is shown, not edited.
|
||
let mut s = self::s();
|
||
let foreign =
|
||
wordcraft_doc::para::InlineObject::Graphic { w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Default::default() };
|
||
s.doc.insert_object(&Pos::body(0, 0), foreign, &Default::default()).unwrap();
|
||
run(&mut s, "select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, wordcraft_doc::para::OBJ.len_utf8())}));
|
||
assert!(s.run("chart.type", &json!({"type": "pie"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn accessibility_reports_a_chart_without_alt_text() {
|
||
use std::sync::Arc;
|
||
use wordcraft_doc::para::InlineObject;
|
||
let mut s = s();
|
||
let chart = InlineObject::Graphic { w: 200.0, h: 100.0, alt: " ".into(), float: Default::default(), graphic: Arc::new(Default::default()) };
|
||
s.doc.insert_object(&Pos::body(0, 0), chart, &Default::default()).unwrap();
|
||
let v = run(&mut s, "file.accessibility", json!({}));
|
||
let issues = v["issues"].as_array().expect("issues");
|
||
assert!(issues.iter().any(|i| i["issue"] == "Chart or diagram has no alternative text"), "{issues:?}");
|
||
}
|
||
|
||
/// #320: the Tabs dialog's command sets and clears stops (inherited ones too), changes the
|
||
/// default interval, and is one undo step.
|
||
#[test]
|
||
fn para_tabs_set_clear_and_undo() {
|
||
use wordcraft_doc::props::{TabAlign, TabLeader};
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Name\tPrice"}));
|
||
run(&mut s, "para.style", json!({"style": "Header"}));
|
||
let stops = |s: &Session| {
|
||
let p = s.doc.para_at(&s.sel.focus).map(|p| p.props.clone()).unwrap_or_default();
|
||
s.doc.styles.resolve_para(&p).tabs.iter().map(|t| (t.pos, t.align, t.leader)).collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(stops(&s).len(), 2, "Header's own stops");
|
||
run(
|
||
&mut s,
|
||
"para.tabs",
|
||
json!({"set": [{"pos": 100.0, "align": "decimal", "leader": "dot"}, {"pos": 300.0}], "clear": [234.0], "default": 18.0}),
|
||
);
|
||
assert_eq!(
|
||
stops(&s),
|
||
vec![(100.0, TabAlign::Decimal, TabLeader::Dot), (300.0, TabAlign::Left, TabLeader::None), (468.0, TabAlign::Right, TabLeader::None)]
|
||
);
|
||
assert_eq!(s.doc.settings.default_tab, 18.0);
|
||
// Setting a stop at an existing position replaces it.
|
||
run(&mut s, "para.tabs", json!({"set": [{"pos": 300.0, "align": "bar"}]}));
|
||
assert!(stops(&s).contains(&(300.0, TabAlign::Bar, TabLeader::None)));
|
||
run(&mut s, "para.tabs", json!({"clearAll": true}));
|
||
assert!(stops(&s).is_empty(), "Clear All removes inherited stops too");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(stops(&s).len(), 3);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(stops(&s).len(), 2);
|
||
assert_eq!(s.doc.settings.default_tab, 36.0, "one undo step restores stops and default together");
|
||
assert!(s.run("para.tabs", &json!({"set": [{"pos": f64::MAX}]})).is_err());
|
||
assert!(s.run("para.tabs", &json!({"clear": "x"})).is_err());
|
||
}
|
||
|
||
/// #320: changing only the default tab interval (a document setting) is its own undo step,
|
||
/// and redo puts it back.
|
||
#[test]
|
||
fn para_tabs_default_only_undoes() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "a\tb"}));
|
||
let before = s.doc.settings.default_tab;
|
||
run(&mut s, "para.tabs", json!({"default": 72.0}));
|
||
assert_eq!(s.doc.settings.default_tab, 72.0);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(s.doc.settings.default_tab, before, "undo restores the default interval");
|
||
assert!(text(&s).contains("a\tb"), "only the setting was undone");
|
||
run(&mut s, "edit.redo", json!({}));
|
||
assert_eq!(s.doc.settings.default_tab, 72.0, "redo sets it again");
|
||
}
|
||
|
||
/// #320: exact border sides with a style, for paragraphs and page borders.
|
||
#[test]
|
||
fn border_sides_apply_exactly() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Boxed"}));
|
||
let side = json!({"style": "double", "width": 1.5, "color": "C00000"});
|
||
run(&mut s, "para.borders", json!({"sides": {"top": side, "bottom": side, "left": side, "right": side}}));
|
||
let b = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.borders).unwrap();
|
||
for x in [b.top, b.bottom, b.left, b.right] {
|
||
let x = x.unwrap();
|
||
assert_eq!((x.style, x.width, x.color), (BorderStyle::Double, 1.5, Some(wordcraft_doc::props::Rgb(0xC0, 0, 0))));
|
||
}
|
||
assert!(b.between.is_none());
|
||
run(&mut s, "para.borders", json!({"sides": {}}));
|
||
assert!(!s.doc.para_at(&s.sel.focus).and_then(|p| p.props.borders).unwrap().any_visible());
|
||
run(&mut s, "design.pageBorders", json!({"sides": {"top": {"style": "dashed", "width": 3.0}}, "applyTo": "document"}));
|
||
let pb = s.doc.last_section.page_borders.unwrap();
|
||
assert_eq!(pb.top.map(|t| (t.style, t.width)), Some((BorderStyle::Dashed, 3.0)));
|
||
assert!(pb.left.is_none());
|
||
run(&mut s, "design.pageBorders", json!({"kind": "box", "style": "thick"}));
|
||
assert_eq!(s.doc.last_section.page_borders.unwrap().left.map(|t| t.style), Some(BorderStyle::Thick));
|
||
run(&mut s, "design.pageBorders", json!({"sides": {}}));
|
||
assert!(s.doc.last_section.page_borders.is_none());
|
||
}
|
||
|
||
/// Encrypt with Password (#55): a document saved with a password opens only with it, keeps it
|
||
/// for the next save, and saves unencrypted once the password is removed.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn password_protected_documents_round_trip() {
|
||
let dir = scratch_dir("password");
|
||
let path = dir.join("secret.docx");
|
||
let path_str = path.to_string_lossy().to_string();
|
||
let mut a = s();
|
||
run(&mut a, "document.setText", json!({"text": "Quarterly numbers"}));
|
||
let r = run(&mut a, "file.save", json!({"path": path_str, "password": "hunter2"}));
|
||
assert_eq!(r["encrypted"], true);
|
||
assert!(wordcraft_docx::is_encrypted(&std::fs::read(&path).unwrap()));
|
||
assert!(a.run("file.save", &json!({"path": dir.join("x.txt").to_string_lossy(), "password": "pw"})).is_err(), "only Word packages encrypt");
|
||
|
||
// No password, then a wrong one: clear errors, and the open document stays.
|
||
let mut t = s();
|
||
let e = t.run("file.open", &json!({"path": path_str})).unwrap_err().to_string();
|
||
assert!(crate::io::needs_password(&e), "{e}");
|
||
let e = t.run("file.open", &json!({"path": path_str, "password": "hunter3"})).unwrap_err().to_string();
|
||
assert!(crate::io::wrong_password(&e), "{e}");
|
||
assert!(t.path.is_none());
|
||
|
||
let r = run(&mut t, "file.open", json!({"path": path_str, "password": "hunter2"}));
|
||
assert_eq!(r["encrypted"], true);
|
||
assert_eq!(text(&t), "Quarterly numbers");
|
||
assert_eq!(run(&mut t, "file.info", json!({}))["encrypted"], true);
|
||
// Saving again keeps the password, as Word does.
|
||
run(&mut t, "text.insert", json!({"text": "Q3 "}));
|
||
run(&mut t, "file.save", json!({}));
|
||
let (doc, encrypted) = crate::io::open_path_with(&path, Some("hunter2")).unwrap();
|
||
assert!(encrypted && doc.plain_text(StoryRef::Body).contains("Q3"));
|
||
|
||
// Removing the password saves the document unencrypted.
|
||
assert!(t.run("file.encrypt", &json!({})).is_err());
|
||
assert!(t.run("file.encrypt", &json!({"password": "x".repeat(300)})).is_err());
|
||
t.dirty = false;
|
||
assert_eq!(run(&mut t, "file.encrypt", json!({"password": null}))["encrypted"], false);
|
||
assert!(t.dirty, "removing the password is a change to save");
|
||
run(&mut t, "file.save", json!({}));
|
||
assert!(crate::io::open_path(&path).unwrap().plain_text(StoryRef::Body).contains("Quarterly"));
|
||
// A password never shows in debug output.
|
||
assert_eq!(format!("{:?}", crate::io::Password::new("hunter2")), "Password(***)");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Shape Format › Shape Effects (#275): presets and custom values apply to the selected shape,
|
||
/// an omitted key is kept, hostile numbers are clamped and each change is one undo step.
|
||
#[test]
|
||
fn shape_effects_apply_and_undo() {
|
||
use wordcraft_doc::effects::{MAX_BLUR, MAX_DISTANCE, Shadow};
|
||
let mut s = s();
|
||
run(&mut s, "insert.shape", json!({"kind": "ellipse"}));
|
||
let effects = |s: &Session| match cmd::objects::selected(s) {
|
||
Some((_, wordcraft_doc::InlineObject::Shape { effects, float, .. })) => (effects, float.effect),
|
||
o => panic!("{o:?}"),
|
||
};
|
||
assert!(effects(&s).0.is_empty());
|
||
run(&mut s, "shape.effects", json!({"shadow": "offsetBottomRight", "glow": {"color": "FF0000", "size": 8}, "softEdge": 5}));
|
||
let (e, extent) = effects(&s);
|
||
assert_eq!(e.shadow, Shadow::preset("offsetBottomRight"));
|
||
assert_eq!(e.glow.map(|g| (g.color, g.size)), Some((wordcraft_doc::props::Rgb(255, 0, 0), 8.0)));
|
||
assert_eq!(e.soft_edge, Some(5.0));
|
||
assert!(extent.iter().all(|v| *v >= 8.0), "room for the glow: {extent:?}");
|
||
// Clearing the glow leaves the shadow and soft edges.
|
||
run(&mut s, "shape.effects", json!({"glow": null}));
|
||
let (e, _) = effects(&s);
|
||
assert!(e.glow.is_none() && e.shadow.is_some() && e.soft_edge == Some(5.0));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(effects(&s).0.glow.map(|g| g.size), Some(8.0));
|
||
// Hostile numbers.
|
||
run(&mut s, "shape.effects", json!({"shadow": {"blur": 1e30, "distance": -5, "angle": 1e20, "transparency": 400}, "softEdge": -3}));
|
||
let (e, _) = effects(&s);
|
||
let sh = e.shadow.unwrap();
|
||
assert!(sh.blur == MAX_BLUR && sh.distance == 0.0 && (0.0..360.0).contains(&sh.angle) && sh.transparency == 100.0, "{sh:?}");
|
||
assert!(e.soft_edge.is_none());
|
||
run(&mut s, "shape.effects", json!({"shadow": {"distance": 1e9}}));
|
||
assert_eq!(effects(&s).0.shadow.map(|s| s.distance), Some(MAX_DISTANCE));
|
||
assert!(s.run("shape.effects", &json!({"shadow": "perspective"})).is_err());
|
||
// Off a shape it's disabled.
|
||
run(&mut s, "select.collapse", json!({"end": true}));
|
||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||
assert!(s.run("shape.effects", &json!({"shadow": null})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn asian_typography_sets_flags_on_selected_paragraphs_and_undoes() {
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "一\n二\n三"}));
|
||
run(&mut s, "select.all", json!({}));
|
||
let r = run(&mut s, "para.asianTypography", json!({"kinsoku": false, "wordWrap": false, "topLinePunct": true}));
|
||
assert_eq!(r["asianTypography"]["kinsoku"], false);
|
||
assert_eq!(r["asianTypography"]["autoSpaceDE"], true, "a flag left out keeps its default");
|
||
for p in s.doc.body.iter().filter_map(|b| b.as_para()) {
|
||
assert_eq!(
|
||
(p.props.kinsoku, p.props.word_wrap, p.props.top_line_punct, p.props.overflow_punct),
|
||
(Some(false), Some(false), Some(true), None)
|
||
);
|
||
}
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.kinsoku.is_none() && p.props.word_wrap.is_none()));
|
||
// Without flags it only reports them.
|
||
let r = run(&mut s, "para.asianTypography", json!({}));
|
||
assert_eq!(r["asianTypography"]["kinsoku"], true);
|
||
}
|
||
|
||
#[test]
|
||
fn manage_styles_lists_paragraph_character_linked_and_table_styles() {
|
||
let mut s = s();
|
||
let list = run(&mut s, "styles.manage", json!({"sort": "alphabetical"}));
|
||
let list = list.as_array().unwrap();
|
||
let find = |name: &str| list.iter().find(|x| x["name"] == name).unwrap_or_else(|| panic!("{name} listed"));
|
||
assert_eq!(find("Normal")["type"], "paragraph");
|
||
assert_eq!(find("Normal")["builtIn"], true);
|
||
assert_eq!(find("Normal")["inGallery"], true);
|
||
assert_eq!(find("Heading 1")["type"], "linked");
|
||
assert_eq!(find("Heading 1")["basedOn"], "Normal");
|
||
assert!(find("Heading 1")["format"]["size"].is_number(), "description formatting");
|
||
assert_eq!(find("Table Grid")["type"], "table");
|
||
assert!(list.iter().any(|x| x["type"] == "character"));
|
||
// The character half of a linked pair is listed once, as the linked style.
|
||
assert!(!list.iter().any(|x| x["id"] == "Heading1Char"));
|
||
let names: Vec<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");
|
||
}
|
||
|
||
/// #548: `.xml` saves a Word XML Document (Flat OPC) and opens it again; other XML is refused.
|
||
#[test]
|
||
fn word_xml_saves_and_opens_through_io() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Kept in one XML file"}));
|
||
let bytes = crate::io::save_bytes("notes.xml", &s.doc).unwrap();
|
||
assert_eq!(wordcraft_docx::sniff_xml(&bytes), wordcraft_docx::XmlKind::FlatOpc);
|
||
let doc = crate::io::open_bytes("notes.XML", &bytes).unwrap();
|
||
assert_eq!(doc.body, crate::io::open_bytes("n.docx", &crate::io::save_bytes("n.docx", &s.doc).unwrap()).unwrap().body);
|
||
let err = crate::io::open_bytes("feed.xml", b"<rss><channel/></rss>").unwrap_err();
|
||
assert!(err.contains("not a Word XML document"), "{err}");
|
||
}
|
||
|
||
/// #146: the first/last column, total row and banded column regions apply in layout (later
|
||
/// regions win: the total row's fill beats the first column's) and round-trip through .docx.
|
||
#[test]
|
||
fn custom_table_style_column_and_total_regions() {
|
||
use wordcraft_doc::Rgb;
|
||
let mut s = s();
|
||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 4}));
|
||
run(
|
||
&mut s,
|
||
"table.look",
|
||
json!({"headerRow": false, "bandedRows": false, "firstColumn": true, "lastColumn": true, "totalRow": true, "bandedColumns": true}),
|
||
);
|
||
let r = run(
|
||
&mut s,
|
||
"table.newStyle",
|
||
json!({"name": "Ledger",
|
||
"firstColumn": {"fill": "112233", "bold": true},
|
||
"lastColumn": {"fill": "445566"},
|
||
"lastRow": {"fill": "778899", "borders": true},
|
||
"bandedColumns": {"fill": "ABCDEF"}}),
|
||
);
|
||
let id = r["id"].as_str().unwrap().to_string();
|
||
let fills = |s: &mut Session, c: Rgb| {
|
||
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
|
||
};
|
||
// 3 rows x 4 columns: the total row takes all 4 of its cells, the first and last columns the
|
||
// two cells above it, and the column band (column 2 of the inner columns 2 and 3) one each.
|
||
assert_eq!(fills(&mut s, Rgb(0x77, 0x88, 0x99)), 4, "total row");
|
||
assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "first column");
|
||
assert_eq!(fills(&mut s, Rgb(0x44, 0x55, 0x66)), 2, "last column");
|
||
assert_eq!(fills(&mut s, Rgb(0xAB, 0xCD, 0xEF)), 2, "first column band");
|
||
|
||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||
let p = back.styles.get(&id).unwrap().table.clone().unwrap();
|
||
assert_eq!(
|
||
(p.first_col_fill, p.first_col_chr.bold, p.last_col_fill, p.total_fill, p.col_band_fill),
|
||
(Some(Rgb(0x11, 0x22, 0x33)), Some(true), Some(Rgb(0x44, 0x55, 0x66)), Some(Rgb(0x77, 0x88, 0x99)), Some(Rgb(0xAB, 0xCD, 0xEF)))
|
||
);
|
||
assert!(p.total_borders.is_some_and(|b| b.any_visible()));
|
||
}
|
||
|
||
/// #146: deleting a custom table style re-bases the styles based on it (keeping their look),
|
||
/// puts its tables on Table Grid, refuses built-in styles, and undoes.
|
||
#[test]
|
||
fn delete_table_style_falls_back_and_undoes() {
|
||
use wordcraft_doc::Rgb;
|
||
let mut s = s();
|
||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
|
||
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false, "wholeTable": {"bold": true}}));
|
||
let style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
|
||
assert_eq!(style(&s).as_deref(), Some("Base"));
|
||
assert!(s.run("table.deleteStyle", &json!({"style": "Table Grid"})).is_err(), "built-in");
|
||
assert!(s.run("table.deleteStyle", &json!({"style": "Normal Table"})).is_err(), "built-in");
|
||
|
||
let r = run(&mut s, "table.deleteStyle", json!({}));
|
||
assert_eq!((r["deleted"].as_str(), r["tables"].as_u64()), (Some("Base"), Some(1)));
|
||
assert!(s.doc.styles.get("Base").is_none());
|
||
assert_eq!(style(&s).as_deref(), Some("TableGrid"), "tables fall back to Table Grid");
|
||
let child = s.doc.styles.table_style("Child").unwrap();
|
||
assert_eq!((child.parts.header_fill, child.chr.bold), (Some(Rgb(0xC0, 0, 0)), Some(true)), "the child keeps its look");
|
||
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("TableGrid"));
|
||
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(s.doc.styles.get("Base").is_some());
|
||
assert_eq!(style(&s).as_deref(), Some("Base"));
|
||
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("Base"));
|
||
}
|
||
|
||
/// #146: a .docx may mark Word's built-in table styles as custom; they still can't be deleted, so
|
||
/// no table is left pointing at a style that's gone.
|
||
#[test]
|
||
fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() {
|
||
let mut s = s();
|
||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||
for st in s.doc.styles.styles.iter_mut() {
|
||
st.builtin = false;
|
||
}
|
||
for name in ["Table Grid", "Normal Table", "TableGrid"] {
|
||
assert!(s.run("table.deleteStyle", &json!({"style": name})).is_err(), "{name}");
|
||
}
|
||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||
let id = s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone().unwrap();
|
||
assert!(s.doc.styles.get(&id).is_some(), "the table's style still exists");
|
||
}
|
||
|
||
/// Delete Cells can leave a row shorter than the table grid; Delete Columns then removed that row's
|
||
/// only cell and every row with it, leaving a table block with no rows.
|
||
#[test]
|
||
fn deleting_the_last_cell_column_never_leaves_a_table_without_rows() {
|
||
let mut s = s();
|
||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
|
||
run(&mut s, "table.deleteCells", json!({}));
|
||
run(&mut s, "table.deleteColumn", json!({}));
|
||
for b in s.doc.body.iter() {
|
||
if let Some(t) = b.as_table() {
|
||
assert!(!t.rows.is_empty(), "table without rows");
|
||
}
|
||
}
|
||
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()), "an emptied table is removed");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(s.doc.body.iter().any(|b| b.as_table().is_some_and(|t| !t.rows.is_empty())));
|
||
}
|
||
|
||
#[test]
|
||
fn center_after_select_paragraph_leaves_the_next_paragraph_alone() {
|
||
// #418: Select Paragraph ends at the next paragraph's start; that paragraph isn't selected.
|
||
use wordcraft_doc::Align;
|
||
let align = |s: &Session, i: usize| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.align);
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Alpha\rBeta", "raw": true}));
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 2}}));
|
||
run(&mut s, "select.paragraph", json!({}));
|
||
assert_eq!(s.selected_text(), "Alpha\n");
|
||
run(&mut s, "para.alignCenter", json!({}));
|
||
assert_eq!((align(&s, 0), align(&s, 1)), (Some(Align::Center), None));
|
||
// Reversed, too; a range into Beta centres both; a caret at Beta's start centres Beta.
|
||
for (a, b, want) in [((1, 0), (0, 0), (Some(Align::Center), None)), ((0, 0), (1, 1), (Some(Align::Center), Some(Align::Center)))] {
|
||
let mut s = crate::Session::new(wordcraft_doc::Document::new());
|
||
run(&mut s, "text.insert", json!({"text": "Alpha\rBeta", "raw": true}));
|
||
run(&mut s, "caret.set", json!({"pos": {"block": a.0, "off": a.1}}));
|
||
run(&mut s, "caret.set", json!({"pos": {"block": b.0, "off": b.1}, "extend": true}));
|
||
run(&mut s, "para.align", json!({"value": "center"}));
|
||
assert_eq!((align(&s, 0), align(&s, 1)), want, "{a:?}..{b:?}");
|
||
}
|
||
let mut s = crate::Session::new(wordcraft_doc::Document::new());
|
||
run(&mut s, "text.insert", json!({"text": "Alpha\rBeta", "raw": true}));
|
||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||
run(&mut s, "para.alignCenter", json!({}));
|
||
assert_eq!((align(&s, 0), align(&s, 1)), (None, Some(Align::Center)));
|
||
}
|
||
|
||
#[test]
|
||
fn change_case_keeps_the_selection_when_lengths_change() {
|
||
// #420: İ lowers to i + U+0307 (one byte more); the selection still covers the result.
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "aİxZ", "raw": true}));
|
||
run(&mut s, "select.text", json!({"text": "İx"}));
|
||
run(&mut s, "format.changeCase", json!({"mode": "lower"}));
|
||
assert_eq!(text(&s), "ai\u{307}xZ");
|
||
assert_eq!(s.selected_text(), "i\u{307}x");
|
||
// ı uppers to I (one byte less); the trailing Z stays out of the selection.
|
||
let mut s = crate::Session::new(wordcraft_doc::Document::new());
|
||
run(&mut s, "text.insert", json!({"text": "ıxZ", "raw": true}));
|
||
run(&mut s, "select.text", json!({"text": "ıx"}));
|
||
run(&mut s, "format.changeCase", json!({"mode": "upper"}));
|
||
assert_eq!(text(&s), "IXZ");
|
||
assert_eq!(s.selected_text(), "IX");
|
||
}
|
||
|
||
#[test]
|
||
fn change_case_keeps_each_runs_formatting_when_lengths_change() {
|
||
// #421: lowercasing a plain İ and a bold x keeps x bold (and the i̇ plain).
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "İx", "raw": true}));
|
||
run(&mut s, "select.text", json!({"text": "x"}));
|
||
run(&mut s, "format.bold", json!({"value": true}));
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "format.changeCase", json!({"mode": "lower"}));
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
assert_eq!(p.text, "i\u{307}x");
|
||
assert_ne!(p.props_of_char(0).bold, Some(true));
|
||
assert_ne!(p.props_of_char(1).bold, Some(true));
|
||
assert_eq!(p.props_of_char(3).bold, Some(true));
|
||
// Typing over the converted selection replaces all of it.
|
||
run(&mut s, "text.insert", json!({"text": "Q", "raw": true}));
|
||
assert_eq!(text(&s), "Q");
|
||
}
|
||
|
||
#[test]
|
||
fn document_grid_sets_lines_and_characters_in_one_undo_step() {
|
||
// #391: Layout › Page Setup › Document Grid.
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "日本語"}));
|
||
let undo = s.undo_labels().len();
|
||
let r = run(&mut s, "layout.documentGrid", json!({"type": "linesAndChars", "linesPerPage": 40, "charsPerLine": 30}));
|
||
assert_eq!(r["type"], "linesAndChars");
|
||
assert_eq!(r["linesPerPage"], 40);
|
||
assert_eq!(r["charsPerLine"], 30);
|
||
let g = crate::cmd::page::sect(&s).doc_grid.unwrap();
|
||
assert!((g.line_pitch - 648.0 / 40.0).abs() < 1e-3, "{g:?}");
|
||
assert_eq!(s.undo_labels().len(), undo + 1);
|
||
// Hostile values are clamped, not trusted.
|
||
let r = run(&mut s, "layout.documentGrid", json!({"linePitch": -5, "charSpace": 1e15}));
|
||
assert_eq!(r["linePitch"], 1.0);
|
||
assert!(r["charPitch"].as_f64().unwrap() <= 1584.0);
|
||
assert!(s.run("layout.documentGrid", &json!({"type": "diagonal"})).is_err());
|
||
run(&mut s, "edit.undo", json!({}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(crate::cmd::page::sect(&s).doc_grid, None);
|
||
}
|
||
|
||
#[test]
|
||
fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours() {
|
||
use wordcraft_doc::para::InlineObject;
|
||
let mut s = s();
|
||
// Without an id: the library is listed and a front end is asked for its picker.
|
||
let r = run(&mut s, "insert.icon", json!({"query": "envelope"}));
|
||
assert_eq!(r["icons"][0]["id"], "mail");
|
||
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "icons"})));
|
||
let all = run(&mut s, "insert.icon", json!({}));
|
||
assert!(all["icons"].as_array().unwrap().len() >= 40);
|
||
assert!(s.run("insert.icon", &json!({"id": "no-such-icon"})).is_err());
|
||
assert!(s.run("insert.icon", &json!({"id": "heart", "color": "red"})).is_err());
|
||
|
||
let r = run(&mut s, "insert.icon", json!({"id": "heart", "color": "C00000", "size": 48}));
|
||
assert_eq!((r["width"].as_f64(), r["height"].as_f64(), r["icon"].as_str()), (Some(48.0), Some(48.0), Some("heart")));
|
||
let Some((_, InlineObject::Image { media, w, h, alt, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") };
|
||
assert_eq!((w, h, alt.as_str()), (48.0, 48.0, "Heart"));
|
||
let png = image::load_from_memory(&s.doc.media[&media]).unwrap().to_rgba8();
|
||
assert_eq!(png.dimensions(), (200, 200), "about 300 pixels per inch");
|
||
let inked: Vec<_> = png.pixels().filter(|p| p.0[3] == 255).collect();
|
||
assert!(!inked.is_empty() && inked.iter().all(|p| p.0[..3] == [0xC0, 0, 0]));
|
||
assert!(png.get_pixel(0, 0).0[3] == 0, "transparent background");
|
||
// It is an ordinary picture: it saves to .docx.
|
||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||
assert_eq!(back.media.len(), 1);
|
||
|
||
// Icon Color repaints it, keeping the transparency.
|
||
run(&mut s, "picture.iconColor", json!({"color": "1F4E79"}));
|
||
let Some((_, InlineObject::Image { media: m2, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") };
|
||
let png2 = image::load_from_memory(&s.doc.media[&m2]).unwrap().to_rgba8();
|
||
assert!(png2.pixels().zip(png.pixels()).all(|(a, b)| a.0[3] == b.0[3] && (a.0[3] == 0 || a.0[..3] == [0x1F, 0x4E, 0x79])));
|
||
assert!(s.run("picture.iconColor", &json!({})).is_err());
|
||
}
|
||
|
||
/// Print Markup (#589): `file.printMarkup` or a command's `markup` puts the comments into PDFs
|
||
/// (balloons beside the scaled page) and at the end of HTML, Markdown and text copies.
|
||
#[test]
|
||
fn print_markup_exports_comments() {
|
||
let mut s = Session::new(wordcraft_doc::Document::from_text("The quarterly figures look strong."));
|
||
s.author = "Ada Reviewer".into();
|
||
s.run("select.text", &json!({"text": "figures"})).unwrap();
|
||
let id = s.run("review.newComment", &json!({"text": "Check the totals"})).unwrap()["id"].as_u64().unwrap();
|
||
s.run("review.reply", &json!({"id": id, "text": "Totals checked"})).unwrap();
|
||
let opts = |s: &Session, v| cmd::file::export_options(s, &v);
|
||
assert!(!opts(&s, json!({})).markup, "off by default");
|
||
let pdf = |s: &Session, v| crate::io::save_bytes_opts("x.pdf", &s.doc, &opts(s, v)).unwrap();
|
||
let html = |s: &Session, v| String::from_utf8(crate::io::save_bytes_opts("x.html", &s.doc, &opts(s, v)).unwrap()).unwrap();
|
||
let txt = |s: &Session, v| String::from_utf8(crate::io::save_bytes_opts("x.txt", &s.doc, &opts(s, v)).unwrap()).unwrap();
|
||
assert!(!html(&s, json!({})).contains("Check the totals"));
|
||
assert!(!txt(&s, json!({})).contains("Check the totals"));
|
||
let plain = pdf(&s, json!({}));
|
||
// The setting turns it on for every copy; a command's `markup` overrides it.
|
||
assert_eq!(s.run("file.printMarkup", &json!({})).unwrap()["value"], true);
|
||
let h = html(&s, json!({}));
|
||
assert!(
|
||
h.contains("<section class=\"comments\">") && h.contains("Ada Reviewer") && h.contains("<q>figures</q>") && h.contains("Totals checked"),
|
||
"{h}"
|
||
);
|
||
assert!(h.find("Check the totals") < h.find("</body>"));
|
||
let t = txt(&s, json!({}));
|
||
assert!(t.contains("[1] Ada Reviewer on “figures”: Check the totals") && t.contains("Totals checked"), "{t}");
|
||
let md = String::from_utf8(crate::io::save_bytes_opts("x.md", &s.doc, &opts(&s, json!({}))).unwrap()).unwrap();
|
||
assert!(md.contains("## Comments") && md.contains("1. **Ada Reviewer**"), "{md}");
|
||
assert_ne!(pdf(&s, json!({})), plain);
|
||
assert!(!html(&s, json!({"markup": false})).contains("Check the totals"));
|
||
// Comments hidden in Track Changes Options stay out.
|
||
s.prefs.markup.comments = false;
|
||
assert!(!html(&s, json!({})).contains("Check the totals"));
|
||
s.prefs.markup.comments = true;
|
||
assert_eq!(s.run("file.printMarkup", &json!({"value": false})).unwrap()["value"], false);
|
||
assert!(html(&s, json!({"markup": true})).contains("Check the totals"));
|
||
// file.exportPdf and file.exportPng take it too.
|
||
let dir = std::env::temp_dir().join(format!("wordcraft-print-markup-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("m.pdf");
|
||
let r = s.run("file.exportPdf", &json!({"path": path.to_string_lossy(), "markup": true})).unwrap();
|
||
assert_eq!(r["markup"], true);
|
||
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF-"));
|
||
let png = dir.join("m.png");
|
||
let wide = s.run("file.exportPng", &json!({"path": png.to_string_lossy(), "markup": true, "scale": 1.0})).unwrap()["width"].as_u64().unwrap();
|
||
let narrow = s.run("file.exportPng", &json!({"path": png.to_string_lossy(), "scale": 1.0})).unwrap()["width"].as_u64().unwrap();
|
||
assert!(wide > narrow + 200, "{wide} vs {narrow}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// #606: the colour commands behind More Colors… take any `RRGGBB` (with or without `#`, any
|
||
/// case), not only the palette's colours; junk is a params error and changes nothing.
|
||
#[test]
|
||
fn colour_commands_take_any_custom_hex() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Custom"}));
|
||
run(&mut s, "select.all", json!({}));
|
||
for (given, want) in [("7A3EC8", "7A3EC8"), ("#0b5d1e", "0B5D1E"), (" ff8800 ", "FF8800")] {
|
||
run(&mut s, "format.color", json!({"color": given}));
|
||
assert_eq!(run(&mut s, "format.state", json!({})).get("color"), Some(&json!(want)));
|
||
}
|
||
let doc = s.doc.clone();
|
||
for junk in [json!("#12"), json!("purple"), json!("GG0000"), json!(12), json!(null), json!("ééé")] {
|
||
assert!(s.run("format.color", &json!({"color": junk})).is_err(), "{junk}");
|
||
}
|
||
assert_eq!(s.doc, doc);
|
||
run(&mut s, "format.color", json!({"color": "auto"}));
|
||
assert_eq!(run(&mut s, "format.state", json!({})).get("color"), Some(&json!("auto")));
|
||
// Shading and page colour take custom colours the same way.
|
||
run(&mut s, "para.shading", json!({"color": "#C0FFEE"}));
|
||
let shading = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.shading);
|
||
assert_eq!(shading, wordcraft_doc::Rgb::parse("C0FFEE"));
|
||
run(&mut s, "design.pageColor", json!({"color": "123ABC"}));
|
||
assert_eq!(s.doc.settings.page_color, wordcraft_doc::Rgb::parse("123ABC"));
|
||
}
|
||
|
||
/// #569: a playlist (.m3u / .m3u8) — or any file with an extension WordCraft doesn't know —
|
||
/// opens as plain text with its encoding detected, like Word's text import; a binary file is a
|
||
/// clean error, never garbage or a panic.
|
||
#[test]
|
||
fn unknown_text_extensions_open_as_text() {
|
||
let lines = |doc: &wordcraft_doc::Document| doc.plain_text(StoryRef::Body);
|
||
let m3u8 = "#EXTM3U\r\n#EXTINF:215,Björk — Jóga\r\nmusic/Jóga.mp3\r\n#EXTINF:-1,Радио\r\nhttp://example.org/live\r\n";
|
||
let doc = crate::io::open_bytes("Mix.M3U8", m3u8.as_bytes()).unwrap();
|
||
assert!(
|
||
lines(&doc).starts_with("#EXTM3U\n#EXTINF:215,Björk — Jóga\nmusic/Jóga.mp3\n#EXTINF:-1,Радио\nhttp://example.org/live"),
|
||
"{:?}",
|
||
lines(&doc)
|
||
);
|
||
assert!(!lines(&doc).contains('\r'));
|
||
// With a BOM, which must not show up as a character.
|
||
let bom = [&[0xEF, 0xBB, 0xBF][..], m3u8.as_bytes()].concat();
|
||
assert!(lines(&crate::io::open_bytes("a.m3u8", &bom).unwrap()).starts_with("#EXTM3U\n"));
|
||
// A plain .m3u in Windows-1252 (é = E9, — = 97).
|
||
let cp1252 = b"#EXTM3U\r\n#EXTINF:100,Caf\xE9 \x97 Live\r\nC:\\Music\\caf\xE9.mp3\r\n";
|
||
assert_eq!(lines(&crate::io::open_bytes("old.m3u", cp1252).unwrap()), "#EXTM3U\n#EXTINF:100,Café — Live\nC:\\Music\\café.mp3\n");
|
||
// Other text files, a file without an extension, a dot in a folder name only.
|
||
for name in ["server.log", "settings.ini", "subs.srt", "README", "/home/a.b/README", "C:\\v1.2\\notes"] {
|
||
assert_eq!(lines(&crate::io::open_bytes(name, b"one\ntwo").unwrap()), "one\ntwo", "{name}");
|
||
}
|
||
// Binary files: an error naming the file, no panic.
|
||
for (name, bytes) in
|
||
[("song.mp3", &b"ID3\x04\0\0\0\0\x01\x02\x03"[..]), ("pic.dat", b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"), ("blob", &[0u8, 1, 2, 3, 0, 255])]
|
||
{
|
||
let err = crate::io::open_bytes(name, bytes).unwrap_err();
|
||
assert!(err.contains(name) && err.contains("not a document"), "{name}: {err}");
|
||
}
|
||
assert!(crate::io::open_bytes("x.pdf", b"%PDF-1.7\n1 0 obj\n").is_err());
|
||
// A Word document or RTF file with the wrong extension opens by what it holds.
|
||
let docx = crate::io::save_bytes("x.docx", &crate::sample::letter()).unwrap();
|
||
assert_eq!(lines(&crate::io::open_bytes("letter.bak", &docx).unwrap()), lines(&crate::io::open_bytes("x.docx", &docx).unwrap()));
|
||
let rtf = crate::io::open_bytes("note.tmp", br"{\rtf1\ansi Hello {\b bold}\par}").unwrap();
|
||
assert!(lines(&rtf).starts_with("Hello bold"), "{:?}", lines(&rtf));
|
||
// Through file.open, both from disk and with the bytes (the web's picker and drops).
|
||
let dir = std::env::temp_dir().join(format!("wordcraft-569-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("road trip.m3u");
|
||
std::fs::write(&path, cp1252).unwrap();
|
||
let mut s = s();
|
||
let r = run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
|
||
assert_eq!(r["paragraphs"], 4);
|
||
assert!(text(&s).contains("Café — Live"));
|
||
let data = crate::cmd::insert::base64_encode(m3u8.as_bytes());
|
||
run(&mut s, "file.open", json!({"path": "Mix.m3u8", "data": data}));
|
||
assert!(text(&s).contains("Радио"));
|
||
let bad = crate::cmd::insert::base64_encode(b"\0\x01\x02binary\0");
|
||
assert!(s.run("file.open", &json!({"path": "x.bin", "data": bad})).is_err());
|
||
assert!(text(&s).contains("Радио"), "a failed open leaves the document alone");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// #549: after a double-click, dragging extends the selection by whole words; after a
|
||
/// triple-click by whole paragraphs; after Ctrl/⌘+click by whole sentences. Forward drags keep
|
||
/// the first unit's start as the anchor, backward drags its end.
|
||
#[test]
|
||
fn drag_after_multi_click_extends_by_units() {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "Alpha beta gamma. Delta epsilon.\rSecond para here.\rThird one."}));
|
||
let drag = |s: &mut Session, unit: &str, start: Pos, end: Pos, pos: Pos| {
|
||
run(s, "select.dragUnit", json!({"unit": unit, "start": start, "end": end, "pos": pos}));
|
||
(s.sel.anchor.clone(), s.sel.focus.clone())
|
||
};
|
||
// Double-click "beta" (6..11 with its trailing space).
|
||
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 7)}));
|
||
run(&mut s, "select.word", json!({}));
|
||
let (a, b) = s.sel.ordered();
|
||
assert_eq!((a.off, b.off), (6, 11));
|
||
// Forward into the middle of "gamma": the whole word joins.
|
||
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 13)), (Pos::body(0, 6), Pos::body(0, 16)));
|
||
// Back into "Alpha": anchored at the end of "beta ", focus at the start of "Alpha".
|
||
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 2)), (Pos::body(0, 11), Pos::body(0, 0)));
|
||
// Back inside the first word: just that word.
|
||
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 8)), (Pos::body(0, 6), Pos::body(0, 11)));
|
||
// Into the next paragraph: whole word there.
|
||
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(1, 8)), (Pos::body(0, 6), Pos::body(1, 12)));
|
||
|
||
// Ctrl+click a sentence, drag into the next sentence.
|
||
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 3)}));
|
||
run(&mut s, "select.sentence", json!({}));
|
||
let (a, b) = s.sel.ordered();
|
||
let (_, f) = drag(&mut s, "sentence", a, b, Pos::body(0, 20));
|
||
assert_eq!(f.off, 32, "the second sentence is selected to its end");
|
||
|
||
// Triple-click the second paragraph, drag up into the first and down into the third.
|
||
run(&mut s, "caret.set", json!({"pos": Pos::body(1, 3)}));
|
||
run(&mut s, "select.paragraph", json!({}));
|
||
let (a, b) = s.sel.ordered();
|
||
assert_eq!((a.clone(), b.clone()), (Pos::body(1, 0), Pos::body(2, 0)));
|
||
assert_eq!(drag(&mut s, "paragraph", a.clone(), b.clone(), Pos::body(0, 5)), (Pos::body(2, 0), Pos::body(0, 0)));
|
||
assert_eq!(drag(&mut s, "paragraph", a.clone(), b.clone(), Pos::body(2, 2)), (Pos::body(1, 0), Pos::body(2, 10)));
|
||
|
||
// Junk never panics and leaves the document alone.
|
||
let before = text(&s);
|
||
for v in [
|
||
json!({}),
|
||
json!({"unit": "line", "start": Pos::body(0, 0), "end": Pos::body(0, 1), "pos": Pos::body(0, 1)}),
|
||
json!({"unit": "word", "start": Pos::body(99, 999), "end": Pos::body(0, 0), "pos": Pos::body(77, usize::MAX)}),
|
||
json!({"unit": "word", "start": {"block": 0, "off": 9_999}, "end": {"block": 0, "off": 1}, "pos": {"block": 5}}),
|
||
] {
|
||
let _ = s.run("select.dragUnit", &v);
|
||
}
|
||
assert_eq!(text(&s), before);
|
||
}
|
||
|
||
/// Home › Font › Enclose Characters (#297): the selection becomes an `EQ \o\ac` field, saved the
|
||
/// way Word stores it (the style in the code's run sizes) and read back; null takes the text out
|
||
/// again, and each step undoes.
|
||
#[test]
|
||
fn enclose_characters_make_an_eq_field_and_come_back_out() {
|
||
use wordcraft_doc::InlineObject;
|
||
let field = |s: &Session| {
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
p.objects.iter().find_map(|o| match o {
|
||
InlineObject::Field { instr, result, .. } => Some((instr.clone(), result.clone())),
|
||
_ => None,
|
||
})
|
||
};
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "No 12 and 字 here"}));
|
||
run(&mut s, "select.text", json!({"text": "No 12"}));
|
||
assert!(s.run("format.enclose", &json!({"shape": "circle"})).is_err(), "five characters don't fit");
|
||
run(&mut s, "select.text", json!({"text": "字"}));
|
||
assert!(s.run("format.enclose", &json!({})).is_err(), "a script must say which shape");
|
||
run(&mut s, "format.enclose", json!({"shape": "circle"}));
|
||
assert_eq!(field(&s), Some(("eq \\o\\ac(○,字)".to_string(), "字".to_string())));
|
||
assert_eq!(text(&s), "No 12 and 字 here", "the field's text reads as the character");
|
||
run(&mut s, "select.text", json!({"text": "12"}));
|
||
run(&mut s, "format.enclose", json!({"shape": "diamond", "style": "enlarge"}));
|
||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||
let codes: Vec<String> =
|
||
p.objects.iter().filter_map(|o| if let InlineObject::Field { instr, .. } = o { Some(instr.clone()) } else { None }).collect();
|
||
assert_eq!(codes, ["eq \\o\\ac(◇,12) \\* enlarge", "eq \\o\\ac(○,字)"]);
|
||
|
||
// Word's structure in the .docx: no result, the symbol and the text in runs of their own
|
||
// sizes; reading it back gives the same fields, styles included.
|
||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||
let back = wordcraft_docx::read(&bytes).unwrap();
|
||
let bp = back.para_at(&Pos::body(0, 0)).unwrap();
|
||
let back_codes: Vec<(String, String)> = bp
|
||
.objects
|
||
.iter()
|
||
.filter_map(|o| if let InlineObject::Field { instr, result, .. } = o { Some((instr.clone(), result.clone())) } else { None })
|
||
.collect();
|
||
assert_eq!(back_codes, [("eq \\o\\ac(◇,12) \\* enlarge".to_string(), "12".to_string()), ("eq \\o\\ac(○,字)".to_string(), "字".to_string())]);
|
||
|
||
// Change the shape of the enclosed characters at the caret, then remove it.
|
||
let e = s.doc.para_at(&Pos::body(0, 0)).unwrap().text.find('\u{FFFC}').unwrap();
|
||
s.sel = crate::Selection::caret(Pos::body(0, e + 3));
|
||
run(&mut s, "format.enclose", json!({"shape": "square"}));
|
||
assert!(field(&s).unwrap().0.starts_with("eq \\o\\ac(□,12) \\* enlarge"), "the style stays");
|
||
run(&mut s, "format.enclose", json!({"shape": null}));
|
||
assert_eq!(text(&s), "No 12 and 字 here");
|
||
assert_eq!(s.selected_text(), "12");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(field(&s).unwrap().0.starts_with("eq \\o\\ac(◇,12)"));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(field(&s), None, "back to plain text");
|
||
assert!(s.run("format.enclose", &json!({"shape": null})).is_err(), "nothing to remove");
|
||
// Text given (the dialog's Text box) goes in at a caret.
|
||
run(&mut s, "caret.docEnd", json!({}));
|
||
assert!(s.run("format.enclose", &json!({"shape": "triangle", "text": "ABC"})).is_err());
|
||
run(&mut s, "format.enclose", json!({"shape": "triangle", "text": "A"}));
|
||
assert_eq!(text(&s), "No 12 and 字 hereA");
|
||
assert_eq!(field(&s), Some(("eq \\o\\ac(△,A)".to_string(), "A".to_string())));
|
||
}
|
||
|
||
#[test]
|
||
fn replace_all_splits_paragraphs_at_a_paragraph_mark_in_the_replacement() {
|
||
// Issue #423: untracked Replace All left `\r` in the paragraph instead of splitting it.
|
||
for with in ["A\rB", "A\u{2029}B"] {
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "cat cat"}));
|
||
run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": with}));
|
||
assert_eq!(body_texts(&s), ["A", "B A", "B"], "{with:?}");
|
||
}
|
||
// A line feed stays a line break.
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "cat"}));
|
||
run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": "A\nB"}));
|
||
assert_eq!(body_texts(&s), ["A\nB"]);
|
||
}
|
||
|
||
/// A one-row table converted from tab-separated `text`.
|
||
fn table_from(text: &str) -> Session {
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": text}));
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "table.fromText", json!({}));
|
||
s
|
||
}
|
||
|
||
fn table_texts(s: &Session) -> Vec<Vec<String>> {
|
||
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
|
||
t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect()).collect()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn delete_columns_deletes_every_selected_column() {
|
||
// Issue #431: only the selection's focus column went.
|
||
let cell = |r: u32, c: u32, off: usize| json!({"story": "body", "path": [0, r, c, 0], "off": off});
|
||
for (anchor, focus) in [(cell(0, 0, 0), cell(1, 1, 1)), (cell(1, 1, 1), cell(0, 0, 0))] {
|
||
let mut s = table_from("Left\tMiddle\tRight\nA\tB\tC");
|
||
run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus}));
|
||
run(&mut s, "table.deleteColumn", json!({}));
|
||
assert_eq!(table_texts(&s), [["Right"], ["C"]]);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(table_texts(&s), [["Left", "Middle", "Right"], ["A", "B", "C"]]);
|
||
}
|
||
// A caret deletes its own column only.
|
||
let mut s = table_from("Left\tMiddle\tRight");
|
||
run(&mut s, "caret.set", json!({"pos": cell(0, 1, 0)}));
|
||
run(&mut s, "table.deleteColumn", json!({}));
|
||
assert_eq!(table_texts(&s), [["Left", "Right"]]);
|
||
}
|
||
|
||
#[test]
|
||
fn split_cells_leaves_the_other_rows_alone() {
|
||
// Issue #432: splitting a cell inserted a grid column with a new cell in every row.
|
||
let spans = |s: &Session| -> Vec<Vec<usize>> {
|
||
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
|
||
t.rows.iter().map(|r| r.cells.iter().map(wordcraft_doc::Cell::span).collect()).collect()
|
||
};
|
||
let mut s = table_from("Left\nA");
|
||
let width: f32 = s.doc.body.iter().find_map(|b| b.as_table()).unwrap().total_width();
|
||
run(&mut s, "table.split", json!({"columns": 2}));
|
||
assert_eq!(table_texts(&s), [vec!["Left", ""], vec!["A"]]);
|
||
assert_eq!(spans(&s), [vec![1, 1], vec![2]]);
|
||
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||
assert!((t.total_width() - width).abs() < 0.1, "{:?}", t.grid);
|
||
assert!((t.grid[0] - width / 2.0).abs() < 0.1, "{:?}", t.grid);
|
||
let mut s = table_from("Left\tMiddle\tRight\nA\tB\tC");
|
||
run(&mut s, "table.split", json!({"columns": 2}));
|
||
assert_eq!(table_texts(&s), [vec!["Left", "", "Middle", "Right"], vec!["A", "B", "C"]]);
|
||
assert_eq!(spans(&s), [vec![1, 1, 1, 1], vec![2, 1, 1]]);
|
||
}
|
||
|
||
/// #385: note options for the whole document or one section, and converting notes, which undoes
|
||
/// in one step.
|
||
#[test]
|
||
fn note_options_per_section_and_convert_notes() {
|
||
use wordcraft_doc::PartKind;
|
||
use wordcraft_doc::para::{InlineObject, NoteKind};
|
||
let mut s = s();
|
||
run(&mut s, "text.insert", json!({"text": "One"}));
|
||
let a = run(&mut s, "references.footnote", json!({"text": "First", "mark": "*"}))["id"].as_u64().unwrap() as u32;
|
||
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||
run(&mut s, "layout.break", json!({"kind": "nextPage"}));
|
||
run(&mut s, "text.insert", json!({"text": "Two"}));
|
||
let b = run(&mut s, "references.footnote", json!({"text": "Second"}))["id"].as_u64().unwrap() as u32;
|
||
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||
// The second section restarts at 3 in letters; the first keeps the document's options.
|
||
let got = run(&mut s, "references.noteOptions", json!({"numFmt": "lowerLetter", "numStart": 3, "numRestart": "eachSect", "scope": "section"}));
|
||
assert_eq!((got["footnote"]["numFmt"].as_str(), got["footnote"]["numStart"].as_u64()), (Some("lowerLetter"), Some(3)));
|
||
let sections = s.doc.sections();
|
||
assert!(sections[0].1.footnote_pr.is_empty());
|
||
assert_eq!(sections[1].1.footnote_pr.num_start, Some(3));
|
||
// Hostile or wrong values are refused or clamped.
|
||
assert!(s.run("references.noteOptions", &json!({"kind": "endnote", "pos": "pageBottom"})).is_err());
|
||
assert!(s.run("references.noteOptions", &json!({"kind": "endnote", "numRestart": "eachPage"})).is_err());
|
||
let got = run(&mut s, "references.noteOptions", json!({"kind": "endnote", "pos": "sectEnd", "numStart": -5}));
|
||
assert_eq!((got["endnote"]["pos"].as_str(), got["endnote"]["numStart"].as_u64()), (Some("sectEnd"), Some(1)));
|
||
// The whole document: the section's own start gives way.
|
||
run(&mut s, "references.noteOptions", json!({"numStart": 2}));
|
||
assert_eq!(s.doc.sections()[1].1.footnote_pr.num_start, None);
|
||
assert_eq!(s.doc.settings.footnote_pr.num_start, Some(2));
|
||
// Convert footnotes to endnotes: kinds, marks and styles; the custom mark stays. One undo.
|
||
let kinds = |s: &Session| -> Vec<NoteKind> {
|
||
let mut v = Vec::new();
|
||
s.doc.objects_in_reading_order(&mut |o| {
|
||
if let InlineObject::NoteRef { kind, .. } = o {
|
||
v.push(*kind);
|
||
}
|
||
});
|
||
v
|
||
};
|
||
assert_eq!(run(&mut s, "references.convertNotes", json!({"to": "endnote"}))["converted"], 2);
|
||
assert_eq!(kinds(&s), vec![NoteKind::Endnote, NoteKind::Endnote]);
|
||
assert!([a, b].iter().all(|id| s.doc.parts[id].kind == PartKind::Endnote));
|
||
let note = s.doc.para(StoryRef::Part(a), &wordcraft_doc::Path::top(0)).unwrap();
|
||
assert_eq!(note.props.style.as_deref(), Some("EndnoteText"));
|
||
assert!(matches!(¬e.objects[0], InlineObject::NoteRef { kind: NoteKind::Endnote, custom, .. } if custom == "*"));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(kinds(&s), vec![NoteKind::Footnote, NoteKind::Footnote]);
|
||
assert_eq!(s.doc.parts[&b].kind, PartKind::Footnote);
|
||
assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err());
|
||
}
|
||
|
||
/// A tiny synthetic Polish Hunspell dictionary (written for these tests). Real dictionaries are
|
||
/// installed on the system, never bundled; every Polish test installs this same one.
|
||
fn polish_test_dictionary() {
|
||
let aff = "SET UTF-8\nTRY aioeznrwcyszżtkpdmłjlubąęgh\nMAP 2\nMAP oó\nMAP zż\nREP 2\nREP ż rz\nREP rz ż\n";
|
||
let dic = "12\npłynie\nrzeka\na\nto\nwiem\nże\ntak\nw\nogóle\nzażółć\ngęślą\njaźń\n";
|
||
wordcraft_proof::pl::install_dictionary(aff, dic).unwrap();
|
||
}
|
||
|
||
/// Review › Language and the proofing commands work in Polish end to end: the language makes
|
||
/// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar,
|
||
/// suggestions come from it, and a suggestion fixes the text.
|
||
#[test]
|
||
fn polish_proofing_through_the_review_commands() {
|
||
polish_test_dictionary();
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "Płynie żeka, a wogóle to wiem że tak."}));
|
||
// English by default: the Polish words are English misspellings.
|
||
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 5);
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "review.language", json!({"lang": "pl_PL", "default": true}));
|
||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the tag is normalized");
|
||
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pl-PL");
|
||
let issues = run(&mut s, "review.issues", json!({}));
|
||
let arr = issues.as_array().unwrap();
|
||
let has = |text: &str, kind: &str, sugg: &str| {
|
||
arr.iter()
|
||
.any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "pl" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg))
|
||
};
|
||
assert!(has("żeka", "spelling", "rzeka"), "{issues}");
|
||
assert!(has("wogóle", "spelling", "w ogóle"), "{issues}");
|
||
assert!(has(" że", "grammar", ", że"), "{issues}");
|
||
assert_eq!(arr.len(), 3, "{issues}");
|
||
assert!(arr.iter().any(|i| i["message"] == "Brak przecinka przed „że”"), "{issues}");
|
||
// F7 from the start selects the first issue; Change applies a suggestion.
|
||
run(&mut s, "caret.docStart", json!({}));
|
||
let first = run(&mut s, "review.spelling", json!({}));
|
||
assert_eq!(first["text"], "żeka");
|
||
run(&mut s, "review.applySuggestion", json!({"text": "rzeka"}));
|
||
assert!(text(&s).starts_with("Płynie rzeka,"), "{}", text(&s));
|
||
// Add to Dictionary works for Polish words too.
|
||
run(&mut s, "review.addToDictionary", json!({"word": "wogóle"}));
|
||
let left = run(&mut s, "review.issues", json!({}));
|
||
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
|
||
}
|
||
|
||
/// `review.language`: tags are validated and normalized, `noProof` alone keeps the language,
|
||
/// no parameters keep the old meaning (US English), and `default` needs a language.
|
||
#[test]
|
||
fn review_language_validates_tags_and_marks_text_unchecked() {
|
||
polish_test_dictionary();
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "Hello wrold"}));
|
||
run(&mut s, "select.all", json!({}));
|
||
assert!(s.run("review.language", &json!({"lang": "not a tag!"})).is_err());
|
||
assert!(s.run("review.language", &json!({"lang": ""})).is_err());
|
||
assert!(s.run("review.language", &json!({"default": true, "noProof": true})).is_err(), "default needs a language");
|
||
assert_eq!(text(&s), "Hello wrold", "a refused command changes nothing");
|
||
run(&mut s, "review.language", json!({"noProof": true}));
|
||
let st = run(&mut s, "format.state", json!({}));
|
||
assert_eq!((st["noProof"].clone(), st["lang"].clone()), (json!(true), json!("en-US")));
|
||
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
|
||
run(&mut s, "review.language", json!({"noProof": false}));
|
||
assert_eq!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len(), 1);
|
||
run(&mut s, "review.language", json!({"lang": "PL"}));
|
||
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pl");
|
||
run(&mut s, "review.language", json!({}));
|
||
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "en-US");
|
||
// A language without proofing tools: no marks.
|
||
run(&mut s, "review.language", json!({"lang": "fr-FR"}));
|
||
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
|
||
}
|
||
|
||
/// The editing language (set by the front end from the interface language): new blank
|
||
/// documents and opened plain text are written in it; the untouched first document switches,
|
||
/// a document with changes never does, and templates stay English.
|
||
#[test]
|
||
fn new_documents_follow_the_editing_language() {
|
||
polish_test_dictionary();
|
||
let mut s = s();
|
||
s.set_editing_language(Some("pl-PL"));
|
||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the untouched blank document switches");
|
||
assert!(!s.dirty && !s.can_undo(), "no change to save or undo");
|
||
run(&mut s, "text.insert", json!({"text": "Zażółć gęślą jaźń."}));
|
||
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
|
||
s.set_editing_language(None);
|
||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "a document with changes keeps its language");
|
||
s.set_editing_language(Some("pl"));
|
||
run(&mut s, "file.new", json!({}));
|
||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl"));
|
||
run(&mut s, "file.new", json!({"template": "letter"}));
|
||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-US"), "templates are English text");
|
||
let data = crate::cmd::insert::base64_encode("Płynie rzeka.".as_bytes());
|
||
run(&mut s, "file.open", json!({"path": "notatka.txt", "data": data}));
|
||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl"));
|
||
assert_eq!(run(&mut s, "review.issues", json!({})), json!([]));
|
||
// Malformed tags fall back to US English.
|
||
s.set_editing_language(Some("not a tag"));
|
||
assert_eq!(s.editing_lang, None);
|
||
}
|
||
|
||
/// A Polish document keeps its languages through .docx: the default (`w:docDefaults`) and an
|
||
/// English run (`w:lang`) come back, and so does the proofing.
|
||
#[test]
|
||
fn proofing_languages_survive_a_docx_round_trip() {
|
||
polish_test_dictionary();
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "Płynie żeka. Hello wrold."}));
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
|
||
run(&mut s, "select.text", json!({"text": "Hello wrold."}));
|
||
run(&mut s, "review.language", json!({"lang": "en-GB"}));
|
||
let bytes = crate::io::save_bytes("polski.docx", &s.doc).unwrap();
|
||
let doc = crate::io::open_bytes("polski.docx", &bytes).unwrap();
|
||
assert_eq!(doc.styles.default_chr.lang.as_deref(), Some("pl-PL"));
|
||
let mut back = Session::new(doc);
|
||
let issues = run(&mut back, "review.issues", json!({}));
|
||
let found: Vec<(String, String)> =
|
||
issues.as_array().unwrap().iter().map(|i| (i["text"].as_str().unwrap().to_string(), i["lang"].as_str().unwrap().to_string())).collect();
|
||
assert_eq!(found, [("żeka".to_string(), "pl".to_string()), ("wrold".to_string(), "en".to_string())], "{issues}");
|
||
}
|
||
|
||
#[test]
|
||
fn hide_white_space_toggles_view_state_only() {
|
||
let mut s = s();
|
||
let before = s.doc.body.len();
|
||
assert!(!s.view.hide_white_space);
|
||
assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({}))["value"], true);
|
||
assert!(s.view.hide_white_space);
|
||
assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({"value": false}))["value"], false);
|
||
assert_eq!(s.doc.body.len(), before, "a view option never edits the document");
|
||
}
|
||
|
||
/// The first SmartArt (or other graphic) in the body, selected.
|
||
fn select_graphic(s: &mut Session) {
|
||
let (path, off) = (0..s.doc.body.len() as u32)
|
||
.find_map(|b| {
|
||
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![b]))?;
|
||
let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(wordcraft_doc::InlineObject::Graphic { .. })))?;
|
||
Some((wordcraft_doc::Path(vec![b]), off))
|
||
})
|
||
.expect("a graphic");
|
||
let at = Pos { story: StoryRef::Body, path, off };
|
||
let end = Pos { off: off + wordcraft_doc::para::OBJ.len_utf8(), ..at.clone() };
|
||
s.sel = crate::Selection { anchor: at, focus: end };
|
||
}
|
||
|
||
/// #497: Insert › SmartArt saves as diagram parts (data, layout, style, colours, drawing) and
|
||
/// reopens as the same editable graphic; an edited data part stays read-only.
|
||
#[test]
|
||
fn smart_art_round_trips_through_docx() {
|
||
use std::io::Read;
|
||
let mut s = s();
|
||
let r = run(
|
||
&mut s,
|
||
"insert.smartArt",
|
||
json!({"layout": "hierarchy", "items": ["Root", "\tLeft", {"text": "Right", "level": 1}], "colors": "colorful"}),
|
||
);
|
||
assert_eq!(r["smartArt"]["layout"], "hierarchy");
|
||
let spec = cmd::smart_art::selected_smart_art(&s).expect("selected after insert");
|
||
let bytes = crate::io::save_bytes("sa.docx", &s.doc).unwrap();
|
||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||
for part in ["data1", "layout1", "quickStyle1", "colors1", "drawing1"] {
|
||
assert!(zip.by_name(&format!("word/diagrams/{part}.xml")).is_ok(), "{part} written");
|
||
}
|
||
let mut ct = String::new();
|
||
zip.by_name("[Content_Types].xml").unwrap().read_to_string(&mut ct).unwrap();
|
||
assert!(ct.contains("diagramDrawing+xml") && ct.contains("diagramData+xml"), "{ct}");
|
||
let mut s2 = Session::new(crate::io::open_bytes("sa.docx", &bytes).unwrap());
|
||
select_graphic(&mut s2);
|
||
assert_eq!(cmd::smart_art::selected_smart_art(&s2).as_deref(), Some(&*spec), "reopens editable");
|
||
run(&mut s2, "smartArt.layout", json!({"layout": "venn"}));
|
||
// Saved again, and once more after reopening: still the same graphic.
|
||
let again = crate::io::save_bytes("sa.docx", &s2.doc).unwrap();
|
||
let mut s3 = Session::new(crate::io::open_bytes("sa.docx", &again).unwrap());
|
||
select_graphic(&mut s3);
|
||
assert_eq!(cmd::smart_art::selected_smart_art(&s3).map(|g| g.layout), Some(wordcraft_doc::smart_art::SmartArtLayout::Venn));
|
||
// A data part another program rewrote: shown, not editable.
|
||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&again)).unwrap();
|
||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||
for i in 0..zip.len() {
|
||
let mut f = zip.by_index(i).unwrap();
|
||
let name = f.name().to_string();
|
||
let mut buf = Vec::new();
|
||
f.read_to_end(&mut buf).unwrap();
|
||
if name.ends_with("data1.xml") {
|
||
buf = String::from_utf8(buf).unwrap().replace("<dgm:bg/>", "<dgm:bg><a:noFill/></dgm:bg>").into_bytes();
|
||
}
|
||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||
std::io::Write::write_all(&mut out, &buf).unwrap();
|
||
}
|
||
let foreign = out.finish().unwrap().into_inner();
|
||
let mut s4 = Session::new(crate::io::open_bytes("sa.docx", &foreign).unwrap());
|
||
select_graphic(&mut s4);
|
||
assert!(cmd::smart_art::selected_smart_art(&s4).is_none());
|
||
assert!(s4.run("smartArt.layout", &json!({"layout": "cycle"})).is_err());
|
||
}
|
||
|
||
/// #497: item, layout and colour edits are one undo step each; promote and demote move an item
|
||
/// with the items under it.
|
||
#[test]
|
||
fn smart_art_edits_undo_one_step_each() {
|
||
let mut s = s();
|
||
run(&mut s, "insert.smartArt", json!({"layout": "process"}));
|
||
let items = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| (i.text.clone(), i.level)).collect::<Vec<_>>();
|
||
let before = items(&s);
|
||
run(&mut s, "smartArt.items", json!({"items": ["One", "Two", "\tTwo a"]}));
|
||
assert_eq!(items(&s), [("One".to_string(), 0), ("Two".to_string(), 0), ("Two a".to_string(), 1)]);
|
||
run(&mut s, "smartArt.demote", json!({"index": 1}));
|
||
assert_eq!(items(&s)[1..], [("Two".to_string(), 1), ("Two a".to_string(), 2)]);
|
||
run(&mut s, "smartArt.promote", json!({"index": 1}));
|
||
run(&mut s, "smartArt.addShape", json!({"after": 0, "text": "New"}));
|
||
assert_eq!(items(&s)[1], ("New".to_string(), 0));
|
||
run(&mut s, "smartArt.colors", json!({"variant": "accent4"}));
|
||
run(&mut s, "smartArt.layout", json!({"layout": "pyramid"}));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap().layout, wordcraft_doc::smart_art::SmartArtLayout::Process);
|
||
for _ in 0..5 {
|
||
run(&mut s, "edit.undo", json!({}));
|
||
}
|
||
assert_eq!(items(&s), before);
|
||
assert!(s.run("smartArt.promote", &json!({"index": 0})).is_err(), "already at the top");
|
||
run(&mut s, "picture.size", json!({"width": 200, "height": 120, "lockAspect": false}));
|
||
}
|
||
|
||
/// #497: hostile SmartArt params are capped or refused, never a panic.
|
||
#[test]
|
||
fn smart_art_hostile_params_are_capped() {
|
||
use wordcraft_doc::smart_art::{MAX_ITEMS, MAX_LEVEL, MAX_TEXT};
|
||
let mut s = s();
|
||
let many: Vec<serde_json::Value> = (0..MAX_ITEMS * 3).map(|i| json!({"text": "x".repeat(10_000), "level": i * 1000})).collect();
|
||
run(&mut s, "insert.smartArt", json!({"layout": "hierarchy", "items": many, "width": 1e30, "height": -5}));
|
||
let g = cmd::smart_art::selected_smart_art(&s).unwrap();
|
||
assert_eq!(g.items.len(), MAX_ITEMS);
|
||
assert!(g.items.iter().all(|i| i.text.chars().count() <= MAX_TEXT && i.level <= MAX_LEVEL));
|
||
for bad in [json!({"items": []}), json!({"items": "x"}), json!({"items": [5]}), json!({"layout": "spiral"}), json!({"layout": 3})] {
|
||
assert!(s.run("insert.smartArt", &bad).is_err(), "{bad}");
|
||
}
|
||
let junk = [json!(null), json!({}), json!({"index": -1, "after": 1e300, "items": [{"level": "deep"}], "variant": [], "layout": {}})];
|
||
for id in [
|
||
"smartArt.items",
|
||
"smartArt.addShape",
|
||
"smartArt.promote",
|
||
"smartArt.demote",
|
||
"smartArt.layout",
|
||
"smartArt.colors",
|
||
"smartArt.reset",
|
||
"smartArt.textPane",
|
||
] {
|
||
for j in &junk {
|
||
let _ = s.run(id, j);
|
||
}
|
||
}
|
||
for _ in 0..MAX_ITEMS {
|
||
let _ = s.run("smartArt.addShape", &json!({}));
|
||
}
|
||
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap().items.len(), MAX_ITEMS);
|
||
let _ = s.layout();
|
||
}
|
||
|
||
/// Outline view of: Heading 1 "One", body "a", Heading 2 "Two", body "b", Heading 1 "Three", body "c".
|
||
fn outline_doc() -> Session {
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "One\na\nTwo\nb\nThree\nc"}));
|
||
for (i, style) in [(0, "Heading1"), (2, "Heading2"), (4, "Heading1")] {
|
||
s.sel = crate::Selection::caret(Pos::body(i, 0));
|
||
run(&mut s, "para.style", json!({"style": style}));
|
||
}
|
||
run(&mut s, "view.outline", json!({}));
|
||
s
|
||
}
|
||
|
||
fn outline_shown(s: &mut Session) -> Vec<u64> {
|
||
let st = run(s, "outline.state", json!({}));
|
||
st["shown"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
||
}
|
||
|
||
/// The body blocks the outline layout draws.
|
||
fn laid_out_blocks(s: &mut Session) -> Vec<u32> {
|
||
let l = s.layout();
|
||
let mut v: Vec<u32> = l.pages[0]
|
||
.items
|
||
.iter()
|
||
.filter_map(|it| match it {
|
||
wordcraft_layout::Placed::Lines { story: StoryRef::Body, path, .. } => path.0.first().copied(),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
v.dedup();
|
||
v
|
||
}
|
||
|
||
#[test]
|
||
fn outline_level_changes_are_undoable() {
|
||
let mut s = outline_doc();
|
||
let level = |s: &mut Session, i: usize| run(s, "outline.state", json!({}))["levels"][i].as_u64().unwrap();
|
||
s.sel = crate::Selection::caret(Pos::body(1, 0));
|
||
run(&mut s, "outline.level", json!({"level": 3}));
|
||
assert_eq!(level(&mut s, 1), 3);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(level(&mut s, 1), 10, "body text again");
|
||
s.sel = crate::Selection::caret(Pos::body(2, 0));
|
||
run(&mut s, "outline.promoteToHeading1", json!({}));
|
||
assert_eq!(level(&mut s, 2), 1);
|
||
run(&mut s, "outline.demoteToBody", json!({}));
|
||
assert_eq!(level(&mut s, 2), 10);
|
||
assert!(s.run("outline.level", &json!({"level": 12})).is_err());
|
||
assert!(s.run("outline.level", &json!({})).is_err());
|
||
// Every outline command survives junk parameters in Outline view.
|
||
for spec in cmd::registry().all().iter().filter(|c| c.id.starts_with("outline.")) {
|
||
for j in [json!(null), json!({"block": 99999, "level": "x", "value": 5}), json!({"block": -1, "level": 0})] {
|
||
let mut s = outline_doc();
|
||
let _ = s.run(spec.id, &j);
|
||
let _ = s.layout();
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn outline_show_level_filters_paragraphs() {
|
||
let mut s = outline_doc();
|
||
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]);
|
||
assert_eq!(laid_out_blocks(&mut s), vec![0, 1, 2, 3, 4, 5]);
|
||
run(&mut s, "outline.showLevel", json!({"level": 1}));
|
||
assert_eq!(outline_shown(&mut s), vec![0, 4]);
|
||
assert_eq!(laid_out_blocks(&mut s), vec![0, 4]);
|
||
run(&mut s, "outline.showLevel2", json!({}));
|
||
assert_eq!(outline_shown(&mut s), vec![0, 2, 4]);
|
||
run(&mut s, "outline.showAll", json!({}));
|
||
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]);
|
||
// Word's keys, and only in Outline view.
|
||
let reg = cmd::registry();
|
||
assert_eq!(reg.by_shortcut("Alt+Shift+1").map(|c| c.id), Some("outline.showLevel1"));
|
||
assert_eq!(reg.by_shortcut("Alt+Shift+A").map(|c| c.id), Some("outline.showAll"));
|
||
assert_eq!(reg.by_shortcut("Alt+Shift+=").map(|c| c.id), Some("outline.expand"));
|
||
assert_eq!(reg.by_shortcut("Alt+Shift+-").map(|c| c.id), Some("outline.collapse"));
|
||
run(&mut s, "outline.close", json!({}));
|
||
assert!(s.run("outline.showLevel1", &json!({})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn outline_collapse_hides_descendants_and_expand_restores() {
|
||
let mut s = outline_doc();
|
||
s.sel = crate::Selection::caret(Pos::body(0, 0));
|
||
// The deepest open level closes first, then the heading itself.
|
||
run(&mut s, "outline.collapse", json!({}));
|
||
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 4, 5]);
|
||
run(&mut s, "outline.collapse", json!({}));
|
||
assert_eq!(outline_shown(&mut s), vec![0, 4, 5]);
|
||
assert_eq!(laid_out_blocks(&mut s), vec![0, 4, 5]);
|
||
// A caret moving into the collapsed text goes on to the next shown paragraph.
|
||
run(&mut s, "caret.end", json!({}));
|
||
run(&mut s, "caret.right", json!({}));
|
||
assert_eq!(s.sel.focus, Pos::body(4, 0));
|
||
// Moving "Three" and "c" up passes "One" with everything collapsed under it, in one undo step.
|
||
s.sel = crate::Selection { anchor: Pos::body(4, 0), focus: Pos::body(5, 1) };
|
||
run(&mut s, "outline.moveUp", json!({}));
|
||
assert_eq!(text(&s), "Three\nc\nOne\na\nTwo\nb");
|
||
assert_eq!(outline_shown(&mut s), vec![0, 1, 2], "the collapsed heading moved and stays collapsed");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(text(&s), "One\na\nTwo\nb\nThree\nc");
|
||
assert_eq!(outline_shown(&mut s), vec![0, 4, 5]);
|
||
// Expand opens one level at a time; double-clicking the symbol (toggle) opens it all.
|
||
s.sel = crate::Selection::caret(Pos::body(0, 0));
|
||
run(&mut s, "outline.expand", json!({}));
|
||
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 4, 5]);
|
||
run(&mut s, "outline.expand", json!({}));
|
||
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]);
|
||
run(&mut s, "outline.toggle", json!({"block": 0}));
|
||
assert_eq!(outline_shown(&mut s), vec![0, 4, 5]);
|
||
run(&mut s, "outline.toggle", json!({"block": 0}));
|
||
assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]);
|
||
// Clicking a symbol selects the heading with what is under it.
|
||
run(&mut s, "outline.selectSubtree", json!({"block": 0}));
|
||
assert_eq!(s.selected_text(), "One\na\nTwo\nb");
|
||
}
|
||
|
||
#[test]
|
||
fn format_font_sets_the_asian_and_latin_fonts_separately() {
|
||
// #505: Word's Font dialog sets an Asian text font and a Latin text font of their own.
|
||
let mut s = s();
|
||
run(&mut s, "document.setText", json!({"text": "Hello 你好"}));
|
||
run(&mut s, "select.all", json!({}));
|
||
let fonts = |s: &Session| {
|
||
let c = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0).clone();
|
||
(c.font, c.font_ea)
|
||
};
|
||
let before = fonts(&s);
|
||
run(&mut s, "format.font", json!({"name": "Georgia", "eastAsia": "SimSun"}));
|
||
assert_eq!(fonts(&s), (Some("Georgia".into()), Some("SimSun".into())));
|
||
assert_eq!(run(&mut s, "format.state", json!({}))["fontEastAsia"], "SimSun");
|
||
// The Asian font alone leaves the Latin font as it is.
|
||
run(&mut s, "format.font", json!({"eastAsia": "Noto Sans CJK SC"}));
|
||
assert_eq!(fonts(&s), (Some("Georgia".into()), Some("Noto Sans CJK SC".into())));
|
||
// Each change is one undo step.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(fonts(&s), (Some("Georgia".into()), Some("SimSun".into())));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(fonts(&s), before);
|
||
assert!(s.run("format.font", &json!({})).is_err() && s.run("format.font", &json!({"eastAsia": " "})).is_err());
|
||
}
|