Files
Marcos Paulo 7d3cde7aba feat(engine): wave 5 engine-wiring
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:01:23 -03:00

144 lines
6.7 KiB
Rust

//! Search in a session: the mirror stays in step with every write, and the
//! `search` command checks its params (SPEC 6 item 6).
mod common;
use common::{on_disk, seed, temp_vault};
use notescraft_engine::{Error, Registry, Session, register_core};
use notescraft_vault::VaultEvent;
use serde_json::{Value, json};
/// A registry plus a session on a two-note vault.
fn fixture(name: &str) -> (Registry<Session>, Session, common::TempVault) {
let vault = temp_vault(name);
seed(vault.path(), &[("alpha.md", "# Alpha\n\nalpha has words\n"), ("notes/beta.md", "# Beta\n\nbeta is quiet\n")]);
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let session = Session::open(vault.path()).expect("open");
(registry, session, vault)
}
/// The paths a query returns, via the session method.
fn hits(session: &Session, query: &str) -> Vec<String> {
session.search(query, 20).expect("search").into_iter().map(|hit| hit.path).collect()
}
/// A note created by `note.create` is searchable, and nothing else changed.
#[test]
fn create_makes_a_note_findable() {
let (_reg, mut session, _vault) = fixture("create");
assert!(hits(&session, "fresh").is_empty(), "nothing matches before the create");
session.create_note("fresh.md", "a fresh start\n").expect("create");
assert_eq!(hits(&session, "fresh"), vec!["fresh.md".to_owned()]);
}
/// After a rename the hit carries the new path and the old one is gone.
#[test]
fn rename_moves_the_hit() {
let (_reg, mut session, _vault) = fixture("rename");
assert_eq!(hits(&session, "alpha"), vec!["alpha.md".to_owned()]);
session.rename_note("alpha.md", "folder/Renamed.md", true).expect("rename");
assert_eq!(hits(&session, "alpha"), vec!["folder/Renamed.md".to_owned()], "the content survives the move");
}
/// A deleted note leaves no hit behind.
#[test]
fn delete_removes_the_hit() {
let (_reg, mut session, _vault) = fixture("delete");
assert_eq!(hits(&session, "quiet"), vec!["notes/beta.md".to_owned()]);
session.delete_note("notes/beta.md").expect("delete");
assert_eq!(hits(&session, "quiet"), [] as [String; 0]);
assert_eq!(hits(&session, "alpha"), vec!["alpha.md".to_owned()], "the other note is untouched");
}
/// Saving a buffer re-indexes it, so the new words are findable and the old
/// ones gone; unsaved edits are not searched.
#[test]
fn save_updates_the_mirror() {
let (reg, mut session, vault) = fixture("save");
session.create_note("words.md", "# Words\n\nalpha has words\n").expect("create words.md");
reg.run("buffer.open", &mut session, &json!({ "path": "words.md" })).expect("open buffer");
let edited = reg.run("buffer.edit", &mut session, &json!({ "path": "words.md", "start": 0, "end": 7, "text": "rebrand" })).expect("edit");
assert_eq!(edited.get("dirty"), Some(&json!(true)));
let body_edit = reg.run("buffer.edit", &mut session, &json!({ "path": "words.md", "start": 9, "end": 14, "text": "branded" })).expect("edit body");
assert_eq!(body_edit.get("dirty"), Some(&json!(true)));
assert!(hits(&session, "rebrand").is_empty(), "no hit before the save");
assert!(hits(&session, "words").contains(&"words.md".to_owned()), "the disk text is still indexed");
reg.run("buffer.save", &mut session, &json!({ "path": "words.md" })).expect("save");
assert_eq!(hits(&session, "rebrand"), vec!["words.md".to_owned()]);
assert!(hits(&session, "branded").contains(&"words.md".to_owned()), "the saved body is in the mirror");
assert_eq!(on_disk(vault.path(), "words.md"), "rebrand\n\nbranded has words\n");
}
/// A change made outside the app is searchable right after `apply_event`.
#[test]
fn an_external_event_is_indexed() {
let (_reg, mut session, vault) = fixture("external");
std::fs::write(vault.path().join("added.md"), "the outside word\n").expect("write outside the app");
let applied = session.apply_event(VaultEvent::Created(String::from("added.md"))).expect("apply");
assert_eq!(applied, notescraft_engine::Applied::Indexed);
assert_eq!(hits(&session, "outside"), vec!["added.md".to_owned()]);
session.apply_event(VaultEvent::Removed(String::from("added.md"))).expect("remove");
assert_eq!(hits(&session, "outside"), [] as [String; 0]);
}
/// The machine code of a refused search param (`invalid_params` or the
/// domain-level `invalid_query`).
fn code(error: &Error) -> &'static str {
match error {
Error::InvalidParams { .. } => "invalid_params",
Error::Query(_) => "invalid_query",
Error::Command { source, .. } => match source.code.as_str() {
"invalid_params" => "invalid_params",
"invalid_query" => "invalid_query",
other => panic!("unexpected code `{other}`"),
},
other => panic!("unexpected error {other:?}"),
}
}
/// The search command reports hits with snippets, and refuses a broken limit.
#[test]
fn the_search_command_checks_its_params() {
let (reg, mut session, _vault) = fixture("command");
let found = reg.run("search", &mut session, &json!({ "query": "alpha" })).expect("search");
let hits = found.get("hits").and_then(Value::as_array).expect("hits array");
assert_eq!(hits.len(), 1);
let hit = hits.first().expect("one hit");
assert_eq!(hit.get("path"), Some(&json!("alpha.md")));
assert_eq!(hit.get("title_match"), Some(&json!(true)));
assert!(hit.get("snippets").and_then(Value::as_array).is_some_and(|snippets| !snippets.is_empty()), "a snippet is shown");
assert!(reg.run("search", &mut session, &json!({ "query": "alpha", "limit": 200 })).is_ok(), "200 hits are allowed");
for bad in [json!({ "query": "alpha", "limit": 201 }), json!({ "query": "alpha", "limit": -1 }), json!({ "query": "alpha", "limit": "many" })] {
let error = reg.run("search", &mut session, &bad).expect_err("must be refused");
assert_eq!(code(&error), "invalid_params", "`{bad}`: {error}");
}
for empty in ["", " ", "-only"] {
let error = reg.run("search", &mut session, &json!({ "query": empty })).expect_err("must be refused");
assert_eq!(code(&error), "invalid_query", "empty query `{empty}`");
}
let error = reg.run("search", &mut session, &json!({ "query": "alpha", "typo": 1 })).expect_err("must be refused");
assert_eq!(code(&error), "invalid_params", "an unknown param is refused");
}
/// The read-only session searches like the writable one.
#[test]
fn a_read_only_session_still_searches() {
let vault = temp_vault("read-only-search");
seed(vault.path(), &[("alpha.md", "alpha words\n")]);
let session = Session::open_read_only(vault.path()).expect("open read-only");
assert_eq!(hits(&session, "alpha"), vec!["alpha.md".to_owned()]);
assert!(session.is_read_only());
}