mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-13 00:17:36 +00:00
review.language takes scope "selection" (default, unchanged) or "document": every run and paragraph mark of every story (body, headers, footers, notes, comments, text boxes, table cells, glossary) takes lang/noProof in one undo step, selection unchanged, pending typing format follows. The Language dialog gets an "Apply to the whole document" check box. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
394 lines
20 KiB
Rust
394 lines
20 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_keeps_text_boxes_of_the_original() {
|
|
// Text boxes are anchored in body paragraphs: one after changed text, one alone in an
|
|
// otherwise empty paragraph, one after text the revised document keeps. Their anchors must
|
|
// survive the compare (marked deleted, since the revised text has no boxes), so no text is lost.
|
|
for show_in in ["original", "revised", "new"] {
|
|
let mut s = Session::new(Document::from_text("Body one.\n\nKeep."));
|
|
let mut boxes = Vec::new();
|
|
for (para, off, text) in [(0usize, "Body one.".len(), "Alpha boxword gamma."), (1, 0, "Vml onlyword here."), (2, "Keep.".len(), "Kept box.")]
|
|
{
|
|
s.run("caret.set", &json!({"pos": Pos::body(para, off)})).unwrap();
|
|
let id = s.run("insert.textBox", &json!({"text": text})).unwrap()["story"].as_u64().unwrap() as u32;
|
|
boxes.push((id, text));
|
|
}
|
|
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
|
|
s.run("review.compare", &json!({"text": "Totally new first.\nSecond new line.\nKeep.", "showIn": show_in})).unwrap();
|
|
for (id, text) in &boxes {
|
|
let anchor = s.doc.text_box_anchor(*id).unwrap_or_else(|| panic!("{show_in}: the box \"{text}\" lost its anchor"));
|
|
assert_eq!(s.doc.plain_text(StoryRef::Part(*id)), *text);
|
|
let p = s.doc.para(anchor.story, &anchor.path).unwrap();
|
|
assert!(
|
|
p.props_of_char(anchor.off - wordcraft_doc::para::OBJ.len_utf8()).del.is_some(),
|
|
"{show_in}: the box \"{text}\" is not marked deleted"
|
|
);
|
|
}
|
|
s.run("review.rejectAll", &json!({})).unwrap();
|
|
assert_eq!(s.doc.plain_text(StoryRef::Body), "Body one.\n\nKeep.");
|
|
for (id, text) in &boxes {
|
|
assert!(s.doc.text_box_anchor(*id).is_some(), "{show_in}: rejecting lost the box \"{text}\"");
|
|
assert_eq!(s.doc.plain_text(StoryRef::Part(*id)), *text);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compare_with_a_revised_file_keeps_its_text_boxes_backed() {
|
|
// Text boxes in both documents, the revised one from a file: every anchor in the result
|
|
// shows a text box story of the result, and both documents' boxes are there, marked.
|
|
fn with_boxes(text: &str, boxes: &[(usize, usize, &str)]) -> Session {
|
|
let mut s = Session::new(Document::from_text(text));
|
|
for (para, off, text) in boxes {
|
|
s.run("caret.set", &json!({"pos": Pos::body(*para, *off)})).unwrap();
|
|
s.run("insert.textBox", &json!({"text": text})).unwrap();
|
|
}
|
|
s
|
|
}
|
|
/// Each text box anchored in the body: its story's text, and whether its anchor is deleted
|
|
/// or inserted.
|
|
fn boxes(d: &Document) -> Vec<(String, bool, bool)> {
|
|
let mut out = Vec::new();
|
|
for path in d.para_paths(StoryRef::Body) {
|
|
let p = d.para(StoryRef::Body, &path).unwrap();
|
|
for (off, o) in p.objects_with_offsets() {
|
|
for id in o.text_boxes() {
|
|
let part = d.parts.get(&id).unwrap_or_else(|| panic!("an anchor shows story {id}, which the result lacks"));
|
|
assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox, "story {id}");
|
|
let c = p.props_of_char(off);
|
|
out.push((d.plain_text(StoryRef::Part(id)), c.del.is_some(), c.ins.is_some()));
|
|
}
|
|
}
|
|
}
|
|
out.sort();
|
|
out
|
|
}
|
|
let rev = with_boxes("Body one.\nNew middle.\nKeep.", &[(1, "New middle.".len(), "Revised box."), (2, "Keep.".len(), "Kept box.")]);
|
|
let path = std::env::temp_dir().join(format!("wc-compare-boxes-{}.docx", std::process::id()));
|
|
crate::io::save_path(&path, &rev.doc).unwrap();
|
|
let t = |t: &str, del, ins| (t.to_string(), del, ins);
|
|
for show_in in ["original", "revised", "new"] {
|
|
for (then, left) in [("review.acceptAll", "Revised box."), ("review.rejectAll", "Original box.")] {
|
|
let mut s = with_boxes("Body one.\nOld middle.\nKeep.", &[(1, "Old middle.".len(), "Original box."), (2, "Keep.".len(), "Kept box.")]);
|
|
s.run("review.compare", &json!({"path": path.to_string_lossy(), "showIn": show_in})).unwrap();
|
|
assert_eq!(boxes(&s.doc), [t("Kept box.", false, false), t("Original box.", true, false), t("Revised box.", false, true)], "{show_in}");
|
|
s.run(then, &json!({})).unwrap();
|
|
assert_eq!(boxes(&s.doc), [t("Kept box.", false, false), t(left, false, false)], "{show_in} {then}");
|
|
}
|
|
}
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[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}");
|
|
}
|
|
|
|
/// #3518: every run and paragraph mark of a story, as (story, path, lang, noProof) rows.
|
|
fn proofing_rows(s: &Session) -> Vec<(StoryRef, Option<String>, Option<bool>)> {
|
|
let stories = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k)));
|
|
let mut out = Vec::new();
|
|
for st in stories {
|
|
for path in s.doc.para_paths(st) {
|
|
let Some(p) = s.doc.para(st, &path) else { continue };
|
|
out.push((st, p.mark.lang.clone(), p.mark.no_proof));
|
|
out.extend(p.runs.iter().map(|r| (st, r.props.lang.clone(), r.props.no_proof)));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A document with its text in German in the body, a table cell, a header, a footnote, a comment
|
|
/// and a text box, as a Word DOCX keeps a `w:lang` on each run, plus a glossary entry.
|
|
fn multi_story_doc() -> Session {
|
|
use wordcraft_doc::{Block, BuildingBlock, CharProps, Paragraph, PartKind, Table};
|
|
let de = || CharProps { lang: Some("de-DE".into()), ..Default::default() };
|
|
let blk = |t: &str| Arc::new(Block::Para(Paragraph::with_text(t, de())));
|
|
let mut d = Document::from_text("Guten Tag");
|
|
d.body = vec![blk("Guten Tag")];
|
|
let mut t = Table::new(1, 2, 200.0);
|
|
if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) {
|
|
c.blocks = vec![blk("Zelle")];
|
|
}
|
|
d.body.push(Arc::new(Block::Table(t)));
|
|
d.body.push(blk("Ende"));
|
|
for kind in [PartKind::Header, PartKind::Footnote, PartKind::Comment, PartKind::TextBox] {
|
|
d.add_part(kind, vec![blk("Teil")]);
|
|
}
|
|
d.glossary.push(BuildingBlock { name: "g".into(), blocks: vec![blk("Baustein")], ..Default::default() });
|
|
Session::new(d)
|
|
}
|
|
|
|
#[test]
|
|
fn language_scope_document_sets_every_story() {
|
|
let mut s = multi_story_doc();
|
|
s.run("select.text", &json!({"text": "Guten"})).unwrap();
|
|
let (sel, before) = (s.sel.clone(), s.doc.clone());
|
|
let rows = proofing_rows(&s);
|
|
assert!(rows.len() >= 10 && rows.iter().any(|r| r.1.as_deref() == Some("de-DE")));
|
|
s.run("review.language", &json!({"lang": "fr-FR", "scope": "document"})).unwrap();
|
|
// Runs that had their own language, the paragraph marks and the empty cell: all French.
|
|
let rows = proofing_rows(&s);
|
|
assert!(rows.iter().all(|r| r.1.as_deref() == Some("fr-FR")), "{rows:?}");
|
|
for kind_story in s.doc.parts.keys() {
|
|
assert!(rows.iter().any(|r| r.0 == StoryRef::Part(*kind_story)));
|
|
}
|
|
assert!(s.doc.plain_text(StoryRef::Body).contains("Zelle"));
|
|
let g = &s.doc.glossary[0].blocks;
|
|
assert_eq!(g[0].as_para().map(|p| p.runs[0].props.lang.clone()), Some(Some("fr-FR".into())));
|
|
assert_eq!(g[0].as_para().map(|p| p.mark.lang.clone()), Some(Some("fr-FR".into())));
|
|
// The selection stays; the document is modified.
|
|
assert_eq!(s.sel, sel);
|
|
assert!(s.doc != before);
|
|
// One undo restores everything.
|
|
s.run("edit.undo", &json!({})).unwrap();
|
|
assert!(s.doc == before);
|
|
assert_eq!(s.sel, sel);
|
|
}
|
|
|
|
#[test]
|
|
fn language_scope_document_no_proof_and_pending() {
|
|
let mut s = multi_story_doc();
|
|
s.run("review.language", &json!({"noProof": true, "scope": "document"})).unwrap();
|
|
assert!(proofing_rows(&s).iter().all(|r| r.2 == Some(true)));
|
|
// The language itself wasn't asked for and stays.
|
|
assert!(proofing_rows(&s).iter().any(|r| r.1.as_deref() == Some("de-DE")));
|
|
// Formatting waiting for typing follows.
|
|
s.run("format.bold", &json!({})).unwrap();
|
|
s.run("review.language", &json!({"lang": "pl-PL", "scope": "document"})).unwrap();
|
|
assert_eq!(s.pending.as_ref().and_then(|p| p.lang.as_deref()), Some("pl-PL"));
|
|
}
|
|
|
|
#[test]
|
|
fn language_default_scope_is_selection_only() {
|
|
let mut s = multi_story_doc();
|
|
s.run("select.text", &json!({"text": "Guten"})).unwrap();
|
|
s.run("review.language", &json!({"lang": "fr-FR"})).unwrap();
|
|
let rows = proofing_rows(&s);
|
|
assert!(rows.iter().any(|r| r.1.as_deref() == Some("fr-FR")));
|
|
assert!(rows.iter().filter(|r| r.1.as_deref() == Some("fr-FR")).count() < rows.len() / 2);
|
|
let mut s = multi_story_doc();
|
|
s.run("select.text", &json!({"text": "Guten"})).unwrap();
|
|
s.run("review.language", &json!({"lang": "fr-FR", "scope": "selection"})).unwrap();
|
|
assert_eq!(proofing_rows(&s), rows);
|
|
}
|
|
|
|
#[test]
|
|
fn language_bad_scope_is_a_params_error() {
|
|
let mut s = multi_story_doc();
|
|
let before = s.doc.clone();
|
|
for bad in [json!("everything"), json!(1), json!(true), json!(["document"])] {
|
|
let e = s.run("review.language", &json!({"lang": "fr-FR", "detect": false, "scope": bad})).unwrap_err();
|
|
assert!(matches!(e, CmdError::Params(_)), "{e:?}");
|
|
}
|
|
assert!(s.doc == before);
|
|
assert!(s.prefs.detect_language);
|
|
}
|