issue 800: fix review round 3 finding, a true sliding window instead of a session quota
mesh/merge-gate head c2cd2e1eb15e merged into trunk 789b0a9474c8 — pass: builds mesh-mail, messenger, new: modules/mesh-mail-notifier → novox; no bus step;…
mesh/repo-check head c2cd2e1eb15e merged into trunk 789b0a9474c8 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
mesh/delivery-group group feat/mail-seats delivered: every member is delivered

A session that only reset after a quiet minute kept counting, and so folding, a steady
trickle of mail forever even with no burst (one mail every 50s). N is now the account's
qualifying mail arrived in the trailing 60 seconds, counted fresh on every arrival; a fold
holds only its own overflow and the notice names just that count, not the whole history.
This commit is contained in:
jochen
2026-10-15 19:03:05 +02:00
parent aa62de2e78
commit c2cd2e1eb1
2 changed files with 151 additions and 133 deletions
@@ -4,12 +4,17 @@ package main
// issue 800 part B). It hears mesh-mail's added events on its own account (runs-as, ADR 0376 §2) and shows each // issue 800 part B). It hears mesh-mail's added events on its own account (runs-as, ADR 0376 §2) and shows each
// new inbox mail at most once, through node-notifier.send: quiet during a first sync (mesh-mail already says // new inbox mail at most once, through node-notifier.send: quiet during a first sync (mesh-mail already says
// nothing then, imap.go ~387), for mail from the account's own address, for mail already marked read (\Seen), // nothing then, imap.go ~387), for mail from the account's own address, for mail already marked read (\Seen),
// and for anything outside the inbox. Of one account's qualifying mail, a mail is shown at once when fewer // and for anything outside the inbox. For each new qualifying mail of an account, arriving at t, N counts that
// than FoldOver of that account's mails were shown in the Window before it; otherwise it is folded and shown, // account's qualifying mail — shown or folded alike, each counted once — that arrived in the 60 seconds up to
// with the other mail its session folds, once Window has passed since the first of them — the window slides // and including t: N <= FoldOver is shown at once; N > FoldOver is folded. The first folded mail opens the
// on the gap between mails, not on time elapsed since the session's first one, so a session already past its // account's fold; every folded mail joins it; the fold is shown, as one notice naming how many it holds and
// quota stays folding for as long as mail keeps arriving within Window of the last (novox/hq ADR 0378 §9, the // the latest sender, once Window has passed since its own first mail — after which a new fold may open. The
// lead's reading, review rounds 1 and 2 of issue 800). // count is a true sliding window of arrivals, not a per-session quota that only resets after a quiet Window:
// review round 2's reading kept a session's count alive, and so folding, for as long as any gap stayed under
// Window, which folds a steady trickle (one mail every 50s, say) forever even though no burst ever happened;
// review round 3 of issue 800 replaces that reading with this one. Nothing pending — a folded mail awaiting
// its fold's deadline, or a failed send awaiting its retry — is ever dropped (novox/hq ADR 0378 §9, review
// round 3 of issue 800, which replaces the round 1 and round 2 readings).
// //
// Nothing here names Betterbird (ADR 0379 §3). // Nothing here names Betterbird (ADR 0379 §3).
@@ -31,14 +36,11 @@ const SeenFlag = "\\Seen"
// that window is folded into a single notice (novox/hq ADR 0378 §9: "more than three"). // that window is folded into a single notice (novox/hq ADR 0378 §9: "more than three").
const FoldOver = 3 const FoldOver = 3
// Window is the sliding minute ADR 0378 §9 judges a burst over. It slides on the gap between one qualifying // Window is the trailing 60 seconds ADR 0378 §9 counts a mail's N over (every qualifying mail of the account,
// mail and the next, not on time elapsed since a session's first mail (review round 2 of issue 800): a mail // shown or folded alike, that arrived in the 60 seconds up to and including this one), and the time a fold
// is shown at once when fewer than FoldOver of the account's mails were shown in the Window before it; past // waits, from its own first mail, before it is shown (review round 3 of issue 800, which replaces the round 1
// that, it is folded and shown, with whatever else its session folds, once Window has passed since the first // and round 2 readings: the count is a true sliding window of arrivals, not a per-session quota that stays
// of them. A session's count never resets while mail keeps arriving within Window of the last one, however // alive, and so keeps folding, for as long as any gap between mails stays under Window).
// long the session has run; it is only ever replaced once idle for a full Window with nothing left pending
// (review round 2 of issue 800, BLOCKING: a buffer is never replaced while it holds an unsent mail). There is
// no separate quiet-gap timer beside this (review round 1 of issue 800: BurstWindow goes).
const Window = time.Minute const Window = time.Minute
// RetryBackoff is how long the notifier waits, after an account's notice fails to send, before it tries that // RetryBackoff is how long the notifier waits, after an account's notice fails to send, before it tries that
@@ -147,16 +149,14 @@ type AccountStatus struct {
Summary string `json:"summary"` Summary string `json:"summary"`
} }
// accountBuffer is one account's sliding session: it stays open, and its count never resets, as long as a // accountBuffer is one account's state: history is every qualifying mail's arrival time, kept only for the
// qualifying mail keeps arriving within Window of the previous one (review round 2 of issue 800). The first // trailing Window, that N is counted against (review round 3 of issue 800); immediate holds mail waiting to be
// FoldOver mails of a session are shown one by one as soon as Flush runs; the rest, from the fourth onward, // shown one by one at the next Flush; folded holds the account's current fold (its first FoldOver mails within
// are folded and shown as one notice once Window has passed since the first of them, whether or not the // Window are never folded, so a fold only ever holds the overflow from the fourth qualifying mail of its own
// session itself is still open. // window onward), due as one notice once Window has passed since foldStart, its first mail's arrival.
type accountBuffer struct { type accountBuffer struct {
lastArrival time.Time // the latest qualifying mail's arrival: a gap of Window since this lets a fresh session open history []time.Time
foldStart time.Time // when this session's first folded mail arrived; zero while nothing is folded foldStart time.Time // when the current fold's first mail arrived; zero while nothing is folded
count int // every qualifying mail this session has counted, shown or folded alike (bullet 3: the
// fold notice names this total, not just the overflow)
immediate []EventBody immediate []EventBody
folded []EventBody folded []EventBody
} }
@@ -229,23 +229,28 @@ func (n *Notifier) Add(e EventBody) error {
} }
now := n.Now() now := n.Now()
b := n.pending[e.Account] b := n.pending[e.Account]
// A session stays open, and its count never resets, as long as a qualifying mail keeps arriving within if b == nil {
// Window of the last one — even once more than Window has passed since the session's own first mail
// (the sliding minute, review round 2 of issue 800). It is replaced only once it is both idle for a full
// Window and fully flushed: nothing pending is ever discarded to make room for a fresh session (BLOCKING:
// a buffer is never replaced while it holds an unsent mail — a failed send waiting for its retry, or a
// folded mail waiting for its minute's end).
if b == nil || (now.Sub(b.lastArrival) >= Window && len(b.immediate) == 0 && len(b.folded) == 0) {
b = &accountBuffer{} b = &accountBuffer{}
n.pending[e.Account] = b n.pending[e.Account] = b
} }
b.lastArrival = now // N is every qualifying mail of this account — shown or folded alike, each counted once — that arrived in
b.count++ // the 60 seconds up to and including now: a true sliding window over arrivals, not a per-session quota that
// stays alive, and so keeps folding, for as long as any gap stays under Window (review round 3 of issue
// 800, which replaces the round 1 and round 2 readings; nothing in this count depends on whether an earlier
// mail has been shown yet).
cutoff := now.Add(-Window)
history := make([]time.Time, 0, len(b.history)+1)
for _, at := range b.history {
if !at.Before(cutoff) {
history = append(history, at)
}
}
b.history = append(history, now)
n.buffered[handle] = true n.buffered[handle] = true
if b.count <= FoldOver { if len(b.history) <= FoldOver {
b.immediate = append(b.immediate, e) b.immediate = append(b.immediate, e)
} else { } else {
if b.foldStart.IsZero() { if len(b.folded) == 0 {
b.foldStart = now b.foldStart = now
} }
b.folded = append(b.folded, e) b.folded = append(b.folded, e)
@@ -320,10 +325,10 @@ func (n *Notifier) Flush() error {
return nil return nil
} }
// flushAccount ships one account's ready notices (its immediate mail, and its folded mail once Window has // flushAccount ships one account's ready notices (its immediate mail, and its fold once Window has passed
// passed since the first of them), and keeps whatever fails to send pending, under cooldown, for the next // since the fold's own first mail), and keeps whatever fails to send pending, under cooldown, for the next
// Flush. Nothing is deleted before it is shown (review round 1 BLOCKING 2), and nothing is replaced, or lost, // Flush. Nothing is deleted before it is shown (review round 1 BLOCKING 2), and nothing pending is ever lost
// while it is still pending (review round 2 BLOCKING; see Add). // (review round 2 BLOCKING, kept under review round 3's rule; see Add).
func (n *Notifier) flushAccount(account string, now time.Time) { func (n *Notifier) flushAccount(account string, now time.Time) {
n.mu.Lock() n.mu.Lock()
if cd, ok := n.cooldown[account]; ok && now.Before(cd) { if cd, ok := n.cooldown[account]; ok && now.Before(cd) {
@@ -336,19 +341,15 @@ func (n *Notifier) flushAccount(account string, now time.Time) {
return return
} }
immediate := append([]EventBody(nil), b.immediate...) immediate := append([]EventBody(nil), b.immediate...)
// The fold group is due Window after its own first mail — independent of whether the session itself is // The fold is due Window after its own first mail (review round 3 of issue 800).
// still open (review round 2 of issue 800: the window slides on gaps, so a session can still be taking in foldDue := len(b.folded) > 0 && now.Sub(b.foldStart) >= Window
// new mail well past its first fold's deadline).
foldDue := !b.foldStart.IsZero() && now.Sub(b.foldStart) >= Window
var fold []EventBody var fold []EventBody
foldCount := b.count // every mail of the account this session has counted (bullet 3), not just the fold's own
if foldDue { if foldDue {
fold = append([]EventBody(nil), b.folded...) fold = append([]EventBody(nil), b.folded...)
} }
n.mu.Unlock() n.mu.Unlock()
if len(immediate) == 0 && len(fold) == 0 { if len(immediate) == 0 && len(fold) == 0 {
n.closeIfIdle(account, b, now)
return return
} }
@@ -363,7 +364,7 @@ func (n *Notifier) flushAccount(account string, now time.Time) {
} }
foldSent := false foldSent := false
if failErr == nil && len(fold) > 0 { if failErr == nil && len(fold) > 0 {
if err := n.show(n.foldNotice(account, fold, foldCount)); err != nil { if err := n.show(n.foldNotice(account, fold, len(fold))); err != nil {
failErr = err failErr = err
} else { } else {
foldSent = true foldSent = true
@@ -376,19 +377,13 @@ func (n *Notifier) flushAccount(account string, now time.Time) {
if foldSent { if foldSent {
cur.folded = cur.folded[len(fold):] cur.folded = cur.folded[len(fold):]
if len(cur.folded) == 0 { if len(cur.folded) == 0 {
cur.foldStart = time.Time{} cur.foldStart = time.Time{} // nothing left folded: a new fold may open (review round 3 of issue 800)
} else { } else {
// More mail folded in while this batch was being sent: its own Window starts now, counted from // More mail folded in while this batch was being sent: its own Window starts now, counted from
// when it was first noticed. Nothing of it is lost either way. // when it was first noticed. Nothing of it is lost either way.
cur.foldStart = now cur.foldStart = now
} }
} }
// The session itself — its lastArrival and count — is only ever dropped once it has gone idle for a
// full Window with nothing left pending (review round 2 of issue 800, BLOCKING: a buffer is never
// replaced, or its unsent mail lost, while it still holds one).
if now.Sub(cur.lastArrival) >= Window && len(cur.immediate) == 0 && len(cur.folded) == 0 {
delete(n.pending, account)
}
} }
if failErr != nil { if failErr != nil {
n.cooldown[account] = now.Add(RetryBackoff) n.cooldown[account] = now.Add(RetryBackoff)
@@ -400,17 +395,6 @@ func (n *Notifier) flushAccount(account string, now time.Time) {
n.mu.Unlock() n.mu.Unlock()
} }
// closeIfIdle drops an account's session once it has gone idle for a full Window with nothing left pending:
// the next qualifying mail then opens a fresh session with a fresh quota (see Add).
func (n *Notifier) closeIfIdle(account string, b *accountBuffer, now time.Time) {
n.mu.Lock()
defer n.mu.Unlock()
if cur := n.pending[account]; cur == b && now.Sub(cur.lastArrival) >= Window &&
len(cur.immediate) == 0 && len(cur.folded) == 0 {
delete(n.pending, account)
}
}
// recordFailureLocked keeps an account's failure in the Store (mu held by the caller, released around the // recordFailureLocked keeps an account's failure in the Store (mu held by the caller, released around the
// Store calls since they may reach the bus): its count, its last error, and since when, so a later Flush and // Store calls since they may reach the bus): its count, its last error, and since when, so a later Flush and
// the health tool agree on how long it has been failing. // the health tool agree on how long it has been failing.
@@ -452,17 +436,17 @@ func (n *Notifier) singleNotice(account string, e EventBody) Notice {
} }
} }
// foldNotice is one notice for everything an account's session folded — its fourth qualifying mail onward — // foldNotice is one notice for everything the account's current fold holds — its qualifying mail from the
// naming the latest sender and how many mail the account has had in total this session, not only the // fourth within some trailing 60 seconds onward — naming how many mail that fold holds (not the account's
// overflow (novox/hq ADR 0378 §9: "more than three new mails from one account within a minute become one // whole history of arrivals) and the latest sender (novox/hq ADR 0378 §9, review round 3 of issue 800, which
// notice that says how many and names the latest sender"; review round 2 of issue 800, bullet 3: the count is // replaces the round 1 and round 2 readings: "<k> more new mail(s) in <account>, latest from <sender of the
// every mail of the account in that minute, "mail" for one and "mails" for more). // last>", "mail" for one and "mails" for more).
func (n *Notifier) foldNotice(account string, events []EventBody, count int) Notice { func (n *Notifier) foldNotice(account string, events []EventBody, count int) Notice {
latest := events[len(events)-1] latest := events[len(events)-1]
notice := Notice{ notice := Notice{
Account: account, Account: account,
Args: map[string]any{ Args: map[string]any{
"summary": strconv.Itoa(count) + " new " + mailWord(count) + " in " + account, "summary": strconv.Itoa(count) + " more new " + mailWord(count) + " in " + account,
"body": "latest from " + latest.sender(), "body": "latest from " + latest.sender(),
"app_name": "mesh-mail", "app_name": "mesh-mail",
}, },
@@ -474,7 +458,8 @@ func (n *Notifier) foldNotice(account string, events []EventBody, count int) Not
return notice return notice
} }
// mailWord says "mail" for a count of one, "mails" for anything else (review round 2 of issue 800, bullet 3). // mailWord says "mail" for a count of one, "mails" for anything else (review round 2 of issue 800, bullet 3;
// kept by review round 3).
func mailWord(count int) string { func mailWord(count int) string {
if count == 1 { if count == 1 {
return "mail" return "mail"
@@ -531,7 +516,7 @@ func (n *Notifier) Status() (map[string]any, error) {
"rules": []string{ "rules": []string{
"it shows each new inbox mail at most once, through node-notifier.send on this machine (novox/hq ADR 0378 §7)", "it shows each new inbox mail at most once, through node-notifier.send on this machine (novox/hq ADR 0378 §7)",
"quiet during a first sync, for mail from the account's own address, for mail already marked read, and for anything outside the inbox", "quiet during a first sync, for mail from the account's own address, for mail already marked read, and for anything outside the inbox",
"of one account's qualifying mail within a sliding minute, the first three are shown one by one; the rest of that minute is folded into one notice naming the count and the latest sender", "of one account's qualifying mail, one arriving when fewer than three of the account's mails fell in the 60 seconds before it is shown at once; the rest is folded and shown, as one notice naming how many it holds and the latest sender, 60 seconds after the fold's own first mail",
}, },
} }
if knownErr != nil { if knownErr != nil {
@@ -210,9 +210,9 @@ func TestAFailedSendIsRetriedWithoutLosingItOrHoldingUpOtherAccounts(t *testing.
} }
// TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd is the review's third test (BLOCKING 3, // TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd is the review's third test (BLOCKING 3,
// ADR 0378 §9, the lead's reading): the first three of an account's qualifying mail within a minute are shown // ADR 0378 §9, the lead's reading, the fold notice's wording updated by review round 3 of issue 800): the
// one by one as Flush runs; the fourth onward is folded and shown as one notice once the window — a minute // first three of an account's qualifying mail within a minute are shown one by one as Flush runs; the fourth
// from the first of them — is over. // onward is folded and shown as one notice once the window — a minute from the first of them — is over.
func TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd(t *testing.T) { func TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd(t *testing.T) {
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
store := newFakeStore() store := newFakeStore()
@@ -246,8 +246,8 @@ func TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd(t *testing
if len(sent) != 4 { if len(sent) != 4 {
t.Fatalf("after the window ended, sent %d notice(s), want 4 (the folded fourth mail too): %+v", len(sent), sent) t.Fatalf("after the window ended, sent %d notice(s), want 4 (the folded fourth mail too): %+v", len(sent), sent)
} }
if sent[3]["summary"] != "4 new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 4") { if sent[3]["summary"] != "1 more new mail in home" || !strings.Contains(sent[3]["body"].(string), "Sender 4") {
t.Errorf("the fold notice is %+v, want it to count every mail of the session (4), naming Sender 4", sent[3]) t.Errorf("the fold notice is %+v, want it to count only what the fold holds (1), naming Sender 4", sent[3])
} }
} }
@@ -298,8 +298,8 @@ func TestARedeliveredEventIsNeverShownOrCountedTwice(t *testing.T) {
if len(sent) != 4 { if len(sent) != 4 {
t.Fatalf("sent %d notice(s), want 4 (one, mail 2, mail 3, and one fold notice): %+v", len(sent), sent) t.Fatalf("sent %d notice(s), want 4 (one, mail 2, mail 3, and one fold notice): %+v", len(sent), sent)
} }
if sent[3]["summary"] != "4 new mails in home" { if sent[3]["summary"] != "1 more new mail in home" {
t.Errorf("the fold notice is %+v, want it to count the redelivered fourth mail once, not twice, among the session's 4", sent[3]) t.Errorf("the fold notice is %+v, want it to count the redelivered fourth mail once, not twice, among the fold's 1", sent[3])
} }
} }
@@ -450,53 +450,15 @@ func TestABufferIsNeverReplacedWhileItHoldsAnUnsentMail(t *testing.T) {
} }
} }
// TestAFoldedMailIsNeverLostBetweenTheWindowsEndAndTheNextFlush is review round 2 of issue 800's second test // TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota is review round 3 of issue 800's first test,
// for the same BLOCKING finding: no failure here, but the fourth mail is folded, its minute ends, and a new // replacing round 2's (which contradicted round 3's rule): N, for a mail arriving at t, is every qualifying
// mail arrives before the next Flush. The folded mail is still shown, never lost. // mail of the account — shown or folded alike — that arrived in the 60 seconds up to and including t. Mails at
func TestAFoldedMailIsNeverLostBetweenTheWindowsEndAndTheNextFlush(t *testing.T) { // 0, 50, 55, 70, 75 and 80 seconds: at 70s the preceding 60 seconds (from 10s) hold 50, 55 and 70 themselves
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} // (0s has aged out), N=3, so 70 is still shown at once, not folded; at 75s the preceding 60 seconds (from 15s)
store := newFakeStore() // still hold 50 too, so it, 55, 70 and 75 themselves make N=4, and 75 folds; 80 folds with it (its own
var sent []map[string]any // preceding 60 seconds hold 50, 55, 70, 75 and 80, N=5). The fold is due 60 seconds after its own first mail
n := newTestNotifier(t, clock, store, &sent) // (75s), at 135s, as one notice naming only what it holds (2) and the latest sender (80s's).
func TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota(t *testing.T) {
account := "home"
for i := 1; i <= 4; i++ {
if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil {
t.Fatalf("Add mail %d: %v", i, err)
}
}
if err := n.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
if len(sent) != 3 {
t.Fatalf("sent %d notice(s), want 3 shown one by one, the fourth folded, not yet due: %+v", len(sent), sent)
}
// The fold's minute is over (a Window since mail 4, its first folded mail), but Flush has not run again
// yet: a new mail arrives in that gap.
clock.Advance(Window + time.Second)
if err := n.Add(mail(account, 5, "m5", "Mail 5", "Sender 5")); err != nil {
t.Fatalf("Add mail 5: %v", err)
}
if err := n.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
if len(sent) != 4 {
t.Fatalf("sent %d notice(s), want 4 (one fold notice carrying both mail 4 and mail 5): %+v", len(sent), sent)
}
if sent[3]["summary"] != "5 new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 5") {
t.Errorf("the fold notice is %+v, want it to count all 5 of the session, naming Sender 5 (the latest)", sent[3])
}
}
// TestTheWindowSlidesOnGapsNotOnElapsedTimeSinceItsFirstMail is review round 2 of issue 800's test for the
// sliding minute: a mail is shown at once when fewer than three of the account's mails were shown in the 60
// seconds before it; otherwise it is folded and shown with the rest 60 seconds after the first of them. Mails
// at 0, 50, 55, 70, 75 and 80 seconds: 0, 50 and 55 are each within 60s of fewer than 3 prior mails, so each
// is shown; 70 is not (50 and 55 both fall in its preceding 60s, and the session is already past its quota),
// so it and everything after it, within the same ongoing session, fold into one notice.
func TestTheWindowSlidesOnGapsNotOnElapsedTimeSinceItsFirstMail(t *testing.T) {
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
store := newFakeStore() store := newFakeStore()
var sent []map[string]any var sent []map[string]any
@@ -513,24 +475,95 @@ func TestTheWindowSlidesOnGapsNotOnElapsedTimeSinceItsFirstMail(t *testing.T) {
t.Fatalf("Flush at mail %d: %v", i+1, err) t.Fatalf("Flush at mail %d: %v", i+1, err)
} }
} }
if len(sent) != 3 { if len(sent) != 4 {
t.Fatalf("sent %d notice(s) before the fold's window ends, want 3 shown one by one (0s, 50s, 55s): %+v", len(sent), sent) t.Fatalf("sent %d notice(s), want 4 shown one by one (0s, 50s, 55s, 70s): %+v", len(sent), sent)
} }
for i, want := range []string{"Mail 1", "Mail 2", "Mail 3"} { for i, want := range []string{"Mail 1", "Mail 2", "Mail 3", "Mail 4"} {
if sent[i]["summary"] != want { if sent[i]["summary"] != want {
t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want) t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want)
} }
} }
// The fold group's own window — a minute from its first mail, at 70s — ends at 130s. // The fold's own window — a minute from its first mail, 75s — ends at 135s.
clock.Advance(50 * time.Second) clock.Advance(55 * time.Second) // now at 80s + 55s = 135s
if err := n.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
if len(sent) != 5 {
t.Fatalf("sent %d notice(s), want 5 (75s and 80s folded into one, at 135s): %+v", len(sent), sent)
}
if sent[4]["summary"] != "2 more new mails in home" || !strings.Contains(sent[4]["body"].(string), "Sender 6") {
t.Errorf("the fold notice is %+v, want it to count only what it holds (2), naming Sender 6 (80s, the latest)", sent[4])
}
}
// TestASteadyTrickleNeverFoldsForever is review round 3 of issue 800's second test: the bug the round found
// (a session that only resets after a quiet minute folds a steady stream forever, even without a burst). One
// mail every 50 seconds, 20 of them: each one's preceding 60 seconds holds only the one before it, so N never
// exceeds 2, and all 20 are shown at once — none ever fold.
func TestASteadyTrickleNeverFoldsForever(t *testing.T) {
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
store := newFakeStore()
var sent []map[string]any
n := newTestNotifier(t, clock, store, &sent)
account := "home"
for i := 1; i <= 20; i++ {
if i > 1 {
clock.Advance(50 * time.Second)
}
if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil {
t.Fatalf("Add mail %d: %v", i, err)
}
if err := n.Flush(); err != nil {
t.Fatalf("Flush at mail %d: %v", i, err)
}
if len(sent) != i {
t.Fatalf("after mail %d, sent %d notice(s), want %d: a steady trickle must never fold: %+v", i, len(sent), i, sent)
}
}
for i, got := range sent {
want := fmt.Sprintf("Mail %d", i+1)
if got["summary"] != want {
t.Errorf("sent[%d] = %+v, want summary %q", i, got, want)
}
}
}
// TestFiveMailsWithinTenSecondsFoldAfterThree is review round 3 of issue 800's third test: five mails within
// ten seconds — the first three shown at once, the fourth opens a fold, the fifth joins it, and the fold is
// shown 60 seconds after the fourth (the fold's own first mail) as one notice.
func TestFiveMailsWithinTenSecondsFoldAfterThree(t *testing.T) {
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
store := newFakeStore()
var sent []map[string]any
n := newTestNotifier(t, clock, store, &sent)
account := "home"
for i := 1; i <= 5; i++ {
if i > 1 {
clock.Advance(2 * time.Second)
}
if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil {
t.Fatalf("Add mail %d: %v", i, err)
}
if err := n.Flush(); err != nil {
t.Fatalf("Flush at mail %d: %v", i, err)
}
}
if len(sent) != 3 {
t.Fatalf("sent %d notice(s), want 3 shown one by one, the fourth and fifth folded, not yet due: %+v", len(sent), sent)
}
// The fold is due 60 seconds after its own first mail (the fourth, at 6s).
clock.Advance(Window)
if err := n.Flush(); err != nil { if err := n.Flush(); err != nil {
t.Fatalf("Flush: %v", err) t.Fatalf("Flush: %v", err)
} }
if len(sent) != 4 { if len(sent) != 4 {
t.Fatalf("sent %d notice(s), want 4 (70s, 75s and 80s folded into one, at 130s): %+v", len(sent), sent) t.Fatalf("sent %d notice(s), want 4 (one fold notice carrying the fourth and fifth): %+v", len(sent), sent)
} }
if sent[3]["summary"] != "6 new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 6") { if sent[3]["summary"] != "2 more new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 5") {
t.Errorf("the fold notice is %+v, want it to count all 6 of the session, naming Sender 6 (80s, the latest)", sent[3]) t.Errorf("the fold notice is %+v, want it to count only what it holds (2), naming Sender 5 (the latest)", sent[3])
} }
} }