diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 638f38b7bf..9cb266452d 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -2217,217 +2217,4 @@ fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn compare_marks_changes() { - let mut s = Session::new(Document::from_text("The cat sat.\nKeep this.\nOld line.")); - let r = s.run("review.compare", &json!({"text": "The dog sat.\nKeep this.\nNew line here."})).unwrap(); - assert!(r["changes"].as_u64().unwrap() >= 2); - s.run("review.acceptAll", &json!({})).unwrap(); - assert_eq!(s.doc.plain_text(StoryRef::Body), "The dog sat.\nKeep this.\nNew line here."); - } - - #[test] - fn compare_options_ignore_case_and_white_space() { - let original = "The Cat sat.\nTwo spaces here."; - let revised = "The cat sat.\nTwo spaces here."; - // By default both differences are changes. - let mut s = Session::new(Document::from_text(original)); - let r = s.run("review.compare", &json!({"text": revised, "author": "Reviewer"})).unwrap(); - assert_eq!(r["changes"], 2); - assert!(s.doc.revisions.iter().any(|x| x.author == "Reviewer")); - // Ignoring case and white space: no changes, and the result reads as the revised text. - let mut s = Session::new(Document::from_text(original)); - let r = s.run("review.compare", &json!({"text": revised, "caseChanges": false, "whiteSpace": false})).unwrap(); - assert_eq!(r["changes"], 0); - assert_eq!(s.doc.plain_text(StoryRef::Body), revised); - // Character level marks only the changed letters. - let mut s = Session::new(Document::from_text("colour")); - s.run("review.compare", &json!({"text": "color", "level": "character"})).unwrap(); - let p = s.doc.body[0].as_para().unwrap(); - let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); - assert_eq!(deleted, "u"); - // Two dates that happen to share digits in a different order ("01.09.2026" vs - // "19.10.2026") must come out as one clean replacement, not a scrambled chain of - // single-character deletions and insertions wherever a digit coincidentally recurs. - let mut s = Session::new(Document::from_text("zawarta w dniu 01.09.2026 pomiędzy:")); - s.run("review.compare", &json!({"text": "zawarta w dniu 19.10.2026 pomiędzy:", "level": "character"})).unwrap(); - let p = s.doc.body[0].as_para().unwrap(); - let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); - let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect(); - assert_eq!(deleted, "01.09"); - assert_eq!(inserted, "19.10"); - assert_eq!(p.run_ranges().filter(|(_, c)| c.del.is_some()).count(), 1, "one clean deleted run, not several scattered ones"); - assert_eq!(p.run_ranges().filter(|(_, c)| c.ins.is_some()).count(), 1, "one clean inserted run, not several scattered ones"); - // Formatting differences become formatting changes. - let mut s = Session::new(Document::from_text("Same words.")); - let mut rev = Document::from_text("Same words."); - rev.format_range(&rev.start_of(StoryRef::Body), &rev.end_of(StoryRef::Body), &|c| c.bold = Some(true)).unwrap(); - let path = std::env::temp_dir().join(format!("wc-compare-{}.docx", std::process::id())); - crate::io::save_path(&path, &rev).unwrap(); - let r = s.run("review.compare", &json!({"path": path.to_string_lossy()})).unwrap(); - let _ = std::fs::remove_file(&path); - assert_eq!(r["changes"], 1); - assert!(s.doc.revisions.iter().any(|x| x.kind == RevisionKind::Format)); - } - - #[test] - fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() { - // "01.09" vs "19.10": a pure LCS finds a scrambled chain of single-character matches - // ('1', '.', '0') threaded through what a reader expects to be one clean replacement. - let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0'], &mut { u64::MAX }); - assert!(ops.contains(&Op::Same), "the raw LCS does find spurious matches here, or this test proves nothing"); - assert_eq!(collapse_noise_matches(&ops), vec![Op::Del, Op::Del, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Ins, Op::Ins, Op::Ins]); - // A plain single-character replacement flanked by a real common prefix and suffix has no - // interior match to begin with (the DP's middle is pure delete/insert), so it's untouched. - let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f'], &mut { u64::MAX }); - assert_eq!(collapse_noise_matches(&ops), ops); - } - - #[test] - fn compare_at_word_level_still_matches_a_whole_unchanged_word() { - // `collapse_noise_matches` only runs for character-level compares (wired up in - // `word_diff`): a single matched *word* between two edits is a real, meaningful match - // (unlike a single matched character), and must never be folded into a replacement. - let mut s = Session::new(Document::from_text("the cat sat")); - s.run("review.compare", &json!({"text": "a cat ran"})).unwrap(); - let p = s.doc.body[0].as_para().unwrap(); - assert!( - p.run_ranges().any(|(r, c)| c.del.is_none() && c.ins.is_none() && p.text[r].trim() == "cat"), - "the unchanged word must survive as plain text" - ); - } - - #[test] - fn compare_bounds_total_diff_work() { - // Many long, slightly different paragraphs: each pair alone fits one comparison table, - // but the work over all pairs must stay bounded. Pairs past the budget are compared - // whole (deleted and inserted), and every pair still counts as a change. - let words = 3000; - let paras = 12; - let doc = |rev: bool| -> String { - (0..paras) - .map(|p| (0..words).map(|w| if w % 10 == 0 && rev { format!("x{p}w{w}") } else { format!("p{p}w{w}") }).collect::>().join(" ")) - .collect::>() - .join("\n") - }; - let (original, revised) = (doc(false), doc(true)); - let mut s = Session::new(Document::from_text(&original)); - let r = s.run("review.compare", &json!({"text": revised})).unwrap(); - assert_eq!(r["changes"], paras); - // A pair diffed word by word keeps most of its text unmarked; one replaced whole keeps - // only its common start and end. - let word_diffed = |b: &Arc| { - let p = b.as_para().unwrap(); - let same: usize = p.run_ranges().filter(|(_, c)| c.ins.is_none() && c.del.is_none()).map(|(r, _)| r.len()).sum(); - same * 2 > p.text.len() - }; - assert!(word_diffed(&s.doc.body[0])); - let diffed = s.doc.body.iter().filter(|b| word_diffed(b)).count() as u64; - let cells = (words as u64) * (words as u64); - assert!(diffed * cells <= 50_000_000 + cells, "{diffed} pairs diffed word by word"); - assert!(!word_diffed(s.doc.body.last().unwrap())); - s.run("review.acceptAll", &json!({})).unwrap(); - assert_eq!(s.doc.plain_text(StoryRef::Body), revised); - } - - #[test] - fn macros_record_and_run() { - let mut s = Session::new(Document::new()); - s.run("tools.recordMacro", &json!({"name": "hi"})).unwrap(); - s.run("text.insert", &json!({"text": "Hi "})).unwrap(); - s.run("format.bold", &json!({})).unwrap(); - s.run("tools.recordMacro", &json!({})).unwrap(); - s.run("tools.macros", &json!({"run": "hi"})).unwrap(); - assert_eq!(s.doc.plain_text(StoryRef::Body), "Hi Hi "); - s.run("edit.repeat", &json!({})).unwrap(); - } - - #[test] - fn autocorrect_on_space() { - let mut s = Session::new(Document::new()); - for t in ["teh", " ", "Teh", " ", "(c)", " "] { - s.run("text.insert", &json!({"text": t})).unwrap(); - } - // The first word of a sentence is capitalised too. - assert_eq!(s.doc.plain_text(StoryRef::Body), "The The © "); - } - - #[test] - fn protection_blocks_edits() { - let mut s = Session::new(Document::from_text("locked")); - s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); - assert!(s.run("text.insert", &json!({"text": "x"})).is_err()); - // The "none" mode is the shorthand for stopping the protection, and it doesn't stop a - // protected document: only the explicit stop does (#1327 follow-up). - assert!(s.run("review.restrict", &json!({"mode": "none"})).is_err()); - assert!(s.run("text.insert", &json!({"text": "x"})).is_err()); - s.run("review.restrict", &json!({"stop": true})).unwrap(); - assert!(s.run("text.insert", &json!({"text": "x"})).is_ok()); - } - - #[test] - fn accessibility_and_quick_parts() { - let mut s = Session::new(crate::sample::sample_document()); - let r = s.run("file.accessibility", &json!({})).unwrap(); - assert!(r["issues"].is_array()); - s.run("select.text", &json!({"text": "Membership"})).unwrap(); - s.run("insert.quickParts", &json!({"save": "m"})).unwrap(); - s.run("caret.docEnd", &json!({})).unwrap(); - s.run("insert.quickParts", &json!({"insert": "m"})).unwrap(); - assert!(s.doc.plain_text(StoryRef::Body).ends_with("Membership")); - } - - /// #407 Language: the selection's language and "do not check", the default language and - /// the detect preference; a bad tag is refused. - #[test] - fn proofing_language_options() { - let mut s = Session::new(Document::from_text("Bonjour")); - s.run("select.all", &json!({})).unwrap(); - s.run("review.language", &json!({"lang": "fr-FR", "noProof": true, "detect": false})).unwrap(); - let rc = s.doc.para_at(&Pos::body(0, 1)).map(|p| p.props_at(1).clone()).unwrap(); - assert_eq!((rc.lang.as_deref(), rc.no_proof), (Some("fr-FR"), Some(true))); - assert!(!s.prefs.detect_language); - s.run("review.language", &json!({"lang": "de-DE", "default": true})).unwrap(); - assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE")); - assert!(s.run("review.language", &json!({"lang": "