mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 20:08:42 +00:00
330 lines
16 KiB
Rust
330 lines
16 KiB
Rust
//! 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::temp_dir("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::temp_dir("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::temp_dir("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"}));
|
||
}
|
||
|
||
#[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::temp_dir("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");
|
||
}
|