5e8020aa38
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/ video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits, block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
217 lines
10 KiB
Rust
217 lines
10 KiB
Rust
//! The `switcher.search`, `note.random` and `note.unique` commands: ranking,
|
|
//! `#`/`^` queries, unicode, speed, randomness and collision suffixes.
|
|
|
|
mod common;
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use common::{on_disk, seed, temp_vault};
|
|
use notescraft_engine::{Registry, Session, register_core};
|
|
use serde_json::{Value, json};
|
|
|
|
fn setup(name: &str, files: &[(&str, &str)]) -> (Registry<Session>, Session, common::TempVault) {
|
|
let vault = temp_vault(name);
|
|
seed(vault.path(), files);
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("register");
|
|
let session = Session::open(vault.path()).expect("open");
|
|
(registry, session, vault)
|
|
}
|
|
|
|
fn run(registry: &Registry<Session>, session: &mut Session, id: &str, params: &Value) -> Value {
|
|
registry.run(id, session, params).unwrap_or_else(|error| panic!("{id}: {error:?}"))
|
|
}
|
|
|
|
/// The `hits` rows of a `switcher.search` result.
|
|
fn rows(result: &Value) -> Vec<Value> {
|
|
result["hits"].as_array().cloned().unwrap_or_default()
|
|
}
|
|
|
|
/// The hit paths, in the order the switcher ranked them.
|
|
fn paths(result: &Value) -> Vec<String> {
|
|
rows(result).iter().map(|hit| hit["path"].as_str().unwrap_or_default().to_owned()).collect()
|
|
}
|
|
|
|
fn search(registry: &Registry<Session>, session: &mut Session, query: &str) -> Value {
|
|
run(registry, session, "switcher.search", &json!({ "query": query }))
|
|
}
|
|
|
|
#[test]
|
|
fn exact_beats_prefix_beats_subsequence() {
|
|
let (registry, mut session, _vault) = setup("switcher-rank", &[("Not.md", ""), ("Note.md", ""), ("Xenolith.md", "")]);
|
|
let result = search(®istry, &mut session, "not");
|
|
assert_eq!(paths(&result), vec!["Not.md", "Note.md", "Xenolith.md"], "exact, then prefix, then subsequence");
|
|
assert!(rows(&result)[0]["score"].as_u64() > rows(&result)[1]["score"].as_u64(), "the exact hit outscores the prefix hit");
|
|
assert!(rows(&result)[1]["score"].as_u64() > rows(&result)[2]["score"].as_u64(), "the prefix hit outscores the subsequence hit");
|
|
}
|
|
|
|
#[test]
|
|
fn aliases_match_and_report_the_alias() {
|
|
let (registry, mut session, _vault) =
|
|
setup("switcher-alias", &[("Real Name.md", "---\naliases: [Spark]\n---\n"), ("Sparkle.md", ""), ("Unrelated.md", "")]);
|
|
let result = search(®istry, &mut session, "spark");
|
|
assert_eq!(paths(&result), vec!["Real Name.md", "Sparkle.md"], "the exact alias outranks the prefix title");
|
|
let first = &rows(&result)[0];
|
|
assert_eq!(first["kind"], "note");
|
|
assert_eq!(first["alias"], "Spark");
|
|
assert_eq!(first["title"], "Real Name");
|
|
assert_eq!(first["heading"], Value::Null, "a note hit carries no heading");
|
|
}
|
|
|
|
#[test]
|
|
fn a_hash_query_searches_headings_of_title_matching_notes() {
|
|
let (registry, mut session, _vault) =
|
|
setup("switcher-hash", &[("Alpha.md", "# Intro\n## Alpha Setup\n"), ("Beta.md", "# Alpha Care\n"), ("Gamma.md", "# Deep Alpha\n")]);
|
|
let scoped = search(®istry, &mut session, "alp#alpha");
|
|
assert_eq!(paths(&scoped), vec!["Alpha.md"], "only notes whose title matches the part before `#`");
|
|
assert_eq!(rows(&scoped)[0]["kind"], "heading");
|
|
assert_eq!(rows(&scoped)[0]["heading"], "Alpha Setup");
|
|
|
|
let everywhere = search(®istry, &mut session, "#alpha");
|
|
assert_eq!(paths(&everywhere), vec!["Alpha.md", "Beta.md", "Gamma.md"], "`#head` alone searches every note");
|
|
assert!(rows(&everywhere).iter().all(|hit| hit["kind"] == "heading"), "a `#` query returns heading hits only");
|
|
}
|
|
|
|
#[test]
|
|
fn a_caret_query_searches_block_ids() {
|
|
let (registry, mut session, _vault) = setup("switcher-caret", &[("Alpha.md", "para ^blk-alpha\n"), ("Beta.md", "para ^alpha-1\n")]);
|
|
let scoped = search(®istry, &mut session, "alp^alpha");
|
|
assert_eq!(paths(&scoped), vec!["Alpha.md"], "only notes whose title matches the part before `^`");
|
|
assert_eq!(rows(&scoped)[0]["kind"], "block");
|
|
assert_eq!(rows(&scoped)[0]["block"], "blk-alpha");
|
|
|
|
let everywhere = search(®istry, &mut session, "^alpha");
|
|
assert_eq!(paths(&everywhere), vec!["Beta.md", "Alpha.md"], "the prefix block id outranks the subsequence one");
|
|
assert!(rows(&everywhere).iter().all(|hit| hit["kind"] == "block"));
|
|
}
|
|
|
|
#[test]
|
|
fn unicode_matches_case_insensitively() {
|
|
let (registry, mut session, _vault) = setup("switcher-unicode", &[("Ünïcode.md", "# Grüber Kopf\n"), ("Plain.md", "")]);
|
|
let prefix = search(®istry, &mut session, "ünï");
|
|
assert_eq!(paths(&prefix), vec!["Ünïcode.md"]);
|
|
let heading = search(®istry, &mut session, "#grüber");
|
|
assert_eq!(paths(&heading), vec!["Ünïcode.md"]);
|
|
assert_eq!(rows(&heading)[0]["heading"], "Grüber Kopf");
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_query_lists_the_most_recently_modified_notes() {
|
|
let (registry, mut session, _vault) = setup("switcher-recent", &[("a.md", "a"), ("b.md", "b"), ("c.md", "c")]);
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
run(®istry, &mut session, "note.write", &json!({ "path": "b.md", "content": "touched" }));
|
|
let result = search(®istry, &mut session, "");
|
|
assert_eq!(paths(&result).first(), Some(&"b.md".to_owned()), "the most recently modified note comes first");
|
|
assert_eq!(paths(&result).len(), 3);
|
|
|
|
let limited = run(®istry, &mut session, "switcher.search", &json!({ "query": "", "limit": 2 }));
|
|
assert_eq!(paths(&limited).len(), 2, "`limit` caps the recent list too");
|
|
}
|
|
|
|
#[test]
|
|
fn five_thousand_notes_answer_in_milliseconds() {
|
|
let vault = temp_vault("switcher-perf");
|
|
let files: Vec<(String, String)> = (0..5000).map(|i| (format!("note-{i:04}.md"), format!("# Heading {i}\n\nBody ^blk-{i:04}\n"))).collect();
|
|
let borrowed: Vec<(&str, &str)> = files.iter().map(|(path, body)| (path.as_str(), body.as_str())).collect();
|
|
seed(vault.path(), &borrowed);
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("register");
|
|
let mut session = Session::open(vault.path()).expect("open");
|
|
for query in ["note", "#head", "^blk", "zzz-no-such-thing"] {
|
|
let start = Instant::now();
|
|
let result = search(®istry, &mut session, query);
|
|
let elapsed = start.elapsed();
|
|
assert!(rows(&result).len() <= 30, "the default limit holds");
|
|
assert!(elapsed.as_millis() < 500, "`{query}` took {elapsed:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn random_stays_inside_a_folder_and_varies() {
|
|
let mut files: Vec<(String, String)> = (0..10).map(|i| (format!("root-{i:02}.md"), String::new())).collect();
|
|
for i in 0..10 {
|
|
files.push((format!("sub/sub-{i:02}.md"), String::new()));
|
|
}
|
|
let borrowed: Vec<(&str, &str)> = files.iter().map(|(path, body)| (path.as_str(), body.as_str())).collect();
|
|
let (registry, mut session, _vault) = setup("note-random-folder", &borrowed);
|
|
|
|
let mut seen: Vec<String> = Vec::new();
|
|
for _ in 0..50 {
|
|
let path = run(®istry, &mut session, "note.random", &json!({ "folder": "sub" }))["path"].as_str().unwrap_or_default().to_owned();
|
|
assert!(path.starts_with("sub/"), "{path} escaped the folder");
|
|
seen.push(path);
|
|
}
|
|
seen.sort();
|
|
seen.dedup();
|
|
assert!(seen.len() > 1, "50 calls varied over the folder's notes");
|
|
|
|
let whole = run(®istry, &mut session, "note.random", &json!({}));
|
|
assert!(whole["path"].as_str().unwrap_or_default().to_ascii_lowercase().ends_with(".md"));
|
|
|
|
let outside = registry.run("note.random", &mut session, &json!({ "folder": "nope" }));
|
|
assert!(outside.is_err(), "a folder without notes is an error");
|
|
}
|
|
|
|
#[test]
|
|
fn random_on_an_empty_vault_is_not_found() {
|
|
let (registry, mut session, _vault) = setup("note-random-empty", &[]);
|
|
let Err(notescraft_engine::Error::Command { source, .. }) = registry.run("note.random", &mut session, &json!({})) else {
|
|
panic!("no note to pick");
|
|
};
|
|
assert_eq!(source.code, "not_found");
|
|
}
|
|
|
|
#[test]
|
|
fn unique_notes_never_overwrite_and_take_the_next_suffix() {
|
|
let (registry, mut session, vault) = setup("note-unique", &[("keep.md", "keep me\n")]);
|
|
let first = run(®istry, &mut session, "note.unique", &json!({}))["path"].as_str().unwrap_or_default().to_owned();
|
|
let stem = first.get(..first.find(".md").unwrap_or(first.len())).unwrap_or_default();
|
|
assert_eq!(stem.len(), 12, "`{first}` is a YYYYMMDDHHmm name");
|
|
assert!(stem.chars().all(|c| c.is_ascii_digit()), "`{first}` is all digits");
|
|
assert_eq!(on_disk(vault.path(), &first), "", "the note is created empty");
|
|
assert_eq!(on_disk(vault.path(), "keep.md"), "keep me\n", "an existing note is untouched");
|
|
|
|
let second = run(®istry, &mut session, "note.unique", &json!({}))["path"].as_str().unwrap_or_default().to_owned();
|
|
assert_eq!(second, format!("{stem}-1.md"), "a collision takes the -1 suffix");
|
|
let third = run(®istry, &mut session, "note.unique", &json!({}))["path"].as_str().unwrap_or_default().to_owned();
|
|
assert_eq!(third, format!("{stem}-2.md"), "the next collision takes -2");
|
|
assert_eq!(on_disk(vault.path(), &first), "", "the first note keeps its empty content");
|
|
}
|
|
|
|
#[test]
|
|
fn unique_needs_a_writable_session() {
|
|
let vault = temp_vault("note-unique-readonly");
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("register");
|
|
let mut session = Session::open_read_only(vault.path()).expect("open read-only");
|
|
assert!(registry.run("note.unique", &mut session, &json!({})).is_err(), "a read-only session refuses the write");
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_params_are_refused_not_panicked() {
|
|
let (registry, mut session, _vault) = setup("switcher-hostile", &[("n.md", "# Head\n")]);
|
|
let long = "x".repeat(201);
|
|
for params in [
|
|
json!({ "query": long }),
|
|
json!({ "query": "ok", "limit": 0 }),
|
|
json!({ "query": "ok", "limit": 101 }),
|
|
json!({ "query": "ok", "limit": -1 }),
|
|
json!({ "query": "ok", "limit": "30" }),
|
|
json!({}),
|
|
json!({ "query": 7 }),
|
|
json!({ "query": "ok", "extra": true }),
|
|
] {
|
|
assert!(registry.run("switcher.search", &mut session, ¶ms).is_err(), "{params}");
|
|
}
|
|
for query in ["#", "^", "###^^^#", "\u{0}nul\u{1}", "note#", "note^", "^", "#", "\u{10FFFF}"] {
|
|
let result = search(®istry, &mut session, query);
|
|
assert!(result["count"].as_u64().unwrap_or_default() <= 30, "`{query}` answered without panicking");
|
|
}
|
|
for params in [json!({ "folder": "../outside" }), json!({ "folder": 7 }), json!({ "folder": "nope/deep" })] {
|
|
let outcome = registry.run("note.random", &mut session, ¶ms);
|
|
assert!(outcome.is_err(), "{params}");
|
|
}
|
|
let registry_still_usable = search(®istry, &mut session, "n");
|
|
assert_eq!(paths(®istry_still_usable), vec!["n.md"], "the registry still answers");
|
|
}
|