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)); }