Add filters chapter

This commit is contained in:
Jon Staab
2026-04-21 12:08:55 -07:00
parent c8f6bc1652
commit a8a57a3d77
6 changed files with 1876 additions and 36 deletions
+568
View File
@@ -0,0 +1,568 @@
use std::collections::BTreeSet;
use coracle_lib::events::{Event, EventContent};
use coracle_lib::filters::{intersect_filters, matches_any, union_filters, Filter};
use coracle_lib::keys::SecretKey;
use coracle_lib::tags::Tag;
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(kind: u16, timestamp: u64, tags: Vec<Tag>) -> Event {
let sk = fixed_secret();
let hashed = EventContent::new("hello", tags)
.kind(kind)
.stamp(timestamp)
.own(sk.public_key())
.hash();
hashed.clone().sign(sk.sign(&hashed.id))
}
// --- Matching ---
#[test]
fn empty_filter_matches_everything() {
let event = make_event(1, 1_700_000_000, vec![]);
assert!(Filter::new().matches(&event));
}
#[test]
fn filter_matches_by_id() {
let event = make_event(1, 1_700_000_000, vec![]);
let filter = Filter::new().add_id(event.id);
assert!(filter.matches(&event));
}
#[test]
fn filter_rejects_wrong_id() {
let event = make_event(1, 1_700_000_000, vec![]);
let filter = Filter::new().add_id([0u8; 32]);
assert!(!filter.matches(&event));
}
#[test]
fn filter_matches_by_kind() {
let event = make_event(1, 1_700_000_000, vec![]);
assert!(Filter::new().add_kind(1).matches(&event));
assert!(!Filter::new().add_kind(2).matches(&event));
}
#[test]
fn filter_matches_by_author() {
let event = make_event(1, 1_700_000_000, vec![]);
let filter = Filter::new().add_author(fixed_secret().public_key());
assert!(filter.matches(&event));
}
#[test]
fn filter_rejects_wrong_author() {
let event = make_event(1, 1_700_000_000, vec![]);
let other_sk = SecretKey::generate();
let filter = Filter::new().add_author(other_sk.public_key());
assert!(!filter.matches(&event));
}
#[test]
fn filter_matches_by_tag() {
let event = make_event(
1,
1_700_000_000,
vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc123"])],
);
assert!(Filter::new().add_tag("t", "nostr").matches(&event));
assert!(!Filter::new().add_tag("e", "something").matches(&event));
assert!(!Filter::new().add_tag("t", "bitcoin").matches(&event));
}
#[test]
fn filter_tag_or_within_same_name() {
let event = make_event(1, 1_700_000_000, vec![Tag::new("t", ["nostr"])]);
let filter = Filter::new().add_tags("t", ["nostr", "bitcoin"]);
assert!(filter.matches(&event));
}
#[test]
fn filter_tag_and_across_names() {
let event = make_event(
1,
1_700_000_000,
vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc"])],
);
let filter = Filter::new().add_tag("t", "nostr").add_tag("p", "abc");
assert!(filter.matches(&event));
let filter = Filter::new().add_tag("t", "nostr").add_tag("e", "missing");
assert!(!filter.matches(&event));
}
#[test]
fn filter_since_inclusive() {
let event = make_event(1, 1000, vec![]);
assert!(Filter::new().add_since(1000).matches(&event));
assert!(Filter::new().add_since(999).matches(&event));
assert!(!Filter::new().add_since(1001).matches(&event));
}
#[test]
fn filter_until_inclusive() {
let event = make_event(1, 1000, vec![]);
assert!(Filter::new().add_until(1000).matches(&event));
assert!(Filter::new().add_until(1001).matches(&event));
assert!(!Filter::new().add_until(999).matches(&event));
}
#[test]
fn filter_limit_ignored_in_matching() {
let event = make_event(1, 1_700_000_000, vec![]);
assert!(Filter::new().add_limit(0).matches(&event));
}
#[test]
fn filter_and_semantics() {
let event = make_event(1, 1_700_000_000, vec![]);
let filter = Filter::new()
.add_kind(1)
.add_author(fixed_secret().public_key());
assert!(filter.matches(&event));
let filter = Filter::new()
.add_kind(2)
.add_author(fixed_secret().public_key());
assert!(!filter.matches(&event));
}
#[test]
fn empty_set_matches_nothing() {
let event = make_event(1, 1_700_000_000, vec![]);
let filter = Filter {
ids: Some(BTreeSet::new()),
..Filter::new()
};
assert!(!filter.matches(&event));
let filter = Filter {
kinds: Some(BTreeSet::new()),
..Filter::new()
};
assert!(!filter.matches(&event));
}
// --- matches_any ---
#[test]
fn matches_any_or_semantics() {
let event = make_event(1, 1_700_000_000, vec![]);
let filters = vec![Filter::new().add_kind(2), Filter::new().add_kind(1)];
assert!(matches_any(&filters, &event));
}
#[test]
fn matches_any_empty_slice() {
let event = make_event(1, 1_700_000_000, vec![]);
assert!(!matches_any(&[], &event));
}
// --- Construction: add ---
#[test]
fn builder_chaining() {
let pk = fixed_secret().public_key();
let filter = Filter::new()
.add_kind(1)
.add_kinds([2, 3])
.add_author(pk)
.add_tag("t", "nostr")
.add_since(1000)
.add_until(2000)
.add_limit(10);
assert_eq!(filter.kinds.as_ref().unwrap().len(), 3);
assert!(filter.kinds.as_ref().unwrap().contains(&1));
assert!(filter.kinds.as_ref().unwrap().contains(&2));
assert!(filter.kinds.as_ref().unwrap().contains(&3));
assert_eq!(filter.since, Some(1000));
assert_eq!(filter.until, Some(2000));
assert_eq!(filter.limit, Some(10));
}
#[test]
fn address_convenience() {
let pk = fixed_secret().public_key();
let addr = coracle_lib::addresses::Address::new(30023, pk, "my-article");
let filter = Filter::new().add_address(&addr);
assert!(filter.kinds.as_ref().unwrap().contains(&30023));
assert!(filter.authors.as_ref().unwrap().contains(&pk));
assert!(filter.tags.get("d").unwrap().contains("my-article"));
}
// --- Construction: remove ---
#[test]
fn remove_id() {
let id = [1u8; 32];
let filter = Filter::new().add_id(id).add_id([2u8; 32]).remove_id(&id);
assert!(!filter.ids.as_ref().unwrap().contains(&id));
assert!(filter.ids.as_ref().unwrap().contains(&[2u8; 32]));
}
#[test]
fn remove_author() {
let pk = fixed_secret().public_key();
let filter = Filter::new().add_author(pk).remove_author(&pk);
// Set exists but is empty — matches nothing
assert!(filter.authors.as_ref().unwrap().is_empty());
}
#[test]
fn remove_kind() {
let filter = Filter::new()
.add_kinds([1, 2, 3])
.remove_kind(&2);
let kinds = filter.kinds.as_ref().unwrap();
assert!(kinds.contains(&1));
assert!(!kinds.contains(&2));
assert!(kinds.contains(&3));
}
#[test]
fn remove_tag_value() {
let filter = Filter::new()
.add_tags("t", ["nostr", "bitcoin"])
.remove_tag("t", "bitcoin");
let values = filter.tags.get("t").unwrap();
assert!(values.contains("nostr"));
assert!(!values.contains("bitcoin"));
}
#[test]
fn remove_tag_last_value_removes_entry() {
let filter = Filter::new()
.add_tag("t", "nostr")
.remove_tag("t", "nostr");
assert!(!filter.tags.contains_key("t"));
}
#[test]
fn remove_on_none_is_noop() {
// Removing from a field that was never set should not panic
let filter = Filter::new()
.remove_id(&[0u8; 32])
.remove_kind(&1)
.remove_author(&fixed_secret().public_key())
.remove_tag("t", "nostr");
assert!(filter.ids.is_none());
assert!(filter.kinds.is_none());
assert!(filter.authors.is_none());
}
// --- Construction: clear ---
#[test]
fn clear_ids() {
let filter = Filter::new().add_id([1u8; 32]).clear_ids();
assert!(filter.ids.is_none());
}
#[test]
fn clear_authors() {
let filter = Filter::new()
.add_author(fixed_secret().public_key())
.clear_authors();
assert!(filter.authors.is_none());
}
#[test]
fn clear_kinds() {
let filter = Filter::new().add_kind(1).clear_kinds();
assert!(filter.kinds.is_none());
}
#[test]
fn clear_tag() {
let filter = Filter::new()
.add_tag("t", "nostr")
.add_tag("e", "abc")
.clear_tag("t");
assert!(!filter.tags.contains_key("t"));
assert!(filter.tags.contains_key("e"));
}
#[test]
fn clear_tags() {
let filter = Filter::new()
.add_tag("t", "nostr")
.add_tag("e", "abc")
.clear_tags();
assert!(filter.tags.is_empty());
}
#[test]
fn clear_since() {
let filter = Filter::new().add_since(1000).clear_since();
assert!(filter.since.is_none());
}
#[test]
fn clear_until() {
let filter = Filter::new().add_until(2000).clear_until();
assert!(filter.until.is_none());
}
#[test]
fn clear_limit() {
let filter = Filter::new().add_limit(10).clear_limit();
assert!(filter.limit.is_none());
}
// --- Serialization ---
#[test]
fn json_round_trip() {
let pk = fixed_secret().public_key();
let filter = Filter::new()
.add_kind(1)
.add_author(pk)
.add_tag("t", "nostr")
.add_tag("e", "abc123")
.add_since(1000)
.add_until(2000)
.add_limit(10);
let json = serde_json::to_string(&filter).unwrap();
let parsed: Filter = serde_json::from_str(&json).unwrap();
assert_eq!(filter, parsed);
}
#[test]
fn json_tag_flattening() {
let filter = Filter::new().add_tag("t", "nostr");
let json = serde_json::to_string(&filter).unwrap();
assert!(json.contains("\"#t\""));
assert!(json.contains("\"nostr\""));
}
#[test]
fn json_empty_filter() {
let filter = Filter::new();
let json = serde_json::to_string(&filter).unwrap();
assert_eq!(json, "{}");
let parsed: Filter = serde_json::from_str(&json).unwrap();
assert_eq!(filter, parsed);
}
#[test]
fn json_limit_zero() {
let filter = Filter::new().add_limit(0);
let json = serde_json::to_string(&filter).unwrap();
assert!(json.contains("\"limit\":0"));
let parsed: Filter = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.limit, Some(0));
}
#[test]
fn json_ids_as_hex() {
let filter = Filter::new().add_id([0xab; 32]);
let json = serde_json::to_string(&filter).unwrap();
assert!(json.contains(&hex::encode([0xab; 32])));
}
#[test]
fn json_ignores_unknown_keys() {
let json = r#"{"kinds": [1], "unknown_field": true}"#;
let filter: Filter = serde_json::from_str(json).unwrap();
assert_eq!(filter.kinds, Some(BTreeSet::from([1])));
}
// --- Identity and grouping ---
#[test]
fn filter_id_deterministic() {
let f1 = Filter::new().add_kind(1).add_tag("t", "nostr");
let f2 = Filter::new().add_kind(1).add_tag("t", "nostr");
assert_eq!(f1.id(), f2.id());
}
#[test]
fn filter_id_differs_for_different_filters() {
let f1 = Filter::new().add_kind(1);
let f2 = Filter::new().add_kind(2);
assert_ne!(f1.id(), f2.id());
}
#[test]
fn filter_group_same_shape() {
let f1 = Filter::new().add_kind(1).add_tag("t", "nostr");
let f2 = Filter::new().add_kind(2).add_tag("t", "bitcoin");
assert_eq!(f1.group(), f2.group());
}
#[test]
fn filter_group_different_shape() {
let f1 = Filter::new().add_kind(1).add_tag("t", "nostr");
let f2 = Filter::new().add_kind(1).add_tag("e", "abc");
assert_ne!(f1.group(), f2.group());
}
#[test]
fn group_key_same_time_window_mergeable() {
let f1 = Filter::new().add_kind(1).add_since(1000).add_until(2000);
let f2 = Filter::new().add_kind(2).add_since(1000).add_until(2000);
assert_eq!(f1.group(), f2.group());
}
#[test]
fn group_key_different_time_window_not_mergeable() {
let f1 = Filter::new().add_kind(1).add_since(1000);
let f2 = Filter::new().add_kind(1).add_since(2000);
assert_ne!(f1.group(), f2.group());
}
#[test]
fn group_key_limit_always_unique() {
let f1 = Filter::new().add_kind(1).add_limit(10);
let f2 = Filter::new().add_kind(1).add_limit(10);
// Even identical limits produce different group keys
assert_ne!(f1.group(), f2.group());
}
#[test]
fn group_key_no_limit_is_mergeable() {
let f1 = Filter::new().add_kind(1);
let f2 = Filter::new().add_kind(2);
assert_eq!(f1.group(), f2.group());
}
// --- union_filters ---
#[test]
fn union_merges_same_shape() {
let pk1 = fixed_secret().public_key();
let pk2 = SecretKey::generate().public_key();
let filters = vec![
Filter::new().add_kind(1).add_author(pk1),
Filter::new().add_kind(1).add_author(pk2),
];
let result = union_filters(&filters);
assert_eq!(result.len(), 1);
let merged = &result[0];
assert!(merged.authors.as_ref().unwrap().contains(&pk1));
assert!(merged.authors.as_ref().unwrap().contains(&pk2));
assert!(merged.kinds.as_ref().unwrap().contains(&1));
}
#[test]
fn union_keeps_different_shapes_separate() {
let filters = vec![
Filter::new().add_kind(1),
Filter::new().add_kind(1).add_tag("t", "nostr"),
];
let result = union_filters(&filters);
assert_eq!(result.len(), 2);
}
#[test]
fn union_keeps_different_time_windows_separate() {
let filters = vec![
Filter::new().add_kind(1).add_since(1000),
Filter::new().add_kind(2).add_since(2000),
];
let result = union_filters(&filters);
assert_eq!(result.len(), 2);
}
#[test]
fn union_keeps_limited_filters_separate() {
let filters = vec![
Filter::new().add_kind(1).add_limit(10),
Filter::new().add_kind(2).add_limit(10),
];
let result = union_filters(&filters);
assert_eq!(result.len(), 2);
}
#[test]
fn union_empty_input() {
let result = union_filters(&[]);
assert!(result.is_empty());
}
#[test]
fn union_merges_tag_values() {
let filters = vec![
Filter::new().add_tag("t", "nostr"),
Filter::new().add_tag("t", "bitcoin"),
];
let result = union_filters(&filters);
assert_eq!(result.len(), 1);
let values = result[0].tags.get("t").unwrap();
assert!(values.contains("nostr"));
assert!(values.contains("bitcoin"));
}
// --- intersect_filters ---
#[test]
fn intersect_cartesian_product() {
let groups = vec![
vec![Filter::new().add_kind(1), Filter::new().add_kind(2)],
vec![Filter::new().add_tag("t", "nostr")],
];
let result = intersect_filters(&groups);
// Two combinations merged by union into one: kinds(1,2)+tag(t)
assert_eq!(result.len(), 1);
assert!(result[0].kinds.as_ref().unwrap().contains(&1));
assert!(result[0].kinds.as_ref().unwrap().contains(&2));
assert!(result[0].tags.contains_key("t"));
}
#[test]
fn intersect_incompatible_shapes_stay_separate() {
let groups = vec![
vec![Filter::new().add_kind(1)],
vec![
Filter::new().add_tag("t", "nostr"),
Filter::new().add_tag("e", "abc"),
],
];
let result = intersect_filters(&groups);
// kind+#t and kind+#e are different shapes, can't merge
assert_eq!(result.len(), 2);
}
#[test]
fn intersect_tightens_time_window() {
let groups = vec![
vec![Filter::new().add_since(1000).add_until(3000)],
vec![Filter::new().add_since(2000).add_until(4000)],
];
let result = intersect_filters(&groups);
assert_eq!(result.len(), 1);
assert_eq!(result[0].since, Some(2000)); // max
assert_eq!(result[0].until, Some(3000)); // min
}
#[test]
fn intersect_empty_groups() {
let result = intersect_filters(&[]);
assert!(result.is_empty());
}
#[test]
fn intersect_single_group_passthrough() {
let groups = vec![vec![
Filter::new().add_kind(1),
Filter::new().add_kind(2),
]];
let result = intersect_filters(&groups);
// Should union the two compatible filters
assert_eq!(result.len(), 1);
assert!(result[0].kinds.as_ref().unwrap().contains(&1));
assert!(result[0].kinds.as_ref().unwrap().contains(&2));
}