Files
vectorcraft/crates/engine/src/tests/spelling.rs
T
dotCooCoo ba2531bc3f Tests: engine tests remove the temp folders they make
vectorcraft_testkit::temp_dir returns a bare path, so every engine test
that wrote into one left the folder in the system temporary folder.

- testkit: TempDir, a test's own folder made through temp_dir (same
  private creation and ownership checks) under a tag no other TempDir in
  the process uses, removed with its contents when dropped, also when
  the test panics.
- Engine unit and integration tests hold a TempDir for the whole test
  instead of calling temp_dir: graph, fileio, compare, journal, library,
  shortcut set, spelling, store, web export, cancel, containers, build,
  color settings, command sweep, font folder, removed font, model-based
  and the font search fuzz.
- The DCS and swatch library tests' own helpers and the PDF editing
  tests' shared vc-pdfedit-<pid> folder use a TempDir per test too.
- The font and link file search, store, color settings and font folder
  helpers no longer delete the private folder temp_dir checked and make
  it again with default permissions.
2026-11-13 09:33:36 -08:00

367 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Edit → Spelling (`text.spelling.check`, `text.spelling.suggest`): the unknown words of the
//! document's type, at the bytes `text.editRange` changes, each in its run's language.
use serde_json::{Value, json};
use crate::cmd::spelling::MAX_DICTIONARY_FILE_BYTES;
use crate::*;
/// Held by each test that changes the custom dictionary, which the whole process shares: a save
/// writes every word in it to the test's file and a reload takes the file's words back, so another
/// test's word could come back after that test deleted it.
fn dictionary_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A session with a new 400 × 400 document.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
/// Runs a command that must succeed and gives its result.
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap()
}
/// New point type at (10, `y`) holding `t`.
fn text(s: &mut Session, y: f64, t: &str) -> NodeId {
NodeId(run(s, "text.create", json!({"x": 10, "y": y, "text": t}))["id"].as_u64().unwrap())
}
/// The unknown words a check lists: (id, start, end, word).
fn unknown(s: &mut Session, p: Value) -> Vec<(u64, u64, u64, String)> {
let r = run(s, "text.spelling.check", p);
assert_eq!(r["more"], false);
r["words"]
.as_array()
.unwrap()
.iter()
.map(|w| (w["id"].as_u64().unwrap(), w["start"].as_u64().unwrap(), w["end"].as_u64().unwrap(), w["word"].as_str().unwrap().to_string()))
.collect()
}
#[test]
fn check_lists_unknown_words_at_the_bytes_edit_range_changes() {
let mut s = session();
// "—" is three bytes, so the unknown word starts at byte 12, not character 10. Punctuation,
// not a word, so the result doesn't depend on which dictionary is built in.
let t = text(&mut s, 50.0, "drawing — qzxvw tools");
assert_eq!(unknown(&mut s, json!({})), [(t.0, 12, 17, "qzxvw".to_string())]);
let w = &run(&mut s, "text.spelling.check", json!({}))["words"][0];
assert_eq!(w["lang"], "en", "text with no language is checked in English");
// Change: the bytes the check gave, as one undo step.
run(&mut s, "text.editRange", json!({"id": t.0, "start": 12, "end": 17, "insert": "vector"}));
assert_eq!(unknown(&mut s, json!({})), []);
run(&mut s, "edit.undo", json!({}));
assert_eq!(unknown(&mut s, json!({})).len(), 1, "undo brings the word back");
}
#[test]
fn check_goes_through_visible_unlocked_type_in_paint_order_or_the_ids_given() {
let mut s = session();
let a = text(&mut s, 50.0, "first qzxvw");
let b = text(&mut s, 100.0, "second wvxzq");
let hidden = text(&mut s, 150.0, "hidden qqzzx");
let locked = text(&mut s, 200.0, "locked zzqqx");
run(&mut s, "object.hide", json!({"ids": [hidden.0]}));
run(&mut s, "object.lock", json!({"ids": [locked.0]}));
let ids: Vec<u64> = unknown(&mut s, json!({})).iter().map(|w| w.0).collect();
assert_eq!(ids, [a.0, b.0], "hidden and locked type are left out");
assert_eq!(unknown(&mut s, json!({"ids": [b.0]})), [(b.0, 7, 12, "wvxzq".to_string())]);
assert_eq!(unknown(&mut s, json!({"ids": null})).len(), 2, "null ids is the default");
let both = [(a.0, 6, 11, "qzxvw".to_string()), (b.0, 7, 12, "wvxzq".to_string())];
assert_eq!(unknown(&mut s, json!({"ids": [b.0, a.0]})), both, "given ids are listed in paint order too");
for ids in [json!(b.0), json!("all"), json!({"id": b.0}), json!([-1]), json!(["x"]), json!([999_999])] {
assert!(s.execute("text.spelling.check", &json!({"ids": ids})).is_err(), "malformed ids {ids} is refused");
}
}
#[test]
fn a_language_without_a_dictionary_marks_nothing() {
let mut s = session();
let t = text(&mut s, 50.0, "qzxvw");
run(&mut s, "text.setFormat", json!({"ids": [t.0], "language": "German"}));
assert_eq!(unknown(&mut s, json!({})), [], "German has no spelling dictionary in craft-proof");
}
#[test]
fn suggest_offers_dictionary_words() {
let mut s = session();
let r = run(&mut s, "text.spelling.suggest", json!({"word": "drawng"}));
let got: Vec<&str> = r["suggestions"].as_array().unwrap().iter().map(|w| w.as_str().unwrap()).collect();
assert!(got.len() <= 5, "five at most by default: {got:?}");
assert!(got.contains(&"drawing"), "{got:?}");
assert_eq!(run(&mut s, "text.spelling.suggest", json!({"word": "drawng", "max": 1}))["suggestions"].as_array().unwrap().len(), 1);
assert!(s.execute("text.spelling.suggest", &json!({})).is_err(), "a word is required");
assert!(s.execute("text.spelling.suggest", &json!({"word": " "})).is_err(), "a blank word is refused");
for max in [json!(0), json!(21), json!(-1), json!("3")] {
assert!(s.execute("text.spelling.suggest", &json!({"word": "drawng", "max": max})).is_err(), "max {max}");
}
}
#[test]
fn each_paragraph_is_checked_on_its_own_at_the_bytes_of_the_whole_text() {
let mut s = session();
// Read as one block, the Vietnamese first paragraph would make the whole text Vietnamese,
// which has no dictionary, and the English typo below it would go unmarked.
let vi = "Cảm ơn những người bạn";
let t = text(&mut s, 50.0, &format!("{vi}\ndrawing qzxvw tools"));
let at = vi.len() + 1 + "drawing ".len();
assert_eq!(unknown(&mut s, json!({})), [(t.0, at as u64, at as u64 + 5, "qzxvw".to_string())]);
}
// The custom dictionary is craft-proof's, shared by the whole process, so each test below uses
// made-up words of its own: tests running at the same time never see each other's.
/// The words a check of the whole active document lists.
fn marked(s: &mut Session) -> Vec<String> {
unknown(s, json!({})).into_iter().map(|w| w.3).collect()
}
#[test]
fn added_words_are_right_in_every_check_until_deleted() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
text(&mut s, 50.0, "drawing Zqvwkx tools");
assert_eq!(marked(&mut s), ["Zqvwkx"]);
// Kept in lower case, so the capitalized word is right too.
let r = run(&mut s, "text.spelling.addWord", json!({"word": " Zqvwkx "}));
assert_eq!((r["word"].as_str(), r["added"].as_bool()), (Some("zqvwkx"), Some(true)));
assert_eq!(marked(&mut s), Vec::<String>::new());
assert_eq!(run(&mut s, "text.spelling.addWord", json!({"word": "zqvwkx"}))["added"], false, "already there");
let words = run(&mut s, "text.spelling.dictionary", json!({}))["words"].clone();
assert!(words.as_array().unwrap().contains(&json!("zqvwkx")), "{words}");
// Another document's check takes it as right too.
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
text(&mut s, 50.0, "zqvwkx");
assert_eq!(marked(&mut s), Vec::<String>::new());
run(&mut s, "text.spelling.removeWord", json!({"word": "ZQVWKX"}));
assert_eq!(marked(&mut s), ["zqvwkx"]);
let e = s.execute("text.spelling.removeWord", &json!({"word": "zqvwkx"})).unwrap_err().to_string();
assert!(e.contains("isn't in the custom dictionary"), "{e}");
}
#[test]
fn a_changed_word_replaces_the_old_one() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
text(&mut s, 50.0, "xkvzqa xkvzqb");
run(&mut s, "text.spelling.addWord", json!({"word": "xkvzqa"}));
assert_eq!(marked(&mut s), ["xkvzqb"]);
assert_eq!(run(&mut s, "text.spelling.changeWord", json!({"word": "xkvzqa", "to": "Xkvzqb"}))["word"], "xkvzqb");
assert_eq!(marked(&mut s), ["xkvzqa"]);
let e = s.execute("text.spelling.changeWord", &json!({"word": "xkvzqa", "to": "x"})).unwrap_err().to_string();
assert!(e.contains("isn't in the custom dictionary"), "{e}");
run(&mut s, "text.spelling.removeWord", json!({"word": "xkvzqb"}));
}
#[test]
fn the_custom_dictionary_file_is_read_and_kept_up_to_date() {
let _one_at_a_time = dictionary_lock();
let dir = vectorcraft_testkit::TempDir::new("spelling-dictionary");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "kqzvwx\nnot one word\n\n").unwrap();
let mut s = session();
s.attach_custom_dictionary(file.clone());
text(&mut s, 50.0, "kqzvwx vkqzwx");
assert_eq!(marked(&mut s), ["vkqzwx"], "the file's word is right; its line of three words is skipped");
run(&mut s, "text.spelling.addWord", json!({"word": "vkqzwx"}));
let saved = std::fs::read_to_string(&file).unwrap();
assert!(saved.lines().any(|l| l == "kqzvwx") && saved.lines().any(|l| l == "vkqzwx"), "{saved}");
run(&mut s, "text.spelling.changeWord", json!({"word": "kqzvwx", "to": "kqzvwy"}));
run(&mut s, "text.spelling.removeWord", json!({"word": "vkqzwx"}));
let saved = std::fs::read_to_string(&file).unwrap();
assert!(saved.lines().any(|l| l == "kqzvwy"), "{saved}");
assert!(!saved.lines().any(|l| l == "kqzvwx" || l == "vkqzwx"), "changed and deleted words are gone from the file: {saved}");
run(&mut s, "text.spelling.removeWord", json!({"word": "kqzvwy"}));
}
#[test]
fn ignore_all_skips_a_word_in_the_active_document_only() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
text(&mut s, 50.0, "Wqzxkv drawing wqzxkv vqzxkw");
run(&mut s, "text.spelling.ignoreAll", json!({"word": "WQZXKV"}));
assert_eq!(marked(&mut s), ["vqzxkw"], "every occurrence, whatever its case");
let words = run(&mut s, "text.spelling.dictionary", json!({}))["words"].clone();
assert!(!words.as_array().unwrap().contains(&json!("wqzxkv")), "not added to the custom dictionary");
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
text(&mut s, 50.0, "wqzxkv");
assert_eq!(marked(&mut s), ["wqzxkv"], "another document still marks it");
}
#[test]
fn custom_dictionary_commands_refuse_what_isnt_one_word() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
let long = "q".repeat(65);
for (id, p) in [
("text.spelling.addWord", json!({})),
("text.spelling.addWord", json!({"word": " "})),
("text.spelling.addWord", json!({"word": "two words"})),
("text.spelling.addWord", json!({"word": long})),
("text.spelling.addWord", json!({"word": 5})),
("text.spelling.removeWord", json!({})),
("text.spelling.changeWord", json!({"word": "qzx"})),
("text.spelling.changeWord", json!({"word": "qzx", "to": "a b"})),
("text.spelling.ignoreAll", json!({"word": "a\tb"})),
] {
assert!(s.execute(id, &p).is_err(), "{id} {p} is refused");
}
let mut empty = Session::new();
assert!(empty.execute("text.spelling.ignoreAll", &json!({"word": "qzx"})).is_err(), "Ignore All needs a document");
assert!(empty.execute("text.spelling.dictionary", &json!({})).is_ok(), "the dictionary is the app's");
}
/// Whether the custom dictionary holds `word`.
fn in_dictionary(s: &mut Session, word: &str) -> bool {
run(s, "text.spelling.dictionary", json!({}))["words"].as_array().unwrap().contains(&json!(word))
}
#[test]
fn a_dictionary_file_that_cant_be_read_whole_is_never_written_over() {
let _one_at_a_time = dictionary_lock();
let dir = vectorcraft_testkit::TempDir::new("spelling-unreadable");
// A folder where the file should be can't be read as one; a file past what craft-proof reads
// would lose its other words if written back.
let folder = dir.join("Custom Dictionary.txt");
std::fs::create_dir_all(&folder).unwrap();
let big = dir.join("big.txt");
let words = "qkzvxw\n".repeat((MAX_DICTIONARY_FILE_BYTES / 7 + 2) as usize);
std::fs::write(&big, &words).unwrap();
for path in [folder.clone(), big.clone()] {
let mut s = session();
s.attach_custom_dictionary(path.clone());
let e = s.execute("text.spelling.addWord", &json!({"word": "zkqvxw"})).unwrap_err().to_string();
assert!(e.contains("can't be read"), "{}: {e}", path.display());
assert!(!in_dictionary(&mut s, "zkqvxw"), "the refused word isn't kept either");
}
assert!(folder.is_dir());
assert_eq!(std::fs::read_to_string(&big).unwrap(), words, "the large file is as it was");
}
#[test]
fn a_change_that_cant_be_saved_is_taken_back() {
let _one_at_a_time = dictionary_lock();
let mut s = session();
run(&mut s, "text.spelling.addWord", json!({"word": "wkqzvx"}));
let dir = vectorcraft_testkit::TempDir::new("spelling-rollback");
let folder = dir.join("Custom Dictionary.txt");
std::fs::create_dir_all(&folder).unwrap();
let mut failing = session();
failing.attach_custom_dictionary(folder);
assert!(failing.execute("text.spelling.removeWord", &json!({"word": "wkqzvx"})).is_err());
assert!(in_dictionary(&mut s, "wkqzvx"), "a delete that wasn't saved leaves the word");
assert!(failing.execute("text.spelling.changeWord", &json!({"word": "wkqzvx", "to": "wkqzvy"})).is_err());
assert!(in_dictionary(&mut s, "wkqzvx") && !in_dictionary(&mut s, "wkqzvy"), "a change that wasn't saved leaves the old word");
run(&mut s, "text.spelling.removeWord", json!({"word": "wkqzvx"}));
}
/// Edit Custom Dictionary's Done: its deletes and adds are saved together, or, when the save
/// fails, none of them is kept (a delete saved before an add that failed would stay otherwise).
#[test]
fn a_dictionary_edit_is_saved_whole_or_not_at_all() {
let _one_at_a_time = dictionary_lock();
const EDIT: &str = "text.spelling.editDictionary";
let dir = vectorcraft_testkit::TempDir::new("spelling-edit");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "pqzvka\npqzvkb\n").unwrap();
let mut s = session();
s.attach_custom_dictionary(file.clone());
// A word already gone and a word already there are skipped; added words are kept in lower case.
let r = run(&mut s, EDIT, json!({"remove": ["pqzvka", "pqzvkz"], "add": ["PqzvkC", "pqzvkb"]}));
assert_eq!(r, json!({"removed": ["pqzvka"], "added": ["pqzvkc"]}));
let saved = std::fs::read_to_string(&file).unwrap();
let lines: Vec<&str> = saved.lines().collect();
assert!(lines.contains(&"pqzvkb") && lines.contains(&"pqzvkc") && !lines.contains(&"pqzvka"), "{saved}");
// A save that fails takes back every delete and add.
let folder = dir.join("folder");
std::fs::create_dir_all(&folder).unwrap();
let mut failing = session();
failing.attach_custom_dictionary(folder);
assert!(failing.execute(EDIT, &json!({"remove": ["pqzvkb", "pqzvkc"], "add": ["pqzvkd"]})).is_err());
assert!(in_dictionary(&mut s, "pqzvkb") && in_dictionary(&mut s, "pqzvkc"), "the deletes are taken back");
assert!(!in_dictionary(&mut s, "pqzvkd"), "the add is taken back");
// A list that isn't one of words is refused before anything changes.
let many: Vec<String> = (0..10_001).map(|i| format!("pqzv{i}")).collect();
for p in [json!({"remove": "pqzvkb"}), json!({"add": ["a b"]}), json!({"add": [5]}), json!({"remove": ["pqzvkb"], "add": many})] {
assert!(s.execute(EDIT, &p).is_err(), "{EDIT} refuses a bad list");
assert!(in_dictionary(&mut s, "pqzvkb"), "{EDIT} changed nothing");
}
let e = s.execute(EDIT, &json!({"add": ["a b"]})).unwrap_err().to_string();
assert!(e.contains("must hold single words of at most 64 characters"), "{e}");
run(&mut s, EDIT, json!({"remove": ["pqzvkb", "pqzvkc"]}));
assert!(!in_dictionary(&mut s, "pqzvkb") && !in_dictionary(&mut s, "pqzvkc"));
}
#[cfg(unix)]
#[test]
fn a_word_another_window_saved_stays_when_this_save_fails() {
let _one_at_a_time = dictionary_lock();
use std::os::unix::fs::PermissionsExt;
let dir = vectorcraft_testkit::TempDir::new("spelling-readonly");
let file = dir.join("Custom Dictionary.txt");
std::fs::write(&file, "").unwrap();
let mut s = session();
s.attach_custom_dictionary(file.clone());
// Another window saves a word this session hasn't taken in, then the folder can't be written:
// the save reads the file, takes the word in, and fails writing.
std::fs::write(&file, "zvkqxw\n").unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let probe = dir.join("probe");
let writable = std::fs::write(&probe, "").is_ok();
let r = (!writable).then(|| s.execute("text.spelling.addWord", &json!({"word": "zvkqxw"})));
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let Some(r) = r else {
// Run as root, the folder stays writable: there is no failing save to check.
return;
};
assert!(r.is_err(), "{r:?}");
assert!(in_dictionary(&mut s, "zvkqxw"), "the word in the file stays, though this Add was taken back");
run(&mut s, "text.spelling.removeWord", json!({"word": "zvkqxw"}));
}
/// `misspellings` finds in one type object what the check lists for it, stops at the most it is
/// asked for and says so, and leaves out the Ignore All words it is given.
#[test]
fn misspellings_of_one_object_are_what_the_check_lists() {
use crate::cmd::spelling::misspellings;
let mut s = session();
let id = text(&mut s, 50.0, "the Vqzrtwx cat\nsat Wzqrvtx Xqrzvtw");
let listed: Vec<_> = unknown(&mut s, json!({"ids": [id.0]})).into_iter().map(|(_, a, b, _)| a as usize..b as usize).collect();
let st = s.doc().unwrap();
let Some(vectorcraft_doc::NodeKind::Text(t)) = st.doc.node(id).map(|n| n.kind.clone()) else { panic!("type") };
let none = Default::default();
let (found, cut) = misspellings(&t, &none, 10);
assert_eq!((found.iter().map(|m| m.range.clone()).collect::<Vec<_>>(), cut), (listed.clone(), false));
assert!(found.iter().all(|m| m.lang.tag() == "en"), "{found:?}");
let (first, cut) = misspellings(&t, &none, 2);
assert_eq!((first.iter().map(|m| m.range.clone()).collect::<Vec<_>>(), cut), (listed[..2].to_vec(), true));
let ignored = std::collections::BTreeSet::from(["wzqrvtx".to_string()]);
let (found, _) = misspellings(&t, &ignored, 10);
assert_eq!(found.iter().map(|m| m.range.clone()).collect::<Vec<_>>(), [listed[0].clone(), listed[2].clone()]);
}
/// Each change to the custom dictionary moves its generation on, whether it is saved or not, so
/// what Auto Spell Check found before is checked again.
#[test]
fn custom_dictionary_changes_move_its_generation_on() {
let _one_at_a_time = dictionary_lock();
use crate::cmd::spelling::dictionary_generation;
let mut s = session();
let mut last = dictionary_generation();
let mut moved = |s: &mut Session, id: &str, p: Value| {
let _ = s.execute(id, &p);
let now = dictionary_generation();
let on = now > last;
last = now;
on
};
assert!(moved(&mut s, "text.spelling.addWord", json!({"word": "Gqzvrtx"})), "add");
assert!(moved(&mut s, "text.spelling.changeWord", json!({"word": "gqzvrtx", "to": "hqzvrtx"})), "change");
assert!(moved(&mut s, "text.spelling.removeWord", json!({"word": "hqzvrtx"})), "delete");
}