mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-28 04:17:06 -06:00
* Fix #1974: the caret and clicks land between the letters of a ligature The glyph's advance is shared evenly among its letters (mirrored in right-to-left text); ligature letters keep their character spacing. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Fix #1974: a long word never wraps inside a ligature Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Fix #1974: a hyphenation point never splits a ligature Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * #1974: ligature tests use a bundled font that draws fi and ffi as ligatures They assumed the installed default-font stand-in draws ligatures; Liberation Sans (Linux) draws none, so they failed there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
2163 lines
100 KiB
Rust
2163 lines
100 KiB
Rust
//! 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;
|
||
|
||
fn s() -> Session {
|
||
Session::new(wordcraft_doc::Document::new())
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
fn para(text: &str, level: Option<u8>) -> (String, Option<u8>, bool) {
|
||
(text.to_string(), level, level.is_some())
|
||
}
|
||
|
||
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(¶("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()
|
||
}
|
||
|
||
#[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));
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
#[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---");
|
||
}
|
||
|
||
#[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()));
|
||
}
|
||
|
||
#[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 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");
|
||
}
|
||
}
|
||
|
||
/// 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"]]);
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
// 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:?}");
|
||
}
|