Add search chapter

This commit is contained in:
Jon Staab
2026-05-20 16:07:58 -07:00
parent d0709e1811
commit 75381b653e
5 changed files with 1014 additions and 9 deletions
+250
View File
@@ -0,0 +1,250 @@
use coracle_lib::events::{Event, EventContent};
use coracle_lib::filters::{intersect_filters, union_filters, Filter};
use coracle_lib::keys::SecretKey;
use coracle_lib::search::SearchQuery;
fn fixed_secret() -> SecretKey {
let bytes: [u8; 32] = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32,
];
SecretKey::from_hex(&hex::encode(bytes)).unwrap()
}
fn make_event(content: &str, created_at: u64) -> Event {
let sk = fixed_secret();
let hashed = EventContent::new()
.content(content)
.kind(1)
.stamp(created_at)
.own(sk.public_key())
.hash();
hashed.clone().sign(sk.sign(&hashed.id))
}
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
// --- SearchQuery parsing ---
#[test]
fn parse_splits_on_whitespace() {
let q = SearchQuery::parse("best nostr apps");
assert_eq!(q.terms, vec!["best", "nostr", "apps"]);
}
#[test]
fn parse_treats_extensions_as_terms() {
// We don't interpret NIP-50 extensions; every token is just a term.
let q = SearchQuery::parse("nostr domain:example.com language:en");
assert_eq!(q.terms, vec!["nostr", "domain:example.com", "language:en"]);
}
#[test]
fn parse_is_empty_for_blank_input() {
assert!(SearchQuery::parse(" ").is_empty());
assert!(SearchQuery::parse("").is_empty());
assert!(SearchQuery::new().is_empty());
assert!(!SearchQuery::parse("nostr").is_empty());
}
#[test]
fn display_joins_terms() {
let q = SearchQuery::parse("nostr best apps");
assert_eq!(q.to_string(), "nostr best apps");
}
#[test]
fn display_round_trips_through_parse() {
let q = SearchQuery::parse("nostr best apps language:en");
assert_eq!(SearchQuery::parse(&q.to_string()), q);
}
// --- Scoring ---
#[test]
fn score_full_match_is_one() {
let q = SearchQuery::parse("nostr apps");
assert!(approx(q.score("i love nostr and apps"), 1.0));
}
#[test]
fn score_no_match_is_zero() {
let q = SearchQuery::parse("nostr");
assert!(approx(q.score("no match here"), 0.0));
}
#[test]
fn score_partial_match_is_fraction() {
let q = SearchQuery::parse("nostr apps");
assert!(approx(q.score("only nostr here"), 0.5));
}
#[test]
fn score_is_case_insensitive() {
let q = SearchQuery::parse("NOSTR");
assert!(approx(q.score("the Nostr protocol"), 1.0));
}
#[test]
fn score_extension_like_term_matches_as_text() {
let q = SearchQuery::parse("language:en");
assert!(approx(q.score("posted with language:en today"), 1.0));
assert!(approx(q.score("no marker here"), 0.0));
}
#[test]
fn score_empty_query_is_one() {
assert!(approx(SearchQuery::parse("").score("anything at all"), 1.0));
assert!(approx(SearchQuery::new().score(""), 1.0));
}
#[test]
fn score_frequency_bonus_orders_partial_matches() {
let q = SearchQuery::parse("alpha beta");
let once = q.score("alpha only");
let many = q.score("alpha alpha alpha");
assert!(approx(once, 0.5));
assert!(many > once, "repeated term should score higher: {many} vs {once}");
assert!(many < 1.0, "a partial match must stay below a full match");
}
#[test]
fn score_never_exceeds_one() {
let q = SearchQuery::parse("nostr");
// Heavy repetition of a full match is still capped at 1.0.
assert!(approx(q.score("nostr nostr nostr nostr nostr"), 1.0));
}
#[test]
fn score_is_bounded() {
let q = SearchQuery::parse("alpha beta gamma");
for content in ["", "alpha", "alpha beta", "alpha alpha gamma gamma", "alpha beta gamma"] {
let s = q.score(content);
assert!((0.0..=1.0).contains(&s), "score {s} out of range for {content:?}");
}
}
// --- Filter integration ---
#[test]
fn add_and_clear_search() {
let f = Filter::new().add_search("nostr");
assert_eq!(f.search.as_deref(), Some("nostr"));
assert_eq!(f.clear_search().search, None);
}
#[test]
fn search_score_without_search_is_one() {
let event = make_event("anything", 1);
assert!(approx(Filter::new().search_score(&event), 1.0));
}
#[test]
fn search_score_with_search() {
let event = make_event("the nostr protocol", 1);
assert!(approx(Filter::new().add_search("nostr").search_score(&event), 1.0));
assert!(approx(Filter::new().add_search("missing").search_score(&event), 0.0));
}
#[test]
fn matches_ignores_search() {
// Structural matching and search scoring are independent.
let event = make_event("hello", 1);
let filter = Filter::new().add_kind(1).add_search("not-in-content");
assert!(filter.matches(&event), "matches must not consider the search field");
assert!(approx(filter.search_score(&event), 0.0));
}
// --- Serialization ---
#[test]
fn search_round_trips_through_json() {
let filter = Filter::new().add_kind(1).add_search("best nostr apps");
let json = serde_json::to_string(&filter).unwrap();
assert!(json.contains("\"search\":\"best nostr apps\""));
let parsed: Filter = serde_json::from_str(&json).unwrap();
assert_eq!(filter, parsed);
}
#[test]
fn search_absent_when_none() {
let json = serde_json::to_string(&Filter::new().add_kind(1)).unwrap();
assert!(!json.contains("search"));
}
// --- Grouping and set algebra ---
#[test]
fn group_distinguishes_searches() {
let f1 = Filter::new().add_kind(1).add_search("alpha");
let f2 = Filter::new().add_kind(1).add_search("beta");
let f3 = Filter::new().add_kind(1).add_search("alpha");
assert_ne!(f1.group(), f2.group());
assert_eq!(f1.group(), f3.group());
}
#[test]
fn union_keeps_different_searches_separate() {
let filters = vec![
Filter::new().add_kind(1).add_search("alpha"),
Filter::new().add_kind(1).add_search("beta"),
];
assert_eq!(union_filters(&filters).len(), 2);
}
#[test]
fn union_merges_same_search() {
let a = SecretKey::generate().public_key();
let b = SecretKey::generate().public_key();
let filters = vec![
Filter::new().add_search("x").add_author(a),
Filter::new().add_search("x").add_author(b),
];
let result = union_filters(&filters);
assert_eq!(result.len(), 1);
assert_eq!(result[0].search.as_deref(), Some("x"));
let authors = result[0].authors.as_ref().unwrap();
assert!(authors.contains(&a) && authors.contains(&b));
}
#[test]
fn intersect_keeps_conflicting_searches_separate() {
let groups = vec![
vec![Filter::new().add_search("nostr")],
vec![Filter::new().add_search("bitcoin")],
];
let result = intersect_filters(&groups);
assert_eq!(result.len(), 2);
let searches: std::collections::BTreeSet<_> =
result.iter().map(|f| f.search.clone()).collect();
assert!(searches.contains(&Some("nostr".to_string())));
assert!(searches.contains(&Some("bitcoin".to_string())));
}
#[test]
fn intersect_combines_when_one_side_has_search() {
let author = SecretKey::generate().public_key();
let groups = vec![
vec![Filter::new().add_author(author)],
vec![Filter::new().add_search("nostr")],
];
let result = intersect_filters(&groups);
assert_eq!(result.len(), 1);
assert_eq!(result[0].search.as_deref(), Some("nostr"));
assert!(result[0].authors.as_ref().unwrap().contains(&author));
}
#[test]
fn intersect_combines_equal_searches() {
let groups = vec![
vec![Filter::new().add_kind(1).add_search("x")],
vec![Filter::new().add_kind(2).add_search("x")],
];
let result = intersect_filters(&groups);
assert_eq!(result.len(), 1);
assert_eq!(result[0].search.as_deref(), Some("x"));
let kinds = result[0].kinds.as_ref().unwrap();
assert!(kinds.contains(&1) && kinds.contains(&2));
}