use serde_json::json; use wordcraft_doc::props::BorderStyle; use wordcraft_doc::{Pos, StoryRef}; use crate::{Session, cmd}; fn s() -> Session { Session::new(wordcraft_doc::Document::new()) } fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value { s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) } fn text(s: &Session) -> String { s.doc.plain_text(StoryRef::Body) } #[test] fn doc_extension_dispatches_to_docbin() { // Garbage bytes with a .doc name must produce an error through the Word 97-2003 // reader — never a panic and never a silently empty document. for name in ["x.doc", "x.dot"] { assert!(crate::io::open_bytes(name, &[0u8; 64]).is_err(), "{name} should fail"); } } #[test] fn txt_open_decodes_windows_1252_and_unmarked_utf16() { // #433: the TXT open path sniffs encodings like the formats importer. let open = |b: &[u8]| crate::io::open_bytes("x.txt", b).unwrap().plain_text(StoryRef::Body); assert_eq!(open(b"\x80"), "€"); assert_eq!(open(b"A\0B\0"), "AB"); assert_eq!(open(b"\0A\0B"), "AB"); assert_eq!(open("€".as_bytes()), "€"); assert_eq!(open(b"\xFF\xFEA\0B\0"), "AB"); assert_eq!(open(b"A\0B"), "AB", "odd byte count stays safe"); assert_eq!(crate::io::decode_text(b"caf\xE9 \x80"), "café €"); } #[test] fn txt_save_writes_list_labels() { // #434: list paragraphs keep their bullet or number and a tab in plain text. let doc = crate::io::open_bytes("l.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap(); assert_eq!(crate::io::save_bytes("l.txt", &doc).unwrap(), "•\talpha\r\n•\tbeta\r\n1.\tfirst\r\n2.\tsecond".as_bytes()); let plain = crate::io::open_bytes("p.md", b"alpha\n\nbeta\n").unwrap(); assert_eq!(crate::io::save_bytes("p.txt", &plain).unwrap(), b"alpha\r\nbeta"); } #[test] fn every_command_has_unique_id() { let reg = cmd::registry(); let mut ids: Vec<&str> = reg.all().iter().map(|c| c.id).collect(); let n = ids.len(); ids.sort(); ids.dedup(); assert_eq!(ids.len(), n, "duplicate command ids"); assert!(n > 150, "{n} commands"); } #[test] fn every_shortcut_runs_one_command() { // `by_shortcut` takes the first match, so a shared key silently shadows the later command (#186). let reg = cmd::registry(); let mut seen = std::collections::HashMap::new(); for c in reg.all() { for k in c.shortcut.split(" / ").filter(|k| !k.is_empty()) { if let Some(other) = seen.insert(k.to_ascii_lowercase(), c.id) { panic!("{k} is bound to both {other} and {}", c.id); } } } assert_eq!(reg.by_shortcut("Shift+F3").map(|c| c.id), Some("format.changeCase")); } #[test] fn word_default_shortcuts_resolve() { // #330: Word's documented default shortcuts reach the matching commands, with Word for // Mac's keys where macOS takes Word's (⌘Q quits, ⌘Space is Spotlight, ⌘⇧G is Find Previous). let reg = cmd::registry(); let id = |k: &str| reg.by_shortcut(k).map(|c| c.id); assert_eq!(id("Mod+Alt+1"), Some("para.heading1")); assert_eq!(id("Mod+Alt+P"), Some("view.printLayout")); assert_eq!(id("Mod+Alt+O"), Some("view.outline")); assert_eq!(id("Mod+Alt+N"), Some("view.draft")); assert_eq!(id("Mod+Alt+S"), Some("view.split")); assert_eq!(id("Mod+Alt+-"), Some("text.emDash")); assert_eq!(id("Mod+Shift+F5"), Some("insert.bookmark")); assert_eq!(id("Alt+F7"), Some("review.spelling")); assert_eq!(id("Shift+F12"), Some("file.save")); assert_eq!(id(cmd::mac_or("Ctrl+Q", "Mod+Q")), Some("para.reset")); assert_eq!(id(cmd::mac_or("Ctrl+Space", "Mod+Space")), Some("format.resetChar")); assert_eq!(id(cmd::mac_or("Ctrl+Shift+D", "Alt+Shift+D")), Some("insert.dateField")); if cfg!(target_os = "macos") { assert_eq!(id("Mod+Shift+G"), Some("edit.findPrevious")); } else { assert_eq!(id("Mod+Shift+G"), Some("review.wordCount")); assert_eq!(id("Mod+Alt+C"), Some("text.copyright")); assert_eq!(id("Mod+Alt+H"), Some("format.highlight")); } assert_eq!(id("Mod+PageUp"), Some("edit.findPrevious")); } #[test] fn reset_shortcuts_remove_direct_formatting_but_keep_styles() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Hello world"})); run(&mut s, "para.heading1", json!({})); run(&mut s, "para.indent", json!({})); run(&mut s, "para.alignCenter", json!({})); run(&mut s, "para.reset", json!({})); let props = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.clone(); assert_eq!(props.style.as_deref(), Some("Heading1"), "Ctrl+Q keeps the paragraph style"); assert_eq!((props.indent_left, props.align), (None, None), "Ctrl+Q drops direct paragraph formatting"); run(&mut s, "select.all", json!({})); run(&mut s, "format.bold", json!({})); run(&mut s, "format.resetChar", json!({})); let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); assert_eq!(p.props_of_char(1).bold, None, "Ctrl+Space drops direct character formatting"); assert_eq!(p.props.style.as_deref(), Some("Heading1"), "Ctrl+Space leaves the paragraph alone"); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "text.emDash", json!({})); assert_eq!(text(&s), "Hello world—"); } #[test] fn typing_enter_undo() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Hello"})); run(&mut s, "text.insert", json!({"text": " world"})); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.insert", json!({"text": "Second"})); assert_eq!(text(&s), "Hello world\nSecond"); run(&mut s, "edit.undo", json!({})); assert_eq!(text(&s), "Hello world\n"); run(&mut s, "edit.undo", json!({})); assert_eq!(text(&s), "Hello world"); run(&mut s, "edit.undo", json!({})); assert_eq!(text(&s), ""); run(&mut s, "edit.redo", json!({})); assert_eq!(text(&s), "Hello world"); } #[test] fn joined_commands_are_one_undo_step() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Hello"})); run(&mut s, "para.indents", json!({"left": 9.0})); run(&mut s, "para.indents", json!({"left": 18.0})); for x in [27.0, 36.0] { s.join_next_undo(); run(&mut s, "para.indents", json!({"left": x})); } let indent = |s: &Session| s.doc.para_at(&s.sel.focus).and_then(|p| p.props.indent_left); assert_eq!(indent(&s), Some(36.0)); // One undo reverts the whole second drag, not just its last frame. run(&mut s, "edit.undo", json!({})); assert_eq!(indent(&s), Some(9.0)); run(&mut s, "edit.undo", json!({})); assert_eq!(indent(&s), None); assert_eq!(text(&s), "Hello"); run(&mut s, "edit.redo", json!({})); run(&mut s, "edit.redo", json!({})); assert_eq!(indent(&s), Some(36.0)); } #[test] fn restore_takes_a_command_back_exactly() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "alpha beta"})); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.insert", json!({"text": "gamma"})); run(&mut s, "edit.undo", json!({})); assert!(s.can_redo()); let (doc, sel, labels, depth, rev) = (s.doc.clone(), s.sel.clone(), s.undo_labels(), s.undo_depth(), s.rev()); s.dirty = false; let snap = s.edit_snapshot(); assert_eq!(snap.doc(), &doc); assert_eq!(snap.undo_depth(), depth); assert!(!snap.dirty()); run(&mut s, "select.all", json!({})); run(&mut s, "format.bold", json!({})); assert_ne!(s.doc, doc); assert_eq!(s.undo_depth(), depth + 1); assert!(!s.can_redo(), "a new command clears redo"); s.restore(snap); assert_eq!(s.doc, doc); assert_eq!(s.sel, sel); assert_eq!(s.undo_labels(), labels, "the command's undo step is gone"); assert!(s.can_redo(), "redo is back"); assert!(!s.dirty); assert!(s.rev() > rev, "the layout is recomputed"); run(&mut s, "edit.redo", json!({})); assert_eq!(text(&s), "alpha beta\ngamma"); } #[test] fn restore_is_exact_at_the_undo_limit() { // 510 steps, each with its own indent: the stack holds the newest 500. let build = || { let mut s = s(); run(&mut s, "text.insert", json!({"text": "alpha"})); for i in 0..510 { run(&mut s, "para.indents", json!({"left": f64::from(i)})); } s }; // What undo walks through: (label, indent) for every step, newest first. let walk = |s: &mut Session| { let mut steps = Vec::new(); while let Some(label) = s.undo_label().map(str::to_string) { s.undo(); steps.push((label, s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.indent_left))); } steps }; let expected = walk(&mut build()); assert_eq!(expected.len(), 500); // One command, and several (each pushes the oldest step out of the full stack). for n in [1, 2, 4] { let mut s = build(); let snap = s.edit_snapshot(); for _ in 0..n { run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); } assert_eq!(s.undo_label(), Some("Table")); assert_eq!(s.undo_depth(), 500); s.restore(snap); assert_eq!(walk(&mut s), expected, "{n} command(s): the oldest steps are back, the commands' steps are gone"); } // A command that fails at the limit puts its eviction back with the stacks, so a later // restore doesn't bring back a step that never left. let mut s = build(); let snap = s.edit_snapshot(); assert!(s.run("para.align", &json!({"value": "bogus"})).is_err()); run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); s.restore(snap); assert_eq!(walk(&mut s), expected, "a failed command between snapshot and restore"); // An automatic change recorded after the fact (`push_undo`) at the limit counts its eviction. let mut s = build(); let snap = s.edit_snapshot(); let (doc, sel) = (s.doc.clone(), s.sel.clone()); s.push_undo("AutoCorrect", doc, sel); s.restore(snap); assert_eq!(walk(&mut s), expected, "push_undo between snapshot and restore"); } #[test] fn restore_keeps_the_open_undo_step() { // Typing in progress: text typed after the restore joins the same undo step. let mut s = s(); run(&mut s, "text.insert", json!({"text": "alpha"})); let depth = s.undo_depth(); let snap = s.edit_snapshot(); run(&mut s, "para.alignCenter", json!({})); s.restore(snap); run(&mut s, "text.insert", json!({"text": " beta"})); assert_eq!(s.undo_depth(), depth, "still one typing step"); // A drag in progress (`join_next_undo`): its next frame joins the same undo step. run(&mut s, "para.indents", json!({"left": 9.0})); s.join_next_undo(); let depth = s.undo_depth(); let snap = s.edit_snapshot(); run(&mut s, "para.indents", json!({"left": 18.0})); s.restore(snap); run(&mut s, "para.indents", json!({"left": 27.0})); assert_eq!(s.undo_depth(), depth, "still one drag step"); run(&mut s, "edit.undo", json!({})); assert_eq!(s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.indent_left), None); } #[test] fn failed_command_keeps_undo_and_redo() { // A command that fails after its undo checkpoint leaves the history exactly as it was: // the redo stack survives, and no empty undo step is added. for (id, params) in [("text.insert", json!({})), ("para.align", json!({"value": "bogus"}))] { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Hello"})); run(&mut s, "edit.undo", json!({})); assert!(s.can_redo()); assert!(s.run(id, ¶ms).is_err(), "{id} should fail"); assert!(s.can_redo(), "{id}: redo lost"); assert!(!s.can_undo(), "{id}: failed command left an undo step"); run(&mut s, "edit.redo", json!({})); assert_eq!(text(&s), "Hello", "{id}"); } // At the undo limit, the oldest step is not evicted by a command that fails. let mut s = s(); let steps = 600; for i in 1..=steps { run(&mut s, "para.indents", json!({"left": i as f32})); } let kept = s.undo_labels().len(); assert!(kept < steps, "the history should be at its limit"); assert!(s.run("para.align", &json!({"value": "bogus"})).is_err()); assert_eq!(s.undo_labels().len(), kept); while s.can_undo() { run(&mut s, "edit.undo", json!({})); } let indent = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.indent_left); assert_eq!(indent, Some((steps - kept) as f32)); } #[test] fn failed_command_restores_history_changed_by_nested_commands() { // `file.inspect` runs `review.deleteComment` (allowed in a comments-only document, and it // checkpoints), then `review.acceptAll` (refused). The refusal must leave the undo and // redo stacks exactly as they were, including at and next to the history limit, where the // outer and nested checkpoints each evict the oldest step. fn timeline(s: &mut Session) -> Vec<(Vec, wordcraft_doc::Document)> { let mut out = Vec::new(); // Straight through the history (the commands won't undo past Restrict Editing). while s.can_redo() { s.redo(); } loop { // Comments are stamped with the time to the second, and the two sessions compared // are built a moment apart, so leave the stamp out of the comparison. let mut doc = s.doc.clone(); doc.comments.values_mut().for_each(|c| c.date.clear()); out.push((s.undo_labels(), doc)); if !s.can_undo() { return out; } s.undo(); } } let kept = { let mut s = s(); for i in 1..=600 { run(&mut s, "para.indents", json!({"left": i as f32})); } s.undo_labels().len() }; // Below the limit there is room for a redo step too; at the limit an Undo would leave one free. for (len, redo) in [(kept - 1, Some("New Comment")), (kept, None)] { let session = || { let mut s = s(); run(&mut s, "text.insert", json!({"text": "note"})); for i in 1..=len + usize::from(redo.is_some()) - 4 { run(&mut s, "para.indents", json!({"left": i as f32})); } run(&mut s, "review.restrict", json!({"mode": "comments"})); run(&mut s, "review.newComment", json!({"text": "one"})); run(&mut s, "review.newComment", json!({"text": "two"})); if redo.is_some() { run(&mut s, "edit.undo", json!({})); } assert_eq!(s.undo_labels().len(), len); assert_eq!(s.redo_label(), redo); s }; let mut failed = session(); let err = failed.run("file.inspect", &json!({"remove": ["comments", "revisions"]})); assert!(matches!(err, Err(crate::CmdError::Disabled(_))), "{len}: {err:?}"); assert_eq!(failed.undo_labels().len(), len, "{len}: undo steps"); assert_eq!(failed.redo_label(), redo, "{len}: redo step"); assert!(timeline(&mut failed) == timeline(&mut session()), "{len}: undo history changed"); } } #[test] fn backspace_and_delete() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "abc"})); run(&mut s, "text.backspace", json!({})); assert_eq!(text(&s), "ab"); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.insert", json!({"text": "cd"})); run(&mut s, "caret.home", json!({})); run(&mut s, "text.backspace", json!({})); // Enter capitalised the first word, as Word's AutoCorrect does. assert_eq!(text(&s), "Abcd"); run(&mut s, "caret.docStart", json!({})); run(&mut s, "text.delete", json!({})); assert_eq!(text(&s), "bcd"); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "text.deleteWordBack", json!({})); assert_eq!(text(&s), ""); } #[test] fn bold_toggles_selection_and_caret_word() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "make this bold"})); run(&mut s, "select.text", json!({"text": "this"})); run(&mut s, "format.bold", json!({})); let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); assert_eq!(p.props_of_char(5).bold, Some(true)); assert_eq!(p.props_of_char(0).bold, None); run(&mut s, "format.bold", json!({})); assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).bold, Some(false)); // Caret inside a word formats the word. run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [0], "off": 11}})); run(&mut s, "format.italic", json!({})); let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); assert_eq!(p.props_of_char(10).italic, Some(true)); assert_eq!(p.props_of_char(13).italic, Some(true)); assert_eq!(p.props_of_char(9).italic, None); } #[test] fn border_toggles_and_saves() { use wordcraft_doc::props::Border; let border = |s: &Session| s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).border; // Saved .docx carries the border iff it is on (read back: the reader maps only `w:bdr` to `border`). let docx_has_bdr = |s: &Session| { let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); back.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).border.is_some() }; let mut s = s(); run(&mut s, "text.insert", json!({"text": "make this boxed"})); run(&mut s, "select.text", json!({"text": "this"})); run(&mut s, "format.border", json!({})); assert_eq!(border(&s), Some(Border::single(0.5))); assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).border, None); assert!(docx_has_bdr(&s)); run(&mut s, "format.border", json!({})); assert_eq!(border(&s), None); run(&mut s, "format.border", json!({"value": true})); run(&mut s, "format.border", json!({"value": true})); assert_eq!(border(&s), Some(Border::single(0.5))); run(&mut s, "format.border", json!({"value": false})); assert_eq!(border(&s), None); assert!(!docx_has_bdr(&s)); } #[test] fn pending_format_applies_to_typing() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "a "})); run(&mut s, "format.bold", json!({})); run(&mut s, "text.insert", json!({"text": "b"})); let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); assert_eq!(p.props_of_char(2).bold, Some(true)); assert_eq!(p.props_of_char(0).bold, None); } #[test] fn styles_and_lists() { let mut s = s(); run(&mut s, "text.insert", json!({"text": "Title"})); run(&mut s, "para.heading1", json!({})); run(&mut s, "text.newParagraph", json!({})); // Heading's next style is Normal. assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props.style.as_deref(), Some("Normal")); run(&mut s, "para.bullets", json!({})); run(&mut s, "text.insert", json!({"text": "one"})); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.insert", json!({"text": "two"})); let n1 = s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.unwrap(); let n2 = s.doc.para_at(&Pos::body(2, 0)).unwrap().props.numbering.unwrap(); assert_eq!(n1.num, n2.num); run(&mut s, "text.newParagraph", json!({})); run(&mut s, "text.newParagraph", json!({})); // empty item ends the list assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_none_or(|n| n.num == 0)); // "1. " autoformat. run(&mut s, "text.insert", json!({"text": "1."})); run(&mut s, "text.insert", json!({"text": " "})); assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_some_and(|n| n.num != 0)); } #[test] fn applying_a_heading_clears_list_numbering() { // Applying a heading (or Title/Normal) to a numbered paragraph removes its direct list // numbering. A dead `&& id == "Normal"` term used to let only Normal clear it, so headings // kept the numbering. for style_cmd in ["para.heading1", "para.heading2", "para.normal"] { let mut s = s(); run(&mut s, "text.insert", json!({"text": "item"})); run(&mut s, "para.bullets", json!({})); assert!( s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0), "{style_cmd}: expected list numbering before applying the style" ); run(&mut s, style_cmd, json!({})); assert!( s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_none_or(|n| n.num == 0), "{style_cmd}: should clear direct list numbering" ); } } #[test] fn find_replace() { let mut s = s(); run(&mut s, "document.setText", json!({"text": "cat dog cat\nCat bird"})); let r = run(&mut s, "edit.find", json!({"text": "cat"})); assert_eq!(r["count"], 3); let r = run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": "fox", "matchCase": true})); assert_eq!(r["replaced"], 2); assert_eq!(text(&s), "fox dog fox\nCat bird"); let r = run(&mut s, "edit.find", json!({"text": "\\b\\w{3}\\b", "regex": true, "matchCase": false})); assert_eq!(r["count"], 4); } #[test] fn find_replace_special_codes() { // ^p finds paragraph marks across paragraphs; Replace All with ^p splits paragraphs and is one undo step. let mut s = s(); run(&mut s, "document.setText", json!({"text": "one\ntwo\nthree"})); let paras = |s: &Session| s.doc.para_paths(StoryRef::Body).len(); assert_eq!(run(&mut s, "edit.find", json!({"text": "^p"}))["count"], 2); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "o^pt", "with": "O+T"}))["replaced"], 1); assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwO+Three", 2)); run(&mut s, "edit.undo", json!({})); assert_eq!((text(&s).as_str(), paras(&s)), ("one\ntwo\nthree", 3)); run(&mut s, "document.setText", json!({"text": "a, b, c"})); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": ", ", "with": "^p"}))["replaced"], 2); assert_eq!((text(&s).as_str(), paras(&s)), ("a\nb\nc", 3)); run(&mut s, "edit.undo", json!({})); assert_eq!((text(&s).as_str(), paras(&s)), ("a, b, c", 1)); // ^t, ^# and ^& (the found text), ^^ is a caret. run(&mut s, "document.setText", json!({"text": "x\ty 12 a^b"})); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^t", "with": " "}))["replaced"], 1); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^#", "with": "<^&>"}))["replaced"], 2); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "^^", "with": "-"}))["replaced"], 1); assert_eq!(text(&s), "x y <1><2> a-b"); // `special: false` reads the text literally. assert_eq!(run(&mut s, "edit.find", json!({"text": "^#", "special": false}))["count"], 0); } #[test] fn find_replace_wildcards() { let mut s = s(); // Groups and back-references swap words; Use wildcards matches case. run(&mut s, "document.setText", json!({"text": "Smith, John and Doe, Jane; smith, x"})); let r = run(&mut s, "edit.replaceAll", json!({"text": "([A-Z][a-z]@), ([A-Z][a-z]@)", "with": "\\2 \\1", "wildcards": true})); assert_eq!(r["replaced"], 2); assert_eq!(text(&s), "John Smith and Jane Doe; smith, x"); // < and > anchor to word starts and ends; * is the shortest run; {n} counts. run(&mut s, "document.setText", json!({"text": "cat concat cats scatter"})); assert_eq!(run(&mut s, "edit.find", json!({"text": "", "wildcards": true}))["count"], 2); assert_eq!(run(&mut s, "edit.find", json!({"text": "", "wildcards": true}))["count"], 2); assert_eq!(run(&mut s, "edit.find", json!({"text": "t{2}", "wildcards": true}))["count"], 1); assert_eq!(run(&mut s, "edit.find", json!({"text": "[!c]at", "wildcards": true}))["count"], 0); assert_eq!(run(&mut s, "edit.find", json!({"text": "[!s ]cat", "wildcards": true}))["count"], 1); // Options of the normal search: prefix, suffix, ignore punctuation. assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "wildcards": false, "matchPrefix": true}))["count"], 2); assert_eq!(run(&mut s, "edit.find", json!({"text": "cat", "matchPrefix": false, "matchSuffix": true}))["count"], 2); run(&mut s, "document.setText", json!({"text": "e.g. eg"})); assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2); } #[test] fn hostile_find_patterns_are_bounded() { let mut s = s(); run(&mut s, "document.setText", json!({"text": &"a".repeat(5000)})); // Patterns that backtrack catastrophically elsewhere finish quickly (linear-time engine). let t = std::time::Instant::now(); for q in ["(a@)@b", "(*)@b", "(a{1,1000}){1,1000}", "(((((a*)*)*)*)*)b"] { let _ = s.run("edit.find", &json!({"text": q, "wildcards": true})); } assert!(t.elapsed().as_secs() < 20); // Bad and oversized patterns are errors, not crashes. for q in ["[z-a]", "a{5", "(ab", "\\1", "x{2000}", "[", "@"] { assert!(s.run("edit.find", &json!({"text": q, "wildcards": true})).is_err(), "{q}"); } assert!(s.run("edit.find", &json!({"text": "x".repeat(2000), "wildcards": false})).is_err()); for q in ["^", "^p^", "^u", "^99999999", "^0", "a^"] { let _ = s.run("edit.replaceAll", &json!({"text": q, "with": "^&^c\\9^p^", "wildcards": false})); } } #[test] fn replace_under_track_changes_skips_deleted_text() { // Replace marks the match deleted and leaves it in the text; the next // step must not find it again, or Replace never advances. let mut s = s(); run(&mut s, "document.setText", json!({"text": "one cat two cat"})); run(&mut s, "review.trackChanges", json!({"value": true})); run(&mut s, "caret.docStart", json!({})); assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 2); let mut remaining = Vec::new(); for _ in 0..4 { remaining.push(run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"}))["remaining"].clone()); } assert_eq!(remaining, [json!(1), json!(0), json!(0), json!(0)]); assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 0); run(&mut s, "review.acceptAll", json!({})); assert_eq!(text(&s), "one dog two dog"); // A match that runs across tracked-deleted text is not in the document. let mut s = self::s(); run(&mut s, "document.setText", json!({"text": "cat"})); run(&mut s, "review.trackChanges", json!({"value": true})); run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 1}, "focus": {"block": 0, "off": 2}})); run(&mut s, "text.delete", json!({})); assert_eq!(text(&s), "cat", "the deletion is tracked, not applied"); assert_eq!(run(&mut s, "edit.find", json!({"text": "cat"}))["count"], 0); assert_eq!(run(&mut s, "edit.find", json!({"text": "t"}))["count"], 1); // Without Track Changes, Replace steps through every match as before. let mut s = self::s(); run(&mut s, "document.setText", json!({"text": "one cat two cat"})); run(&mut s, "caret.docStart", json!({})); run(&mut s, "edit.find", json!({"text": "cat"})); run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"})); let r = run(&mut s, "edit.replace", json!({"text": "cat", "with": "dog"})); assert_eq!(r["remaining"], 0); assert_eq!(text(&s), "one dog two dog"); } #[test] fn find_reads_the_text_around_tracked_deletions() { // `edits` are (start, end) byte ranges deleted with Track Changes on, last first. fn tracked(text: &str, edits: &[(usize, usize)]) -> Session { let mut s = self::s(); run(&mut s, "document.setText", json!({"text": text})); run(&mut s, "review.trackChanges", json!({"value": true})); for (a, b) in edits { run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": a}, "focus": {"block": 0, "off": b}})); run(&mut s, "text.delete", json!({})); } assert_eq!(text, self::text(&s), "the deletions are tracked, not applied"); s } let offs = |r: &serde_json::Value| -> Vec<(u64, u64)> { r["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect() }; // "aaa" with the first "a" deleted reads "aa": the rejected raw match 0..2 must not hide // the live one at 1..3. let mut s = tracked("aaa", &[(0, 1)]); assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "aa"}))), [(1, 3)]); assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "^a", "regex": true}))), [(1, 2)]); assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "aa", "with": "b"}))["replaced"], 1); // Whole words follow the live text: deleting the space joins "cat" and "fish"... let mut s = tracked("cat fish", &[(3, 4)]); assert_eq!(run(&mut s, "edit.find", json!({"text": "fish", "wholeWord": true}))["count"], 0); assert_eq!(run(&mut s, "edit.find", json!({"text": "catfish", "wholeWord": true}))["count"], 0, "a match may not span a deletion"); assert_eq!(run(&mut s, "edit.find", json!({"text": "fish", "wholeWord": false}))["count"], 1); // ...and deleting the "X" leaves "fish" a word of its own. let mut s = tracked("cat Xfish", &[(4, 5)]); assert_eq!(offs(&run(&mut s, "edit.find", json!({"text": "FISH", "wholeWord": true}))), [(5, 9)]); assert_eq!(run(&mut s, "edit.find", json!({"text": "FISH", "wholeWord": true, "matchCase": true}))["count"], 0); // Replace and the Find Next/Previous steps agree with Find. let mut s = tracked("aaa aaa", &[(4, 5), (0, 1)]); run(&mut s, "caret.docStart", json!({})); assert_eq!(run(&mut s, "edit.find", json!({"text": "aa"}))["count"], 2); assert_eq!(run(&mut s, "edit.findNext", json!({}))["index"], 1); assert_eq!(run(&mut s, "edit.findPrevious", json!({}))["index"], 0); let r = run(&mut s, "edit.replace", json!({"text": "aa", "with": "b"})); assert_eq!(r["remaining"], 1); } #[test] fn find_next_with_options_visits_every_match() { // The Find dialog sends its options with each Find Next (#468): every click is one step. let opts = json!({"text": "cat", "with": "", "matchCase": false, "wholeWord": false, "regex": false}); let starts = |text: &str, clicks: usize, cmd: &str| { let mut s = s(); run(&mut s, "document.setText", json!({"text": text})); run(&mut s, "caret.docStart", json!({})); (0..clicks).map(|_| run(&mut s, cmd, opts.clone())["index"].as_u64().unwrap()).collect::>() }; assert_eq!(starts("cat cat cat cat", 5, "edit.findNext"), [0, 1, 2, 3, 0]); assert_eq!(starts("cat cat", 3, "edit.findNext"), [0, 1, 0]); assert_eq!(starts("cat cat cat", 4, "edit.findPrevious"), [2, 1, 0, 2]); // A new query takes effect at once, without a separate `edit.find`. let mut s = s(); run(&mut s, "document.setText", json!({"text": "cat dog cat dog"})); run(&mut s, "caret.docStart", json!({})); assert_eq!(run(&mut s, "edit.findNext", opts.clone())["index"], 0); let r = run(&mut s, "edit.findNext", json!({"text": "dog"})); assert_eq!((r["index"].clone(), r["count"].clone()), (json!(0), json!(2))); assert_eq!(s.sel.ordered().0.off, 4); } #[test] fn clipboard_round_trip() { let mut s = s(); run(&mut s, "document.setText", json!({"text": "alpha beta\ngamma"})); run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 1, "off": 2}})); run(&mut s, "edit.copy", json!({})); assert_eq!(s.clipboard_text, "beta\nga"); run(&mut s, "caret.docEnd", json!({})); run(&mut s, "edit.paste", json!({})); assert_eq!(text(&s), "alpha beta\ngammabeta\nga"); run(&mut s, "select.all", json!({})); run(&mut s, "edit.cut", json!({})); assert_eq!(text(&s), ""); run(&mut s, "edit.paste", json!({"text": "plain\ntext"})); assert_eq!(text(&s), "plain\ntext"); } fn bold_at(s: &Session, block: usize, off: usize) -> bool { let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off }; s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false) } #[test] fn paste_special_lists_formats_and_pastes_text_without_formatting() { let mut s = s(); run(&mut s, "document.setText", json!({"text": "Bold words"})); run(&mut s, "select.text", json!({"text": "Bold"})); run(&mut s, "format.bold", json!({})); run(&mut s, "edit.copy", json!({})); // No format: the formats are listed (and a front end is asked to show the dialog). let r = run(&mut s, "edit.pasteSpecial", json!({})); let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); assert_eq!(ids, ["formatted", "text"]); assert_eq!(s.ui_requests.last().unwrap()["open"], "pasteSpecial"); run(&mut s, "caret.docEnd", json!({})); let r = run(&mut s, "edit.pasteSpecial", json!({"as": "text"})); assert_eq!(r["pastedAs"], "text"); assert_eq!(text(&s), "Bold wordsBold"); assert!(bold_at(&s, 0, 0)); assert!(!bold_at(&s, 0, 11), "unformatted text drops the bold"); // Formatted keeps it. run(&mut s, "edit.pasteSpecial", json!({"as": "formatted"})); assert_eq!(text(&s), "Bold wordsBoldBold"); assert!(bold_at(&s, 0, 15)); // Text that someone else copied since: our rich copy is no longer offered. let r = run(&mut s, "edit.pasteSpecial", json!({"text": "elsewhere"})); assert_eq!(r["formats"].as_array().unwrap().len(), 1); assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err()); } #[test] fn paste_special_html_and_rtf_keep_bold() { let mut s = s(); run(&mut s, "document.setText", json!({"text": "x"})); run(&mut s, "caret.docEnd", json!({})); let html = "

plain strong

"; let r = run(&mut s, "edit.pasteSpecial", json!({"html": html, "text": "plain strong"})); let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect(); assert_eq!(ids, ["html", "text"]); run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": html})); assert_eq!(text(&s), "xplain strong"); assert!(!bold_at(&s, 0, 2)); assert!(bold_at(&s, 0, 8), "the run stays bold"); // HTML source copied as text can be pasted as HTML too. run(&mut s, "document.setText", json!({"text": ""})); run(&mut s, "edit.pasteSpecial", json!({"as": "html", "text": "hi there

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

Logo

T

Web

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

Up

Sneak

In

"#; 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#"

Link

"#).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#"

Pipe

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