Add search chapter
This commit is contained in:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user