diff --git a/desktop/src-tauri/src/bench/item.rs b/desktop/src-tauri/src/bench/item.rs index 877dcd38d5c..771c50a2b53 100644 --- a/desktop/src-tauri/src/bench/item.rs +++ b/desktop/src-tauri/src/bench/item.rs @@ -318,7 +318,7 @@ pub(crate) struct Item { /// next one posts without the p tag. A new Drop starts false. #[serde(default)] pub quiet_repost: bool, - /// Its card left the relay (retired for a refresh, deleted by hand) and + /// Its card is gone (retired for a refresh, deleted by hand) and /// has not posted again: it keeps its `MAX_CARDS` place till it does. #[serde(default)] pub repost: bool, @@ -1013,8 +1013,8 @@ pub(crate) fn render_card(item: &Item, hhmm: &str) -> String { out } -/// A stale card kept for what is on it (publish (b)), from its text as -/// posted: closed, it no longer says how to answer, and says where to now. +/// An old post (`replaced`), from what it shows: closed, it no longer says how +/// to answer, and says where to now. pub(crate) fn render_closed(posted: &str) -> String { let open = [ format!(" {TAP}"), diff --git a/desktop/src-tauri/src/bench/mod.rs b/desktop/src-tauri/src/bench/mod.rs index 9ac64faffba..4f709f6d3bb 100644 --- a/desktop/src-tauri/src/bench/mod.rs +++ b/desktop/src-tauri/src/bench/mod.rs @@ -142,15 +142,17 @@ pub(crate) struct BenchState { /// Each bound channel's join or admin check: cards go only where it passed. channels: BTreeMap, /// Engaged or owner-told cards a new question left behind, and stale ones - /// closed for what is on them: on the relay for good, never adopted again - /// by recover (which would retire them as stale). + /// retired: on the relay for good, never adopted again by recover (which + /// would retire them as stale). 64 bytes each, a few a day at 4 Nov 2026. kept: Vec, - /// Each kept card posted in a thread, as (its root, its created_at): it - /// still ends the root-reply window of an older card there. + /// Each kept card in a thread with anything on it, as (its root, its + /// created_at): it still ends the root-reply window of an older card there. kept_at: Vec<(String, u64)>, /// Old posts owed an edit or a reply or watched for a tap; since when their read fails. replaced: Vec, replaced_unread: Option, + /// Receipts still to become old posts (`replaced::backfill`); None: unread. + receipts_todo: Option>, /// Live cards whose owner's page was cut this tick, or every item while /// roles.json does not read: their answers are unread, so nothing /// expires, restamps, routes, retires or replaces them. diff --git a/desktop/src-tauri/src/bench/publish.rs b/desktop/src-tauri/src/bench/publish.rs index 32f319e625b..87409e8c79d 100644 --- a/desktop/src-tauri/src/bench/publish.rs +++ b/desktop/src-tauri/src/bench/publish.rs @@ -8,9 +8,8 @@ use std::sync::{Mutex, PoisonError}; use super::channels::{card_channel, channel_ready, joined, reads_replies}; use super::{ - item, live_card, local_hhmm, next_ts, outbox, post_as_writer, query, query_writer, same_drop, - save_both, spend, submit, BenchState, Ctx, Outcome, Posted, BOARD_MAX_AGE_SECS, MAX_CARDS, - RECOVER_LIMIT, + item, live_card, local_hhmm, next_ts, outbox, post_as_writer, query, same_drop, save_both, + spend, submit, BenchState, Ctx, Outcome, Posted, BOARD_MAX_AGE_SECS, MAX_CARDS, RECOVER_LIMIT, }; use crate::events; @@ -337,55 +336,29 @@ fn df_answer(parts: [&str; 3]) -> Result { nostr::Tag::parse(["df-answer", n, by, id]).map_err(|e| e.to_string()) } -/// Whether a recovered card has anything on it but the writer's own seeds: -/// a tap, a reply, a route notice, or a Recorded or receipt edit. A cut page -/// holds more than the four seeds, so it counts too. -async fn touched( +/// What a post has on it but the writer's own seeds: a tap, a reply, a route +/// notice, or a Recorded, receipt or closing edit. A cut page holds more than +/// the four seeds, so it counts too. +pub(super) async fn on_post( ctx: &Ctx<'_>, s: &BenchState, ch: &str, card: &str, now: u64, -) -> Result { +) -> Result, String> { let filter = serde_json::json!({ "kinds": [7, 9, 40003], "#h": [ch], "#e": [card], "limit": RECOVER_LIMIT, }); let writer = ctx.writer.public_key(); - Ok(query(ctx, s, filter, now).await?.iter().any(|e| { + let mut on = query(ctx, s, filter, now).await?; + on.retain(|e| { let on_card = e .tags .iter() .any(|t| matches!(t.as_slice(), [k, v, ..] if k == "e" && v == card)); on_card && !(e.kind.as_u16() == 7 && e.pubkey == writer) - })) -} - -/// A stale card kept for what is on it (`touched`), for good: edited closed -/// from its own text as posted (`replaced::old_post`), its thread root (its -/// parent) ending older cards' root-reply windows there, and watched for a tap -/// (`replaced`). One gone from the relay is only kept. -async fn close( - ctx: &Ctx<'_>, - s: &mut BenchState, - id: &str, - ch: &str, - card: &Posted, - now: u64, -) -> Result<(), String> { - let filter = serde_json::json!({ "ids": [card.event_id], "kinds": [9], "#h": [ch] }); - if let Some(e) = query_writer(ctx, s, filter, now).await?.first() { - let text = super::replaced::old_post(s, id, &e.content); - let text = text + &item::thread_line(ch, &card.event_id); - match edit(ctx, s, ch, &card.event_id, &text, now).await { - Err(err) if !err.contains("not found") => return Err(err), - _ => {} - } - let root = item::parent(e).map(|r| (r.to_string(), card.created_at)); - s.kept_at.extend(root); - super::replaced::closed(s, id, ch, card, item::parent(e), now); - } - s.kept.push(card.event_id.clone()); - Ok(()) + }); + Ok(on) } /// A thread the relay will not take: its root is gone, in another channel or @@ -398,8 +371,9 @@ fn bad_thread(e: &str) -> bool { /// Cards, seeds, then the board, at most `budget` posts. A card is posted /// once per Drop and card format; it is edited to record an answer and, once -/// closed out, to become a receipt. Only a card nobody engaged with is ever -/// deleted. Every post made once a card's own id is known links its thread. +/// stale or closed out, to become an old post (`replaced`), so only a recovered +/// orphan nobody touched is ever deleted. Every post made once a card's own id +/// is known links its thread. /// An owner's notice that fails to go again lands in `failed`, for /// lastError, and the rest goes on. #[allow(clippy::too_many_arguments)] @@ -497,30 +471,23 @@ pub(super) async fn publish( } } - // (a2b) A close-out keeps the card: edited to the receipt, with the - // answer and this drop as its Outcome. A later close-out edits it again. + // (a2b) A close-out keeps the card, owed its edit to the receipt + // (`replaced`), with the answer and this drop as its Outcome. A later + // close-out edits it again. for id in s.items.keys().cloned().collect::>() { let item = &s.items[&id]; let (Some(_), Some(card)) = (&item.request, &item.card) else { continue; }; let hash = item::card_hash(item); - if card.hash == hash { + // A channel not ready takes no edit (`replaced`): the hash stays behind, + // so the close-out is picked up once it is ready. + if card.hash == hash || !channel_ready(s, item) { continue; } - if !spend(budget, 1) { - return Ok(()); - } - let (event_id, ch) = (card.event_id.clone(), card_channel(s, item)); - let text = item::render_card(item, hhmm) + &item::thread_line(&ch, &event_id); - match edit(ctx, s, &ch, &event_id, &text, now).await { - Ok(_) => { - let i = s.items.get_mut(&id).expect("receipt id"); - i.card.as_mut().expect("receipt card").hash = hash; - } - Err(e) if e.contains("not found") => forget_card(ctx, s, &id), - Err(e) => return Err(e), - } + super::replaced::receipt(s, &id, now); + let i = s.items.get_mut(&id).expect("receipt id"); + i.card.as_mut().expect("receipt card").hash = hash; } // (a3) Tell the writer's route target of a settled answer change, and of @@ -704,12 +671,13 @@ pub(super) async fn publish( s.items.get_mut(&id).expect("routed id").routed_reply = Some(r.event_id.clone()); } - // (b) Delete cards that are stale (another Drop, card format or stamp) or + // (b) Retire cards that are stale (another Drop, card format or stamp) or // no longer asked (superseded, expired, no longer pending), never for // MAX_CARDS, unless someone answered or replied on them or they are a - // receipt: those stay on the relay for good, and so, closed, does one - // with anything else on it but its seeds. A card in a channel that is - // not ready, or `held`, waits: its taps are read once it is again. + // receipt: those keep their place. A retired card stays on the relay for + // good, kept from recover and owed its edit closed (`replaced`), so no + // failure here fails the tick. A card in a channel that is not ready, or + // `held`, waits: its taps are read once it is again. let stale: Vec<(String, String, Posted)> = s .items .values() @@ -722,36 +690,27 @@ pub(super) async fn publish( }) .collect(); for (id, ch, card) in stale { - // One post per card, its lookups riding on it: the delete, or the edit - // that closes it. - if !spend(budget, 1) { - return Ok(()); - } - // A reply or tap the item never read or no longer holds (free text on - // an unscoped card with no route, or one asked again) still makes the - // card a record: it stays, as `carry_card` keeps one, but closed, - // since nothing reads it any more. - if touched(ctx, s, &ch, &card.event_id, now).await? { - close(ctx, s, &id, &ch, &card, now).await?; - } else { - delete(ctx, s, &ch, &card.event_id, now).await?; - } + super::replaced::retired(s, &id, &ch, &card, now); + s.kept.push(card.event_id); forget_card(ctx, s, &id); } - // (c) Post missing cards, adopting a recovered orphan when its hash matches. + // (c) Post missing cards, adopting a recovered orphan when its hash matches + // and it was not edited closed: that post says a tap there doesn't count. for id in &wanted { if s.items[id].card.is_some() { continue; } let ch = card_channel(s, &s.items[id]); if let Some(orphan) = s.orphan_cards.get(id).cloned() { - if orphan.hash == item::card_hash(&s.items[id]) { + let on = on_post(ctx, s, &ch, &orphan.event_id, now).await?; + let closed = on.iter().any(super::replaced::closes); + if orphan.hash == item::card_hash(&s.items[id]) && !closed { s.items.get_mut(id).expect("wanted id").card = s.orphan_cards.remove(id); continue; } // An answered, replied or receipt card stays on the relay for good. - if touched(ctx, s, &ch, &orphan.event_id, now).await? { + if !on.is_empty() { s.kept.push(orphan.event_id.clone()); } else { if !spend(budget, 1) { diff --git a/desktop/src-tauri/src/bench/replaced.rs b/desktop/src-tauri/src/bench/replaced.rs index d1ced82d268..71653d50c73 100644 --- a/desktop/src-tauri/src/bench/replaced.rs +++ b/desktop/src-tauri/src/bench/replaced.rs @@ -1,11 +1,15 @@ -//! Old posts: cards a newer card replaced, or that stopped being asked, left -//! on the relay. Only a live card is read (`poll_answers`), yet the old post -//! stays in the owner's feed with its pills, and Block's phone shows nothing -//! but text and keycaps. So it says on its first line that a tap there doesn't -//! count and loses its seeds, and a tap on it all the same gets one reply in -//! its thread. That reply is all such a tap ever does: a tap is tied to the -//! card that was shown, so it never answers, never reaches the outbox and -//! never moves to the new card. +//! Old posts: cards a newer card replaced, that stopped being asked or that +//! closed out as receipts, left on the relay. Only a live card is read +//! (`poll_answers`), yet the old post stays in the owner's feed with its pills, +//! and Block's phone shows nothing but text and keycaps. So it says on its first +//! line that a tap there doesn't count and loses its seeds, and a tap on it all +//! the same gets one reply in its thread. That reply is all such a tap ever +//! does: a tap is tied to the card that was shown, so it never answers, never +//! reaches the outbox and never moves to the new card. +//! +//! None is deleted. A phone that lost its socket across the delete keeps +//! showing the post, the relay refuses a tap on it and the phone's code ignores +//! the refusal, so that tap would vanish with nothing said. //! //! Nothing here fails a tick: lastError stops the steward kit's approvals, and //! the live cards are fine. What keeps failing is on the board (`alarm`). @@ -14,10 +18,11 @@ use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; -use super::channels::{card_channel, ready}; -use super::publish::{edit, post_message, thread_of}; +use super::channels::{card_channel, channel_ready, ready}; +use super::publish::{edit, forget_card, on_post, post_message, thread_of}; use super::{ - item, post_as_writer, query, query_writer, spend, BenchState, Ctx, Posted, RECOVER_LIMIT, + item, post_as_writer, query, query_writer, same_drop, spend, BenchState, Ctx, Posted, + RECOVER_LIMIT, }; use crate::events; @@ -30,10 +35,11 @@ const NOT_ASKED: &str = "No longer asked. A tap here doesn't count."; const WATCH_SECS: u64 = 86_400; /// Old posts read for taps at once, newest first. One batched /query per tick /// while any is watched, however many: +30/min of the relay's 300/min (about -/// 5/s), beside `poll_answers`' up to 120. Sized for ten times the churn at 1 -/// Nov 2026 (28 drops in a day, 7 cards kept in all, most re-drops deleted as -/// untouched). A cut page keeps its newest events, the ones looked for. Past -/// this the oldest go unread and the board says so (`alarm`). +/// 5/s), beside `poll_answers`' up to 120. Sized for three times the busiest day +/// at 1 Nov 2026, 28 drops if each left an old post; ten times that outgrows it. +/// A cut page keeps its newest events, the ones looked for. Past this the oldest +/// go unread and the board says so (`alarm`); raising it only lengthens the one +/// query's `#e`. pub(super) const WATCH_MAX: usize = 100; /// An old post that would not update is tried again this soon. const RETRY_SECS: u64 = 60; @@ -60,6 +66,11 @@ pub(crate) struct Replaced { pub at: u64, /// The item as the post shows it, until the post is edited. pub shown: Option>, + /// Owed its edit, before its seeds, from its own text on the relay: its + /// item already holds the newer Drop (`retired`). With a `root`, edited: + /// what is on it is still to read, for `kept_at`. + #[serde(default)] + pub from_post: bool, pub seeds_off: bool, /// Read for a tap: the newest old post of its item, not told yet. pub watch: bool, @@ -83,6 +94,7 @@ impl Replaced { root: root.map(str::to_string), at: now, shown: None, + from_post: false, seeds_off: false, watch: true, seen: Vec::new(), @@ -106,34 +118,92 @@ fn add(s: &mut BenchState, r: Replaced) { s.replaced.push(r); } +/// The post of `old`, showing it, with the answer it was left with no new tap. +fn showing(s: &BenchState, old: &item::Item, card: &Posted, now: u64) -> Replaced { + let asked = old.request.as_deref().unwrap_or(old); + let ch = card_channel(s, old); + Replaced { + shown: Some(Box::new(old.clone())), + seen: asked.answer.iter().map(|a| a.reaction_id.clone()).collect(), + ..Replaced::new(&old.id, &ch, card, asked.reply_to.as_deref(), now) + } +} + /// A new Drop left the card of the item it replaces behind (`carry_card`): it -/// has no card, the item still in state does. publish edits it from what it -/// shows, its Recorded line or receipt included. +/// has no card, the item still in state does. Edited from what it shows, its +/// Recorded line or receipt included. pub(super) fn left_behind(s: &mut BenchState, new: &item::Item, now: u64) { let Some(old) = s.items.get(&new.id).filter(|_| new.card.is_none()) else { return; }; let Some(card) = &old.card else { return }; - let asked = old.request.as_deref().unwrap_or(old); - let ch = card_channel(s, old); + let r = showing(s, old, card, now); + add(s, r); +} + +/// A close-out (publish (a2b)): its card stays the item's, and the post becomes +/// the receipt. A later one, while the post is still here, changes what it shows +/// and starts its 24 h again. A new card format or stamp is no close-out, so it +/// is edited again unwatched, as `backfill` does: most closed out days ago. +pub(super) fn receipt(s: &mut BenchState, id: &str, now: u64) { + let Some((i, card)) = s.items.get(id).and_then(|i| Some((i, i.card.as_ref()?))) else { + return; + }; + let r = showing(s, i, card, now); + let closed_out = !same_drop(card, i); + let same = |o: &&mut Replaced| o.card.event_id == r.card.event_id; + match s.replaced.iter_mut().find(same) { + Some(o) => (o.shown, o.at) = (r.shown, r.at), + None if closed_out => add(s, r), + None => s.replaced.push(Replaced { watch: false, ..r }), + } +} + +/// A stale card publish (b) just retired: its item holds the newer Drop, so it +/// is edited from its own text as posted. +pub(super) fn retired(s: &mut BenchState, id: &str, ch: &str, card: &Posted, now: u64) { let r = Replaced { - shown: Some(Box::new(old.clone())), - seen: asked.answer.iter().map(|a| a.reaction_id.clone()).collect(), - ..Replaced::new(&new.id, &ch, card, asked.reply_to.as_deref(), now) + from_post: true, + ..Replaced::new(id, ch, card, None, now) }; add(s, r); } -/// A stale card publish (b) just edited closed (`old_post`). -pub(super) fn closed( - s: &mut BenchState, - id: &str, - ch: &str, - card: &Posted, - root: Option<&str>, - now: u64, -) { - add(s, Replaced::new(id, ch, card, root, now)); +/// Receipts from before a close-out made an old post still show their pills and +/// no first line. One at a time, so a relay refusing them holds one, not all +/// (`alarm`), and live work goes first: 92 at 4 Nov 2026 take about 4 minutes. +/// Unwatched: most closed out days ago, past any window. +// ponytail: the 8 cards kept before old posts keep their pills, as state holds +// only their ids; read each one's newest edit from the relay if he taps one. +fn backfill(s: &mut BenchState, now: u64) { + if s.receipts_todo.is_none() { + let receipts = s + .items + .values() + .filter(|i| i.request.is_some() && i.card.is_some()); + s.receipts_todo = Some(receipts.map(|i| i.id.clone()).collect()); + } + if s.replaced.iter().any(|r| !r.idle()) { + return; + } + let todo = s.receipts_todo.as_deref().unwrap_or_default(); + let waits = |id: &String| s.items.get(id).is_some_and(|i| !channel_ready(s, i)); + let Some(at) = todo.iter().position(|id| !waits(id)) else { + return; + }; + let id = s.receipts_todo.as_mut().expect("just found").remove(at); + let Some(i) = s.items.get(&id).filter(|i| i.request.is_some()) else { + return; + }; + let Some(card) = &i.card else { return }; + let r = showing(s, i, card, now); + s.replaced.push(Replaced { watch: false, ..r }); +} + +/// Whether `e` is an edit that made a post an old post. +pub(super) fn closes(e: &nostr::Event) -> bool { + let first = e.content.lines().next().unwrap_or_default(); + e.kind.as_u16() == 40003 && (first == REPLACED || first == NOT_ASKED) } fn asked(i: &item::Item) -> bool { @@ -273,7 +343,7 @@ async fn read(ctx: &Ctx<'_>, s: &mut BenchState, now: u64, owner_set: &[String]) /// never comes back from /query, so this resumes), its one reply. Ok(false): /// the tick's budget or lookups ran out first. The relay is asked before the /// reply goes, so a crash after it landed and before state saved never sends -/// it twice. +/// it twice. A post no longer there is an Err saying "not found" (`tick`). async fn finish( ctx: &Ctx<'_>, s: &mut BenchState, @@ -285,6 +355,32 @@ async fn finish( ) -> Result { let r = s.replaced[i].clone(); let (ch, card) = (&r.channel, &r.card.event_id); + if r.from_post { + if *budget == 0 || !spend(lookups, 1) { + return Ok(false); + } + // Edited, in a thread: with anything else on it, it ends older cards' + // root-reply windows there, as a kept card does. With nothing, a reply + // naming only the root is still theirs. + if let Some(root) = &r.root { + let on = on_post(ctx, s, ch, card, now).await?; + if on.iter().any(|e| !closes(e)) { + s.kept_at.push((root.clone(), r.card.created_at)); + } + s.replaced[i].from_post = false; + return Ok(true); + } + let filter = serde_json::json!({ "ids": [card], "kinds": [9], "#h": [ch] }); + let posted = query_writer(ctx, s, filter, now).await?; + let e = posted.first().ok_or("not found: no such post")?; + let text = old_post(s, &r.item, &e.content) + &item::thread_line(ch, card); + spend(budget, 1); + edit(ctx, s, ch, card, &text, now).await?; + let root = item::parent(e).map(str::to_string); + let r = &mut s.replaced[i]; + (r.from_post, r.root) = (root.is_some(), root); + return Ok(true); + } if let Some(shown) = &r.shown { if !spend(budget, 1) { return Ok(false); @@ -366,6 +462,10 @@ pub(super) async fn tick( hhmm: &str, owner_set: &[String], ) { + // Past 30 failing the loop runs out of lookups every tick and never reaches + // the retain below, so one that has failed goes at its window here. + s.replaced + .retain(|r| r.retry_at == 0 || now < r.at + WATCH_SECS); read(ctx, s, now, owner_set).await; let mut lookups = LOOKUPS_PER_TICK; for i in 0..s.replaced.len() { @@ -374,6 +474,7 @@ pub(super) async fn tick( continue; } match finish(ctx, s, i, budget, &mut lookups, now, hhmm).await { + // Not `break`: the retain would drop what is past its window untried. Ok(false) => return, Ok(true) => s.replaced[i].retry_at = 0, // Gone from the relay: nothing is left to edit or to reply under. @@ -381,6 +482,12 @@ pub(super) async fn tick( let r = &mut s.replaced[i]; (r.shown, r.tapped, r.seeds_off, r.watch) = (None, None, true, false); r.retry_at = 0; + // A receipt's card is still its item's, deleted by hand. + let (id, gone) = (r.item.clone(), r.card.event_id.clone()); + let card = s.items.get(&id).and_then(|i| i.card.as_ref()); + if card.is_some_and(|c| c.event_id == gone) { + forget_card(ctx, s, &id); + } } Err(e) => { eprintln!("bench: old card {}: {e}", s.replaced[i].card.event_id); @@ -392,4 +499,5 @@ pub(super) async fn tick( // try above, and one that keeps failing must not retry for good. s.replaced .retain(|r| now < r.at + WATCH_SECS && !(r.idle() && !r.watch)); + backfill(s, now); } diff --git a/desktop/src-tauri/src/bench/tests.rs b/desktop/src-tauri/src/bench/tests.rs index 71c3e4d2b21..3f11c36c697 100644 --- a/desktop/src-tauri/src/bench/tests.rs +++ b/desktop/src-tauri/src/bench/tests.rs @@ -776,6 +776,8 @@ struct Relay { clock: Mutex>, /// A /query naming this id in its `#e` fails with a 500. e_down: Mutex>, + /// A write on this id, or a /query for it by `ids`, fails with a 500. + post_down: Mutex>, } fn tag_value(ev: &Value, name: &str) -> Option { @@ -823,6 +825,9 @@ async fn fake_events( return (StatusCode::OK, Json(reply)); } let target = tag_value(&ev, "e").unwrap_or_default(); + if r.post_down.lock().unwrap().as_ref() == Some(&target) { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({}))); + } if matches!(kind, 5 | 9 | 40003) && r.gone.lock().unwrap().contains(&target) { let message = match kind { 5 => "target event not found", @@ -865,6 +870,10 @@ async fn fake_query( if down.is_some_and(|id| q[0]["#e"].as_array().is_some_and(|e| e.contains(&id))) { return Err(StatusCode::INTERNAL_SERVER_ERROR); } + let down = r.post_down.lock().unwrap().clone().map(|id| json!(id)); + if down.is_some_and(|id| q[0]["ids"].as_array().is_some_and(|e| e.contains(&id))) { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } let mut evs = r.query_reply.lock().unwrap().clone(); if let Some(authors) = q[0]["authors"].as_array() { evs.retain(|e| authors.contains(&e["pubkey"])); @@ -938,6 +947,7 @@ impl Bench { admins_down: Mutex::new(false), clock: Mutex::new(None), e_down: Mutex::new(None), + post_down: Mutex::new(None), }); let base = fake_relay(relay.clone()).await; let state: &'static AppState = Box::leak(Box::new(crate::app_state::build_app_state())); @@ -1108,9 +1118,11 @@ async fn resync_is_idempotent() { } #[tokio::test] -async fn pending_reword_deletes_and_reposts_never_edits() { +async fn pending_reword_reposts_and_edits_the_old_card_closed() { let mut b = seeded_bench(Gate::Open, &[]).await; let old_card = b.card("orchestrator/relay-choice"); + let old_post = answers::posted(&b, &old_card.event_id); + answers::reply(&b, &[&old_post]); let old_board = b.s.board.clone().unwrap(); let before = b.posts().len(); b.drop_file( @@ -1124,16 +1136,15 @@ async fn pending_reword_deletes_and_reposts_never_edits() { .iter() .map(|(e, _)| e["kind"].as_u64().unwrap()) .collect(); - assert_eq!(kinds, [5, 9, 7, 7, 9, 5]); - assert_eq!(tag_value(&posts[0].0, "e").unwrap(), old_card.event_id); + assert_eq!(kinds, [9, 7, 7, 40003, 9, 5]); let new_card = b.card("orchestrator/relay-choice"); assert_ne!(new_card.hash, old_card.hash); assert_eq!( - tag_value(&posts[1].0, "client").unwrap(), + tag_value(&posts[0].0, "client").unwrap(), format!("bench:card:orchestrator/relay-choice@{}", new_card.hash) ); + assert_eq!(tag_value(&posts[3].0, "e").unwrap(), old_card.event_id); assert_eq!(tag_value(&posts[5].0, "e").unwrap(), old_board.event_id); - assert!(!b.kinds().contains(&40003)); } #[tokio::test] @@ -1331,7 +1342,7 @@ async fn vanished_targets_are_dropped_not_retried() { b.settle(2).await; assert_eq!(b.kinds()[before..], [40003, 9]); assert_ne!(b.s.board.as_ref().unwrap().event_id, old_board.event_id); - // A hard-purged card: the retiring kind 5 is refused, the repost goes on. + // A hard-purged card: nothing is left to edit, the repost goes on. let old_card = b.card("orchestrator/relay-choice"); b.relay .gone @@ -1345,7 +1356,7 @@ async fn vanished_targets_are_dropped_not_retried() { &drop_json(&[("options", json!(["Stay", "Go"]))]), ); b.settle(3).await; - assert_eq!(b.kinds()[before..], [5, 9, 7, 7, 9, 5]); + assert_eq!(b.kinds()[before..], [9, 7, 7, 9, 5]); assert_ne!( b.card("orchestrator/relay-choice").event_id, old_card.event_id @@ -1382,8 +1393,8 @@ async fn a_tick_never_exceeds_the_post_budget() { b.tick().await.unwrap(); assert!(b.posts().len() - n <= MAX_POSTS_PER_TICK); } - // Delete, card, seed; then the board and its predecessor's delete as a pair. - assert_eq!(b.kinds()[before..], [5, 9, 7, 9, 5]); + // Card, seed; then the board and its predecessor's delete as a pair. + assert_eq!(b.kinds()[before..], [9, 7, 9, 5]); } #[tokio::test] diff --git a/desktop/src-tauri/src/bench/tests/answers.rs b/desktop/src-tauri/src/bench/tests/answers.rs index 261a1811628..79089a9cfd6 100644 --- a/desktop/src-tauri/src/bench/tests/answers.rs +++ b/desktop/src-tauri/src/bench/tests/answers.rs @@ -457,16 +457,15 @@ async fn pending_reword_closes_a_card_someone_replied_or_reacted_on() { &drop_json(&[("options", json!(["Stay", "Move away"]))]), ); b.settle(3).await; - // No delete: the old card is closed, then a new one, its seeds, the - // board. + // No delete: a new card and its seeds, the old one closed, the board. let posts = b.posts()[before..].to_vec(); assert_eq!( b.kinds()[before..], - [40003, 9, 7, 7, 9, 5], + [9, 7, 7, 40003, 9, 5], "heart: {heart}" ); - assert_eq!(tag_value(&posts[0].0, "e").unwrap(), old_card.event_id); - let closed = text(&posts[0].0); + assert_eq!(tag_value(&posts[3].0, "e").unwrap(), old_card.event_id); + let closed = text(&posts[3].0); assert!(closed.contains("**Relay choice**\n\nStay or move?\n\n1. Stay\n2. Move\n\nas of")); assert!(!closed.contains("Tap one number"), "{closed}"); assert!(closed.contains("\n\nClosed: a tap or reply here is no longer read.")); @@ -479,30 +478,52 @@ async fn pending_reword_closes_a_card_someone_replied_or_reacted_on() { } } +/// The worst case at once: every card asked again in one tick. New cards go +/// first, each tick stays inside the post budget and one lookup, each old post +/// is edited once and none deleted. #[tokio::test] -async fn stale_cards_are_looked_up_within_the_post_budget() { +async fn cards_asked_again_at_once_stay_inside_the_budgets() { let mut b = seeded_bench(Gate::Open, &[]).await; let ids = ["a", "b", "c", "d", "relay-choice"]; for id in &ids[..4] { b.drop_file("orchestrator", id, &drop_json(&[("title", json!(id))])); } b.settle(10).await; - assert!(ids + let cards: Vec = ids .iter() - .all(|id| b.s.items[&format!("orchestrator/{id}")].card.is_some())); + .map(|id| b.card(&format!("orchestrator/{id}")).event_id) + .collect(); + let old: Vec = cards.iter().map(|c| posted(&b, c)).collect(); + reply(&b, &old.iter().collect::>()); // All five asked again at once, against four posts a tick. for id in ids { let title = json!(format!("{id}, reworded")); b.drop_file("orchestrator", id, &drop_json(&[("title", title)])); } - let (posts, queries) = (b.posts().len(), b.relay.queries.lock().unwrap().len()); - b.tick().await.unwrap(); - assert_eq!(b.kinds()[posts..], [5, 5, 5, 5]); - let lookups = b.relay.queries.lock().unwrap()[queries..] - .iter() - .filter(|q| q[0]["kinds"] == json!([7, 9, 40003])) - .count(); - assert_eq!(lookups, 4); + let start = b.posts().len(); + let writer = json!([b.writer_hex()]); + for tick in 0..24 { + let (posts, queries) = (b.posts().len(), b.relay.queries.lock().unwrap().len()); + b.tick().await.unwrap(); + assert!(b.posts().len() - posts <= MAX_POSTS_PER_TICK); + let q = b.relay.queries.lock().unwrap()[queries..].to_vec(); + let lookup = |q: &&Value| q[0]["authors"] == writer || q[0]["ids"].is_array(); + assert!(q.iter().filter(lookup).count() <= 1); + if tick == 0 { + assert_eq!(b.kinds()[posts..], [9, 9, 9, 9]); + } + } + let posts = b.posts()[start..].to_vec(); + for card in &cards { + let on = |e: &&(Value, Option)| tag_value(&e.0, "e").as_ref() == Some(card); + let kinds: Vec = posts + .iter() + .filter(on) + .map(|(e, _)| e["kind"].as_u64().unwrap()) + .collect(); + assert_eq!(kinds, [40003], "{card}"); + } + assert_eq!(b.s.kept, cards); } #[tokio::test] @@ -740,19 +761,20 @@ async fn older_card_format_case(older: &str) { let answer = b.s.items[ITEM].answer.clone(); assert!(answer.is_some()); // The tapped card takes the new text by its Recorded edit; only the - // unanswered one is refreshed: delete, quiet repost, seeds, board pair. + // unanswered one is refreshed: quiet repost, seeds, board pair. No post of + // the old one is on this fake relay to edit. let posts = b.posts()[before..].to_vec(); - assert_eq!(b.kinds()[before..], [40003, 5, 9, 7, 7, 9, 5]); + assert_eq!(b.kinds()[before..], [40003, 9, 7, 7, 9, 5]); assert_eq!(tag_value(&posts[0].0, "e").unwrap(), kept); - assert_eq!(tag_value(&posts[1].0, "e").unwrap(), old); + assert_eq!(b.s.kept, [old]); let new_card = b.card(OTHER); assert_eq!( - tag_value(&posts[2].0, "client").unwrap(), + tag_value(&posts[1].0, "client").unwrap(), format!("bench:card:{OTHER}@{}", card_hash(&b.s.items[OTHER])) ); assert!(text(&posts[0].0).contains("\n**Relay choice**\n")); - assert!(text(&posts[2].0).contains("\n**Other**\n") && text(&posts[2].0).contains(HINT)); - assert_eq!(tag_value(&posts[2].0, "p"), None); + assert!(text(&posts[1].0).contains("\n**Other**\n") && text(&posts[1].0).contains(HINT)); + assert_eq!(tag_value(&posts[1].0, "p"), None); assert!(!b.s.items[OTHER].quiet_repost); // The answered card, its answer and its outbox file stay. assert_eq!(b.card(ITEM).event_id, kept); diff --git a/desktop/src-tauri/src/bench/tests/capacity.rs b/desktop/src-tauri/src/bench/tests/capacity.rs index 7d842454e08..64de11aa4e0 100644 --- a/desktop/src-tauri/src/bench/tests/capacity.rs +++ b/desktop/src-tauri/src/bench/tests/capacity.rs @@ -202,8 +202,8 @@ async fn a_card_retired_for_a_refresh_keeps_its_place() { b.drop_file("orchestrator", "a-first", &attention); b.settle(2).await; assert_eq!(queued(&b), ["a-first", "hold-36", "hold-37", "hold-38"]); - // Four cards change format at once: (b) spends the tick retiring them, - // and they come back before the newcomer, which still waits. + // Four cards change format at once: (b) retires them, and they come back + // before the newcomer, which still waits. let refreshed = ["hold-32", "hold-33", "hold-34", "hold-35"]; for id in refreshed { let i = b.s.items.get_mut(&format!("orchestrator/{id}")).unwrap(); @@ -211,7 +211,7 @@ async fn a_card_retired_for_a_refresh_keeps_its_place() { i.card.as_mut().unwrap().hash = format!("{hash}.old-format"); } b.tick().await.unwrap(); - assert!(b.s.items["orchestrator/hold-32"].card.is_none()); + assert_eq!(b.s.kept.len(), refreshed.len()); b.settle(4).await; for id in refreshed { let i = &b.s.items[&format!("orchestrator/{id}")]; diff --git a/desktop/src-tauri/src/bench/tests/channels.rs b/desktop/src-tauri/src/bench/tests/channels.rs index ca6d81ab1c1..ef280cafd16 100644 --- a/desktop/src-tauri/src/bench/tests/channels.rs +++ b/desktop/src-tauri/src/bench/tests/channels.rs @@ -5,6 +5,7 @@ use super::super::roles::AgentAccess; use super::answers::{posted, reply, tap, ITEM}; +use super::replaced::last_edit; use super::*; pub(super) const X: &str = "105f15cc-1d5a-431a-9826-9402fba1a12b"; @@ -628,15 +629,50 @@ async fn closed_card_keeps_its_own_thread_root() { b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(r2))])); b.settle(3).await; assert_eq!(b.s.kept, [old.event_id.as_str()]); - assert_eq!(b.s.kept_at, [(r1, old.created_at)]); - let closed = &b.posts()[before].0; - assert_eq!(closed["kind"], 40003); - assert_eq!(tag_value(closed, "e").unwrap(), old.event_id); + assert_eq!(b.s.kept_at, [(r1.clone(), old.created_at)]); + assert_eq!(b.s.replaced[0].root, Some(r1), "where its one reply goes"); + let on_old = |e: &Value| e["kind"] == 40003 && tag_value(e, "e") == Some(old.event_id.clone()); + assert!(b.posts()[before..].iter().any(|(e, _)| on_old(e))); let new = b.card(ASK); assert_ne!(new.event_id, old.event_id); assert_eq!(parent(&posted(&b, &new.event_id)), Some(r2.as_str())); } +/// A newer card nobody touched, withdrawn: with only its seeds and closing edit +/// on it, a reply naming only the root still reaches the one open card there. +#[tokio::test] +async fn a_retired_card_nobody_touched_ends_no_older_cards_window() { + let mut b = seeded_bench(Gate::Open, &[]).await; + bind(&mut b); + let list = admins(&b, &b.relay.keys.clone(), &[]); + reply(&b, &[&list]); + let root = "ab".repeat(32); + b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(root))])); + b.settle(6).await; + let second = |validity: &str| { + ask(&[ + ("replyTo", json!(root)), + ("title", json!("Second")), + ("validity", json!(validity)), + ]) + }; + b.drop_file("reset-worker", "second", &second("current")); + b.settle(6).await; + let newer = b.card("reset-worker/second").event_id; + let post = posted(&b, &newer); + reply(&b, &[&list, &post]); + b.drop_file("reset-worker", "second", &second("superseded")); + b.tick().await.unwrap(); + let closing = serde_json::from_value(last_edit(&b, &newer)).unwrap(); + reply(&b, &[&list, &post, &closing]); + b.settle(3).await; + assert!(b.s.kept_at.is_empty(), "{:?}", b.s.kept_at); + let said = thread_reply(&b.ctx.owner, &root, "1", b.s.last_created_at + 5, &[]); + reply(&b, &[&list, &said]); + b.settle(3).await; + assert_eq!(b.s.items[ASK].answer.as_ref().map(|a| a.option), Some(0)); +} + #[tokio::test] async fn unreadable_roles_hold_scoped_drops_and_every_card() { let (mut b, _p, _list) = bound_bench().await; diff --git a/desktop/src-tauri/src/bench/tests/one_tap.rs b/desktop/src-tauri/src/bench/tests/one_tap.rs index f4471580b5a..6077d9fcc37 100644 --- a/desktop/src-tauri/src/bench/tests/one_tap.rs +++ b/desktop/src-tauri/src/bench/tests/one_tap.rs @@ -501,9 +501,9 @@ async fn an_unanswered_card_asked_again_is_replaced_as_today() { b.drop_file("orchestrator", "relay-choice", &drop); }; let replaced = |b: &Bench, before: usize, old: &str| { - assert_eq!(b.kinds()[before..], [5, 9, 7, 7, 9, 5]); - assert_eq!(tag_value(&b.posts()[before].0, "e").as_deref(), Some(old)); - content(&b.posts()[before + 1].0).to_string() + assert_eq!(b.kinds()[before..], [9, 7, 7, 9, 5]); + assert!(b.s.kept.iter().any(|k| k == old)); + content(&b.posts()[before].0).to_string() }; let old = b.card(ITEM).event_id; let before = b.posts().len(); diff --git a/desktop/src-tauri/src/bench/tests/owner_word.rs b/desktop/src-tauri/src/bench/tests/owner_word.rs index dd62e9b714d..e31aaf8b717 100644 --- a/desktop/src-tauri/src/bench/tests/owner_word.rs +++ b/desktop/src-tauri/src/bench/tests/owner_word.rs @@ -199,6 +199,40 @@ async fn a_cut_owner_page_retires_nothing() { assert!(!deleted(&b)); } +/// `held` is all that keeps a card from retiring when only its card format +/// moved: nothing asked again waits in the inbox, and the tap is still unread. +#[tokio::test] +async fn a_cut_owner_page_leaves_a_stale_format_card_up() { + let mut b = seeded_bench(Gate::Open, &[]).await; + bind(&mut b); + let list = admins(&b, &b.relay.keys.clone(), &[]); + reply(&b, &[&list]); + let root = "ab".repeat(32); + b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(root))])); + b.settle(6).await; + let i = b.s.items.get_mut(ASK).unwrap(); + i.card.as_mut().unwrap().hash = format!("{}.3", i.hash); + let card = b.card(ASK); + let two = tap(&b.ctx.owner, &card.event_id, OPTION_EMOJI[1], b.now); + let chat = owners_chat(&b, &root, card.created_at); + let mut evs = vec![&list, &two]; + evs.extend(&chat); + reply(&b, &evs); + let before = b.posts().len(); + for _ in 0..3 { + let _ = b.tick().await; + } + assert_eq!(b.s.held, [ASK]); + assert_eq!(b.card(ASK), card); + assert!(b.s.replaced.is_empty()); + let edited = |e: &Value| e["kind"] == 40003 && tag_value(e, "e") == Some(card.event_id.clone()); + assert!(!b.posts()[before..].iter().any(|(e, _)| edited(e))); + // Read in full, the tap counts on the card it was made on. + reply(&b, &[&list, &two]); + b.tick().await.unwrap(); + assert_eq!(b.s.items[ASK].answer.as_ref().map(|a| a.option), Some(1)); +} + #[tokio::test] async fn a_re_asked_card_still_ends_an_older_cards_window() { let mut b = seeded_bench(Gate::Open, &[]).await; diff --git a/desktop/src-tauri/src/bench/tests/receipts.rs b/desktop/src-tauri/src/bench/tests/receipts.rs index 556e7c8a66e..b6b6638f8dd 100644 --- a/desktop/src-tauri/src/bench/tests/receipts.rs +++ b/desktop/src-tauri/src/bench/tests/receipts.rs @@ -1,6 +1,6 @@ //! Receipts: a close-out keeps the card and edits it to add the answer and -//! the outcome. Child of tests.rs so the fake relay and Bench harness are in -//! scope. +//! the outcome, as an old post (replaced.rs). Child of tests.rs so the fake +//! relay and Bench harness are in scope. use super::answers::{last_query, reply, tap, ITEM}; use super::*; @@ -43,51 +43,65 @@ async fn close_out_edits_the_card_into_a_receipt() { let before = b.posts().len(); close_out(&b, "Moved the relay"); b.tick().await.unwrap(); - // The card's edit, then the board's: nothing is deleted or reposted. + // The board's edit, then the card's: nothing is deleted or reposted. let posts = b.posts()[before..].to_vec(); assert_eq!(b.kinds()[before..], [40003, 40003]); - assert_eq!(tag_value(&posts[0].0, "e").unwrap(), card.event_id); - assert_eq!(tag_value(&posts[0].0, "h").unwrap(), b.channel()); + assert_eq!(tag_value(&posts[1].0, "e").unwrap(), card.event_id); + assert_eq!(tag_value(&posts[1].0, "h").unwrap(), b.channel()); let item = &b.s.items[ITEM]; - let receipt = content(&posts[0].0); + let receipt = content(&posts[1].0); let link = format!( "buzz://message?channel={}&id={}", b.channel(), card.event_id ); + // The time the close-out came in, not the tick's. + let came_in = chrono::DateTime::parse_from_rfc3339(NOW).unwrap(); + let came_in = local_hhmm(came_in.timestamp() as u64); // The edit knows the card's id, so the receipt links its thread. assert_eq!( receipt, - render_card(item, "14:05") + &format!("\nOpen thread: {link}") + format!( + "No longer asked. A tap here doesn't count.\n{}\n\nClosed: a tap or reply here is \ + no longer read. If this is still asked, answer on the newer card.\n\ + Open thread: {link}", + render_card(item, &came_in) + ) ); let request = item.request.as_ref().unwrap(); let at = &request.answer.as_ref().unwrap().hhmm; assert!( - receipt.starts_with("馃敶 路 Dreamforge 路 orchestrator\n**Relay choice**\n"), + receipt.starts_with( + "No longer asked. A tap here doesn't count.\n\ + 馃敶 路 Dreamforge 路 orchestrator\n**Relay choice**\n" + ), "{receipt}" ); assert!( - receipt.ends_with(&format!( - "1. Stay\n2. Move\n\nAnswered: 2. Move 路 {at}\n\nOutcome 路 14:05\n\ + receipt.contains(&format!( + "1. Stay\n2. Move\n\nAnswered: 2. Move 路 {at}\n\nOutcome 路 {came_in}\n\ **Moved the relay**\n\nMoved the community to the new relay.\n\n\ - PR: https://github.com/x/y/pull/1\nOpen thread: {link}" + PR: https://github.com/x/y/pull/1\n" )), "{receipt}" ); assert_eq!(item.card.as_ref().unwrap().event_id, card.event_id); assert_eq!(item.card.as_ref().unwrap().hash, card_hash(item)); // The board links the decision to its receipt. - let board = content(&posts[1].0); + let board = content(&posts[0].0); assert!( board.contains(&format!( "- 馃Л Moved the relay (dreamforge) (claude) 路 {link}" )), "{board}" ); - // The poll reads the open card only, and nothing more is posted. + // The poll reads the open card, the old posts' read the receipt, and + // nothing more is posted. let before = b.posts().len(); b.settle(2).await; - assert_eq!(last_query(&b)[0]["#e"], json!([other])); + let queries = b.relay.queries.lock().unwrap().clone(); + assert_eq!(queries[queries.len() - 2][0]["#e"], json!([other])); + assert_eq!(last_query(&b)[0]["#e"], json!([card.event_id])); assert_eq!(b.posts().len(), before); // A relaunch keeps the receipt: no delete, no repost. let relay: Vec = b @@ -113,13 +127,12 @@ async fn unanswered_close_out_is_kept_and_a_second_one_re_edits() { b.tick().await.unwrap(); let posts = b.posts()[before..].to_vec(); assert_eq!(b.kinds()[before..], [40003, 40003]); - let receipt = content(&posts[0].0); + let receipt = content(&posts[1].0); assert!( - receipt.contains( - "\nAnswered: no answer recorded\n\nOutcome 路 14:05\n**Not needed after all**" - ), + receipt.contains("\nAnswered: no answer recorded\n\nOutcome 路 "), "{receipt}" ); + assert!(receipt.contains("\n**Not needed after all**"), "{receipt}"); // Later ticks neither retire nor re-edit it. let before = b.posts().len(); b.settle(3).await; @@ -129,8 +142,8 @@ async fn unanswered_close_out_is_kept_and_a_second_one_re_edits() { b.tick().await.unwrap(); let posts = b.posts()[before..].to_vec(); assert_eq!(b.kinds()[before..], [40003, 40003]); - assert_eq!(tag_value(&posts[0].0, "e").unwrap(), card); - let receipt = content(&posts[0].0); + assert_eq!(tag_value(&posts[1].0, "e").unwrap(), card); + let receipt = content(&posts[1].0); assert!( receipt.contains("**Relay choice**") && receipt.contains("**Moved after all**"), "{receipt}" diff --git a/desktop/src-tauri/src/bench/tests/replaced.rs b/desktop/src-tauri/src/bench/tests/replaced.rs index f0540114bf9..9aec14b3a9c 100644 --- a/desktop/src-tauri/src/bench/tests/replaced.rs +++ b/desktop/src-tauri/src/bench/tests/replaced.rs @@ -3,13 +3,14 @@ //! harness are in scope. use super::answers::{last_query, outbox, posted, reply, tap, ITEM}; -use super::channels::X; +use super::channels::{admins, ask, bind, bound_bench, hex, ASK, X}; use super::receipts::close_out; use super::*; use crate::bench::channels::ChannelCheck; -use crate::bench::replaced::{closed, tick as old_posts_tick, WATCH_MAX}; +use crate::bench::replaced::{retired, tick as old_posts_tick, Replaced, WATCH_MAX}; const REPLACED: &str = "Replaced by a newer card. A tap here doesn't count."; +const NOT_ASKED: &str = "No longer asked. A tap here doesn't count."; fn content(ev: &Value) -> &str { ev["content"].as_str().unwrap() @@ -21,7 +22,7 @@ fn redrop(b: &Bench, title: &str) { } /// The post the Bench last edited `card` to. -fn last_edit(b: &Bench, card: &str) -> Value { +pub(super) fn last_edit(b: &Bench, card: &str) -> Value { b.posts() .into_iter() .rev() @@ -91,6 +92,25 @@ async fn old_tick(b: &mut Bench, mut budget: usize) -> usize { budget } +/// The scoped card ASK closes out. +fn done_in_x() -> String { + drop_json(&[ + ("project", json!("reset")), + ("kind", json!("status")), + ("state", json!("done")), + ("severity", Value::Null), + ("options", Value::Null), + ("title", json!("Done in X")), + ]) +} + +/// An old post already edited closed. +fn closed(s: &mut BenchState, id: &str, ch: &str, card: &Posted, root: Option<&str>, now: u64) { + retired(s, id, ch, card, now); + let r = s.replaced.last_mut().unwrap(); + (r.from_post, r.root) = (false, root.map(str::to_string)); +} + /// An old post with no history, its id made from `n`, in `ch`. fn old_card(b: &mut Bench, n: usize, ch: &str) -> Posted { let old = Posted { @@ -190,8 +210,8 @@ async fn a_cards_header_stays_as_posted_through_a_rename() { close_out(&b, "Moved the relay"); b.tick().await.unwrap(); let receipt = last_edit(&b, &second.event_id); - let closed_head = "馃敶 路 Forge 路 orchestrator\n"; - assert!(content(&receipt).starts_with(closed_head), "{receipt}"); + let closed_head = format!("{NOT_ASKED}\n馃敶 路 Forge 路 orchestrator\n"); + assert!(content(&receipt).starts_with(&closed_head), "{receipt}"); assert!(content(&receipt).contains("Moved the relay"), "{receipt}"); } @@ -243,10 +263,7 @@ async fn a_re_ask_edits_the_old_post_first_line_and_takes_its_seeds_off() { async fn a_touched_stale_card_says_so_first_too() { for (drop, first) in [ (vec![("options", json!(["Stay", "Move away"]))], REPLACED), - ( - vec![("validity", json!("superseded"))], - "No longer asked. A tap here doesn't count.", - ), + (vec![("validity", json!("superseded"))], NOT_ASKED), ] { let mut b = seeded_bench(Gate::Open, &[]).await; let old = b.card(ITEM); @@ -290,17 +307,151 @@ async fn an_old_receipt_keeps_its_outcome_time() { assert!(!text.contains("23:59"), "{text}"); } -/// `pending_reword_deletes_and_reposts_never_edits` pins the post; a deleted -/// card has nothing left to say so on, and the relay refuses a tap on it. +/// An untouched card stays as any other: a phone that lost its socket across a +/// delete would keep showing it, and the relay would refuse a tap there with +/// nothing said. It says so from its own text, its seeds come off and a tap on +/// it is told, once. #[tokio::test] -async fn an_untouched_stale_card_is_only_deleted() { +async fn an_untouched_stale_card_is_edited_not_deleted() { let mut b = seeded_bench(Gate::Open, &[]).await; let old = b.card(ITEM).event_id; + let on_card = seeds(&b, &old); + let post = posted(&b, &old); + let mut relay: Vec<&nostr::Event> = on_card.iter().collect(); + relay.push(&post); + reply(&b, &relay); redrop(&b, "Relay choice, reworded"); + b.settle(4).await; + + let edited = last_edit(&b, &old); + let text = content(&edited); + assert!(text.starts_with(&format!("{REPLACED}\n馃敶 路 ")), "{text}"); + assert!(text.contains("**Relay choice**"), "as asked: {text}"); + assert!(!text.contains("reworded"), "{text}"); + assert!(text.ends_with(&thread_line(&b.channel(), &old)), "{text}"); + let off = removed(&b); + assert!(!off.contains(&old), "deleted: {off:?}"); + for seed in &on_card { + assert!(off.contains(&seed.id.to_hex()), "{off:?}"); + } + assert_eq!(b.s.kept, [old.as_str()]); + + let stray = tap(&b.ctx.owner, &old, OPTION_EMOJI[0], b.now + 1); + reply(&b, &[&stray]); b.settle(3).await; - assert!(removed(&b).contains(&old)); - assert!(b.s.replaced.is_empty()); - assert!(b.s.kept.is_empty()); + assert_eq!(replies(&b).len(), 1); + assert!(content(&replies(&b)[0]).contains("The live card is the one that counts:")); +} + +/// lastError stops the kit's approvals: a lookup that fails, an edit refused or +/// a post gone from the relay is the board's and the retry's, never the tick's. +#[tokio::test] +async fn a_stale_card_that_will_not_update_fails_no_tick() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let old = b.card(ITEM).event_id; + let post = posted(&b, &old); + reply(&b, &[&post]); + *b.relay.post_down.lock().unwrap() = Some(old.clone()); + redrop(&b, "Relay choice, reworded"); + b.tick().await.unwrap(); + assert!(b.s.replaced[0].from_post); + // Saved by a build before this one, the key is absent and state still loads. + let mut saved = serde_json::to_value(&b.s.replaced[0]).unwrap(); + saved.as_object_mut().unwrap().remove("fromPost"); + assert!(!serde_json::from_value::(saved).unwrap().from_post); + let retry = b.s.replaced[0].retry_at; + assert_eq!(retry, b.now + 60); + b.settle(2).await; + assert!( + board(&b).contains("\n\nOld cards\nWould not update: 1."), + "{}", + board(&b) + ); + assert!(b.s.replaced[0].from_post, "not before its retry"); + *b.relay.post_down.lock().unwrap() = None; + b.now = retry; + b.settle(4).await; + assert!(content(&last_edit(&b, &old)).starts_with(REPLACED)); + assert!(!board(&b).contains("Old cards"), "{}", board(&b)); + + // One gone from the relay is let go, with nothing to edit. + let gone = b.card(ITEM).event_id; + reply(&b, &[]); + let before = b.posts().len(); + redrop(&b, "Relay choice, reworded again"); + b.settle(3).await; + assert!(!b.s.replaced.iter().any(|r| r.card.event_id == gone)); + let edited = |e: &Value| e["kind"] == 40003 && tag_value(e, "e") == Some(gone.clone()); + assert!(!b.posts()[before..].iter().any(|(e, _)| edited(e))); + assert!(b.s.kept.contains(&gone)); +} + +/// Nothing an old post does brings a new card back: edited, kept and read again +/// through a relaunch, the item keeps the one newer card it was given. +#[tokio::test] +async fn an_old_post_never_brings_a_repost() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let old = b.card(ITEM).event_id; + let post = posted(&b, &old); + reply(&b, &[&post]); + redrop(&b, "Relay choice, reworded"); + b.settle(6).await; + let new = b.card(ITEM).event_id; + assert_ne!(new, old); + let board_id = b.s.board.as_ref().unwrap().event_id.clone(); + let (new_post, board_post) = (posted(&b, &new), posted(&b, &board_id)); + let before = b.posts().len(); + b.settle(10).await; + assert_eq!(b.posts().len(), before, "quiet once it is done"); + + // A relaunch reads both posts: the old one is kept, so never adopted. + reply(&b, &[&post, &new_post, &board_post]); + b.s.needs_rebuild = true; + b.settle(10).await; + assert_eq!(b.card(ITEM).event_id, new); + assert!(b.s.orphan_cards.is_empty() && b.s.strays.is_empty()); + assert_eq!(b.posts().len(), before, "no repost, no delete"); + + // State lost: the newest post is the card, the old one only listed. + b.s.kept.clear(); + b.s.needs_rebuild = true; + b.settle(10).await; + assert_eq!(b.card(ITEM).event_id, new); + assert_eq!(b.s.strays, [old]); + assert_eq!(b.posts().len(), before, "no repost, no delete"); +} + +/// State lost, then the same Drop again: a post edited closed says a tap +/// doesn't count, so a new card goes up. One with its Recorded edit is the card. +#[tokio::test] +async fn lost_state_never_adopts_a_post_edited_closed() { + for closed in [true, false] { + let mut b = seeded_bench(Gate::Open, &[]).await; + let first = b.card(ITEM).event_id; + let post = posted(&b, &first); + if closed { + reply(&b, &[&post]); + let gone = drop_json(&[("validity", json!("superseded"))]); + b.drop_file("orchestrator", "relay-choice", &gone); + } else { + let owner_tap = tap(&b.ctx.owner, &first, OPTION_EMOJI[0], b.now); + reply(&b, &[&post, &owner_tap]); + } + b.settle(4).await; + let edit = last_edit(&b, &first); + assert_eq!(content(&edit).starts_with(NOT_ASKED), closed); + reply(&b, &[&post, &serde_json::from_value(edit).unwrap()]); + b.s.items.clear(); + b.s.kept.clear(); + b.s.replaced.clear(); + b.s.needs_rebuild = true; + b.settle(1).await; + assert!(b.s.orphan_cards.contains_key(ITEM)); + b.drop_file("orchestrator", "relay-choice", &drop_json(&[])); + b.settle(3).await; + assert_eq!(b.card(ITEM).event_id == first, !closed); + assert_eq!(b.s.kept.contains(&first), closed); + } } #[tokio::test] @@ -509,10 +660,11 @@ async fn a_recorded_answer_from_a_clock_ahead_is_no_new_tap() { /// A tap after the item closed out: nothing is asked, so no live card is named. #[tokio::test] async fn a_tap_after_a_close_out_is_told_nothing_is_asked() { - let (mut b, old, answer) = answered_then_replaced().await; + let (mut b, _old, answer) = answered_then_replaced().await; + let receipt = b.card(ITEM).event_id; close_out(&b, "Moved the relay"); b.settle(2).await; - let stray = tap(&b.ctx.owner, &old.event_id, OPTION_EMOJI[0], b.now); + let stray = tap(&b.ctx.owner, &receipt, OPTION_EMOJI[0], b.now); reply(&b, &[&answer, &stray]); b.settle(2).await; let told = replies(&b); @@ -522,6 +674,278 @@ async fn a_tap_after_a_close_out_is_told_nothing_is_asked() { assert!(!text.contains("live card"), "{text}"); } +/// Close-out receipts are the largest group of dead pills: the first line, the +/// seeds off, the answer it closed with no new tap, its card still the item's +/// (the kit's `findCard` matches `item.card`). +/// The relay refuses a reply whose root is not its thread's, so a tap on a +/// receipt posted in a thread is told under that thread's root. +#[tokio::test] +async fn a_tap_on_a_receipt_in_a_thread_is_told_under_its_root() { + let mut b = seeded_bench(Gate::Open, &[]).await; + bind(&mut b); + let list = admins(&b, &b.relay.keys.clone(), &[]); + reply(&b, &[&list]); + let root = "ab".repeat(32); + b.drop_file("reset-worker", "welcome", &ask(&[("replyTo", json!(root))])); + b.settle(6).await; + let card = b.card(ASK).event_id; + b.drop_file("reset-worker", "welcome", &done_in_x()); + b.settle(3).await; + assert!(b.s.items[ASK].request.is_some()); + let stray = tap(&b.ctx.owner, &card, OPTION_EMOJI[0], b.now + 5); + reply(&b, &[&list, &stray]); + b.settle(3).await; + let told = replies(&b); + assert_eq!(told.len(), 1); + let named = |marker: &str| { + let tags = told[0]["tags"].as_array().unwrap(); + let tag = tags.iter().find(|t| t[0] == "e" && t[3] == marker).unwrap(); + tag[1].as_str().unwrap().to_string() + }; + assert_eq!(named("root"), root); + assert_eq!(named("reply"), card); +} + +/// A close-out in a channel that stays refused for more than a day: its edit +/// waits with the channel, unowed, and goes out once the channel is ready. +#[tokio::test] +async fn a_close_out_in_a_channel_not_ready_keeps_its_edit() { + let (mut b, p, list) = bound_bench().await; + let card = b.card(ASK).event_id; + let bad = admins(&b, &b.relay.keys.clone(), &[&hex(&p.craig)]); + reply(&b, &[&bad]); + b.now += 601; + b.settle(2).await; + assert!(b.s.channels[X].refused.is_some()); + b.drop_file("reset-worker", "welcome", &done_in_x()); + let before = b.posts().len(); + b.settle(2).await; + assert!(b.s.items[ASK].request.is_some()); + b.now += 86_400; + b.settle(2).await; + let edited = |b: &Bench| { + let on_card = |e: &Value| e["kind"] == 40003 && tag_value(e, "e").as_deref() == Some(&card); + b.posts()[before..].iter().any(|(e, _)| on_card(e)) + }; + assert!(!edited(&b), "not while the channel is refused"); + reply(&b, &[&list]); + b.now += 61; + b.settle(4).await; + assert_eq!(b.s.channels[X].refused, None); + assert!(edited(&b), "the close-out's edit never went out"); + let receipt = last_edit(&b, &card); + assert!(content(&receipt).starts_with(NOT_ASKED), "{receipt}"); + assert!(content(&receipt).contains("Done in X"), "{receipt}"); +} + +/// A later close-out's edit, failing once near the end of the first one's +/// 24 h, still gets its retry. +#[tokio::test] +async fn a_later_close_out_gets_its_own_window() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM).event_id; + close_out(&b, "First report"); + b.settle(3).await; + assert!(b.s.replaced[0].shown.is_none() && b.s.replaced[0].watch); + b.now = b.s.replaced[0].at + 86_400 - 6; + *b.relay.post_down.lock().unwrap() = Some(card.clone()); + close_out(&b, "Second report"); + let before = b.posts().len(); + b.tick().await.unwrap(); + assert!(b.s.replaced[0].retry_at > 0, "one failed try"); + *b.relay.post_down.lock().unwrap() = None; + b.now += 60; + b.settle(4).await; + assert_eq!(b.s.last_error, None); + // The fake relay logs a refused write too, so two is the retry. + let second = b.posts()[before..] + .iter() + .filter(|(e, _)| e["kind"] == 40003 && tag_value(e, "e").as_deref() == Some(&card)) + .filter(|(e, _)| content(e).contains("Second report")) + .count(); + assert_eq!(second, 2); +} + +/// A new card format is no close-out: receipts closed days ago are edited +/// again, unwatched, and leave the cap to posts a tap could still come to. +#[tokio::test] +async fn a_format_bump_watches_no_old_receipt() { + let mut b = seeded_bench(Gate::Open, &[]).await; + close_out(&b, "Moved the relay"); + b.settle(3).await; + let receipt = b.s.items[ITEM].clone(); + b.s.replaced.clear(); + b.s.receipts_todo = Some(Vec::new()); + for n in 0..=WATCH_MAX { + let mut i = receipt.clone(); + i.id = format!("orchestrator/r{n}"); + let c = i.card.as_mut().unwrap(); + c.event_id = format!("{n:064x}"); + c.hash = format!("{}.3", i.hash); + b.s.items.insert(i.id.clone(), i); + } + b.tick().await.unwrap(); + assert_eq!(b.s.replaced.len(), WATCH_MAX + 1); + assert!(b.s.replaced.iter().all(|r| !r.watch)); + b.tick().await.unwrap(); + assert!(!board(&b).contains("Watched for a tap"), "{}", board(&b)); +} + +#[tokio::test] +async fn a_receipt_says_so_and_loses_its_seeds() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM); + let on_card = seeds(&b, &card.event_id); + assert_eq!(on_card.len(), 2); + // Its clock is ahead, so only `seen` keeps it from counting as new. + let answer = tap(&b.ctx.owner, &card.event_id, OPTION_EMOJI[1], b.now + 500); + let mut relay: Vec<&nostr::Event> = on_card.iter().collect(); + relay.push(&answer); + reply(&b, &relay); + b.tick().await.unwrap(); + close_out(&b, "Moved the relay"); + b.settle(4).await; + + let edited = last_edit(&b, &card.event_id); + let text = content(&edited); + assert!(text.starts_with(&format!("{NOT_ASKED}\n馃敶 路 ")), "{text}"); + assert!(text.contains("\nAnswered: 2. Move"), "{text}"); + assert!(text.contains("**Moved the relay**"), "{text}"); + let off = removed(&b); + for seed in &on_card { + assert!(off.contains(&seed.id.to_hex()), "{off:?}"); + } + assert!(!off.contains(&answer.id.to_hex()) && !off.contains(&card.event_id)); + assert!(replies(&b).is_empty(), "the answer it closed with"); + assert!(b.s.kept.is_empty()); + assert_eq!(b.card(ITEM).event_id, card.event_id); +} + +/// Its edit failing used to fail the tick, and lastError stops the kit. +#[tokio::test] +async fn a_receipt_that_will_not_update_fails_no_tick_and_one_gone_is_forgotten() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM).event_id; + close_out(&b, "Moved the relay"); + *b.relay.post_down.lock().unwrap() = Some(card.clone()); + b.tick().await.unwrap(); + assert!(b.s.replaced[0].shown.is_some() && b.s.replaced[0].retry_at > 0); + // A second close-out while it is owed: one post, one edit, the last words. + close_out(&b, "Moved after all"); + b.tick().await.unwrap(); + assert_eq!(b.s.replaced.len(), 1); + *b.relay.post_down.lock().unwrap() = None; + b.now = b.s.replaced[0].retry_at; + b.settle(2).await; + let text = last_edit(&b, &card); + assert!(content(&text).contains("**Moved after all**"), "{text}"); + + // A later close-out on a receipt deleted by hand: the item lets it go. + b.relay.gone.lock().unwrap().insert(card); + close_out(&b, "Moved again"); + b.settle(3).await; + assert!(b.s.items[ITEM].card.is_none()); +} + +/// Receipts from before this build: one at a time and unwatched, each with its +/// first line and without its seeds; then never again. +#[tokio::test] +async fn old_receipts_are_edited_one_at_a_time_then_never_again() { + let mut b = seeded_bench(Gate::Open, &[]).await; + b.drop_file( + "orchestrator", + "other", + &drop_json(&[("title", json!("Other"))]), + ); + b.settle(4).await; + let (a, z) = (b.card(ITEM).event_id, b.card("orchestrator/other").event_id); + let on_card = [seeds(&b, &a), seeds(&b, &z)].concat(); + let done = drop_json(&[ + ("kind", json!("status")), + ("state", json!("done")), + ("severity", Value::Null), + ("options", Value::Null), + ]); + b.drop_file("orchestrator", "relay-choice", &done); + b.drop_file("orchestrator", "other", &done); + b.settle(6).await; + // As a build before this left them: nothing owed, not listed. + b.s.replaced.clear(); + b.s.receipts_todo = None; + reply(&b, &on_card.iter().collect::>()); + let before = b.posts().len(); + b.tick().await.unwrap(); + assert_eq!( + b.posts().len(), + before, + "listed this tick, edited from the next" + ); + assert_eq!(b.s.receipts_todo.as_ref().unwrap().len(), 1); + assert!(b.s.replaced.len() == 1 && !b.s.replaced[0].watch); + b.settle(10).await; + assert_eq!(b.s.receipts_todo.as_deref(), Some(&[][..])); + assert!(b.s.replaced.is_empty(), "let go once done"); + for card in [&a, &z] { + let text = last_edit(&b, card); + assert!(content(&text).starts_with(NOT_ASKED), "{text}"); + } + let off = removed(&b); + for seed in &on_card { + let n = off.iter().filter(|id| **id == seed.id.to_hex()).count(); + assert_eq!(n, 1, "{off:?}"); + } + let before = b.posts().len(); + b.settle(5).await; + assert_eq!(b.posts().len(), before, "never again"); +} + +/// One that can't be told yet stays listed; one asked again since has a live card, +/// which is never edited as an old post. +#[tokio::test] +async fn an_old_receipt_waits_for_its_channel_and_one_asked_again_is_skipped() { + let mut b = seeded_bench(Gate::Open, &[]).await; + close_out(&b, "Moved the relay"); + b.settle(3).await; + b.s.replaced.clear(); + b.s.receipts_todo = Some(vec![ITEM.to_string()]); + let in_x = |b: &mut Bench, ch: Option| { + b.s.items + .get_mut(ITEM) + .unwrap() + .request + .as_mut() + .unwrap() + .channel = ch; + }; + in_x(&mut b, Some(X.to_string())); + b.settle(2).await; + assert!(b.s.replaced.is_empty()); + assert_eq!(b.s.receipts_todo.as_ref().unwrap().len(), 1); + in_x(&mut b, None); + redrop(&b, "Relay choice, asked again"); + b.settle(6).await; + assert_eq!(b.s.receipts_todo.as_deref(), Some(&[][..])); + let live = b.card(ITEM).event_id; + let on_live = |e: &Value| e["kind"] == 40003 && tag_value(e, "e").as_ref() == Some(&live); + assert!(!b.posts().iter().any(|(e, _)| on_live(e))); +} + +/// A relay refusing them holds one on the board, not all of them in state. +#[tokio::test] +async fn an_old_receipt_that_will_not_update_holds_the_rest() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let card = b.card(ITEM).event_id; + close_out(&b, "Moved the relay"); + b.settle(3).await; + b.s.replaced.clear(); + b.s.receipts_todo = Some(vec![ITEM.to_string(), ITEM.to_string()]); + *b.relay.post_down.lock().unwrap() = Some(card); + b.settle(4).await; + assert_eq!(b.s.replaced.len(), 1); + assert_eq!(b.s.receipts_todo.as_ref().unwrap().len(), 1); + assert!(board(&b).contains("Would not update: 1."), "{}", board(&b)); +} + /// A channel that isn't ready: nothing there is read, edited or counted against /// the cap, and it all resumes when the channel is ready. #[tokio::test] @@ -623,6 +1047,56 @@ async fn removals_and_replies_stop_at_the_post_budget() { assert!(b.s.replaced[1].tapped.is_some()); } +/// One lookup a tick and a minute's retry: with more posts failing than a +/// minute's lookups, every tick runs out. They still go at the end of their +/// window. +#[tokio::test] +async fn posts_failing_past_the_lookups_still_go_at_their_window() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let ch = b.channel(); + let seeds: Vec<_> = (1..=40) + .map(|n| { + let old = old_card(&mut b, n, &ch); + tap(&b.ctx.writer, &old.event_id, OPTION_EMOJI[0], b.now) + }) + .collect(); + reply(&b, &seeds.iter().collect::>()); + let end = b.now + 86_400; + *b.relay.clock.lock().unwrap() = Some(b.now + 10_000); + for _ in 0..120 { + b.now += 2; + old_tick(&mut b, 4).await; + } + assert_eq!(b.s.replaced.len(), 40); + b.now = end; + // The 30 tried go at once; the 10 waiting behind them go a tick each. + for _ in 0..10 { + b.now += 2; + old_tick(&mut b, 4).await; + } + assert!(b.s.replaced.is_empty()); +} + +/// Out of lookups, a post past its window that hasn't failed waits for its try: +/// a Bench off for a day still closes what it owed. +#[tokio::test] +async fn past_its_window_a_post_still_gets_its_try_when_lookups_run_out() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let ch = b.channel(); + let seeds = [1, 2].map(|n| { + let old = old_card(&mut b, n, &ch); + tap(&b.ctx.writer, &old.event_id, OPTION_EMOJI[0], b.now) + }); + reply(&b, &[&seeds[0], &seeds[1]]); + b.now += 86_400; + old_tick(&mut b, 4).await; + assert_eq!(removed(&b).len(), 1); + assert_eq!(b.s.replaced.len(), 2); + old_tick(&mut b, 4).await; + assert_eq!(removed(&b).len(), 2); + assert!(b.s.replaced.is_empty()); +} + /// With no post left to spend, the lookups before a removal or a reply wait too. #[tokio::test] async fn no_budget_no_lookup() { @@ -664,6 +1138,17 @@ async fn lookups_are_capped_per_tick() { assert_eq!(lookups(&b, from), 1); } +/// Its lookup is a lookup and its edit a post, like any other. +#[tokio::test] +async fn an_edit_from_its_post_spends_a_post() { + let mut b = seeded_bench(Gate::Open, &[]).await; + let (ch, card) = (b.channel(), b.card(ITEM)); + let post = posted(&b, &card.event_id); + reply(&b, &[&post]); + retired(&mut b.s, "orchestrator/old", &ch, &card, b.now); + assert_eq!(old_tick(&mut b, 4).await, 3); +} + /// A read that keeps failing fails no tick (lastError stops the kit's approvals). /// Past a minute the board says taps go unanswered, until a read goes through. #[tokio::test] diff --git a/desktop/src-tauri/src/bench/tests/routes.rs b/desktop/src-tauri/src/bench/tests/routes.rs index 5c1e9cf6883..17143feb63c 100644 --- a/desktop/src-tauri/src/bench/tests/routes.rs +++ b/desktop/src-tauri/src/bench/tests/routes.rs @@ -778,8 +778,8 @@ async fn a_reword_closes_a_routed_card_with_only_a_reply() { let reworded = drop_json(&[("options", json!(["Stay", "Move away"]))]); b.drop_file("orchestrator", "relay-choice", &reworded); b.settle(3).await; - assert_eq!(b.kinds()[before..], [40003, 9, 7, 7, 9, 5]); - let closed = &b.posts()[before].0; + assert_eq!(b.kinds()[before..], [9, 7, 7, 40003, 9, 5]); + let closed = &b.posts()[before + 3].0; assert_eq!(tag_value(closed, "e").unwrap(), old_card.event_id); assert!(content(closed).contains("\n\nClosed: a tap or reply here is no longer read.")); assert_eq!(b.s.kept, [old_card.event_id]); diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 9a4e56b363f..bfe1b5823c0 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -283,6 +283,7 @@ export function MembersSidebar({ ? pastedProfileQuery.data?.profiles[pastedPubkey] : undefined; const pastedKeyResolved = + pastedPubkey !== null && pastedProfileQuery.isSuccess && !pastedProfileQuery.isFetching && !rosterQuery.isLoading; diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index c193b09e97e..d7ea18dab1e 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -4787,6 +4787,26 @@ test("members sidebar offers nothing for a malformed key", async ({ page }) => { await expect(page.getByText("No matching people or agents.")).toBeVisible(); }); +test("members sidebar keeps the outsider notice to pasted keys once the roster is cached", async ({ + page, +}) => { + await page.goto("/"); + await openMembersSidebar(page, "general"); + + const search = page.getByTestId("channel-management-search-users"); + const outsiderNotice = page.getByText( + "That key isn鈥檛 a member of this community yet.", + ); + await search.fill(PASTED_NPUB); + await expect(outsiderNotice).toBeVisible(); + + await search.clear(); + await search.fill("zzzz-matches-nobody"); + + await expect(page.getByText("No matching people or agents.")).toBeVisible(); + await expect(outsiderNotice).toHaveCount(0); +}); + test("members sidebar pages add-member search beyond the first 50 people", async ({ page, }) => {