Files
wordcraft/crates/engine/src/tests_typing.rs
T

3115 lines
149 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Typing behaviour checked side by side against Word: keystrokes go through the same commands
//! as the keyboard (one `text.insert` per character, Enter, Tab, Backspace), and the expected
//! results are what Word produces for the same keys.
use serde_json::json;
use wordcraft_doc::props::{BorderStyle, VertAlign};
use wordcraft_doc::{Pos, StoryRef};
use crate::Session;
/// Create an empty session for command-driven typing fixtures.
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
/// Execute a registry command and report its id on a test failure.
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
}
/// Type like a person: `\n` is Enter, `\t` is Tab, `\u{8}` is Backspace, everything else one
/// character at a time.
fn typ(s: &mut Session, keys: &str) {
for c in keys.chars() {
match c {
'\n' => run(s, "text.newParagraph", json!({})),
'\t' => run(s, "text.tab", json!({})),
'\u{8}' => run(s, "text.backspace", json!({})),
c => run(s, "text.insert", json!({"text": c.to_string()})),
}
}
}
/// Body paragraphs as (text, list level if in a list, in "List Paragraph").
fn paras(s: &Session) -> Vec<(String, Option<u8>, bool)> {
s.doc
.body
.iter()
.filter_map(|b| b.as_para())
.map(|p| (p.text.clone(), p.props.numbering.filter(|n| n.num != 0).map(|n| n.level), p.props.style.as_deref() == Some("ListParagraph")))
.collect()
}
/// Expected paragraph text and list membership for typing assertions.
fn para(text: &str, level: Option<u8>) -> (String, Option<u8>, bool) {
(text.to_string(), level, level.is_some())
}
/// Create a session and enter its fixture through the actual typing commands.
fn typed(keys: &str) -> Session {
let mut s = s();
typ(&mut s, keys);
s
}
#[test]
fn sample_text_formulas_expand_on_enter() {
for name in ["rand", "lorem"] {
for (args, paragraphs, sentences) in [
("", 5, 3),
("2", 2, 3),
(" 2 ", 2, 3),
("2,4", 2, 4),
("2, 4", 2, 4),
(" 2 , 4 ", 2, 4),
("1,1", 1, 1),
("200,1", 200, 1),
("1,200", 1, 200),
] {
let mut s = typed(&format!("={name}({args})"));
let depth = s.undo_depth();
typ(&mut s, "\n");
let generated: Vec<_> = paras(&s).into_iter().map(|p| p.0).collect();
assert_eq!(generated.len(), paragraphs + 1, "{name}({args})");
for text in generated.iter().take(paragraphs) {
assert_eq!(text.matches('.').count(), sentences);
assert!(text.starts_with(if name == "lorem" { "Lorem ipsum" } else { "A blank page" }), "{name}({args}): {text:?}");
}
assert_eq!(generated.last().unwrap(), "");
assert_eq!(s.sel.focus, Pos::body(paragraphs, 0));
assert_eq!(s.undo_depth(), depth + 1);
let expanded = s.doc.clone();
run(&mut s, "edit.undo", json!({}));
assert_eq!(texts(&s), [format!("={name}({args})")]);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc, expanded);
}
}
}
#[test]
fn sample_text_invalid_parameters_use_defaults() {
for name in ["rand", "lorem"] {
for args in ["0", "-1", "201", "abc", "1.5", "99999999999999999999999999999", "2,0", "0,4", "2,201", "2,no", "2,", ",4", "1,2,3"] {
let s = typed(&format!("={name}({args})\n"));
let texts = texts(&s);
assert_eq!(texts.len(), 6, "{name}({args})");
assert!(texts.iter().take(5).all(|text| text.matches('.').count() == 3));
}
}
}
#[test]
fn sample_text_requires_an_exact_paragraph_and_caret_at_end() {
for formula in [" =rand()", "=rand() ", "prefix =rand()", "=lorem() suffix", "=other()", "=rand(", "=rand(())"] {
assert_eq!(texts(&typed(&format!("{formula}\n"))), [formula, ""]);
}
let mut s = typed("=rand()");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\n");
assert_eq!(texts(&s), ["", "=rand()"]);
let mut s = typed("=rand()suffix");
s.sel = crate::Selection { anchor: Pos::body(0, 7), focus: Pos::body(0, 13) };
typ(&mut s, "\n");
assert_eq!(texts(&s), ["=rand()", ""]);
}
#[test]
fn sample_text_preserves_typing_and_paragraph_formatting() {
let mut s = s();
run(&mut s, "format.bold", json!({}));
run(&mut s, "para.alignCenter", json!({}));
typ(&mut s, "=rand(2,1)\n");
for p in s.doc.body.iter().filter_map(|b| b.as_para()).take(2) {
assert_eq!(p.props.align, Some(wordcraft_doc::props::Align::Center));
assert_eq!(p.props_at(0).bold, Some(true));
}
}
#[test]
fn sample_text_replacement_tracks_changes() {
let mut s = typed("=lorem(2,1)");
run(&mut s, "review.trackChanges", json!({}));
typ(&mut s, "\n");
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).any(|r| r.props.ins.is_some()));
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(texts(&s), ["=lorem(2,1)"]);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(texts(&s).len(), 3);
assert!(!s.doc.plain_text(StoryRef::Body).contains("=lorem"));
}
#[test]
fn enter_twice_after_a_list_item_ends_the_list() {
let s = typed("* item\n\ntext");
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("text", None)]);
}
#[test]
fn more_enters_after_a_list_make_blank_paragraphs() {
// The "stuck Enter" bug: after the list ended, further Enters did nothing.
let s = typed("* item\n\n\ntext");
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("text", None)]);
let s = typed("1. item\n\n\n\nafter");
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("", None), para("after", None)]);
}
#[test]
fn the_paragraph_after_a_list_is_normal_text() {
let mut s = typed("* Apple\nBanana\n\nAfter list");
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!(p.props.style, None, "back to Normal, not an indented List Paragraph");
assert_eq!(p.props.numbering, None);
// And the next paragraph too.
typ(&mut s, "\nMore");
assert_eq!(paras(&s).last(), Some(&para("More", None)));
}
#[test]
fn enter_on_an_empty_nested_item_moves_it_up_a_level_first() {
let s = typed("* a\n\tb\n\ntext");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", Some(0))]);
let s = typed("* a\n\tb\n\n\ntext");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]);
let s = typed("* a\n\tb\n\n\n\ntext");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("", None), para("text", None)]);
let s = typed("* a\n\tb\n\tc\n\n\n\ntext");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("C", Some(2)), para("text", None)]);
let s = typed("1. a\n\tb\n\n\ntext");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]);
}
/// #2143: in a Word file whose list comes from the paragraph styles (List Number, List Number 2
/// with `w:numPr` in styles.xml, none on the paragraphs), Enter on an empty nested item moves it
/// up a level and then ends the list, as with direct numbering.
#[test]
fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() {
use std::io::Write;
let w = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
let style = |id: &str, name: &str, ilvl: u8| {
format!(
r#"<w:style w:type="paragraph" w:styleId="{id}"><w:name w:val="{name}"/><w:pPr><w:numPr><w:ilvl w:val="{ilvl}"/><w:numId w:val="1"/></w:numPr></w:pPr></w:style>"#
)
};
let styles = format!(
r#"<w:styles {w}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{}{}</w:styles>"#,
style("ListNumber", "List Number", 0),
style("ListNumber2", "List Number 2", 1)
);
let numbering = format!(
r#"<w:numbering {w}><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:start w:val="1"/><w:numFmt w:val="lowerLetter"/><w:lvlText w:val="%2."/><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
);
let p = |st: &str, text: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
let body: String =
[p("ListNumber", "Lorem"), p("ListNumber", "Ipsum"), p("ListNumber2", "Latin"), p("ListNumber2", "Nonsense"), p("ListNumber2", "")].concat();
let document = format!(r#"<w:document {w}><w:body>{body}</w:body></w:document>"#);
let rel = |id: &str, kind: &str, target: &str| {
format!(r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{kind}" Target="{target}"/>"#)
};
let rels_ns = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#;
let main = format!("<Relationships {rels_ns}>{}</Relationships>", rel("rId1", "officeDocument", "word/document.xml"));
let doc_rels =
format!("<Relationships {rels_ns}>{}{}</Relationships>", rel("rId1", "styles", "styles.xml"), rel("rId2", "numbering", "numbering.xml"));
let ct = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/></Types>"#;
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for (name, data) in [
("[Content_Types].xml", ct),
("_rels/.rels", main.as_str()),
("word/_rels/document.xml.rels", doc_rels.as_str()),
("word/document.xml", document.as_str()),
("word/styles.xml", styles.as_str()),
("word/numbering.xml", numbering.as_str()),
] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
}
let doc = crate::io::open_bytes("t.docx", &z.finish().unwrap().into_inner()).unwrap();
let mut s = Session::new(doc);
// The list level each paragraph shows, wherever its numbering comes from.
let levels = |s: &Session| -> Vec<Option<u8>> {
s.doc
.body
.iter()
.filter_map(|b| b.as_para())
.map(|p| s.doc.styles.resolve_para(&p.props).numbering.filter(|n| n.num != 0).map(|n| n.level))
.collect()
};
assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]);
s.sel = crate::Selection::caret(Pos::body(4, 0));
typ(&mut s, "\n");
assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(0)]);
assert_eq!(s.sel.focus, Pos::body(4, 0));
typ(&mut s, "\n");
assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), None]);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]);
}
#[test]
fn shift_tab_and_tab_change_list_levels() {
let mut s = typed("1. One\nTwo\n\tSub a\nSub b\n");
run(&mut s, "text.backTab", json!({}));
typ(&mut s, "Three\n\nDone");
assert_eq!(
paras(&s),
vec![para("One", Some(0)), para("Two", Some(0)), para("Sub a", Some(1)), para("Sub b", Some(1)), para("Three", Some(0)), para("Done", None)]
);
}
#[test]
fn backspace_at_the_start_of_an_item_removes_the_number_then_the_indent() {
let mut s = typed("* One\nTwo");
run(&mut s, "caret.home", json!({}));
let text_x = |s: &mut Session| s.layout().caret(&Pos::body(1, 0)).map(|c| c.x);
let before = text_x(&mut s);
typ(&mut s, "\u{8}");
// Number gone, still indented with the list: the text stays where it was.
assert_eq!(paras(&s)[1], ("Two".to_string(), None, true));
assert_eq!(text_x(&mut s), before);
typ(&mut s, "\u{8}|");
// Second Backspace: Normal paragraph, not joined to the one above.
assert_eq!(paras(&s), vec![para("One", Some(0)), para("|Two", None)]);
}
#[test]
fn backspace_on_an_empty_item_keeps_the_paragraph() {
let s = typed("* One\n\u{8}Z");
assert_eq!(paras(&s), vec![para("One", Some(0)), ("Z".to_string(), None, true)]);
}
#[test]
fn enter_in_the_middle_or_at_the_start_of_an_item() {
let mut s = typed("* Hello world");
for _ in 0..5 {
run(&mut s, "caret.left", json!({}));
}
typ(&mut s, "\nX");
assert_eq!(paras(&s), vec![para("Hello ", Some(0)), para("Xworld", Some(0))]);
let mut s = typed("* first\nsecond");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\nY");
assert_eq!(paras(&s), vec![para("First", Some(0)), para("", Some(0)), para("Ysecond", Some(0))]);
}
fn label_at(s: &Session, i: usize) -> String {
let n = s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).unwrap();
s.doc.numbering.level(n.num, n.level).map(|l| l.text.clone()).unwrap_or_default()
}
/// #3352 follow-up (CodeRabbit): a heading numbered only through its style's linked Multilevel
/// List (#1793) — `props.numbering` itself unset — isn't papered over by a fresh bullet when "- "
/// is typed at its start; the trigger stays as plain text and the heading's own number is kept.
#[test]
fn list_autoformat_does_not_override_a_style_linked_heading_number() {
let mut s = s();
typ(&mut s, "One");
run(&mut s, "para.heading1", json!({}));
run(&mut s, "list.multilevelPreset", json!({"preset": "headings"}));
typ(&mut s, "\nTwo");
run(&mut s, "para.heading2", json!({}));
assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]);
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering, None, "numbered through the style, not directly");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "- ");
assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")], "the heading number survives");
assert_eq!(paras(&s)[1].0, "- Two", "the trigger stays as typed text, not stripped into a bullet");
}
#[test]
fn list_autoformat_triggers() {
let s = typed("- Item\nNext");
assert_eq!(label_at(&s, 0), "-");
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("Next", Some(0))]);
let s = typed("1) a\nb");
assert_eq!(label_at(&s, 0), "%1)");
let s = typed("a. alpha\nbeta");
assert_eq!(paras(&s), vec![para("Alpha", Some(0)), para("beta", Some(0))]);
assert_eq!(label_at(&s, 0), "%1.");
let n = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.numbering).unwrap();
assert_eq!(s.doc.numbering.level(n.num, 0).map(|l| l.format), Some(wordcraft_doc::section::NumFormat::LowerLetter));
}
/// #3352: typing the trigger at the start of an already-written paragraph (not only a fresh,
/// empty one) still turns it into a list item, keeping the rest of the paragraph's text — as in
/// Word, confirmed in Word for Windows 2026-10-21.
#[test]
fn list_autoformat_triggers_in_front_of_existing_text() {
let mut s = s();
typ(&mut s, "On peut faire mieux");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "- ");
assert_eq!(paras(&s), vec![para("On peut faire mieux", Some(0))]);
assert_eq!(label_at(&s, 0), "-");
// Undo right after takes only the AutoFormat back, as for a fresh paragraph.
run(&mut s, "edit.undo", json!({}));
assert_eq!(paras(&s), vec![("- On peut faire mieux".to_string(), None, false)]);
}
#[test]
fn typing_1_dot_starts_numbering_again() {
let s = typed("1. a\nb\n\nmid\n1. c\nd");
let num = |i: usize| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).map(|n| n.num);
assert_eq!(num(0), num(1));
assert_eq!(num(3), num(4));
assert_ne!(num(0), num(3), "a new list restarts at 1");
}
#[test]
fn undo_right_after_autoformat_undoes_only_the_autoformat() {
let mut s = typed("* ");
assert!(paras(&s)[0].1.is_some());
run(&mut s, "edit.undo", json!({}));
typ(&mut s, "x");
assert_eq!(paras(&s), vec![("* x".to_string(), None, false)]);
// Same for AutoCorrect.
let mut s = typed("teh ");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "teh ");
}
#[test]
fn autocorrect_as_you_type() {
let s = typed("teh cat said hello. world (c) \"quoted\" don't -- yes wait... i think so. e.g. this. ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "The cat said hello. World © “quoted” don’t – yes wait… I think so. E.g. this. ");
// Abbreviations and initials don't end sentences.
let s = typed("see e.g. the map. Ask J. smith or Dr. jones ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "See e.g. the map. Ask J. smith or Dr. jones ");
// Fractions; "iPhone" and file names stay as typed.
let s = typed("1/2 cup. iPhone. see readme.txt ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "½ cup. iPhone. See readme.txt ");
}
#[test]
fn smart_quotes_nested_and_opening_delimiters() {
let s = typed("\"'nested'\" '\"inner\"' (\"'parens'\")");
assert_eq!(s.doc.plain_text(StoryRef::Body), "“‘nested’” ‘“inner”’ (“‘parens’”)");
// Empty quotes preserve open then close
let s = typed("\"\" ''");
assert_eq!(s.doc.plain_text(StoryRef::Body), "“” ‘’");
// After opening guillemets and low quotes
let s = typed("«'french'» „'german'“");
assert_eq!(s.doc.plain_text(StoryRef::Body), "«‘french’» „‘german’“");
}
/// #2223: the double-hyphen arrow is a replacement, not the existing arrow bullet.
#[test]
fn double_hyphen_arrow_autocorrect_preserves_typing_scope_and_undo() {
let mut s = typed("go --> ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → ");
assert_eq!(paras(&s)[0].1, None);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Go --> ");
let s = typed("go --> here");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here");
let s = typed("--> ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "→ ");
assert_eq!(paras(&s)[0].1, None);
}
#[test]
fn double_hyphen_arrow_respects_autocorrect_preferences() {
for prefs in [json!({"enabled": false}), json!({"replaceText": false}), json!({"delete": "-->"})] {
let mut s = s();
run(&mut s, "tools.autocorrect", prefs);
typ(&mut s, "go --> here");
assert!(s.doc.plain_text(StoryRef::Body).ends_with(" --> here"));
}
let mut s = s();
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
typ(&mut s, "go --> here");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Go → here");
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"add": {"from": "-->", "to": "custom"}}));
typ(&mut s, "go --> here");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Go custom here");
}
/// #835: the English replacement list applies only to English text. Polish "i" ("and") stays
/// lowercase; symbols still change; English and untagged text still get "I".
#[test]
fn autocorrect_replacement_list_follows_the_texts_language() {
// Polish as the document's default language.
let mut s = s();
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
typ(&mut s, "Ala i kot. kawa i herbata teh (c) ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot. Kawa i herbata teh © ");
// Polish on the run only: the word's own language counts.
let mut s = self::s();
typ(&mut s, "Ala");
run(&mut s, "select.all", json!({}));
run(&mut s, "review.language", json!({"lang": "pl-PL"}));
run(&mut s, "caret.docEnd", json!({}));
typ(&mut s, " i kot ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ");
// English (any region) and untagged text (the default proofing language is English).
let mut s = self::s();
run(&mut s, "review.language", json!({"lang": "en-GB", "default": true}));
typ(&mut s, "so i think teh end ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end ");
let s = typed("so i think teh end ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end ");
// A Polish interface: a new document is Polish by its default language, and text with no
// language at all (a .docx without `w:lang`) follows the editing language too.
let mut s = self::s();
s.set_editing_language(Some("pl-PL"));
typ(&mut s, "Ala i kot ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "document default from the editing language");
let mut s = self::s();
s.set_editing_language(Some("pl-PL"));
s.doc.styles.default_chr.lang = None;
typ(&mut s, "Ala i kot ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "untagged text in the editing language");
// `eng` is English too, as for proofing.
let mut s = self::s();
run(&mut s, "review.language", json!({"lang": "eng", "default": true}));
typ(&mut s, "so i think ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think ");
}
#[test]
fn autocorrect_options_change_what_typing_does() {
let mut s = s();
// A user entry, a deleted built-in entry, an added exception; names of days.
run(&mut s, "tools.autocorrect", json!({"add": {"from": "wc", "to": "WordCraft"}}));
run(&mut s, "tools.autocorrect", json!({"delete": "teh"}));
run(&mut s, "tools.autocorrect", json!({"addException": "approx."}));
typ(&mut s, "wc ships on monday. teh end is approx. three weeks ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "WordCraft ships on Monday. Teh end is approx. three weeks ");
// AutoFormat options off: straight quotes, no dashes, no list; sentences not capitalised.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"smartQuotes": false, "dashes": false, "bullets": false, "capSentences": false, "capDays": false}));
typ(&mut s, "* \"hi\" a -- b sunday ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "* \"hi\" a -- b sunday ");
assert_eq!(paras(&s)[0].1, None);
}
#[test]
fn enter_finishes_a_word_for_autocorrect() {
let s = typed("hello\nteh end\n");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n");
}
/// A word after a no-break space or an ideographic space is corrected as after a plain space:
/// replacements, sentence capitals, ordinals and links.
#[test]
fn autocorrect_finds_the_word_after_a_wide_space() {
let s = typed("a\u{a0}teh x\u{3000}teh ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}the x\u{3000}the ");
let s = typed("end.\u{a0}hello end.\u{3000}world ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "End.\u{a0}Hello end.\u{3000}World ");
let s = typed("the\u{a0}1st and\u{3000}22nd ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let sup = |t: &str| p.props_of_char(p.text.find(t).unwrap() + t.len() - 2).vert_align == Some(VertAlign::Superscript);
assert!(sup("1st") && sup("22nd"));
let s = typed("see\u{a0}https://example.org/log, and\u{3000}https://example.net/day, then ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let link = |t: &str| p.props_of_char(p.text.find(t).unwrap()).link.clone();
assert_eq!(link("https://example.org").as_deref(), Some("https://example.org/log"));
assert_eq!(link("https://example.net").as_deref(), Some("https://example.net/day"));
}
#[test]
fn ordinals_become_superscript() {
let s = typed("the 1st and 22nd and 13th but not 1th ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let sup = |t: &str| {
let i = p.text.find(t).unwrap() + t.len() - 2;
p.props_of_char(i).vert_align == Some(VertAlign::Superscript)
};
assert!(sup("1st") && sup("22nd") && sup("13th"));
assert!(!sup("1th"));
}
#[test]
fn web_addresses_become_links() {
let mut s = typed("see https://example.org/log, then ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let at = p.text.find("https").unwrap();
assert_eq!(p.props_of_char(at).link.as_deref(), Some("https://example.org/log"));
assert_eq!(p.props_of_char(p.text.find(',').unwrap()).link, None, "trailing comma stays outside");
typ(&mut s, "more");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props_of_char(p.text.find("more").unwrap()).link, None);
}
#[test]
fn dashes() {
let s = typed("a -- b and c - d and e--f and café--g ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f and café—g ");
}
/// #2036: a dash AutoFormat makes right after a symbol is text, not the symbol's font.
#[test]
fn a_dash_after_a_symbol_is_not_in_the_symbol_font() {
let mut s = s();
typ(&mut s, "e");
run(&mut s, "insert.symbol", json!({"char": "\u{F0FC}", "font": "Wingdings"}));
typ(&mut s, "--f ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let dash = p.text.find('\u{2014}').expect("em dash");
assert_eq!(p.props_of_char(dash).sym_font, None);
}
/// #2036: the en dashes of "x -- y" and "x - y" after a symbol (and its symbol-font space) are
/// text, not the symbol's font.
#[test]
fn en_dashes_after_a_symbol_are_not_in_the_symbol_font() {
for keys in ["-- f ", "- f "] {
let mut s = s();
typ(&mut s, "e");
run(&mut s, "insert.symbol", json!({"char": "\u{F0FC} ", "font": "Wingdings"}));
typ(&mut s, keys);
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let dash = p.text.find('\u{2013}').unwrap_or_else(|| panic!("en dash for {keys:?}: {:?}", p.text));
assert_eq!(p.props_of_char(dash).sym_font, None, "{keys:?}");
}
}
/// #2036: an AutoCorrect replacement of a word that starts with a symbol-font character is text.
#[test]
fn an_autocorrect_replacement_of_a_symbol_word_is_not_in_the_symbol_font() {
let mut s = s();
run(&mut s, "insert.symbol", json!({"char": "(", "font": "Symbol"}));
typ(&mut s, "c) ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let c = p.text.find('\u{A9}').unwrap_or_else(|| panic!("(c) → ©: {:?}", p.text));
assert_eq!(p.props_of_char(c).sym_font, None);
}
/// #2036: a letter AutoCorrect capitalises after a symbol-font character is text, not the symbol's font.
#[test]
fn a_capitalised_word_after_a_symbol_is_not_in_the_symbol_font() {
let mut s = s();
run(&mut s, "insert.symbol", json!({"char": "m", "font": "Wingdings"}));
typ(&mut s, "onday ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let m = p.text.find('M').unwrap_or_else(|| panic!("monday → Monday: {:?}", p.text));
assert_eq!(p.props_of_char(m).sym_font, None);
}
/// Whether every character of the body paragraph `i` is marked deleted.
fn all_deleted(s: &Session, i: usize, text: &str) -> bool {
s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.text == text && p.run_ranges().all(|(_, c)| c.del.is_some()))
}
#[test]
fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() {
let bottom = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom);
let tracked = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.props.fmt_change.is_some());
// Characters that were there before Track Changes was turned on are marked deleted.
let mut s = typed("Above\n---");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\n");
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
assert!(all_deleted(&s, 1, "---"), "{:?}", paras(&s));
// Enter again after the deleted characters is a plain Enter.
typ(&mut s, "\n");
assert_eq!(texts(&s), ["above", "---", ""]);
assert_eq!(s.sel.focus, Pos::body(2, 0));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(texts(&s), ["above", "---"]);
assert!(bottom(&s, 0).is_none());
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(texts(&s), ["above", ""]);
assert!(bottom(&s, 0).is_some());
// At the top of the document the border goes on the characters' paragraph, and typing
// continues in a new one.
let mut s = typed("---");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\nText");
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s));
assert_eq!(texts(&s)[1], "text");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(texts(&s), ["---"]);
assert!(bottom(&s, 0).is_none());
// Typed with Track Changes on, the characters go and the border is still a change.
let mut s = typed("");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "---\n");
assert_eq!(texts(&s), ["", ""]);
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
run(&mut s, "review.rejectAll", json!({}));
assert!(bottom(&s, 0).is_none());
}
#[test]
fn a_tracked_table_line_keeps_its_characters_as_a_deletion() {
let mut s = typed("+--+------+");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\nA");
let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) };
assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell");
assert!(all_deleted(&s, 1, "+--+------+"), "{:?}", paras(&s));
// Enter after the deleted characters makes no second table.
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 11}}));
typ(&mut s, "\n");
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_none()).count(), 1, "{:?}", paras(&s));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.rejectAll", json!({}));
assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.text == "+--+------+"));
}
#[test]
fn border_line_autoformat() {
let s = typed("Above\n---\nBelow");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\nBelow");
let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).unwrap();
assert_eq!(b.style, BorderStyle::Single);
assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none());
for (keys, style) in [("===", BorderStyle::Double), ("***", BorderStyle::Dotted), ("___", BorderStyle::Thick)] {
let s = typed(&format!("x\n{keys}\n"));
let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom);
assert_eq!(b.map(|b| b.style), Some(style), "{keys}");
}
// At the top of the document the line goes under that paragraph and typing continues below.
let s = typed("---\nText");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\nText");
assert!(s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).is_some());
assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none());
}
#[test]
fn undo_right_after_a_border_line_brings_the_characters_back() {
let mut s = typed("Above\n---\n");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n");
run(&mut s, "edit.undo", json!({}));
// The AutoFormat is undone on its own: the characters are back, the Enter stays.
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---\n");
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.borders.is_none()));
assert_eq!(s.sel.focus, Pos::body(2, 0));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---");
}
/// The first paragraph's (left, first-line) indents as set on the paragraph itself.
fn indents(s: &Session) -> (Option<f32>, Option<f32>) {
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
(p.props.indent_left, p.props.indent_first)
}
#[test]
fn tab_and_backspace_at_a_paragraph_start_set_its_indents() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "Some text");
run(&mut s, "caret.docStart", json!({}));
// The first Tab indents the first line, the next one the whole paragraph.
typ(&mut s, "\t");
assert_eq!(indents(&s), (None, Some(36.0)));
typ(&mut s, "\t");
assert_eq!(indents(&s), (Some(36.0), Some(36.0)));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
// Backspace takes the first-line indent back, then the left indent, and stops there.
typ(&mut s, "\u{8}");
assert_eq!(indents(&s), (Some(36.0), Some(0.0)));
typ(&mut s, "\u{8}\u{8}");
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
// Each Tab is one undo step.
typ(&mut s, "\t");
run(&mut s, "edit.undo", json!({}));
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
// A left indent in characters goes back one tab stop too, instead of the paragraphs joining:
// six characters of 11 or 12 points are 66 or 72 points, two tab stops.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "One\nTwo");
let second = Pos::body(1, 0);
s.doc.para_mut(StoryRef::Body, &second.path).unwrap().props.indent_left_chars = Some(6.0);
s.sel = crate::Selection::caret(second.clone());
typ(&mut s, "\u{8}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nTwo");
let p = s.doc.para_at(&second).unwrap();
assert_eq!((p.props.indent_left, p.props.indent_left_chars), (Some(36.0), Some(0.0)));
// With formatting limited to the document's styles the option is off: Tab types a tab
// character at the start of the first line and of a later one, and Backspace at the start
// keeps the paragraph's left indent.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "One");
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "Two");
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true}));
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
run(&mut s, "caret.docStart", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tOne\n\tTwo");
assert_eq!(indents(&s), (None, None));
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.indent_left = Some(36.0);
typ(&mut s, "\u{8}\u{8}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\n\tTwo");
assert_eq!(indents(&s), (Some(36.0), None));
// An empty paragraph takes a tab character.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "\tText");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tText");
assert_eq!(indents(&s), (None, None));
// At the start of a list item numbered by its style, Tab types a tab character, as with the
// option off.
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
run(&mut s, "caret.docStart", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tAlpha\nBeta");
assert_eq!(indents(&s), (None, None));
// With the option off (the default), Tab types a tab character.
let mut s = self::s();
typ(&mut s, "Some text");
run(&mut s, "caret.docStart", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tSome text");
assert_eq!(indents(&s), (None, None));
}
#[test]
fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, &"Words that run on past the end of one line. ".repeat(6));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.down", json!({}));
run(&mut s, "caret.home", json!({}));
assert!(s.sel.focus.off > 0, "the caret is on the second line");
typ(&mut s, "\t");
assert_eq!(indents(&s), (Some(36.0), None));
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
// After a line break too; inside a line Tab still types a tab character.
let mut s = self::s();
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
typ(&mut s, "One");
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "Two");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
assert_eq!(indents(&s), (Some(36.0), None));
run(&mut s, "caret.right", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nT\two");
// Before a later line of a list item numbered by its style, Tab takes it down a level, as
// for a list item numbered directly.
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.end", json!({}));
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "More");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props.numbering.map(|n| n.level), Some(1));
assert_eq!(indents(&s), (None, None));
// A list level past the last one, from a style or set directly, goes to the last level.
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
let style = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n });
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.end", json!({}));
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "More");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\t");
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
let num = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.unwrap().num;
s.doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
s.sel = crate::Selection::caret(Pos::body(1, 0));
typ(&mut s, "\t");
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.down", json!({}));
run(&mut s, "caret.home", json!({}));
assert_eq!((s.sel.focus.path.clone(), s.sel.focus.off), (Pos::body(0, 0).path, 6), "the caret is before \"More\"");
typ(&mut s, "\t");
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
run(&mut s, "outline.demote", json!({}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
}
#[test]
fn plus_and_hyphen_line_becomes_a_table() {
let mut s = typed("+--+------+\nA");
let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) };
assert_eq!(t.rows.len(), 1);
assert_eq!(t.grid.len(), 2);
assert!((t.grid[1] / t.grid[0] - 3.0).abs() < 0.01, "widths follow the hyphens: {:?}", t.grid);
assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell");
assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()), "a paragraph follows the table");
// Undo right after brings the typed characters back, the Enter stays.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "+--+------+\n");
assert!(s.doc.body.iter().all(|b| b.as_para().is_some()));
// Not a table: text around it, or more columns than a table can hold.
let s = typed("x +--+--+\n");
assert_eq!(s.doc.plain_text(StoryRef::Body), "X +--+--+\n");
let s = typed(&format!("+{}\n", "-+".repeat(64)));
assert!(s.doc.body.iter().all(|b| b.as_para().is_some()));
}
/// Body paragraphs as (text, paragraph style).
fn styled(s: &Session) -> Vec<(String, Option<String>)> {
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.style.clone())).collect()
}
fn with_heading_styles(keys: &str) -> Session {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
typ(&mut s, keys);
s
}
#[test]
fn two_enters_after_a_short_line_make_it_a_heading() {
let h = |t: &str, st: Option<&str>| (t.to_string(), st.map(str::to_string));
// No tab: Heading 1; one or two tabs: Heading 2 or 3, without the tabs. Typing goes on in
// the paragraph after the heading.
for (keys, style) in [("Introduction\n\nText", "Heading1"), ("\tOur methods\n\nText", "Heading2"), ("\t\tStep 2\n\nText", "Heading3")] {
let s = with_heading_styles(keys);
let text = keys.trim_start_matches('\t').split('\n').next().unwrap_or_default();
assert_eq!(styled(&s), [h(text, Some(style)), h("Text", None)], "{keys:?}");
}
// Five words is the most; one Enter only ends the line.
let s = with_heading_styles("One two three four five\n\n");
assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1"));
assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]);
// Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style.
for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] {
let s = with_heading_styles(keys);
assert!(
s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.as_deref().is_none_or(|st| !st.starts_with("Heading"))),
"{keys:?}: {:?}",
styled(&s)
);
}
let mut q = self::s();
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
run(&mut q, "para.style", json!({"style": "Quote"}));
typ(&mut q, "Quoted\n\n");
assert_eq!(q.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Quote"));
// A list item numbered by the Normal style is not body text either.
let mut q = self::s();
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
let num = q.doc.numbering.try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
if let Some(normal) = q.doc.styles.get_mut("Normal") {
normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
}
typ(&mut q, "Item\n\n");
assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q));
// Off by default.
let s = typed("Introduction\n\n");
assert_eq!(styled(&s), [h("Introduction", None), h("", None), h("", None)]);
}
#[test]
fn undo_right_after_a_heading_autoformat_keeps_the_enter() {
let mut s = with_heading_styles("\tMethods\n\n");
assert_eq!(styled(&s), [("Methods".to_string(), Some("Heading2".to_string())), (String::new(), None)]);
assert_eq!(s.sel.focus, Pos::body(1, 0));
run(&mut s, "edit.undo", json!({}));
// The AutoFormat is undone on its own: the tab and Normal text are back, the Enter stays.
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tMethods\n\n");
assert!(styled(&s).iter().all(|(_, st)| st.is_none()));
assert_eq!(s.sel.focus, Pos::body(2, 0));
}
#[test]
fn a_tracked_heading_autoformat_can_be_rejected() {
// With Track Changes on, the tab is marked deleted and the style change is a formatting
// revision, so Reject All brings the line back as it was.
let mut s = with_heading_styles("\tMethods\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("\tMethods", Some("Heading2")));
assert!(p.run_ranges().any(|(r, c)| r.start == 0 && c.del.is_some()), "the tab is marked deleted");
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(styled(&s).first(), Some(&("\tMethods".to_string(), None)));
// A tab this author inserted is removed outright, and the style change is still tracked.
let mut s = with_heading_styles("Title\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
typ(&mut s, "\t");
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2")));
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None)));
// A line holding a tracked deletion is left alone.
let mut s = with_heading_styles("Old title\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 3}}));
typ(&mut s, "\u{8}\u{8}\u{8}");
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "\n");
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
}
#[test]
fn a_locked_heading_style_is_not_applied_as_you_type() {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true, "lockedStyles": ["Heading1"]}));
typ(&mut s, "Locked\n\n\tOpen\n\n");
let st = styled(&s);
assert_eq!(st.first(), Some(&("Locked".to_string(), None)), "{st:?}");
assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}");
}
#[test]
fn arrow_bullets() {
for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] {
let s = typed(&format!("{keys}item\nnext"));
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("next", Some(0))], "{keys}");
assert_eq!(label_at(&s, 0), bullet, "{keys}");
}
// An arrow list doesn't join a plain bullet list.
let s = typed("* a\n\n> b");
assert_eq!(label_at(&s, 0), "\u{2022}");
assert_eq!(label_at(&s, 1), "\u{27A2}");
}
#[test]
fn smileys_are_replaced_inside_words_too() {
let s = typed("so ok:) sad:( meh:| :) fine:-) ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "So ok\u{263A} sad\u{2639} meh\u{1F610} \u{263A} fine\u{263A} ");
}
#[test]
fn a_large_mixed_document_types_like_word() {
// The corpus typed into Word and WordCraft side by side; the paragraph structure must match.
let mut s = s();
let prose = "The keeper's log runs to four hundred pages, and almost every one begins with the weather. ";
typ(&mut s, &format!("Notes\n{}\n", prose.repeat(20)));
typ(&mut s, "The supplies were:\n* Lamp oil, 1/2 barrel\nWicks of the 1st quality\n\tSpare chimneys\nSpare springs\n");
run(&mut s, "text.backTab", json!({}));
typ(&mut s, "Salt beef\n\nThe schedule:\n1. Signal the boat\nLower the stores\n\tCheck the net\n");
run(&mut s, "text.backTab", json!({}));
typ(&mut s, "Sign the manifest\n\n---\nIn March the storm broke -- and he slept.\n");
typ(&mut s, "a. Inspect the glass\nReplace the seals\n\n");
typ(&mut s, &format!("Afterword. teh keeper retired. {}\n* one more\nsecond\n\n\nFinal.", prose.repeat(10)));
let got: Vec<(String, Option<u8>)> = paras(&s).into_iter().map(|(t, l, _)| (t.chars().take(24).collect(), l)).collect();
let want: Vec<(&str, Option<u8>)> = vec![
("Notes", None),
("The keeper’s log runs to", None),
("The supplies were:", None),
("Lamp oil, ½ barrel", Some(0)),
("Wicks of the 1st quality", Some(0)),
("Spare chimneys", Some(1)),
("Spare springs", Some(1)),
("Salt beef", Some(0)),
("The schedule:", None),
("Signal the boat", Some(0)),
("Lower the stores", Some(0)),
("Check the net", Some(1)),
("Sign the manifest", Some(0)),
("In March the storm broke", None),
("Inspect the glass", Some(0)),
("Replace the seals", Some(0)),
("Afterword. The keeper re", None),
("One more", Some(0)),
("Second", Some(0)),
("", None),
("Final.", None),
];
let want: Vec<(String, Option<u8>)> = want.into_iter().map(|(t, l)| (t.to_string(), l)).collect();
assert_eq!(got, want);
// Every non-list paragraph is Normal (no list indent left behind).
for (t, l, lp) in paras(&s) {
assert!(l.is_some() || !lp, "{t:?} still a List Paragraph");
}
}
#[test]
fn enter_right_after_starting_a_list_cancels_it() {
let s = typed("* \nPlain");
assert_eq!(paras(&s), vec![para("Plain", None)]);
}
#[test]
fn shift_enter_stays_inside_the_list_item() {
let mut s = typed("* line one");
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "cont\ntwo");
assert_eq!(paras(&s), vec![para("Line one\ncont", Some(0)), para("two", Some(0))]);
}
/// Body paragraph texts, lower-cased (AutoCorrect may capitalise a sentence start).
fn texts(s: &Session) -> Vec<String> {
paras(s).into_iter().map(|p| p.0.to_lowercase()).collect()
}
#[test]
fn tracked_enter_and_backspace_track_the_paragraph_mark() {
// Issue #229: with Track Changes on, Enter inserts a tracked paragraph mark that Reject All
// takes out again; Backspace over your own Enter removes it outright.
let mut s = typed("one");
run(&mut s, "review.trackChanges", json!({"value": true}));
typ(&mut s, "\ntwo");
assert_eq!(texts(&s), ["one", "two"]);
typ(&mut s, "\u{8}\u{8}\u{8}\u{8}");
assert_eq!(texts(&s), ["one"]);
typ(&mut s, "\n");
assert_eq!(texts(&s), ["one", ""]);
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(texts(&s), ["one"]);
// A break that was already there isn't removed: Backspace marks it deleted and moves before it.
let mut s = typed("one\ntwo");
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "\u{8}");
assert_eq!(texts(&s), ["one", "two"]);
assert_eq!(s.sel.focus, Pos::body(0, 3));
assert!(s.doc.para_at(&Pos::body(0, 0)).is_some_and(|p| p.mark.del.is_some()));
}
/// Linked text in each body paragraph, and whether its paragraph mark carries a link.
fn links(s: &Session) -> Vec<(String, bool)> {
s.doc
.body
.iter()
.filter_map(|b| b.as_para())
.map(|p| {
let linked = p.run_ranges().filter(|(_, c)| c.link.is_some()).filter_map(|(r, _)| p.text.get(r)).collect();
(linked, p.mark.link.is_some())
})
.collect()
}
#[test]
fn a_link_covers_only_its_own_text() {
// Issue #413: linking a whole paragraph also linked its mark, so Enter carried the link into
// the next paragraph and everything typed there (or hovered below it) became the link.
let mut s = typed("hi\nhello");
run(&mut s, "select.text", json!({"text": "hello"}));
run(&mut s, "insert.link", json!({"url": "https://example.org/"}));
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 5}}));
typ(&mut s, "\nnext");
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "\n");
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "above");
assert_eq!(texts(&s), ["hi", "above", "hello", "next"]);
let unlinked = (String::new(), false);
assert_eq!(links(&s), [unlinked.clone(), unlinked.clone(), ("Hello".into(), false), unlinked]);
}
#[test]
fn typing_at_either_end_of_a_link_is_not_linked() {
// As in Word: a link typed in with Ctrl+K, then text typed after it, at its end later on,
// at its start, or after Enter, stays outside the link; typing inside it extends it.
let mut s = typed("see ");
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "site"}));
typ(&mut s, " now");
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}}));
typ(&mut s, "!");
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 4}}));
typ(&mut s, "a ");
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 8}}));
typ(&mut s, "x");
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 11}}));
typ(&mut s, "\nmore");
assert_eq!(texts(&s), ["see a sixte", "more! now"]);
assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]);
}
#[test]
fn typing_then_selecting_then_typing_are_two_undo_steps() {
// #419: a selection that moves the insertion point ends the typing group, like a caret move.
let mut s = typed("alpha");
run(&mut s, "select.text", json!({"text": "ph"}));
run(&mut s, "text.insert", json!({"text": "X"}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alXa");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha");
assert_eq!(s.selected_text(), "ph");
assert!(s.can_undo());
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "");
// Uninterrupted typing (a read-only command in between included) is still one step.
let mut s = typed("alpha");
run(&mut s, "document.inspect", json!({}));
typ(&mut s, "X");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "");
}
#[test]
fn backspace_at_the_start_of_the_document_is_not_an_edit() {
// #23: a key that changes nothing adds no undo step, marks no unsaved changes and
// leaves the redo stack alone.
let mut s = typed("alpha");
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
s.dirty = false;
let depth = s.undo_depth();
typ(&mut s, "\u{8}");
assert_eq!(s.undo_depth(), depth, "no undo step");
assert!(!s.dirty, "no unsaved changes");
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha");
}
#[test]
fn undoing_bold_at_the_caret_turns_bold_typing_off_again() {
// #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold.
let mut s = s();
run(&mut s, "format.bold", json!({"value": true}));
run(&mut s, "edit.undo", json!({}));
assert_ne!(s.typing_props().bold, Some(true));
typ(&mut s, "abc");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, "abc");
assert_ne!(p.props_of_char(0).bold, Some(true));
// Redo brings bold typing back.
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "format.bold", json!({"value": true}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.redo", json!({}));
typ(&mut s, "abc");
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).bold, Some(true));
}
/// Myanmar (#604): typed in memory order (consonant, medial, then the prevowel ေ that is drawn
/// before it), the caret steps over whole syllables and Backspace removes one sign at a time.
#[test]
fn myanmar_typing_steps_by_syllable_and_backspaces_by_sign() {
// ကြေး ("money"): က + ြ + ေ + း, then မြန်မာ.
let word = "\u{1000}\u{103C}\u{1031}\u{1038}";
let mut s = typed(word);
assert_eq!(paras(&s), vec![para(word, None)]);
// Backspace takes the visarga, then the vowel sign: the consonant and medial stay.
typ(&mut s, "\u{8}");
assert_eq!(paras(&s)[0].0, "\u{1000}\u{103C}\u{1031}");
typ(&mut s, "\u{8}\u{1031}\u{1038}");
assert_eq!(paras(&s)[0].0, word, "retyped");
// Left from the end skips the whole syllable (the visarga and aa signs aren't grapheme
// extenders, so a grapheme step would stop inside it).
let mut s = typed("\u{1019}\u{102C}\u{1005}\u{102C}"); // မာစာ
run(&mut s, "caret.left", json!({}));
assert_eq!(s.sel.focus.off, 6);
run(&mut s, "caret.left", json!({}));
assert_eq!(s.sel.focus.off, 0);
run(&mut s, "caret.right", json!({}));
assert_eq!(s.sel.focus.off, 6);
// Delete removes the whole syllable after the caret.
run(&mut s, "text.delete", json!({}));
assert_eq!(paras(&s)[0].0, "\u{1019}\u{102C}");
}
/// Devanagari and Khmer: Backspace removes the last character (a matra), not the conjunct.
#[test]
fn indic_and_khmer_backspace_removes_one_character() {
let mut s = typed("\u{0915}\u{094D}\u{0937}\u{093F}\u{8}"); // क्षि ⌫
assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}\u{0937}");
typ(&mut s, "\u{8}");
assert_eq!(paras(&s)[0].0, "\u{0915}\u{094D}");
let s = typed("\u{1781}\u{17D2}\u{1798}\u{17C2}\u{8}"); // ខ្មែ ⌫
assert_eq!(paras(&s)[0].0, "\u{1781}\u{17D2}\u{1798}");
// Latin keeps grapheme Backspace: e + combining acute goes as one.
let s = typed("ae\u{301}\u{8}");
assert_eq!(paras(&s)[0].0, "a");
}
/// Press a shortcut the way the keyboard does: run the command bound to it.
fn press(s: &mut Session, combo: &str) -> Result<serde_json::Value, crate::CmdError> {
let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}"));
s.run(id, &json!({}))
}
/// Word's outline keys: Alt+Shift+arrows, ⌃⇧+arrows on macOS.
fn outline_key(arrow: &str) -> String {
if cfg!(target_os = "macos") { format!("Ctrl+Shift+{arrow}") } else { format!("Alt+Shift+{arrow}") }
}
#[test]
fn alt_shift_down_then_up_moves_a_paragraph_and_back_and_undoes() {
let mut s = typed("One\nTwo\nThree");
run(&mut s, "caret.docStart", json!({}));
press(&mut s, &outline_key("Down")).unwrap();
assert_eq!(texts(&s), ["two", "one", "three"]);
assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret moves with the paragraph");
press(&mut s, &outline_key("Up")).unwrap();
assert_eq!(texts(&s), ["one", "two", "three"]);
assert!(press(&mut s, &outline_key("Up")).is_err(), "nothing above the first paragraph");
run(&mut s, "edit.undo", json!({}));
assert_eq!(texts(&s), ["two", "one", "three"], "each move is one undo step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(texts(&s), ["one", "two", "three"]);
// A selected paragraph (with its mark) moves alone; list items keep their list.
let mut s = typed("1. First\nSecond\n\nAfter");
run(&mut s, "select.paragraph", json!({}));
press(&mut s, &outline_key("Up")).unwrap();
assert_eq!(paras(&s), vec![para("First", Some(0)), para("After", None), para("Second", Some(0))]);
assert!(press(&mut s, &outline_key("Down")).is_ok());
assert!(press(&mut s, &outline_key("Down")).is_err(), "nothing below the last paragraph");
}
fn style_of(s: &Session, i: usize) -> Option<String> {
s.doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| p.props.style.clone())
}
#[test]
fn alt_shift_left_and_right_promote_and_demote_headings_and_list_items() {
let mut s = typed("Intro\nBody");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "para.heading2", json!({}));
run(&mut s, "caret.docEnd", json!({}));
// Promoted body text takes the level of the heading before it; then one level at a time.
press(&mut s, &outline_key("Left")).unwrap();
assert_eq!(style_of(&s, 1).as_deref(), Some("Heading2"));
press(&mut s, &outline_key("Right")).unwrap();
assert_eq!(style_of(&s, 1).as_deref(), Some("Heading3"));
press(&mut s, &outline_key("Left")).unwrap();
press(&mut s, &outline_key("Left")).unwrap();
assert_eq!(style_of(&s, 1).as_deref(), Some("Heading1"));
assert!(press(&mut s, &outline_key("Left")).is_err(), "Heading 1 is the top level");
for _ in 0..8 {
press(&mut s, &outline_key("Right")).unwrap();
}
assert_eq!(style_of(&s, 1).as_deref(), Some("Heading9"));
assert!(press(&mut s, &outline_key("Right")).is_err(), "Heading 9 is the lowest heading");
// In a list they change the item's level, like Tab and Shift+Tab.
let mut s = typed("* One\nTwo");
press(&mut s, &outline_key("Right")).unwrap();
assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(1))]);
press(&mut s, &outline_key("Left")).unwrap();
assert_eq!(paras(&s), vec![para("One", Some(0)), para("Two", Some(0))]);
}
#[test]
fn down_and_up_from_a_line_start_beside_a_table_go_into_it() {
let mut s = typed("Intro line\n");
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let (table, _, _) = s.sel.focus.path.cell().unwrap();
typ(&mut s, "a");
let cell = |r: u32, c: u32| {
let mut p = table.0.clone();
p.extend([r, c, 0]);
wordcraft_doc::Path(p)
};
// From the start of the line above, left of the cells' text: the first cell, then the one
// below, not the paragraph after the table.
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.down", json!({}));
assert_eq!(s.sel.focus.path, cell(0, 0));
run(&mut s, "caret.down", json!({}));
assert_eq!(s.sel.focus.path, cell(1, 0), "then the cell below");
// Up from the start of the paragraph after it: the last row's first cell.
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "caret.up", json!({}));
assert_eq!(s.sel.focus.path, cell(1, 0));
// Shift+Down from the line above takes the first row, not the whole table.
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.down", json!({"extend": true}));
assert_eq!(s.sel.focus.path, cell(0, 1));
}
#[test]
fn alt_x_toggles_between_a_character_and_its_code() {
if !cfg!(target_os = "macos") {
assert_eq!(s().registry.by_shortcut("Alt+X").map(|c| c.id), Some("text.toggleUnicode"));
}
let mut s = s();
run(&mut s, "text.insert", json!({"text": "x 00e9"}));
run(&mut s, "text.toggleUnicode", json!({}));
assert_eq!(texts(&s), ["x é"]);
run(&mut s, "text.toggleUnicode", json!({}));
assert_eq!(texts(&s), ["x 00e9"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(texts(&s), ["x é"], "one undo step");
run(&mut s, "text.insert", json!({"text": " U+1F600"}));
run(&mut s, "text.toggleUnicode", json!({}));
assert_eq!(texts(&s), ["x é 😀"]);
// A selected code converts too.
run(&mut s, "text.insert", json!({"text": " 2014"}));
run(&mut s, "select.text", json!({"text": "2014"}));
run(&mut s, "text.toggleUnicode", json!({}));
assert_eq!(texts(&s), ["x é 😀 —"]);
}
#[test]
fn alt_numpad_codes_map_like_word_on_windows() {
use crate::cmd::text::{alt_code_char, oem_code_page};
let c = |code| alt_code_char(code, 1252, 437);
// Leading 0: Windows-1252.
assert_eq!(c("0233"), Some('é'));
assert_eq!(c("0128"), Some('€'));
assert_eq!(c("0150"), Some('–'));
assert_eq!(c("065"), Some('A'));
assert_eq!(c("0032"), Some(' '));
assert_eq!(c("0255"), Some('ÿ'));
// No leading 0: the OEM code page (437 here, 850 in Western Europe).
assert_eq!(c("233"), Some('Θ'));
// The issue said 233 → é in 850; the CP850 mapping file has Ú at 0xE9 (é is 130 in both).
assert_eq!(alt_code_char("233", 1252, 850), Some('Ú'));
assert_eq!(alt_code_char("130", 1252, 850), Some('é'));
assert_eq!(c("130"), Some('é'));
assert_eq!(alt_code_char("155", 1252, 850), Some('ø'));
assert_eq!(c("155"), Some('¢'));
assert_eq!(c("65"), Some('A'));
assert_eq!(c("1"), Some('☺'));
assert_eq!(c("7"), Some('•'));
assert_eq!(c("127"), Some('⌂'));
assert_eq!(c("176"), Some('░'));
// Above 255: the Unicode character with that decimal value.
assert_eq!(c("8364"), Some('€'));
assert_eq!(c("09733"), Some('★'));
assert_eq!(c("128512"), Some('😀'));
// Nothing for junk, 0, control characters, surrogates or values past Unicode.
for bad in ["", "0", "000", "0009", "0013", "0127", "0129", "x1", "-5", "55296", "1114112", "99999999999999999999", "4294967361"] {
assert_eq!(c(bad), None, "{bad:?}");
}
assert_eq!(oem_code_page(None), 437);
assert_eq!(oem_code_page(Some("en-US")), 437);
assert_eq!(oem_code_page(Some("en_GB.UTF-8")), 850);
assert_eq!(oem_code_page(Some("fr-FR")), 850);
assert_eq!(oem_code_page(Some("de")), 850);
assert_eq!(oem_code_page(Some("ru-RU")), 437, "only 437/850 without the shell's code pages");
}
#[test]
fn alt_numpad_codes_follow_the_system_code_pages() {
use crate::cmd::text::{alt_code_char, windows_code_pages};
// Polish (1250/852): Alt+0185 and Alt+165 are both ą, not ¹ / Ñ.
assert_eq!(alt_code_char("0185", 1250, 852), Some('ą'));
assert_eq!(alt_code_char("165", 1250, 852), Some('ą'));
assert_eq!(alt_code_char("0185", 1252, 437), Some('¹'));
// Russian (1251/866), Greek (1253/737), Turkish (1254/857).
assert_eq!(alt_code_char("0207", 1251, 866), Some('П'));
assert_eq!(alt_code_char("143", 1251, 866), Some('П'));
assert_eq!(alt_code_char("0193", 1253, 737), Some('Α'));
assert_eq!(alt_code_char("128", 1253, 737), Some('Α'));
assert_eq!(alt_code_char("0253", 1254, 857), Some('ı'));
assert_eq!(alt_code_char("141", 1254, 857), Some('ı'));
// Above 255 the code page doesn't matter.
assert_eq!(alt_code_char("8364", 1251, 866), Some('€'));
// A byte the code page leaves unassigned types nothing.
assert_eq!(alt_code_char("213", 1254, 857), None);
assert_eq!(alt_code_char("0219", 874, 437), None);
assert_eq!(windows_code_pages(None), (1252, 437));
assert_eq!(windows_code_pages(Some("en-US")), (1252, 437));
assert_eq!(windows_code_pages(Some("de-DE")), (1252, 850));
assert_eq!(windows_code_pages(Some("pl-PL")), (1250, 852));
assert_eq!(windows_code_pages(Some("sr-Latn-RS")), (1250, 852));
assert_eq!(windows_code_pages(Some("ru_RU.UTF-8")), (1251, 866));
assert_eq!(windows_code_pages(Some("el-GR")), (1253, 737));
assert_eq!(windows_code_pages(Some("tr-TR")), (1254, 857));
assert_eq!(windows_code_pages(Some("ja-JP")), (1252, 437));
}
#[test]
fn alt_numpad_code_types_one_character() {
// `texts` lowercases; the capitals matter here.
let raw = |s: &Session| paras(s).into_iter().map(|p| p.0).collect::<Vec<_>>();
let mut s = s();
run(&mut s, "text.insert", json!({"text": "caf"}));
run(&mut s, "text.typeCode", json!({"code": "0233"}));
run(&mut s, "text.insert", json!({"text": " "}));
run(&mut s, "text.typeCode", json!({"code": "233", "oem": 437}));
run(&mut s, "text.insert", json!({"text": " "}));
run(&mut s, "text.typeCode", json!({"code": "155", "oem": 850}));
assert_eq!(raw(&s), ["Café Θ ø"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(raw(&s), ["Café Θ "], "one undo step per code");
assert!(s.run("text.typeCode", &json!({"code": "0"})).is_err());
assert!(s.run("text.typeCode", &json!({"code": "65", "oem": 855})).is_err());
assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 932})).is_err());
assert!(s.run("text.typeCode", &json!({"code": "065", "ansi": 70000})).is_err());
assert_eq!(raw(&s), ["Café Θ "]);
// The system's code pages: Polish Alt+0185 and Alt+165 both type ą.
run(&mut s, "text.typeCode", json!({"code": "0185", "ansi": 1250, "oem": 852}));
run(&mut s, "text.typeCode", json!({"code": "165", "ansi": 1250, "oem": 852}));
run(&mut s, "text.typeCode", json!({"code": "143", "oem": 866}));
assert_eq!(raw(&s), ["Café Θ ąąП"]);
}
#[test]
fn shift_f5_goes_back_through_the_last_edits() {
let mut s = typed("One\nTwo\nThree");
run(&mut s, "caret.docStart", json!({}));
let mut back = || {
press(&mut s, "Shift+F5").unwrap();
s.sel.focus.clone()
};
assert_eq!(back(), Pos::body(2, 5), "the last edit first");
assert_eq!(back(), Pos::body(1, 3));
assert_eq!(back(), Pos::body(0, 3));
assert_eq!(back(), Pos::body(2, 5), "then round again");
assert!(Session::new(wordcraft_doc::Document::new()).run("edit.goBack", &json!({})).is_err(), "nothing edited yet");
}
#[test]
fn shift_right_at_the_end_of_a_cell_selects_the_cell_first() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
let (table, _, _) = s.sel.focus.path.cell().unwrap();
typ(&mut s, "ab");
run(&mut s, "caret.left", json!({}));
let shift_right = |s: &mut Session| {
run(s, "caret.right", json!({"extend": true}));
s.cell_range().map(|r| (r.rows, r.cols))
};
assert_eq!(shift_right(&mut s), None, "the letter first");
assert_eq!((s.sel.anchor.off, s.sel.focus.off), (1, 2));
assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 0))), "then the cell alone");
assert_eq!(shift_right(&mut s), Some(((0, 0), (0, 1))), "then the next cell with it");
// In an empty cell the first press takes it.
let mut empty = table.0.clone();
empty.extend([1, 0, 0]);
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path(empty), off: 0 };
run(&mut s, "caret.set", json!({"pos": pos}));
assert_eq!(shift_right(&mut s), Some(((1, 1), (0, 0))));
}
#[test]
fn two_initial_capitals_are_corrected() {
let s = typed("the WOrd and THe end, not IDs or CDs. TV is fine ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "The Word and The end, not IDs or CDs. TV is fine ");
// "TEh" loses its second capital, then the replacement list fixes the typo.
assert_eq!(typed("TEh ").doc.plain_text(StoryRef::Body), "The ");
// A user exception.
let mut s = typed("");
run(&mut s, "tools.autocorrect", json!({"addInitialCapsException": "WCraft"}));
typ(&mut s, "WCraft ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "WCraft ");
}
#[test]
fn accidental_caps_lock_is_corrected() {
let s = typed("a wORD and hELLO there. iOS stays ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "A Word and Hello there. iOS stays ");
}
#[test]
fn capitalisation_fixes_undo_and_turn_off() {
let mut s = typed("WOrd");
typ(&mut s, " ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Word ");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "WOrd ");
let mut s = typed("x wORD");
typ(&mut s, " ");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "X wORD ");
let mut s = typed("");
run(&mut s, "tools.autocorrect", json!({"twoInitialCaps": false, "capsLock": false}));
typ(&mut s, "x WOrd wORD ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "X WOrd wORD ");
}
#[test]
fn first_letter_of_a_table_cell_is_capitalised() {
let cell_text = |s: &Session, c: usize| {
s.doc.body.iter().find_map(|b| b.as_table()).and_then(|t| t.rows.first()?.cells.get(c)?.blocks.first()?.as_para().map(|p| p.text.clone()))
};
let mut s = typed("");
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
typ(&mut s, "apples and pears\tplums ");
assert_eq!(cell_text(&s, 0).as_deref(), Some("Apples and pears"));
assert_eq!(cell_text(&s, 1).as_deref(), Some("Plums "));
run(&mut s, "edit.undo", json!({}));
assert_eq!(cell_text(&s, 1).as_deref(), Some("plums "));
let mut s = typed("");
run(&mut s, "tools.autocorrect", json!({"capCells": false}));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
typ(&mut s, "plums ");
assert_eq!(cell_text(&s, 0).as_deref(), Some("plums "));
}
/// File › Options › Advanced, "Typing replaces selected text" (#485): on (Word's default) the
/// typed text replaces the selection; off, it goes in front of the selection and keeps it.
#[test]
fn typing_over_a_selection_follows_the_replace_selection_option() {
let mut s = typed("One two");
run(&mut s, "select.text", json!({"text": "two"}));
typ(&mut s, "X");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One X");
let mut s = typed("One two");
run(&mut s, "file.options", json!({"typingReplacesSelection": false}));
run(&mut s, "select.text", json!({"text": "two"}));
typ(&mut s, "XY");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One XYtwo");
}
/// Overtype (#485): typed characters replace the ones after the caret, never past the end of the
/// paragraph; the Insert key toggles it only with "Use the Insert key to control overtype" on
/// (the UI checks that option; `text.overtype` is the switch).
#[test]
fn overtype_replaces_the_characters_after_the_caret() {
let mut s = typed("Abcdef\nXy");
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 1)}));
run(&mut s, "text.overtype", json!({"value": true}));
typ(&mut s, "XY");
assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYdef\nXy");
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5)}));
typ(&mut s, "123");
assert_eq!(s.doc.plain_text(StoryRef::Body), "AXYde123\nXy", "the paragraph mark isn't typed over");
run(&mut s, "text.overtype", json!({}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)}));
typ(&mut s, "Z");
assert_eq!(s.doc.plain_text(StoryRef::Body), "ZAXYde123\nXy", "insert mode again");
}
#[test]
fn reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted() {
// Issue #417: Paste's paragraph marks weren't tracked, so Reject All left the split.
for text in ["X\nY", "\n"] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "AB", "raw": true}));
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 1}}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "edit.paste", json!({"text": text}));
let (head, tail) = text.split_once('\n').unwrap_or_default();
assert_eq!(texts(&s), [format!("a{}", head.to_lowercase()), format!("{}b", tail.to_lowercase())], "{text:?}");
let pasted = texts(&s);
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(texts(&s), ["ab"], "{text:?}");
assert!(s.doc.revisions.is_empty(), "{text:?}");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(texts(&s), pasted, "{text:?}: accepting keeps the break");
}
}
#[test]
fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() {
// Issue #422: the pasted paragraph separators went through Enter, which ends the list on an
// empty item, so `A\n\nB` lost its empty paragraph.
// Paste of plain text from another program goes the same way.
for (id, v) in [
("edit.pasteText", json!({"text": "A\n\nB"})),
("edit.pasteSpecial", json!({"as": "text", "text": "A\n\nB"})),
("edit.paste", json!({"text": "A\n\nB"})),
] {
let mut s = s();
run(&mut s, "para.bullets", json!({}));
run(&mut s, id, v);
assert_eq!(paras(&s), vec![para("A", Some(0)), para("", Some(0)), para("B", Some(0))], "{id}");
}
// Enter twice still ends the list.
let mut s = s();
run(&mut s, "para.bullets", json!({}));
typ(&mut s, "A\n\nB");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", None)]);
}
/// Each body paragraph's text and paragraph formatting.
fn para_formats(s: &Session) -> Vec<(String, wordcraft_doc::props::ParaProps)> {
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.clone())).collect()
}
/// Plain text from another program pastes as Keep Text Only does, as in Word: every pasted line
/// becomes a paragraph formatted like the paragraph it lands in. Paste gave the lines after the
/// first default formatting, and the text after the caret went with the last line, so a
/// right-to-left paragraph came out left-to-right from its second line on.
#[test]
fn plain_text_paste_gives_every_line_the_formatting_of_its_paragraph() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "بداية نهاية", "raw": true}));
run(&mut s, "para.rtl", json!({}));
run(&mut s, "para.indents", json!({"left": 18, "right": 36}));
run(&mut s, "para.spacing", json!({"after": 12}));
let before = para_formats(&s);
let [(_, props)] = &before[..] else { panic!("{before:?}") };
assert_eq!(props.bidi, Some(true));
let off = "بداية ".len();
for id in ["edit.paste", "edit.pasteText"] {
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": off}}));
run(&mut s, id, json!({"text": "سطر أول\r\nسطر ثان\r\nسطر ثالث"}));
let want: Vec<(String, _)> = ["بداية سطر أول", "سطر ثان", "سطر ثالثنهاية"].iter().map(|t| (t.to_string(), props.clone())).collect();
assert_eq!(para_formats(&s), want, "{id}");
// One undo step takes the whole paste out again.
run(&mut s, "edit.undo", json!({}));
assert_eq!(para_formats(&s), before, "{id}");
}
}
/// Empty lines in pasted plain text come through as empty paragraphs, formatted like the rest,
/// and so does a line break at the end of the text. The text takes the formatting before the
/// caret.
#[test]
fn plain_text_paste_keeps_empty_lines_as_paragraphs() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Lead ", "raw": true}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "para.alignCenter", json!({}));
run(&mut s, "para.spacing", json!({"before": 6, "after": 18}));
let props = para_formats(&s)[0].1.clone();
run(&mut s, "edit.paste", json!({"text": "Alpha one.\n\nAlpha two.\n\n\nAlpha three.\n"}));
let got = para_formats(&s);
let texts: Vec<&str> = got.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(texts, ["Lead Alpha one.", "", "Alpha two.", "", "", "Alpha three.", ""]);
assert!(got.iter().all(|(_, p)| *p == props), "{got:?}");
assert_eq!(s.sel.focus, Pos::body(6, 0), "the caret ends after the pasted text");
let bold = s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.runs.iter().all(|r| r.props.bold == Some(true)));
assert!(bold, "the pasted text takes the bold before the caret");
}
/// #713: Arabic or Hebrew typed into an empty paragraph makes it right-to-left; a paragraph that
/// already has letters, or whose style sets a direction, keeps its own.
#[test]
fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() {
let bidi = |s: &Session, i: usize| s.doc.body.iter().filter_map(|b| b.as_para()).nth(i).and_then(|p| p.props.bidi);
// Empty paragraph: the first Arabic letter flips it, the rest keep it.
let s = typed("سلام");
assert_eq!(bidi(&s, 0), Some(true));
// Digits first, then Arabic: still no letters before, so it flips.
let s = typed("1. سلام");
assert_eq!(bidi(&s, 0), Some(true));
// Latin first: Arabic typed later doesn't flip the paragraph.
let s = typed("Hello سلام");
assert_eq!(bidi(&s, 0), None);
// A new paragraph after an RTL one starts its own detection.
let s = typed("سلام\nHello");
assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word");
// A style that sets left-to-right wins.
let mut doc = Session::new(wordcraft_doc::Document::new());
if let Some(st) = doc.doc.styles.get_mut("Normal") {
st.para.bidi = Some(false);
}
typ(&mut doc, "سلام");
assert_eq!(bidi(&doc, 0), None);
// An opened paragraph without w:bidi lays out left-to-right whatever its text: the caret
// after the Arabic word sits near the left margin, not the right one.
let mut doc = typed("سلام");
let path = doc.sel.focus.path.clone();
if let Ok(p) = doc.doc.para_mut(StoryRef::Body, &path) {
p.props.bidi = None;
p.touch();
}
let x = doc.layout().caret(&doc.sel.focus).map(|c| c.x).unwrap_or(f32::MAX);
assert!(x < 300.0, "left-to-right paragraph: caret at {x}");
}
/// Select All then Delete or Backspace empties the document, tables included (Word, #1198):
/// whether the text starts with a table or with a paragraph.
#[test]
fn select_all_then_delete_removes_tables() {
let tables = |s: &Session| s.doc.body.iter().filter(|b| b.as_table().is_some()).count();
// With the caret in a table, Select All grows the selection a step at a time (cell, row,
// table, document; #1478): press it until the whole document is selected.
let select_whole_document = |s: &mut Session| {
for _ in 0..6 {
let before = s.sel.clone();
run(s, "select.all", json!({}));
if s.sel == before {
break;
}
}
};
for key in ["text.delete", "text.backspace"] {
let mut s = typed("");
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
typ(&mut s, "cell");
assert_eq!(tables(&s), 1);
select_whole_document(&mut s);
run(&mut s, key, json!({}));
assert_eq!(tables(&s), 0, "{key}: a table at the start of the document goes");
assert_eq!(s.doc.plain_text(StoryRef::Body), "", "{key}");
let mut s = typed("before\n");
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
typ(&mut s, "cell");
assert_eq!(tables(&s), 1);
select_whole_document(&mut s);
run(&mut s, key, json!({}));
assert_eq!(tables(&s), 0, "{key}: a table after text goes");
assert_eq!(s.doc.plain_text(StoryRef::Body), "", "{key}");
}
}
/// #898: a click just after a citation at the end of a paragraph puts the caret before the
/// citation's hidden field end; what is typed there goes after the citation, as in Word, instead
/// of into its result (Zotero then reports the citation as edited by hand).
#[test]
fn typing_after_a_citation_goes_outside_its_field() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{InlineObject, Path};
let mut doc = wordcraft_doc::Document::from_text("Cats purr ");
let c = CharProps::default();
let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
let start = p.len();
p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap();
let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap();
p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap();
let mut s = Session::new(doc);
let end = Pos::body(0, s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().len());
let at = s.layout().caret(&end).unwrap();
let hit = s.layout().hit(at.page, at.x + 20.0, at.top + at.height / 2.0, StoryRef::Body).unwrap();
s.sel = crate::Selection::caret(hit);
typ(&mut s, ". Dogs bark.");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Cats purr (Smith 2020). Dogs bark.");
let f = s.doc.field_ranges(StoryRef::Body);
assert_eq!(f.len(), 1);
let inside = Pos { off: f[0].start.off + wordcraft_doc::para::OBJ.len_utf8(), ..f[0].start.clone() };
assert_eq!(s.doc.copy_range(&inside, &f[0].end).plain_text(), "(Smith 2020)");
}
/// Tab in a table typed in right-to-left text (#2634): like Word, a table inserted in a
/// right-to-left paragraph is a right-to-left table, so its first cell stands at the right and
/// Tab moves to the cell on the left (the next one in reading order); Shift+Tab comes back. A
/// table inserted in a left-to-right paragraph is unchanged: Tab moves right.
#[test]
fn tab_in_a_table_inserted_in_right_to_left_text_moves_left() {
// The caret's cell column and the x of that cell on the page.
fn caret_cell_x(s: &mut Session) -> (usize, f32) {
let (_, r, c) = s.sel.focus.path.cell().expect("caret in a cell");
let x = s
.layout()
.pages
.iter()
.flat_map(|p| p.items.iter())
.find_map(|it| match it {
wordcraft_layout::Placed::Cell { rect, row, cell, covered: false, .. } if *row == r && *cell == c => Some(rect.x),
_ => None,
})
.expect("cell laid out");
(c, x)
}
for (keys, rtl) in [("مرحبا\n", true), ("Hello\n", false)] {
let mut s = s();
typ(&mut s, keys);
run(&mut s, "insert.table", json!({"rows": 1, "cols": 3}));
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("table");
assert_eq!(t.props.bidi_visual, rtl, "{keys:?}: the table takes the paragraph's direction");
let (c0, x0) = caret_cell_x(&mut s);
typ(&mut s, "\t");
let (c1, x1) = caret_cell_x(&mut s);
assert_eq!((c0, c1), (0, 1), "{keys:?}: Tab goes to the next cell in reading order");
assert_eq!(x1 < x0, rtl, "{keys:?}: Tab moves {} ({x0} -> {x1})", if rtl { "left" } else { "right" });
run(&mut s, "text.backTab", json!({}));
assert_eq!(caret_cell_x(&mut s), (0, x0), "{keys:?}: Shift+Tab comes back");
}
}
#[test]
fn ctrl_tab_inserts_a_tab_inside_a_table_cell() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
typ(&mut s, "A");
run(&mut s, "text.insertTab", json!({}));
typ(&mut s, "B");
let (_, r, c) = s.sel.focus.path.cell().expect("caret in a cell");
assert_eq!((r, c), (0, 0), "Ctrl+Tab stays in the cell");
assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.text.as_str()), Some("A\tB"));
let key = if cfg!(target_os = "macos") { "Alt+Tab" } else { "Mod+Tab" };
assert_eq!(s.keymap.resolve(&s.registry, key).map(|c| c.id), Some("text.insertTab"));
}
/// A .docx of two paragraphs in two sections, as Word writes it: the first section's properties
/// in the first paragraph's mark (portrait), the last section's in the body (landscape, starting
/// as `kind`).
fn two_section_docx(kind: &str) -> Vec<u8> {
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
let files = [
(
"[Content_Types].xml",
r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#.to_string(),
),
(
"_rels/.rels",
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#.to_string(),
),
(
"word/document.xml",
format!(
r#"<w:document xmlns:w="{W}"><w:body><w:p><w:pPr><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:pPr><w:r><w:t>One.</w:t></w:r></w:p><w:p><w:r><w:t>Two.</w:t></w:r></w:p><w:sectPr><w:type w:val="{kind}"/><w:pgSz w:w="15840" w:h="12240" w:orient="landscape"/></w:sectPr></w:body></w:document>"#
),
),
];
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, data) in files {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// #2127: Backspace just after a section break, or Delete just before it, removes the break, as
/// in Word: the two paragraphs join, the text before the break takes the following section's
/// page setup, and Undo brings the break back. Imported breaks and ones made here alike.
#[test]
fn backspace_and_delete_remove_a_section_break() {
let made_here = || {
let mut s = typed("One.Two.");
s.sel = crate::Selection::caret(Pos::body(0, 4));
run(&mut s, "layout.break", json!({"kind": "nextPage"}));
s
};
for source in ["nextPage", "continuous", "made here"] {
for (key, caret) in [("text.backspace", Pos::body(1, 0)), ("text.delete", Pos::body(0, 4))] {
let mut s = match source {
"made here" => made_here(),
kind => Session::new(crate::io::open_bytes("s.docx", &two_section_docx(kind)).unwrap()),
};
assert_eq!(s.doc.sections().len(), 2, "{source}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source}");
let after = s.doc.sections().last().map(|(_, sp)| (**sp).clone()).unwrap();
s.sel = crate::Selection::caret(caret);
run(&mut s, key, json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "One.Two.", "{source} {key}");
let sections = s.doc.sections();
assert_eq!(sections.len(), 1, "{source} {key}: the break is gone");
assert_eq!(*sections[0].1, after, "{source} {key}: the following section's setup");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source} {key}: undo");
assert_eq!(s.doc.sections().len(), 2, "{source} {key}: undo brings the break back");
}
}
}
/// A .docx laid out like the one attached to #2127: a continuous section break, with the
/// document's only header, on the mark of an empty paragraph after "One.", and the last
/// section (nextPage, no header of its own, so linked to the previous one) in the body.
/// `then` adds a paragraph after the break; without it the break is on the last paragraph and
/// the last section has none.
fn break_on_the_last_paragraph_docx(then: Option<&str>) -> Vec<u8> {
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let then = then.map(|t| format!("<w:p><w:r><w:t>{t}</w:t></w:r></w:p>")).unwrap_or_default();
let files = [
(
"[Content_Types].xml",
r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/></Types>"#.to_string(),
),
(
"_rels/.rels",
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#.to_string(),
),
(
"word/_rels/document.xml.rels",
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>"#.to_string(),
),
("word/header1.xml", format!(r#"<w:hdr xmlns:w="{W}"><w:p><w:r><w:t>Lab report</w:t></w:r></w:p></w:hdr>"#)),
(
"word/document.xml",
format!(
r#"<w:document xmlns:w="{W}" xmlns:r="{R}"><w:body><w:p><w:r><w:t>One.</w:t></w:r></w:p><w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rId2"/><w:type w:val="continuous"/><w:pgSz w:w="11920" w:h="16850"/><w:pgMar w:top="1320" w:right="1275" w:bottom="280" w:left="1417" w:header="480" w:footer="0" w:gutter="0"/></w:sectPr></w:pPr></w:p>{then}<w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="11920" w:h="16850"/><w:pgMar w:top="1320" w:right="1275" w:bottom="280" w:left="1417" w:header="480" w:footer="0" w:gutter="0"/></w:sectPr></w:body></w:document>"#
),
),
];
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, data) in files {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// #2127: Delete at the end of the last paragraph removes a section break on its mark too,
/// though there's no paragraph after it to join. The text takes the last section's setup, and
/// the header the last section showed by linking to the removed one stays. Removing the break
/// by joining two paragraphs keeps that header as well. Undo brings the break back.
#[test]
fn delete_removes_a_section_break_on_the_last_paragraph() {
for then in [None, Some("Two.")] {
let mut s = Session::new(crate::io::open_bytes("s.docx", &break_on_the_last_paragraph_docx(then)).unwrap());
let sections = s.doc.sections();
assert_eq!(sections.len(), 2, "{then:?}");
let header = sections[0].1.headers.default;
assert!(header.is_some() && sections[1].1.headers.default.is_none(), "{then:?}: the header is on the break's section");
let mut after = sections[1].1.clone();
after.headers.default = header;
s.sel = crate::Selection::caret(Pos::body(1, 0));
run(&mut s, "text.delete", json!({}));
let text = if then.is_some() { "One.\nTwo." } else { "One.\n" };
assert_eq!(s.doc.plain_text(StoryRef::Body), text, "{then:?}");
let sections = s.doc.sections();
assert_eq!(sections.len(), 1, "{then:?}: the break is gone");
assert_eq!(*sections[0].1, after, "{then:?}: the last section's setup, with the header");
run(&mut s, "edit.undo", json!({}));
let sections = s.doc.sections();
assert_eq!(sections.len(), 2, "{then:?}: undo brings the break back");
assert_eq!((sections[0].1.headers.default, sections[1].1.headers.default), (header, None), "{then:?}: undo");
}
}
fn first_letter_list(s: &Session) -> Vec<String> {
s.prefs.autocorrect.exceptions.clone()
}
/// Undoing an automatic sentence capital adds the word before it to the First Letter exceptions
/// (Word: "Automatically add words to list", #1857); typing the line again isn't capitalised.
#[test]
fn undoing_a_sentence_capital_adds_a_first_letter_exception() {
let mut s = typed("hello there. world ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World ");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world ");
assert_eq!(first_letter_list(&s), ["there."]);
run(&mut s, "text.newParagraph", json!({}));
typ(&mut s, "x there. world ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world \nX there. world ");
}
#[test]
fn backspacing_a_sentence_capital_and_retyping_adds_a_first_letter_exception() {
let mut s = typed("hello there. world ");
typ(&mut s, "\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. world ");
assert_eq!(first_letter_list(&s), ["there."]);
}
#[test]
fn automatic_exceptions_can_be_turned_off() {
let mut s = typed("");
run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false}));
typ(&mut s, "hello there. world ");
run(&mut s, "edit.undo", json!({}));
assert!(first_letter_list(&s).is_empty());
let mut s = typed("");
run(&mut s, "tools.autocorrect", json!({"autoAddFirstLetter": false}));
typ(&mut s, "hello there. world \u{8}\u{8}\u{8}\u{8}\u{8}\u{8}world ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello there. World ");
assert!(first_letter_list(&s).is_empty());
}
#[test]
fn undoing_a_replacement_adds_no_exception() {
let mut s = typed("so teh ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "So the ");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh ");
assert!(first_letter_list(&s).is_empty());
assert!(s.prefs.autocorrect.entries.is_empty() && s.prefs.autocorrect.removed.is_empty());
typ(&mut s, "and teh ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "So teh and the ");
}
/// Checked in Word for Mac: the same rule as First Letter.
#[test]
fn undoing_an_initial_caps_fix_adds_an_initial_caps_exception() {
let mut s = typed("so TWo ");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.prefs.autocorrect.initial_caps_exceptions, ["TWo"]);
typ(&mut s, "and TWo ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "So TWo and TWo ");
}
#[test]
fn undoing_other_edits_after_a_capital_adds_no_exception() {
// Typing more, then one Undo, and a second Undo that reverses the capital's step later.
let mut s = typed("hello there. world more text");
run(&mut s, "edit.undo", json!({}));
assert!(first_letter_list(&s).is_empty());
run(&mut s, "edit.undo", json!({}));
assert!(first_letter_list(&s).is_empty(), "only the Undo that reverses the capital itself counts");
}
#[test]
fn a_full_undo_history_does_not_match_later_undos() {
let mut s = typed("");
for _ in 0..300 {
typ(&mut s, "x\n");
}
typ(&mut s, "hello there. world more text\nand more");
run(&mut s, "edit.undo", json!({}));
assert!(first_letter_list(&s).is_empty());
}
#[test]
fn a_new_document_forgets_the_capital() {
let mut s = typed("hello there. world ");
run(&mut s, "file.new", json!({}));
typ(&mut s, "abc\ndef\nghi\njkl");
for _ in 0..8 {
let _ = s.run("edit.undo", &json!({}));
}
assert!(first_letter_list(&s).is_empty());
}
#[test]
fn an_overlong_word_is_not_added_as_an_exception() {
let mut s = typed(&format!("hi {}. world ", "a".repeat(5000)));
run(&mut s, "edit.undo", json!({}));
assert!(first_letter_list(&s).is_empty());
}
/// Each body paragraph's laid-out number (`None`: none) and its style.
fn numbers(s: &mut Session) -> Vec<(Option<String>, String)> {
let l = s.layout();
let labels: Vec<Option<String>> = l
.pages
.iter()
.flat_map(|p| &p.items)
.filter_map(|i| {
if let wordcraft_layout::Placed::Lines { para, l0: 0, .. } = i { Some(para.label.as_ref().map(|lb| lb.text.clone())) } else { None }
})
.collect();
let styles = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.clone().unwrap_or_else(|| "Normal".into()));
labels.into_iter().zip(styles).collect()
}
fn num(label: Option<&str>, style: &str) -> (Option<String>, String) {
(label.map(str::to_string), style.to_string())
}
/// #1793 (Word for Mac, 2026-10-15): after the "1 / 1.1 / 1.1.1" heading list, a new Heading 2
/// typed after "Three" is numbered 2.1 on its own; the body text after it stays unnumbered.
#[test]
fn a_new_heading_after_a_heading_linked_list_is_numbered() {
let mut s = s();
typ(&mut s, "One");
run(&mut s, "para.heading1", json!({}));
typ(&mut s, "\nTwo");
run(&mut s, "para.heading2", json!({}));
typ(&mut s, "\nThree");
run(&mut s, "para.heading1", json!({}));
// The caret in "Three" (a Heading 1, the list's first level).
run(&mut s, "list.multilevelPreset", json!({"preset": "headings"}));
run(&mut s, "caret.docEnd", json!({}));
typ(&mut s, "\n");
run(&mut s, "para.heading2", json!({}));
typ(&mut s, "Four\nBody");
assert_eq!(
numbers(&mut s),
[num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2"), num(Some("2"), "Heading1"), num(Some("2.1"), "Heading2"), num(None, "Normal")]
);
}
/// #1793: plain Numbering on a Heading 1 numbers it "1." and keeps Heading 1 (Word: "1. One").
#[test]
fn numbering_a_heading_keeps_the_heading_style() {
let mut s = s();
typ(&mut s, "One");
run(&mut s, "para.heading1", json!({}));
run(&mut s, "para.numbering", json!({}));
assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1")]);
}
/// #1793: applying a heading style that carries list numbering numbers the paragraph, even one
/// whose numbering was switched off before.
#[test]
fn applying_a_numbered_heading_style_numbers_the_paragraph() {
let mut s = s();
typ(&mut s, "One");
run(&mut s, "para.heading1", json!({}));
run(&mut s, "list.multilevelPreset", json!({"preset": "headings"}));
typ(&mut s, "\nTwo");
run(&mut s, "para.numbering", json!({}));
run(&mut s, "para.numbering", json!({"off": true}));
assert_eq!(numbers(&mut s)[1].0, None);
run(&mut s, "para.heading2", json!({}));
assert_eq!(numbers(&mut s), [num(Some("1"), "Heading1"), num(Some("1.1"), "Heading2")]);
}
/// #1793: "1. " typed at the start of a heading numbers it and keeps the heading style; only
/// body text becomes List Paragraph.
#[test]
fn typing_1_dot_in_a_heading_keeps_the_heading_style() {
let mut s = s();
run(&mut s, "para.heading1", json!({}));
typ(&mut s, "1. One\n");
run(&mut s, "para.normal", json!({}));
typ(&mut s, "1. Body");
assert_eq!(numbers(&mut s), [num(Some("1."), "Heading1"), num(Some("1."), "ListParagraph")]);
for (style, keys) in [("Heading2", "a) Part"), ("Subtitle", "1. Item")] {
let mut s = self::s();
run(&mut s, "para.style", json!({"style": style}));
typ(&mut s, keys);
let got = numbers(&mut s);
assert_eq!(got.len(), 1);
assert!(got[0].0.is_some(), "{style}: a list");
assert_eq!(got[0].1, style);
}
}
// Backspace and Delete on selected tables, rows and columns (Word for Mac, #1844).
/// "before", a 3×3 table with cells `a1`…`c3` (column letter, row number), "after"; the caret
/// in the cell at row `r`, column `c`.
fn table_between(r: u32, c: u32) -> Session {
let mut s = typed("before\n");
run(&mut s, "insert.table", json!({"rows": 3, "cols": 3}));
typ(&mut s, "a1\tb1\tc1\ta2\tb2\tc2\ta3\tb3\tc3");
run(&mut s, "caret.docEnd", json!({}));
typ(&mut s, "after");
let shape: Vec<&str> = s.doc.body.iter().map(|b| if b.as_table().is_some() { "table" } else { "para" }).collect();
assert_eq!(shape, ["para", "table", "para"], "setup");
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, 0], "off": 0}}));
s
}
fn tables(s: &Session) -> usize {
s.doc.body.iter().filter(|b| b.as_table().is_some()).count()
}
/// The first top-level table's cells, row by row (tracked deletions included).
fn table_cells(s: &Session) -> Vec<Vec<String>> {
s.doc
.body
.iter()
.find_map(|b| b.as_table())
.map(|t| {
t.rows
.iter()
.map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect())
.collect()
})
.unwrap_or_default()
}
#[test]
fn backspace_with_the_whole_table_selected_removes_the_table() {
let mut s = table_between(1, 1);
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(tables(&s), 0, "the table stays: {:?}", table_cells(&s));
assert_eq!(texts(&s), ["before", "after"]);
// The caret is in the paragraph that followed the table.
assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(1), off: 0 });
assert!(s.sel.is_collapsed());
// One undo brings it back.
run(&mut s, "edit.undo", json!({}));
assert_eq!(tables(&s), 1);
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]);
}
#[test]
fn delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table() {
let mut s = table_between(0, 0);
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "text.delete", json!({}));
assert_eq!(tables(&s), 1);
assert_eq!(table_cells(&s), [["", "", ""], ["", "", ""], ["", "", ""]]);
assert_eq!(texts(&s), ["before", "after"]);
}
#[test]
fn delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table() {
for key in ["text.delete", "text.backspace"] {
// From the first cell to the end of the document: Word's rule widens it to whole rows.
let mut s = table_between(0, 0);
run(&mut s, "caret.docEnd", json!({"extend": true}));
run(&mut s, key, json!({}));
assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s));
assert_eq!(texts(&s), ["before", ""], "{key}");
}
}
#[test]
fn delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table() {
for key in ["text.delete", "text.backspace"] {
// From the start of "before" down into the last row: the rows are taken whole.
let mut s = table_between(0, 0);
run(&mut s, "caret.docStart", json!({}));
for _ in 0..3 {
run(&mut s, "caret.down", json!({"extend": true}));
}
assert_eq!(s.sel.focus.path.cell().map(|(_, r, _)| r), Some(2), "{key}: the selection reaches the last row");
run(&mut s, key, json!({}));
assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s));
assert_eq!(texts(&s), ["after"], "{key}");
assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }, "{key}");
}
}
#[test]
fn backspace_deletes_selected_rows_and_delete_clears_them() {
let mut s = table_between(1, 0);
run(&mut s, "table.selectRow", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a3", "b3", "c3"]]);
let mut s = table_between(1, 0);
run(&mut s, "table.selectRow", json!({}));
run(&mut s, "text.delete", json!({}));
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["", "", ""], ["a3", "b3", "c3"]]);
}
#[test]
fn backspace_deletes_selected_columns_and_delete_clears_them() {
let mut s = table_between(0, 1);
run(&mut s, "table.selectColumn", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(table_cells(&s), [["a1", "c1"], ["a2", "c2"], ["a3", "c3"]]);
let mut s = table_between(0, 1);
run(&mut s, "table.selectColumn", json!({}));
run(&mut s, "text.delete", json!({}));
assert_eq!(table_cells(&s), [["a1", "", "c1"], ["a2", "", "c2"], ["a3", "", "c3"]]);
}
/// The first top-level table's cells, row by row, one entry per block: a paragraph's text, or
/// `<table>` for a nested table.
fn cell_blocks(s: &Session) -> Vec<Vec<Vec<String>>> {
let block = |b: &std::sync::Arc<wordcraft_doc::Block>| b.as_para().map_or_else(|| "<table>".to_string(), |p| p.plain_text());
s.doc
.body
.iter()
.find_map(|b| b.as_table())
.map(|t| t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().map(block).collect()).collect()).collect())
.unwrap_or_default()
}
#[test]
fn delete_on_whole_cells_leaves_one_empty_paragraph_in_each() {
// a1 starts with a nested table and holds three paragraphs (centred, centred, right-aligned),
// b2 holds a nested table after its text (#2802).
let setup = || {
let mut s = table_between(0, 0);
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 0, 0, 0], "off": 2}}));
run(&mut s, "para.alignCenter", json!({}));
typ(&mut s, "\nX\nY");
run(&mut s, "para.alignRight", json!({}));
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
typ(&mut s, "n");
let b2 = |s: &Session| s.doc.body.get(1).and_then(|b| b.as_table()).map(|t| t.rows[1].cells[1].blocks.clone()).unwrap_or_default();
let nested = b2(&s).iter().find(|b| b.as_table().is_some()).map(|b| (**b).clone()).expect("setup: a table nested in b2");
s.doc.insert_block(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0]), nested).unwrap();
s
};
let all: Vec<(usize, usize)> = (0..3).flat_map(|r| (0..3).map(move |c| (r, c))).collect();
let cases: [(&str, (usize, usize), &str, Vec<(usize, usize)>); 5] = [
("table.selectRow", (0, 0), "text.delete", vec![(0, 0), (0, 1), (0, 2)]),
("table.selectColumn", (0, 0), "text.delete", vec![(0, 0), (1, 0), (2, 0)]),
("table.selectRow", (1, 1), "text.delete", vec![(1, 0), (1, 1), (1, 2)]),
("table.selectTable", (0, 0), "text.delete", all),
// One cell of several: Backspace clears it too.
("table.selectCell", (0, 0), "text.backspace", vec![(0, 0)]),
];
for (select, (r, c), key, cleared) in cases {
let mut s = setup();
let before = cell_blocks(&s);
assert_eq!(before[0][0], ["<table>", "a1", "X", "Y"], "setup");
assert!(before[1][1].len() > 2 && before[1][1].contains(&"<table>".to_string()), "setup: {:?}", before[1][1]);
// a1's first paragraph is after the nested table.
let first = usize::from((r, c) == (0, 0));
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, first], "off": 0}}));
run(&mut s, select, json!({}));
run(&mut s, key, json!({}));
let mut want = before.clone();
for &(r, c) in &cleared {
want[r][c] = vec![String::new()];
}
assert_eq!(cell_blocks(&s), want, "{select} {key}");
// The paragraph left keeps the cell's last paragraph's formatting (its mark ends the cell).
if cleared.contains(&(0, 0)) {
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1, 0, 0, 0])).map(|p| p.props.align);
assert_eq!(p, Some(Some(wordcraft_doc::props::Align::Right)), "{select} {key}");
}
assert!(s.sel.is_collapsed(), "{select} {key}");
assert_eq!(s.sel.focus.path.cell().map(|(_, r, c)| (r, c)), cleared.first().copied(), "{select} {key}");
// One undo brings everything back.
run(&mut s, "edit.undo", json!({}));
assert_eq!(cell_blocks(&s), before, "{select} {key}: undo");
}
}
#[test]
fn backspace_on_a_selected_nested_table_removes_only_it() {
let mut s = table_between(1, 1);
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
typ(&mut s, "x");
let cell_blocks = |s: &Session| {
s.doc
.body
.iter()
.find_map(|b| b.as_table())
.and_then(|t| t.rows.get(1)?.cells.get(1))
.map(|c| c.blocks.iter().filter(|b| b.as_table().is_some()).count())
};
assert_eq!(cell_blocks(&s), Some(1), "setup: a table nested in b2");
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(cell_blocks(&s), Some(0), "the nested table stays");
assert_eq!(tables(&s), 1, "the outer table goes too");
// The caret is in the cell's paragraph after where the nested table was.
assert_eq!(s.sel.focus.path.cell().map(|(t, r, c)| (t.0, r, c)), Some((vec![1], 1, 1)));
assert!(s.doc.para_at(&s.sel.focus).is_some());
}
#[test]
fn backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
typ(&mut s, "x");
s.doc.body.retain(|b| b.as_table().is_some());
assert_eq!(s.doc.body.len(), 1, "setup: the table is the only block");
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(tables(&s), 0);
assert_eq!(texts(&s), [""]);
assert!(s.doc.para_at(&s.sel.focus).is_some(), "the caret is in a paragraph: {:?}", s.sel.focus);
}
#[test]
fn tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table() {
let mut s = table_between(0, 0);
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "text.backspace", json!({}));
// Like Table Layout › Delete Table with tracking on: the structure stays, the text is marked deleted.
assert_eq!(tables(&s), 1);
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]);
assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforeafter");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforea1b1c1a2b2c2a3b3c3after");
assert!(s.doc.revisions.is_empty());
}
/// One selected cell isn't a whole row or column, even in a one-row or one-column table:
/// Backspace clears it (Word's cell selection has no end-of-row mark).
#[test]
fn backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it() {
for (rows, cols, at) in [(1, 3, [0, 1]), (3, 1, [1, 0])] {
let mut s = typed("before\n");
run(&mut s, "insert.table", json!({"rows": rows, "cols": cols}));
typ(&mut s, "x\ty\tz");
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, at[0], at[1], 0], "off": 0}}));
run(&mut s, "table.selectCell", json!({}));
run(&mut s, "text.backspace", json!({}));
let want: Vec<Vec<String>> =
if rows == 1 { vec![vec!["x".into(), "".into(), "z".into()]] } else { vec![vec!["x".into()], vec!["".into()], vec!["z".into()]] };
assert_eq!(table_cells(&s), want, "{rows}×{cols}");
}
}
/// #1670: smart quotes in Hebrew follow the characters around the quote in logical order, as
/// Word does (Word for Mac 16.113.4): an opening “ after a space or at the start, a closing ”
/// after a letter, and ” inside an acronym (צה"ל), not gershayim; ' after a letter is ’.
#[test]
fn smart_quotes_in_hebrew_use_logical_order() {
let bidi = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).next().and_then(|p| p.props.bidi);
let s = typed("הוא אמר \"שלום\" לי");
assert_eq!(bidi(&s), Some(true), "a right-to-left paragraph");
assert_eq!(s.doc.plain_text(StoryRef::Body), "הוא אמר “שלום” לי");
let s = typed("\"שלום\"");
assert_eq!(s.doc.plain_text(StoryRef::Body), "“שלום”");
let s = typed("צה\"ל ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "צה”ל ");
let s = typed("ג'ירפה 'כן'");
assert_eq!(s.doc.plain_text(StoryRef::Body), "ג’ירפה ‘כן’");
}
/// #1923: Turkish and Azerbaijani case mapping (i↔İ, ı↔I) in AutoCorrect, checked against Word
/// with Turkish proofing; other languages keep the Unicode default.
#[test]
fn autocorrect_capitalisation_follows_turkic_case_mapping() {
for tag in ["tr-TR", "az-AZ"] {
let mut s = s();
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
typ(&mut s, "istanbul güzel. ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul güzel. ", "{tag}: i capitalises to İ");
let mut s = self::s();
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
typ(&mut s, "İNgilizce ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "{tag}: İ kept, N lowercased, no combining dot");
let mut s = self::s();
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
typ(&mut s, "ılık ırmak. ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ilık ırmak. ", "{tag}: ı capitalises to I");
let mut s = self::s();
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
typ(&mut s, "iSTANBUL ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "İstanbul ", "{tag}: cAPS LOCK fix");
let mut s = self::s();
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
typ(&mut s, "kızım. TIklamak ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Kızım. Tıklamak ", "{tag}: TWo INitial CApitals lowercases I to ı");
}
// Other languages are unchanged; a stray İ still lowercases to a single "i".
let mut s = s();
run(&mut s, "review.language", json!({"lang": "en-US", "default": true}));
typ(&mut s, "istanbul güzel. ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul güzel. ");
let mut s = self::s();
run(&mut s, "review.language", json!({"lang": "en-US", "default": true}));
typ(&mut s, "İNgilizce ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "İngilizce ", "no combining dot in any language");
}
/// #3479: Format > Change Case follows the same Turkic case mapping as AutoCorrect, using
/// each run's own language; other languages keep the Unicode default.
#[test]
fn change_case_follows_turkic_case_mapping() {
for tag in ["tr-TR", "az-AZ"] {
for (mode, input, want) in [
("upper", "istanbul", "İSTANBUL"),
("lower", "IŞIK", "ışık"),
("title", "istanbul güzel", "İstanbul Güzel"),
("sentence", "istanbul. izmir", "İstanbul. İzmir"),
("toggle", "iI", "İı"),
] {
let mut s = s();
run(&mut s, "review.language", json!({"lang": tag, "default": true}));
run(&mut s, "text.insert", json!({"text": input, "raw": true}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.changeCase", json!({"mode": mode}));
assert_eq!(s.doc.plain_text(StoryRef::Body), want, "{tag} {mode}");
}
}
// Other languages keep the Unicode default.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "istanbul", "raw": true}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.changeCase", json!({"mode": "title"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Istanbul", "en-US");
// Mixed runs: each run's own language decides.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "IŞIK IŞIK", "raw": true}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 5), "extend": true}));
run(&mut s, "review.language", json!({"lang": "tr-TR"}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 6)}));
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 11), "extend": true}));
run(&mut s, "review.language", json!({"lang": "en-US"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.changeCase", json!({"mode": "lower"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "ışık işik", "mixed runs");
}
// Spelling as you type (#1552): the word being typed gets no red squiggle until the caret leaves
// it; F7 and the Spelling pane still report it. See docs/typing-parity.md. Paragraphs start with
// a correct word so AutoCorrect's sentence capital doesn't change the misspelled one.
/// The words body paragraph `i` has a spelling squiggle under, in order.
fn squiggles(s: &mut Session, i: usize) -> Vec<String> {
let path = wordcraft_doc::Path::top(i);
let text = s.doc.para(StoryRef::Body, &path).map(|p| p.text.clone()).unwrap_or_default();
let l = s.layout();
l.pages
.iter()
.flat_map(|pg| pg.items.iter())
.filter_map(|it| match it {
wordcraft_layout::Placed::Lines { para, path: p, l0: 0, .. } if *p == path => {
Some(para.issues.iter().filter(|i| !i.2).filter_map(|i| text.get(i.0..i.1).map(str::to_string)).collect::<Vec<_>>())
}
_ => None,
})
.flatten()
.collect()
}
/// The misspelled words `review.issues` (the Spelling pane) reports, in order.
fn reported(s: &mut Session) -> Vec<String> {
let issues = s.run("review.issues", &json!({})).unwrap();
issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").map(|i| i["text"].as_str().unwrap().to_string()).collect()
}
#[test]
fn the_word_being_typed_is_not_marked_until_it_ends() {
let mut s = typed("The helo");
assert_eq!(squiggles(&mut s, 0), Vec::<String>::new(), "no squiggle while the word is typed");
// F7 and the Spelling pane still see it.
assert_eq!(reported(&mut s), ["helo"]);
let next = s.run("review.spelling", &json!({})).unwrap();
assert_eq!(next["text"], "helo");
// A space ends the word.
let mut s = typed("The helo ");
assert_eq!(squiggles(&mut s, 0), ["helo"]);
typ(&mut s, "wrld");
assert_eq!(squiggles(&mut s, 0), ["helo"], "the next word is being typed");
assert_eq!(reported(&mut s), ["helo", "wrld"]);
// So does punctuation.
typ(&mut s, ".");
assert_eq!(squiggles(&mut s, 0), ["helo", "wrld"]);
// Enter and Tab too.
let mut s = typed("The helo\n");
assert_eq!(squiggles(&mut s, 0), ["helo"]);
let mut s = typed("The helo\t");
assert_eq!(squiggles(&mut s, 0), ["helo"]);
}
#[test]
fn moving_the_caret_out_of_the_typed_word_marks_it() {
for key in ["caret.left", "caret.home", "caret.wordLeft", "caret.docStart"] {
let mut s = typed("The helo");
run(&mut s, key, json!({}));
assert_eq!(squiggles(&mut s, 0), ["helo"], "{key}");
// Coming back to the end of the word doesn't hide the squiggle again.
run(&mut s, "caret.end", json!({}));
assert_eq!(s.sel.focus, Pos::body(0, 8));
assert_eq!(squiggles(&mut s, 0), ["helo"], "{key} then End");
}
// A selection ends it too.
let mut s = typed("The helo");
run(&mut s, "select.all", json!({}));
assert_eq!(squiggles(&mut s, 0), ["helo"]);
}
#[test]
fn a_click_elsewhere_marks_the_typed_word_and_a_click_back_keeps_the_mark() {
let mut s = typed("Fine\nThe helo");
assert_eq!(squiggles(&mut s, 1), Vec::<String>::new());
// The canvas sets the caret directly on a click.
s.sel = crate::Selection::caret(Pos::body(0, 2));
assert_eq!(squiggles(&mut s, 1), ["helo"]);
s.sel = crate::Selection::caret(Pos::body(1, 8));
assert_eq!(squiggles(&mut s, 1), ["helo"], "an existing misspelled word keeps its squiggle");
// Typing into it makes it the word being typed again.
typ(&mut s, "o");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Fine\nThe heloo");
assert_eq!(squiggles(&mut s, 1), Vec::<String>::new());
typ(&mut s, " ");
assert_eq!(squiggles(&mut s, 1), ["heloo"]);
}
#[test]
fn backspace_into_a_finished_word_edits_it_again() {
let mut s = typed("The helo ");
assert_eq!(squiggles(&mut s, 0), ["helo"]);
typ(&mut s, "\u{8}");
assert_eq!(squiggles(&mut s, 0), Vec::<String>::new());
typ(&mut s, "\u{8}\u{8}llo ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "The hello ");
assert_eq!(squiggles(&mut s, 0), Vec::<String>::new());
}
#[test]
fn autocorrect_and_inserted_text_are_marked_as_usual() {
// AutoCorrect fixes the word as the space ends it: nothing to mark.
let mut s = typed("I typed teh ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "I typed the ");
assert_eq!(squiggles(&mut s, 0), Vec::<String>::new());
// Text inserted whole (a paste, an agent): only the word the caret ends in is being typed.
let mut s = typed("");
run(&mut s, "text.insert", json!({"text": "The helo wrld"}));
assert_eq!(squiggles(&mut s, 0), ["helo"]);
// Undo moves the caret: whatever is left is marked, as the Spelling pane reports it.
let mut s = typed("The helo");
run(&mut s, "caret.left", json!({}));
typ(&mut s, "x");
assert_eq!(s.doc.plain_text(StoryRef::Body), "The helxo");
assert_eq!(squiggles(&mut s, 0), Vec::<String>::new());
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "The helo");
assert_eq!(squiggles(&mut s, 0), ["helo"]);
assert_eq!(reported(&mut s), ["helo"]);
}
#[test]
fn apostrophes_and_hyphens_stay_part_of_the_word_being_typed() {
// Smart quotes turn the apostrophe into ’, which the spelling check keeps in the word.
let mut s = typed("It doesn'");
assert_eq!(s.doc.plain_text(StoryRef::Body), "It doesn’");
assert_eq!(reported(&mut s), ["doesn"], "the stem alone is a misspelling");
assert_eq!(squiggles(&mut s, 0), Vec::<String>::new());
let mut s = typed("It doesn't ");
assert_eq!(squiggles(&mut s, 0), Vec::<String>::new());
// A hyphen joins the parts of a compound being typed.
let mut s = typed("The nonn-");
assert_eq!(reported(&mut s), ["nonn"]);
assert_eq!(squiggles(&mut s, 0), Vec::<String>::new());
typ(&mut s, "exist");
assert_eq!(squiggles(&mut s, 0), Vec::<String>::new());
typ(&mut s, " ");
assert!(!squiggles(&mut s, 0).is_empty());
assert_eq!(squiggles(&mut s, 0), reported(&mut s));
// A hyphen after a space starts no word.
let mut s = typed("The helo -");
assert_eq!(squiggles(&mut s, 0), ["helo"]);
}
#[test]
fn text_commands_that_edit_nothing_leave_an_existing_word_marked() {
let mut s = typed("Fine\nThe helo");
s.sel = crate::Selection::caret(Pos::body(0, 2));
assert_eq!(squiggles(&mut s, 1), ["helo"]);
s.sel = crate::Selection::caret(Pos::body(1, 8));
// Insert switches overtype; Delete at the end of the document and Shift+Tab change no text.
run(&mut s, "text.overtype", json!({}));
assert_eq!(squiggles(&mut s, 1), ["helo"], "Insert");
run(&mut s, "text.overtype", json!({}));
let _ = s.run("text.delete", &json!({}));
assert_eq!(squiggles(&mut s, 1), ["helo"], "Delete with nothing after the caret");
let _ = s.run("text.backTab", &json!({}));
assert_eq!(squiggles(&mut s, 1), ["helo"], "Shift+Tab");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Fine\nThe helo");
}
#[test]
fn typing_over_backward_selection_keeps_its_font() {
// As in Word, text typed over a selection takes the first selected character's formatting,
// whichever way the selection was made (#2879).
for (anchor, focus) in [(6, 11), (11, 6)] {
let mut s = s();
typ(&mut s, "Hello world");
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}}));
run(&mut s, "format.font", json!({"name": "Courier New"}));
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": anchor}, "focus": {"block": 0, "off": focus}}));
typ(&mut s, "X");
let p = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
assert_eq!(p.text, "Hello X", "anchor={anchor} focus={focus}");
assert_eq!(p.props_of_char(6).font.as_deref(), Some("Courier New"), "anchor={anchor} focus={focus}");
assert_eq!(p.props_of_char(0).font, None, "anchor={anchor} focus={focus}");
}
}
#[test]
fn right_arrow_moves_through_a_ligature_one_letter_at_a_time() {
// A font that draws "ffi" as one glyph (bundled, so the test doesn't depend on the installed
// fonts); each Right press moves the drawn caret (#1974).
let mut s = s();
run(&mut s, "format.font", json!({"name": "Source Serif 4"}));
typ(&mut s, "ffi");
let drawn_as_one_glyph = s
.layout()
.pages
.iter()
.flat_map(|p| p.items.iter())
.any(|it| matches!(it, wordcraft_layout::Placed::Lines { para, .. } if para.glyphs.len() < para.clusters.len()));
assert!(drawn_as_one_glyph, "Source Serif 4 should draw ffi as a ligature");
run(&mut s, "caret.home", json!({}));
let mut xs = Vec::new();
for _ in 0..4 {
let l = s.layout();
xs.push((s.sel.focus.off, l.caret(&s.sel.focus).unwrap().x));
run(&mut s, "caret.right", json!({}));
}
let offs: Vec<usize> = xs.iter().map(|p| p.0).collect();
assert_eq!(offs, [0, 1, 2, 3]);
assert!(xs.windows(2).all(|w| w[0].1 < w[1].1), "caret x after each Right press: {xs:?}");
}
/// Two original paragraphs whose list comes from a style, optionally through a basedOn chain.
fn style_inherited_list(kind: wordcraft_doc::numbering::ListKind, chain: bool) -> Session {
use wordcraft_doc::props::NumRef;
use wordcraft_doc::styles::Style;
let mut doc = wordcraft_doc::Document::from_text("Alpha\nBeta");
let num = doc.numbering.try_add_list(kind).unwrap();
doc.styles.styles.push(Style {
id: "ListBase".into(),
name: "Original List".into(),
para: wordcraft_doc::ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() },
..Default::default()
});
if chain {
doc.styles.styles.push(Style {
id: "ListChild".into(),
name: "Original Derived".into(),
based_on: Some("ListBase".into()),
..Default::default()
});
}
for i in 0..2 {
doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap().props.style = Some(if chain { "ListChild" } else { "ListBase" }.into());
}
Session::new(doc)
}
#[test]
fn toggle_style_inherited_list_off_preserves_other_paragraphs_and_round_trips() {
use wordcraft_doc::numbering::ListKind;
for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] {
for chain in [false, true] {
let mut s = style_inherited_list(kind, chain);
let before = s.doc.clone();
s.sel = crate::Selection::caret(Pos::body(0, 2));
run(&mut s, command, json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props.numbering.unwrap().num, 0, "{command}, chain={chain}");
assert_eq!(p.props.style, before.para_at(&Pos::body(0, 0)).unwrap().props.style);
assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0)));
assert_eq!(s.doc.styles, before.styles);
assert_eq!(s.doc.numbering, before.numbering);
let after = s.doc.clone();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc, before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc, after);
let bytes = crate::io::save_bytes("list.docx", &s.doc).unwrap();
let saved = crate::io::open_bytes("list.docx", &bytes).unwrap();
let a = saved.para_at(&Pos::body(0, 0)).unwrap();
let b = saved.para_at(&Pos::body(1, 0)).unwrap();
assert_eq!(a.props.numbering.unwrap().num, 0);
assert!(saved.styles.resolve_para(&a.props).numbering.is_none());
assert!(saved.styles.resolve_para(&b.props).numbering.is_some_and(|n| n.num != 0));
assert_eq!(a.text, "Alpha");
assert_eq!(b.text, "Beta");
}
}
}
#[test]
fn explicit_list_off_override_is_not_toggled_off_again() {
use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::props::NumRef;
for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] {
let mut s = style_inherited_list(kind, true);
s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num: 0, level: 0 });
s.sel = crate::Selection::caret(Pos::body(0, 2));
run(&mut s, command, json!({}));
assert!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0));
// A mixed selection must turn the list on, rather than remove the inherited item.
s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num: 0, level: 0 });
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 4) };
run(&mut s, command, json!({}));
for i in 0..2 {
let p = s.doc.para_at(&Pos::body(i, 0)).unwrap();
assert!(s.doc.styles.resolve_para(&p.props).numbering.is_some_and(|n| n.num != 0));
}
}
}
#[test]
fn list_toggle_uses_effective_level_and_override() {
use wordcraft_doc::numbering::{ListKind, levels_for};
use wordcraft_doc::props::NumRef;
for (kind, command, wrong) in [(ListKind::Bullet, "para.bullets", "para.numbering"), (ListKind::Numbered, "para.numbering", "para.bullets")] {
for override_level in [false, true] {
for explicit_kind in [false, true] {
let mut s = style_inherited_list(kind, true);
let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet };
let num = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap().para.numbering.as_mut().unwrap();
num.level = 1;
let num_id = num.num;
s.doc.numbering.abstracts[0].levels[0] = levels_for(opposite)[0].clone();
if override_level {
s.doc.numbering.abstracts[0].levels[1] = levels_for(opposite)[1].clone();
s.doc.numbering.nums[0].level_overrides.push((1, levels_for(kind)[1].clone()));
}
let before = s.doc.clone();
let params = if explicit_kind { json!({"kind": if kind == ListKind::Bullet { "bullet" } else { "numbered" }}) } else { json!({}) };
run(&mut s, command, params);
assert_eq!(
s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering,
Some(NumRef { num: 0, level: 0 }),
"{command}, override={override_level}, explicit={explicit_kind}"
);
assert_eq!(s.doc.styles, before.styles);
assert_eq!(s.doc.numbering, before.numbering);
assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0)));
let mut other = Session::new(before);
run(&mut other, wrong, json!({}));
assert!(other.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.is_some_and(|n| n.num != 0));
assert!(other.doc.numbering.level(num_id, 1).is_some());
}
}
}
}
#[test]
fn list_paragraph_style_survives_inherited_toggle_off() {
use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::props::{Align, ParaProps};
use wordcraft_doc::styles::Style;
for kind in [ListKind::Bullet, ListKind::Numbered] {
let mut s = style_inherited_list(kind, false);
*s.doc.styles.styles.iter_mut().find(|x| x.id == "ListParagraph").unwrap() = Style {
id: "ListParagraph".into(),
name: "Original formatted list".into(),
based_on: Some("ListBase".into()),
para: ParaProps { align: Some(Align::Right), space_after: Some(17.0), ..Default::default() },
..Default::default()
};
s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.style = Some("ListParagraph".into());
let before = s.doc.clone();
run(&mut s, if kind == ListKind::Bullet { "para.bullets" } else { "para.numbering" }, json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props.style.as_deref(), Some("ListParagraph"));
let effective = s.doc.styles.resolve_para(&p.props);
assert!(effective.numbering.is_none());
assert_eq!(effective.align, Align::Right);
assert_eq!(effective.space_after, 17.0);
assert_eq!(s.doc.styles, before.styles);
assert_eq!(s.doc.numbering, before.numbering);
assert_eq!(s.doc.para_at(&Pos::body(1, 0)), before.para_at(&Pos::body(1, 0)));
let after = s.doc.clone();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc, before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc, after);
let bytes = crate::io::save_bytes("list.docx", &s.doc).unwrap();
let saved = crate::io::open_bytes("list.docx", &bytes).unwrap();
let p = saved.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props.style.as_deref(), Some("ListParagraph"));
let effective = saved.styles.resolve_para(&p.props);
assert!(effective.numbering.is_none());
assert_eq!(effective.align, Align::Right);
assert_eq!(effective.space_after, 17.0);
}
}
#[test]
fn direct_list_toggle_still_clears_the_automatic_list_paragraph_style() {
for command in ["para.bullets", "para.numbering"] {
let mut s = Session::new(wordcraft_doc::Document::from_text("Direct list"));
run(&mut s, command, json!({}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.style.as_deref(), Some("ListParagraph"));
run(&mut s, command, json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert!(p.props.style.is_none());
assert!(s.doc.styles.resolve_para(&p.props).numbering.is_none());
}
}
#[test]
fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() {
use wordcraft_doc::numbering::{ListKind, levels_for};
use wordcraft_doc::props::{NumRef, ParaProps};
for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] {
let mut s = style_inherited_list(kind, false);
let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet };
s.doc.numbering.abstracts[0].levels[0] = levels_for(opposite)[0].clone();
let num = s.doc.numbering.nums[0].id;
s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num, level: 1 });
s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props = ParaProps::default();
let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone();
s.sel = crate::Selection::caret(Pos::body(1, 0));
run(&mut s, command, json!({}));
let n = s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.unwrap();
assert_eq!(n.level, 0);
let level = s.doc.numbering.level(n.num, n.level).unwrap();
assert_eq!(level.format == wordcraft_doc::section::NumFormat::Bullet, kind == ListKind::Bullet);
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap(), &before);
}
}
/// #4085: the registered shortcut toggles the last selected highlight, while explicit
/// colour commands remain idempotent setters for the palette, CLI and automation.
#[test]
#[cfg(not(target_os = "macos"))]
fn highlight_shortcut_toggles_the_last_colour() {
let mut s = typed("Alpha Beta");
run(&mut s, "select.all", json!({}));
let colour = |s: &mut Session| s.run("format.state", &json!({})).unwrap()["highlight"].clone();
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), json!("FFFF00"));
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), serde_json::Value::Null);
run(&mut s, "edit.undo", json!({}));
assert_eq!(colour(&mut s), json!("FFFF00"));
run(&mut s, "edit.redo", json!({}));
assert_eq!(colour(&mut s), serde_json::Value::Null);
run(&mut s, "format.highlight", json!({"color":"blue"}));
run(&mut s, "format.highlight", json!({"color":"blue"}));
assert_eq!(colour(&mut s), json!("0000FF"));
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), serde_json::Value::Null);
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), json!("0000FF"));
run(&mut s, "format.highlight", json!({"color":"none"}));
press(&mut s, "Mod+Alt+H").unwrap();
assert_eq!(colour(&mut s), serde_json::Value::Null);
assert_eq!(s.doc.body[0].as_para().unwrap().text, "Alpha Beta");
}
/// #4085: mixed selections take one colour; removing an inherited colour writes an
/// explicit No Color, and a collapsed caret toggles pending formatting for future typing.
#[test]
fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
let mut s = typed("Alpha Beta ");
run(&mut s, "select.text", json!({"text":"Alpha"}));
run(&mut s, "format.highlight", json!({"color":"blue"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF"));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
s.doc.styles.default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
run(&mut s, "format.highlight", json!({"color":"blue"}));
run(&mut s, "format.highlight", json!({}));
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None)));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Blue));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None));
run(&mut s, "text.insert", json!({"text":"X"}));
run(&mut s, "select.text", json!({"text":"X"}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
let before = serde_json::to_value(&s.doc).unwrap();
assert!(s.run("format.highlight", &json!({"color":123})).is_err());
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
}
/// A differently coloured final paragraph must remain visible to the shortcut beyond
/// the formatting-state query limit, including a style-inherited tail and Undo/Redo.
#[cfg(not(target_os = "macos"))]
#[test]
fn highlight_toggle_checks_past_the_resolved_run_limit() {
use wordcraft_doc::para::Run;
use wordcraft_doc::props::{CharProps, Highlight};
for count in [10_000, 10_001, 10_002] {
for inherited in [false, true] {
let mut doc = wordcraft_doc::Document::from_text(&format!("{}\nZ", "x".repeat(count)));
doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().runs = (0..count)
.map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } })
.collect();
if inherited {
doc.styles.default_chr.highlight = Some(Highlight::Yellow);
} else {
doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow);
}
doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().mark.highlight = Some(Highlight::Blue);
let original = doc.clone();
let mut s = Session::new(doc);
s.last_highlight = Highlight::Blue;
run(&mut s, "select.all", json!({}));
press(&mut s, "Mod+Alt+H").unwrap();
assert!(
s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::Blue)),
"count={count}, inherited={inherited}"
);
press(&mut s, "Mod+Alt+H").unwrap();
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::None)));
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(serde_json::to_value(&s.doc).unwrap(), serde_json::to_value(&original).unwrap());
run(&mut s, "edit.redo", json!({}));
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
}
}
}
/// A command preview uses the real session's remembered colour without changing its
/// document, colour choice or undo history when the preview toggles highlighting off.
#[test]
fn highlight_preview_preserves_the_session_colour_and_document() {
use wordcraft_doc::props::Highlight;
let mut s = typed("Alpha");
run(&mut s, "select.all", json!({}));
run(&mut s, "format.highlight", json!({"color":"blue"}));
let original = serde_json::to_value(&s.doc).unwrap();
let depth = s.undo_depth();
let (preview, _, _) = s.preview_layout("format.highlight", &json!({})).unwrap();
assert!(preview.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::None)));
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
assert_eq!(s.last_highlight, Highlight::Blue);
assert_eq!(s.undo_depth(), depth);
}
/// Whole-cell column selections include paragraph marks, including empty cells and
/// inherited mark colours; adjacent unselected cells and undo/redo keep their formatting.
#[test]
fn highlight_toggle_checks_whole_cell_marks() {
use wordcraft_doc::props::Highlight;
use wordcraft_doc::{CharProps, Paragraph};
for inherited in [false, true] {
for empty in [false, true] {
let mut s = s();
run(&mut s, "insert.table", json!({"rows":2,"cols":2}));
let (table, _, _) = s.sel.focus.path.cell().unwrap();
let pos = |row, col| {
let mut path = table.clone();
path.0.extend([row, col, 0]);
Pos::new(StoryRef::Body, path, 0)
};
if inherited {
s.doc.styles.default_chr.highlight = Some(Highlight::Yellow);
}
for row in 0..2 {
for col in 0..2 {
let p = s.doc.para_mut(StoryRef::Body, &pos(row, col).path).unwrap();
let colour = if col == 0 { Highlight::Blue } else { Highlight::Red };
*p = Paragraph::with_text(
if empty && row == 1 && col == 0 { "" } else { "AB" },
CharProps { highlight: Some(colour), ..Default::default() },
);
p.mark.highlight = Some(colour);
if row == 1 && col == 0 {
p.mark.highlight = if inherited { None } else { Some(Highlight::Yellow) };
}
}
}
s.last_highlight = Highlight::Blue;
run(&mut s, "caret.set", json!({"pos":pos(0,0)}));
run(&mut s, "table.selectColumn", json!({}));
assert!(!s.cell_range().unwrap().stream);
let original = serde_json::to_value(&s.doc).unwrap();
run(&mut s, "format.highlight", json!({}));
for row in 0..2 {
let p = s.doc.para_at(&pos(row, 0)).unwrap();
assert_eq!(p.mark.highlight, Some(Highlight::Blue), "inherited={inherited}, empty={empty}");
assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
}
run(&mut s, "format.highlight", json!({}));
for row in 0..2 {
let p = s.doc.para_at(&pos(row, 0)).unwrap();
assert_eq!(p.mark.highlight, Some(Highlight::None));
assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::None)));
let other = s.doc.para_at(&pos(row, 1)).unwrap();
assert_eq!(other.mark.highlight, Some(Highlight::Red));
assert!(other.runs.iter().all(|r| r.props.highlight == Some(Highlight::Red)));
}
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.para_at(&pos(1, 0)).unwrap().mark.highlight, Some(Highlight::Blue));
}
}
}
/// Fully selected marks participate even when the selection has no text runs or an
/// empty paragraph between blue runs; repeated toggles and undo preserve these targets.
#[test]
fn highlight_toggle_checks_ordinary_paragraph_marks() {
use wordcraft_doc::props::Highlight;
for text in ["AB", "AB\n\nAB", "\n"] {
let mut doc = wordcraft_doc::Document::from_text(text);
doc.styles.default_chr.highlight = Some(Highlight::Blue);
let paths = doc.para_paths(StoryRef::Body);
for path in &paths {
let p = doc.para_mut(StoryRef::Body, path).unwrap();
p.mark.highlight = Some(Highlight::Blue);
for r in &mut p.runs {
r.props.highlight = Some(Highlight::Blue);
}
}
let mismatch = if text == "AB\n\nAB" { 1 } else { 0 };
doc.para_mut(StoryRef::Body, &paths[mismatch]).unwrap().mark.highlight = Some(Highlight::Yellow);
let original = serde_json::to_value(&doc).unwrap();
let mut s = Session::new(doc);
s.last_highlight = Highlight::Blue;
run(&mut s, "select.all", json!({}));
run(&mut s, "format.highlight", json!({}));
for path in &paths {
let p = s.doc.para(StoryRef::Body, path).unwrap();
assert_eq!(p.mark.highlight, Some(Highlight::Blue), "text={text:?}");
assert!(p.runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
}
run(&mut s, "format.highlight", json!({}));
assert!(paths.iter().all(|p| s.doc.para(StoryRef::Body, p).unwrap().mark.highlight == Some(Highlight::None)));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
}
}
/// A partial text column excludes paragraph marks and text beyond its selected offsets,
/// so a different mark colour must not prevent clearing the selected blue characters.
#[test]
fn highlight_partial_column_ignores_unselected_marks() {
use wordcraft_doc::props::Highlight;
let mut doc = wordcraft_doc::Document::from_text("AB\nAB");
for path in doc.para_paths(StoryRef::Body) {
let p = doc.para_mut(StoryRef::Body, &path).unwrap();
p.mark.highlight = Some(Highlight::Yellow);
for r in &mut p.runs {
r.props.highlight = Some(Highlight::Blue);
}
}
let mut s = Session::new(doc);
s.last_highlight = Highlight::Blue;
run(&mut s, "select.column", json!({"anchor":Pos::body(0,0),"focus":Pos::body(1,1)}));
run(&mut s, "format.highlight", json!({}));
for row in 0..2 {
let p = s.doc.para_at(&Pos::body(row, 0)).unwrap();
assert_eq!(p.props_of_char(0).highlight, Some(Highlight::None));
assert_eq!(p.props_of_char(1).highlight, Some(Highlight::Blue));
assert_eq!(p.mark.highlight, Some(Highlight::Yellow));
}
}