Files
wordcraft/crates/engine/src/cmd/tools/tests.rs
T

213 lines
11 KiB
Rust

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::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.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<Block>| {
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": "<script>"})).is_err());
}
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
/// document that tracks changes the text would go with no revision to show for it, and the
/// revision trail would be broken behind the user's back. The removal is refused instead,
/// and the document is left as it was.
#[test]
fn inspect_removals_are_refused_while_the_document_tracks_changes() {
let mut s = Session::new(Document::from_text("Visible text.\nHidden text."));
s.run("select.text", &json!({"text": "Hidden"})).unwrap();
s.run("format.hidden", &json!({"value": true})).unwrap();
// Tracking without Restrict Editing isolates the Inspector guard from the protected
// document check added separately in #3285.
s.run("review.trackChanges", &json!({"value": true})).unwrap();
assert!(s.doc.settings.track_changes, "the document doesn't track changes");
let before = s.doc.clone();
let err = s.run("file.inspect", &json!({"remove": ["hidden"]})).unwrap_err();
assert!(matches!(err, CmdError::Disabled(_)), "{err}");
assert_eq!(s.doc, before, "the document changed despite the refusal");
// Reading the document stays allowed.
let found = s.run("file.inspect", &json!({})).unwrap();
assert_eq!(found["found"]["hiddenText"], true, "{found}");
}
/// A document that doesn't track changes is inspected as before: the hidden text goes.
#[test]
fn inspect_removals_work_when_the_document_does_not_track_changes() {
let mut s = Session::new(Document::from_text("Visible text.\nHidden text."));
s.run("select.text", &json!({"text": "Hidden"})).unwrap();
s.run("format.hidden", &json!({"value": true})).unwrap();
assert!(!s.doc.settings.track_changes);
let out = s.run("file.inspect", &json!({"remove": ["hidden"]})).unwrap();
assert_eq!(out["residual"], json!([]), "{out}");
let text = s.doc.plain_text(StoryRef::Body);
assert!(!text.contains("Hidden"), "the hidden text is still there: {text}");
assert!(text.contains("Visible text."), "{text}");
}