commit 76e17d3d85d07107fa71024c97586bb41e86d49e
parent 0fb70c73a820e94f41e43b99c9d168026a34f950
Author: kernelkind <kernelkind@gmail.com>
Date: Thu, 19 Feb 2026 13:30:12 -0500
test(scoped-subs): cover runtime upsert, ownership, and account-switch semantics
Add focused unit tests for set/clear/drop behavior, shared owners, empty-filter updates, key stability, and account-switch unsubscribe/restore contracts in the scoped subscription runtime.
Signed-off-by: kernelkind <kernelkind@gmail.com>
Diffstat:
1 file changed, 1120 insertions(+), 0 deletions(-)
diff --git a/crates/notedeck/src/scoped_subs.rs b/crates/notedeck/src/scoped_subs.rs
@@ -809,3 +809,1123 @@ fn aggregate_eose_status(
all_eosed,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::EguiWakeup;
+ use enostr::{OutboxPool, OutboxSessionHandler};
+ use std::hash::Hash;
+
+ #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+ enum FakeApp {
+ Timelines,
+ Threads,
+ Messages,
+ }
+
+ fn empty_config(_scope: SubScope) -> SubConfig {
+ SubConfig {
+ relays: RelaySelection::AccountsRead,
+ filters: Vec::new(),
+ use_transparent: false,
+ }
+ }
+
+ fn live_config(scope: SubScope) -> SubConfig {
+ let mut config = empty_config(scope);
+ config.filters = vec![Filter::new().kinds(vec![1]).limit(5).build()];
+ config
+ }
+
+ fn relay_set(url: &str) -> HashSet<NormRelayUrl> {
+ let mut relays = HashSet::new();
+ relays.insert(NormRelayUrl::new(url).unwrap());
+ relays
+ }
+
+ fn account_pk(tag: u8) -> Pubkey {
+ Pubkey::new([tag; 32])
+ }
+
+ fn make_key(parts: impl Hash) -> SubKey {
+ SubKey::new(parts)
+ }
+
+ fn accountsread_spec(scope: SubScope, kind: u64, limit: u64) -> SubConfig {
+ let mut spec = empty_config(scope);
+ spec.filters = vec![Filter::new().kinds(vec![kind]).limit(limit).build()];
+ spec.relays = RelaySelection::AccountsRead;
+ spec
+ }
+
+ fn explicit_account_spec() -> SubConfig {
+ let explicit_relay = NormRelayUrl::new("wss://relay-explicit.example.com").unwrap();
+ let mut spec = empty_config(SubScope::Account);
+ spec.filters = vec![Filter::new().kinds(vec![10002]).limit(1).build()];
+ spec.relays = RelaySelection::Explicit({
+ let mut set = HashSet::new();
+ set.insert(explicit_relay);
+ set
+ });
+ spec
+ }
+
+ fn outbox<'a>(pool: &'a mut OutboxPool) -> Outbox<'a> {
+ OutboxSessionHandler::new(pool, EguiWakeup::new(egui::Context::default()))
+ }
+
+ fn slot_status(
+ runtime: &ScopedSubRuntime,
+ pool: &mut OutboxPool,
+ selected_account_pubkey: Pubkey,
+ slot: SubSlotId,
+ key: SubKey,
+ scope: SubScope,
+ ) -> ScopedSubEoseStatus {
+ let outbox = outbox(pool);
+ runtime.sub_eose_status_with_selected(&outbox, selected_account_pubkey, slot, key, scope)
+ }
+
+ /// Verifies repeated set_sub calls for the same key perform create-then-update semantics.
+ #[test]
+ fn set_sub_is_upsert_for_existing_key() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let relays = relay_set("wss://relay-a.example.com");
+ let key = SubKey::new(("messages", "dm-list", 7u8));
+ let scope = SubScope::Global;
+ let slot = runtime.create_slot();
+
+ let first = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account_pk(0x01),
+ slot,
+ scope,
+ key,
+ empty_config(scope.clone()),
+ );
+ let second = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account_pk(0x01),
+ slot,
+ scope,
+ key,
+ empty_config(scope),
+ );
+
+ assert!(matches!(first, SetSubResult::Created));
+ assert!(matches!(second, SetSubResult::Updated));
+ assert_eq!(runtime.desired_len(), 1);
+ assert_eq!(runtime.live_len(), 0);
+ assert_eq!(runtime.slot_len(), 1);
+ }
+
+ /// Verifies repeated ensure_sub calls for the same key are create-then-noop.
+ #[test]
+ fn ensure_sub_is_create_or_ignore_for_existing_key() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let relays = relay_set("wss://relay-a.example.com");
+ let key = SubKey::new(("messages", "dm-list", 9u8));
+ let slot = runtime.create_slot();
+
+ let first = runtime.ensure_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account_pk(0x01),
+ slot,
+ SubScope::Global,
+ key,
+ empty_config(SubScope::Global),
+ );
+
+ let second = runtime.ensure_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account_pk(0x01),
+ slot,
+ SubScope::Global,
+ key,
+ empty_config(SubScope::Global),
+ );
+
+ assert!(matches!(first, EnsureSubResult::Created));
+ assert!(matches!(second, EnsureSubResult::AlreadyExists));
+ assert_eq!(runtime.desired_len(), 1);
+ assert_eq!(runtime.live_len(), 0);
+ assert_eq!(runtime.slot_len(), 1);
+ }
+
+ /// Verifies ensure_sub does not mutate existing live filter state.
+ #[test]
+ fn ensure_sub_does_not_modify_existing_live_sub() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let relays = relay_set("wss://relay-a.example.com");
+ let key = SubKey::new(("timeline", "home", 1u8));
+ let slot = runtime.create_slot();
+
+ let mut initial = empty_config(SubScope::Global);
+ initial.filters = vec![Filter::new().kinds(vec![1]).limit(10).build()];
+
+ let created = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account_pk(0x01),
+ slot,
+ SubScope::Global,
+ key,
+ initial,
+ );
+ assert!(matches!(created, SetSubResult::Created));
+
+ let scoped = ScopedSubRuntime::scoped_key(ResolvedSubScope::Global, key);
+ let live_id = runtime.live.get(&scoped).copied().expect("live sub id");
+ let before = pool
+ .filters(&live_id)
+ .expect("stored filters before ensure")
+ .iter()
+ .map(|f| f.json().expect("filter json"))
+ .collect::<Vec<_>>();
+
+ let mut replacement = empty_config(SubScope::Global);
+ replacement.filters = vec![Filter::new().kinds(vec![3]).limit(1).build()];
+ let ensured = runtime.ensure_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account_pk(0x01),
+ slot,
+ SubScope::Global,
+ key,
+ replacement,
+ );
+ assert!(matches!(ensured, EnsureSubResult::AlreadyExists));
+
+ let after = pool
+ .filters(&live_id)
+ .expect("stored filters after ensure")
+ .iter()
+ .map(|f| f.json().expect("filter json"))
+ .collect::<Vec<_>>();
+ assert_eq!(before, after);
+ }
+
+ /// Verifies aggregate EOSE helper treats zero tracked relays as not fully EOSE'd.
+ #[test]
+ fn aggregate_eose_status_zero_tracked_relays_is_not_all_eosed() {
+ let status = aggregate_eose_status(std::iter::empty());
+ assert_eq!(
+ status,
+ ScopedSubLiveEoseStatus {
+ tracked_relays: 0,
+ any_eose: false,
+ all_eosed: false,
+ }
+ );
+ }
+
+ /// Verifies aggregate EOSE helper reports partial EOSE when relay legs are mixed.
+ #[test]
+ fn aggregate_eose_status_mixed_relays_reports_partial_eose() {
+ let status = aggregate_eose_status([
+ RelayReqStatus::InitialQuery,
+ RelayReqStatus::Eose,
+ RelayReqStatus::Closed,
+ ]);
+ assert_eq!(
+ status,
+ ScopedSubLiveEoseStatus {
+ tracked_relays: 3,
+ any_eose: true,
+ all_eosed: false,
+ }
+ );
+ }
+
+ /// Verifies aggregate EOSE helper reports fully EOSE'd only when all tracked relays are EOSE.
+ #[test]
+ fn aggregate_eose_status_all_relays_eose_reports_all_eosed() {
+ let status = aggregate_eose_status([RelayReqStatus::Eose, RelayReqStatus::Eose]);
+ assert_eq!(
+ status,
+ ScopedSubLiveEoseStatus {
+ tracked_relays: 2,
+ any_eose: true,
+ all_eosed: true,
+ }
+ );
+ }
+
+ /// Verifies EOSE status lookup returns Missing when the slot does not own the requested key.
+ #[test]
+ fn sub_eose_status_missing_when_slot_does_not_own_key() {
+ let runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let status = slot_status(
+ &runtime,
+ &mut pool,
+ account_pk(0x01),
+ SubSlotId(999),
+ make_key(("missing", 1u8)),
+ SubScope::Global,
+ );
+ assert_eq!(status, ScopedSubEoseStatus::Missing);
+ }
+
+ /// Verifies empty-filter desired state reports Inactive because no live outbox sub exists.
+ #[test]
+ fn sub_eose_status_inactive_for_desired_without_live_sub() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let relays = relay_set("wss://relay-a.example.com");
+ let slot = runtime.create_slot();
+ let key = make_key(("inactive", 1u8));
+ let selected = account_pk(0x01);
+
+ let _ = runtime.ensure_sub_with_relays(
+ &mut outbox(&mut pool),
+ &relays,
+ selected,
+ slot,
+ SubScope::Global,
+ key,
+ empty_config(SubScope::Global),
+ );
+
+ let status = slot_status(&runtime, &mut pool, selected, slot, key, SubScope::Global);
+ assert_eq!(status, ScopedSubEoseStatus::Inactive);
+ }
+
+ /// Verifies live subscriptions expose aggregate EOSE state without leaking outbox ids.
+ #[test]
+ fn sub_eose_status_live_reports_tracked_relays_and_eose_flags() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let relays = relay_set("wss://relay-a.example.com");
+ let slot = runtime.create_slot();
+ let key = make_key(("live", 1u8));
+ let selected = account_pk(0x01);
+
+ let _ = runtime.set_sub_with_relays(
+ &mut outbox(&mut pool),
+ &relays,
+ selected,
+ slot,
+ SubScope::Global,
+ key,
+ live_config(SubScope::Global),
+ );
+
+ let status = slot_status(&runtime, &mut pool, selected, slot, key, SubScope::Global);
+ let ScopedSubEoseStatus::Live(live) = status else {
+ panic!("expected live status, got {status:?}");
+ };
+
+ assert_eq!(live.tracked_relays, 1);
+ assert!(!live.any_eose);
+ assert!(!live.all_eosed);
+ }
+
+ /// Verifies account switch makes old account-scoped subs inactive and restores them on switch-back.
+ #[test]
+ fn account_scoped_sub_eose_status_transitions_inactive_and_restores_on_switch_back() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let relays_a = relay_set("wss://relay-a.example.com");
+ let relays_b = relay_set("wss://relay-b.example.com");
+ let account_a = account_pk(0x0A);
+ let account_b = account_pk(0x0B);
+ let slot = runtime.create_slot();
+ let key = make_key(("account-scoped", 1u8));
+
+ let _ = runtime.set_sub_with_relays(
+ &mut outbox(&mut pool),
+ &relays_a,
+ account_a,
+ slot,
+ SubScope::Account,
+ key,
+ live_config(SubScope::Account),
+ );
+
+ let before = slot_status(&runtime, &mut pool, account_a, slot, key, SubScope::Account);
+ assert!(matches!(before, ScopedSubEoseStatus::Live(_)));
+
+ runtime.on_account_switched_with_relays(
+ &mut outbox(&mut pool),
+ account_a,
+ account_b,
+ &relays_b,
+ );
+
+ let old_while_switched =
+ slot_status(&runtime, &mut pool, account_a, slot, key, SubScope::Account);
+ assert_eq!(old_while_switched, ScopedSubEoseStatus::Inactive);
+
+ let new_missing = slot_status(&runtime, &mut pool, account_b, slot, key, SubScope::Account);
+ assert_eq!(new_missing, ScopedSubEoseStatus::Missing);
+
+ runtime.on_account_switched_with_relays(
+ &mut outbox(&mut pool),
+ account_b,
+ account_a,
+ &relays_a,
+ );
+
+ let restored = slot_status(&runtime, &mut pool, account_a, slot, key, SubScope::Account);
+ assert!(matches!(restored, ScopedSubEoseStatus::Live(_)));
+ }
+
+ /// Verifies upsert updates a live subscription in place, and replaces it when transport mode changes.
+ #[test]
+ fn set_sub_upsert_modifies_live_sub() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let key = SubKey::new(("timeline", 1u64));
+ let scope = SubScope::Global;
+ let relays_a = relay_set("wss://relay-a.example.com");
+ let relays_b = relay_set("wss://relay-b.example.com");
+ let slot = runtime.create_slot();
+
+ let mut spec = empty_config(scope.clone());
+ spec.filters = vec![Filter::new().kinds(vec![1]).limit(2).build()];
+
+ let first = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays_a,
+ account_pk(0x01),
+ slot,
+ scope,
+ key,
+ spec.clone(),
+ );
+ assert!(matches!(first, SetSubResult::Created));
+
+ let scoped = ScopedSubRuntime::scoped_key(ResolvedSubScope::Global, key);
+ let live_id = runtime.live.get(&scoped).copied().expect("live sub id");
+ assert_eq!(pool.filters(&live_id).expect("stored filters").len(), 1);
+
+ let mut updated = spec.clone();
+ updated.filters = vec![Filter::new().kinds(vec![3]).limit(1).build()];
+
+ let res = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays_b,
+ account_pk(0x01),
+ slot,
+ scope,
+ key,
+ updated.clone(),
+ );
+ assert!(matches!(res, SetSubResult::Updated));
+
+ assert_eq!(
+ pool.filters(&live_id)
+ .expect("updated filters should exist")
+ .len(),
+ 1
+ );
+
+ let mut transparent_update = updated;
+ transparent_update.use_transparent = true;
+
+ let res = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays_b,
+ account_pk(0x01),
+ slot,
+ scope,
+ key,
+ transparent_update,
+ );
+ assert!(matches!(res, SetSubResult::Updated));
+
+ let new_live_id = runtime.live.get(&scoped).copied().expect("replacement id");
+ assert_ne!(live_id, new_live_id);
+ assert!(pool.filters(&live_id).is_none());
+ }
+
+ /// Verifies clearing the last owner unsubscribes the live outbox subscription and removes desired state.
+ #[test]
+ fn clear_sub_unsubscribes_live_subscription() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let key = SubKey::new(("timeline", 1u64));
+ let relays = relay_set("wss://relay-a.example.com");
+ let slot = runtime.create_slot();
+
+ let mut spec = empty_config(SubScope::Global);
+ spec.filters = vec![Filter::new().kinds(vec![1]).limit(2).build()];
+
+ runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account_pk(0x01),
+ slot,
+ SubScope::Global,
+ key,
+ spec,
+ );
+
+ let scoped = ScopedSubRuntime::scoped_key(ResolvedSubScope::Global, key);
+ let live_id = runtime.live.get(&scoped).copied().expect("live sub id");
+
+ assert!(matches!(
+ runtime.clear_sub_with_selected(
+ &mut OutboxSessionHandler::new(
+ &mut pool,
+ EguiWakeup::new(egui::Context::default())
+ ),
+ account_pk(0x01),
+ slot,
+ key,
+ SubScope::Global
+ ),
+ ClearSubResult::Cleared
+ ));
+
+ assert_eq!(runtime.desired_len(), 0);
+ assert_eq!(runtime.live_len(), 0);
+ assert_eq!(runtime.slot_len(), 0);
+ assert!(pool.filters(&live_id).is_none());
+
+ assert!(matches!(
+ runtime.clear_sub_with_selected(
+ &mut OutboxSessionHandler::new(
+ &mut pool,
+ EguiWakeup::new(egui::Context::default())
+ ),
+ account_pk(0x01),
+ slot,
+ key,
+ SubScope::Global
+ ),
+ ClearSubResult::NotFound
+ ));
+ }
+
+ /// Verifies multiple owners share one live sub and only the final clear unsubscribes it.
+ #[test]
+ fn multiple_slots_share_single_live_sub_until_last_clear() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let relays = relay_set("wss://relay-a.example.com");
+ let account = account_pk(0x33);
+ let key = SubKey::new(("thread", [9u8; 32]));
+
+ let mut spec = empty_config(SubScope::Account);
+ spec.filters = vec![Filter::new().kinds(vec![1]).limit(25).build()];
+
+ let slot_a = runtime.create_slot();
+ let slot_b = runtime.create_slot();
+
+ let a = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account,
+ slot_a,
+ SubScope::Account,
+ key,
+ spec.clone(),
+ );
+ let b = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account,
+ slot_b,
+ SubScope::Account,
+ key,
+ spec,
+ );
+
+ assert!(matches!(a, SetSubResult::Created));
+ assert!(matches!(b, SetSubResult::Updated));
+
+ let scoped = ScopedSubRuntime::scoped_key(ResolvedSubScope::Account(account), key);
+ let live_id = runtime.live.get(&scoped).copied().expect("live sub id");
+ assert_eq!(runtime.desired_len(), 1);
+ assert_eq!(runtime.live_len(), 1);
+ assert_eq!(runtime.slot_len(), 2);
+ assert!(pool.filters(&live_id).is_some());
+
+ assert!(matches!(
+ runtime.clear_sub_with_selected(
+ &mut OutboxSessionHandler::new(
+ &mut pool,
+ EguiWakeup::new(egui::Context::default())
+ ),
+ account,
+ slot_a,
+ key,
+ SubScope::Account
+ ),
+ ClearSubResult::StillInUse
+ ));
+
+ assert_eq!(runtime.desired_len(), 1);
+ assert_eq!(runtime.live_len(), 1);
+ assert_eq!(runtime.slot_len(), 1);
+ assert!(pool.filters(&live_id).is_some());
+
+ assert!(matches!(
+ runtime.clear_sub_with_selected(
+ &mut OutboxSessionHandler::new(
+ &mut pool,
+ EguiWakeup::new(egui::Context::default())
+ ),
+ account,
+ slot_b,
+ key,
+ SubScope::Account
+ ),
+ ClearSubResult::Cleared
+ ));
+
+ assert_eq!(runtime.desired_len(), 0);
+ assert_eq!(runtime.live_len(), 0);
+ assert_eq!(runtime.slot_len(), 0);
+ assert!(pool.filters(&live_id).is_none());
+ }
+
+ /// Verifies dropping a slot clears every scoped sub owned by that slot.
+ #[test]
+ fn drop_slot_clears_all_owned_subs() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let account = account_pk(0x4A);
+ let relays = relay_set("wss://relay-a.example.com");
+ let slot = runtime.create_slot();
+
+ let key_account = SubKey::new(("timeline", "home"));
+ let key_global = SubKey::new(("global", "discovery"));
+
+ let mut account_spec = empty_config(SubScope::Account);
+ account_spec.filters = vec![Filter::new().kinds(vec![1]).limit(5).build()];
+
+ let mut global_spec = empty_config(SubScope::Global);
+ global_spec.filters = vec![Filter::new().kinds(vec![0]).limit(5).build()];
+
+ let _ = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account,
+ slot,
+ SubScope::Account,
+ key_account,
+ account_spec,
+ );
+ let _ = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account,
+ slot,
+ SubScope::Global,
+ key_global,
+ global_spec,
+ );
+
+ assert_eq!(runtime.desired_len(), 2);
+ assert_eq!(runtime.live_len(), 2);
+ assert_eq!(runtime.slot_len(), 1);
+
+ assert!(matches!(
+ runtime.drop_slot(
+ &mut OutboxSessionHandler::new(
+ &mut pool,
+ EguiWakeup::new(egui::Context::default())
+ ),
+ slot
+ ),
+ DropSlotResult::Dropped
+ ));
+
+ assert_eq!(runtime.desired_len(), 0);
+ assert_eq!(runtime.live_len(), 0);
+ assert_eq!(runtime.slot_len(), 0);
+
+ assert!(matches!(
+ runtime.drop_slot(
+ &mut OutboxSessionHandler::new(
+ &mut pool,
+ EguiWakeup::new(egui::Context::default())
+ ),
+ slot
+ ),
+ DropSlotResult::NotFound
+ ));
+ }
+
+ /// Verifies account switch unsubscribes the old account scope and restores it when switching back.
+ #[test]
+ fn account_switch_unsubscribes_old_scope_and_restores_new_scope() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let account_a = account_pk(0xAA);
+ let account_b = account_pk(0xBB);
+ let relays_a = relay_set("wss://relay-a.example.com");
+ let relays_b = relay_set("wss://relay-b.example.com");
+ let key = SubKey::new(("timeline", "account-scoped"));
+ let slot = runtime.create_slot();
+
+ let mut scoped_spec = empty_config(SubScope::Account);
+ scoped_spec.filters = vec![Filter::new().kinds(vec![1]).limit(2).build()];
+
+ let _ = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays_a,
+ account_a,
+ slot,
+ SubScope::Account,
+ key,
+ scoped_spec,
+ );
+
+ let scoped_a = ScopedSubRuntime::scoped_key(ResolvedSubScope::Account(account_a), key);
+ let initial_live_id = runtime.live.get(&scoped_a).copied().expect("live id for A");
+ assert!(pool.filters(&initial_live_id).is_some());
+
+ runtime.on_account_switched_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ account_a,
+ account_b,
+ &relays_b,
+ );
+
+ assert!(runtime.live.get(&scoped_a).is_none());
+ assert!(pool.filters(&initial_live_id).is_none());
+ assert_eq!(runtime.desired_len(), 1);
+
+ runtime.on_account_switched_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ account_b,
+ account_a,
+ &relays_a,
+ );
+
+ let restored_live_id = runtime
+ .live
+ .get(&scoped_a)
+ .copied()
+ .expect("account A should be restored on switch back");
+ assert!(pool.filters(&restored_live_id).is_some());
+ }
+
+ /// Verifies account-scoped and global subscriptions obey the account-switch contract across app domains.
+ #[test]
+ fn account_switch_contract_with_multiple_apps_and_mixed_scopes() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let account_a = account_pk(0xA1);
+ let account_b = account_pk(0xB2);
+ let peer_pk = account_pk(0xCC);
+
+ let relays_a = relay_set("wss://relay-a.example.com");
+ let relays_b = relay_set("wss://relay-b.example.com");
+ let explicit_relay = NormRelayUrl::new("wss://relay-explicit.example.com").expect("relay");
+
+ let key_timeline_a = make_key((FakeApp::Timelines, "home", 1u64, account_a));
+ let key_thread_a = make_key((FakeApp::Threads, "root", [7u8; 32], account_a));
+ let key_messages_a = make_key((FakeApp::Messages, "dm-relay-list", peer_pk, account_a));
+ let key_global = make_key((FakeApp::Timelines, "global-discovery", 99u64));
+
+ let mut timeline_spec_a = empty_config(SubScope::Account);
+ timeline_spec_a.filters = vec![Filter::new().kinds(vec![1]).limit(50).build()];
+ timeline_spec_a.relays = RelaySelection::AccountsRead;
+
+ let mut thread_spec_a = empty_config(SubScope::Account);
+ thread_spec_a.filters = vec![Filter::new().kinds(vec![1]).limit(200).build()];
+ thread_spec_a.relays = RelaySelection::AccountsRead;
+ thread_spec_a.use_transparent = true;
+
+ let mut messages_spec_a = empty_config(SubScope::Account);
+ messages_spec_a.filters = vec![Filter::new().kinds(vec![10002]).limit(20).build()];
+ messages_spec_a.relays = RelaySelection::Explicit({
+ let mut set = HashSet::new();
+ set.insert(explicit_relay.clone());
+ set
+ });
+
+ let mut global_spec = empty_config(SubScope::Global);
+ global_spec.filters = vec![Filter::new().kinds(vec![0]).limit(10).build()];
+ global_spec.relays = RelaySelection::Explicit({
+ let mut set = HashSet::new();
+ set.insert(explicit_relay.clone());
+ set
+ });
+
+ let slot_timeline = runtime.create_slot();
+ let slot_thread = runtime.create_slot();
+ let slot_messages = runtime.create_slot();
+ let slot_global = runtime.create_slot();
+
+ let _ = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays_a,
+ account_a,
+ slot_timeline,
+ SubScope::Account,
+ key_timeline_a,
+ timeline_spec_a,
+ );
+ let _ = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays_a,
+ account_a,
+ slot_thread,
+ SubScope::Account,
+ key_thread_a,
+ thread_spec_a,
+ );
+ let _ = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays_a,
+ account_a,
+ slot_messages,
+ SubScope::Account,
+ key_messages_a,
+ messages_spec_a,
+ );
+ let _ = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays_a,
+ account_a,
+ slot_global,
+ SubScope::Global,
+ key_global,
+ global_spec,
+ );
+
+ let scoped_timeline_a =
+ ScopedSubRuntime::scoped_key(ResolvedSubScope::Account(account_a), key_timeline_a);
+ let scoped_thread_a =
+ ScopedSubRuntime::scoped_key(ResolvedSubScope::Account(account_a), key_thread_a);
+ let scoped_messages_a =
+ ScopedSubRuntime::scoped_key(ResolvedSubScope::Account(account_a), key_messages_a);
+ let scoped_global = ScopedSubRuntime::scoped_key(ResolvedSubScope::Global, key_global);
+
+ let timeline_id_a = runtime
+ .live
+ .get(&scoped_timeline_a)
+ .copied()
+ .expect("timeline A live");
+ let thread_id_a = runtime
+ .live
+ .get(&scoped_thread_a)
+ .copied()
+ .expect("thread A live");
+ let messages_id_a = runtime
+ .live
+ .get(&scoped_messages_a)
+ .copied()
+ .expect("messages A live");
+ let global_id = runtime
+ .live
+ .get(&scoped_global)
+ .copied()
+ .expect("global live");
+
+ assert!(pool.filters(&timeline_id_a).is_some());
+ assert!(pool.filters(&thread_id_a).is_some());
+ assert!(pool.filters(&messages_id_a).is_some());
+ assert!(pool.filters(&global_id).is_some());
+
+ runtime.on_account_switched_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ account_a,
+ account_b,
+ &relays_b,
+ );
+
+ assert!(
+ runtime.live.get(&scoped_timeline_a).is_none()
+ && runtime.live.get(&scoped_thread_a).is_none()
+ && runtime.live.get(&scoped_messages_a).is_none()
+ );
+ assert!(
+ pool.filters(&timeline_id_a).is_none()
+ && pool.filters(&thread_id_a).is_none()
+ && pool.filters(&messages_id_a).is_none()
+ );
+ assert!(runtime.live.get(&scoped_global).is_some() && pool.filters(&global_id).is_some());
+ assert_eq!(runtime.desired_len(), 4);
+
+ runtime.on_account_switched_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ account_b,
+ account_a,
+ &relays_a,
+ );
+
+ let restored_timeline_id = runtime
+ .live
+ .get(&scoped_timeline_a)
+ .copied()
+ .expect("timeline A restored");
+ let restored_thread_id = runtime
+ .live
+ .get(&scoped_thread_a)
+ .copied()
+ .expect("thread A restored");
+ let restored_messages_id = runtime
+ .live
+ .get(&scoped_messages_a)
+ .copied()
+ .expect("messages A restored");
+
+ assert!(pool.filters(&restored_timeline_id).is_some());
+ assert!(pool.filters(&restored_thread_id).is_some());
+ assert!(pool.filters(&restored_messages_id).is_some());
+ }
+
+ #[derive(Clone)]
+ struct SubmittedSub {
+ scoped: ScopedSubKey,
+ live_id: OutboxSubId,
+ }
+
+ // Scenario harness for selected-account read-relay retarget tests.
+ // Keep this narrow; it is intentionally not a generic scoped-subs fixture.
+ struct RetargetReadRelaysTest {
+ runtime: ScopedSubRuntime,
+ pool: OutboxPool,
+ selected_account: Pubkey,
+ other_account: Pubkey,
+ relay_a: HashSet<NormRelayUrl>,
+ relay_b: HashSet<NormRelayUrl>,
+ }
+
+ impl RetargetReadRelaysTest {
+ fn new() -> Self {
+ Self {
+ runtime: ScopedSubRuntime::default(),
+ pool: OutboxPool::default(),
+ selected_account: account_pk(0xA1),
+ other_account: account_pk(0xB2),
+ relay_a: relay_set("wss://relay-a.example.com"),
+ relay_b: relay_set("wss://relay-b.example.com"),
+ }
+ }
+
+ fn submit_accountsread_account_home(&mut self) -> SubmittedSub {
+ self.submit_sub(
+ SubScope::Account,
+ make_key((FakeApp::Timelines, "home", 1u64)),
+ accountsread_spec(SubScope::Account, 1, 50),
+ )
+ }
+
+ fn submit_accountsread_global_feed(&mut self) -> SubmittedSub {
+ self.submit_sub(
+ SubScope::Global,
+ make_key((FakeApp::Timelines, "global-ish", 2u64)),
+ accountsread_spec(SubScope::Global, 0, 10),
+ )
+ }
+
+ fn submit_account_explicit_messages(&mut self) -> SubmittedSub {
+ self.submit_sub(
+ SubScope::Account,
+ make_key((FakeApp::Messages, "explicit", 3u64)),
+ explicit_account_spec(),
+ )
+ }
+
+ fn submit_accountsread_other_account_home(&mut self) -> SubmittedSub {
+ self.submit_sub_for_account(
+ self.other_account,
+ SubScope::Account,
+ make_key((FakeApp::Timelines, "home", 99u64)),
+ accountsread_spec(SubScope::Account, 1, 25),
+ )
+ }
+
+ fn submit_sub(&mut self, scope: SubScope, key: SubKey, spec: SubConfig) -> SubmittedSub {
+ self.submit_sub_for_account(self.selected_account, scope, key, spec)
+ }
+
+ fn submit_sub_for_account(
+ &mut self,
+ account: Pubkey,
+ scope: SubScope,
+ key: SubKey,
+ spec: SubConfig,
+ ) -> SubmittedSub {
+ let slot = self.runtime.create_slot();
+ let _ = self.runtime.set_sub_with_relays(
+ &mut outbox(&mut self.pool),
+ &self.relay_a,
+ account,
+ slot,
+ scope,
+ key,
+ spec,
+ );
+
+ let resolved_scope = match scope {
+ SubScope::Account => ResolvedSubScope::Account(account),
+ SubScope::Global => ResolvedSubScope::Global,
+ };
+ let scoped = ScopedSubRuntime::scoped_key(resolved_scope, key);
+ let live_id = self.runtime.live.get(&scoped).copied().unwrap();
+
+ SubmittedSub { scoped, live_id }
+ }
+
+ fn retarget_to_relay_b(&mut self) {
+ self.runtime
+ .retarget_selected_account_read_relays_with_relays(
+ &mut outbox(&mut self.pool),
+ self.selected_account,
+ &self.relay_b,
+ );
+ }
+
+ fn assert_live_id_unchanged(&self, sub: &SubmittedSub) {
+ assert_eq!(self.runtime.live.get(&sub.scoped), Some(&sub.live_id));
+ }
+
+ fn assert_still_live(&self, sub: &SubmittedSub) {
+ assert!(self.pool.filters(&sub.live_id).is_some());
+ }
+
+ fn switch_selected_account_away(&mut self) {
+ self.runtime.on_account_switched_with_relays(
+ &mut outbox(&mut self.pool),
+ self.selected_account,
+ self.other_account,
+ &self.relay_b,
+ );
+ }
+
+ fn assert_not_live(&self, sub: &SubmittedSub) {
+ assert!(self.runtime.live.get(&sub.scoped).is_none());
+ assert!(self.pool.filters(&sub.live_id).is_none());
+ }
+
+ fn assert_live_recreated(&self, sub: &SubmittedSub) {
+ let recreated_live_id = self.runtime.live.get(&sub.scoped).copied().unwrap();
+ assert_ne!(recreated_live_id, sub.live_id);
+ assert!(self.pool.filters(&recreated_live_id).is_some());
+ assert!(self.pool.filters(&sub.live_id).is_none());
+ }
+ }
+
+ /// Verifies selected-account relay list refresh retargets all AccountsRead subs in scope.
+ #[test]
+ fn selected_account_relay_refresh_updates_account_and_global_accountsread_subs() {
+ let mut t = RetargetReadRelaysTest::new();
+
+ let account_home = t.submit_accountsread_account_home();
+ let global_feed = t.submit_accountsread_global_feed();
+ let explicit_messages = t.submit_account_explicit_messages();
+
+ t.retarget_to_relay_b();
+
+ t.assert_live_id_unchanged(&account_home);
+ t.assert_live_id_unchanged(&global_feed);
+ t.assert_live_id_unchanged(&explicit_messages);
+
+ t.assert_still_live(&account_home);
+ t.assert_still_live(&global_feed);
+ t.assert_still_live(&explicit_messages);
+ }
+
+ /// Verifies retargeting recreates a missing live AccountsRead sub from desired state.
+ #[test]
+ fn selected_account_relay_retarget_recreates_missing_live_sub() {
+ let mut t = RetargetReadRelaysTest::new();
+
+ let account_home = t.submit_accountsread_account_home();
+ t.switch_selected_account_away();
+ t.assert_not_live(&account_home);
+
+ t.retarget_to_relay_b();
+
+ t.assert_live_recreated(&account_home);
+ }
+
+ /// Verifies retargeting the selected account does not touch another account's account-scoped sub.
+ #[test]
+ fn selected_account_relay_retarget_ignores_other_account_scoped_subs() {
+ let mut t = RetargetReadRelaysTest::new();
+
+ let selected_account_home = t.submit_accountsread_account_home();
+ let other_account_home = t.submit_accountsread_other_account_home();
+
+ t.retarget_to_relay_b();
+
+ t.assert_live_id_unchanged(&selected_account_home);
+ t.assert_live_id_unchanged(&other_account_home);
+ t.assert_still_live(&selected_account_home);
+ t.assert_still_live(&other_account_home);
+ }
+
+ /// Verifies typed SubKey builder output is stable for identical inputs.
+ #[test]
+ fn subkey_builder_is_stable_and_typed() {
+ let key_a = SubKey::builder(FakeApp::Messages)
+ .with("dm-relay-list")
+ .with(account_pk(0x11))
+ .with(42u64)
+ .finish();
+ let key_b = SubKey::builder(FakeApp::Messages)
+ .with("dm-relay-list")
+ .with(account_pk(0x11))
+ .with(42u64)
+ .finish();
+ let key_c = SubKey::builder(FakeApp::Messages)
+ .with("dm-relay-list")
+ .with(account_pk(0x11))
+ .with(43u64)
+ .finish();
+
+ assert_eq!(key_a, key_b);
+ assert_ne!(key_a, key_c);
+ }
+
+ /// Verifies that upserting an empty filter set removes the active live subscription
+ /// while preserving desired state for future restoration.
+ #[test]
+ fn set_sub_with_empty_filters_removes_live_but_keeps_desired() {
+ let mut runtime = ScopedSubRuntime::default();
+ let mut pool = OutboxPool::default();
+ let relays = relay_set("wss://relay-a.example.com");
+ let key = SubKey::new(("messages", "dm-relay-list", 1u8));
+ let slot = runtime.create_slot();
+
+ let mut initial = empty_config(SubScope::Global);
+ initial.filters = vec![Filter::new().kinds(vec![10002]).limit(10).build()];
+
+ let created = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account_pk(0x01),
+ slot,
+ SubScope::Global,
+ key,
+ initial,
+ );
+ assert!(matches!(created, SetSubResult::Created));
+
+ let scoped = ScopedSubRuntime::scoped_key(ResolvedSubScope::Global, key);
+ let live_id = runtime.live.get(&scoped).copied().expect("live sub id");
+ assert!(pool.filters(&live_id).is_some());
+
+ let emptied = runtime.set_sub_with_relays(
+ &mut OutboxSessionHandler::new(&mut pool, EguiWakeup::new(egui::Context::default())),
+ &relays,
+ account_pk(0x01),
+ slot,
+ SubScope::Global,
+ key,
+ empty_config(SubScope::Global),
+ );
+ assert!(matches!(emptied, SetSubResult::Updated));
+ assert_eq!(runtime.desired_len(), 1);
+ assert_eq!(runtime.live_len(), 0);
+ assert!(pool.filters(&live_id).is_none());
+ }
+}