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.
570 lines
21 KiB
Go
570 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeStore is Store kept in memory, for the test alone.
|
|
type fakeStore struct {
|
|
mu sync.Mutex
|
|
handles map[string]bool
|
|
statuses map[string]AccountStatus
|
|
failures map[string]FailureStatus
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{
|
|
handles: map[string]bool{},
|
|
statuses: map[string]AccountStatus{},
|
|
failures: map[string]FailureStatus{},
|
|
}
|
|
}
|
|
|
|
func (s *fakeStore) HandleSeen(handle string) (bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.handles[handle], nil
|
|
}
|
|
|
|
func (s *fakeStore) MarkHandle(handle string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.handles[handle] = true
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeStore) PutStatus(account string, st AccountStatus) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.statuses[account] = st
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeStore) Statuses() (map[string]AccountStatus, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := map[string]AccountStatus{}
|
|
for k, v := range s.statuses {
|
|
out[k] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *fakeStore) PutFailure(account string, f FailureStatus) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.failures[account] = f
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeStore) ClearFailure(account string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.failures, account)
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeStore) Failures() (map[string]FailureStatus, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := map[string]FailureStatus{}
|
|
for k, v := range s.failures {
|
|
out[k] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// fakeClock is a clock the test moves by hand, so Flush's window needs no real sleep.
|
|
type fakeClock struct {
|
|
mu sync.Mutex
|
|
now time.Time
|
|
}
|
|
|
|
func (c *fakeClock) Now() time.Time {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.now
|
|
}
|
|
|
|
func (c *fakeClock) Advance(d time.Duration) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.now = c.now.Add(d)
|
|
}
|
|
|
|
func newTestNotifier(t *testing.T, clock *fakeClock, store *fakeStore, sent *[]map[string]any) *Notifier {
|
|
t.Helper()
|
|
return &Notifier{
|
|
Now: clock.Now,
|
|
Send: func(args map[string]any) error {
|
|
*sent = append(*sent, args)
|
|
return nil
|
|
},
|
|
Accounts: func() (map[string]string, error) { return map[string]string{}, nil },
|
|
Store: store,
|
|
EmitShown: func(string, string) error { return nil },
|
|
Logf: t.Logf,
|
|
}
|
|
}
|
|
|
|
func mail(account string, uid int, messageID, subject, senderName string) EventBody {
|
|
return EventBody{Account: account, Folder: "INBOX", UID: uint32(uid), MessageID: messageID, Subject: subject,
|
|
From: []Person{{Name: senderName, Address: "s@example.com"}}}
|
|
}
|
|
|
|
// TestFlushShowsAccountsInASortedOrderNotAMapsIterationOrder is the review's first test for BLOCKING 1: Flush
|
|
// used to walk a map, so which account's notice went out first was whichever order Go's map iteration gave
|
|
// that run. Run with -count=50: green every time only once Flush sorts the accounts itself.
|
|
func TestFlushShowsAccountsInASortedOrderNotAMapsIterationOrder(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)
|
|
|
|
for _, account := range []string{"zeta", "mango", "alpha", "delta"} {
|
|
if err := n.Add(mail(account, 1, account+"-m1", account, "Someone")); err != nil {
|
|
t.Fatalf("Add(%s): %v", account, err)
|
|
}
|
|
}
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
|
|
want := []string{"alpha", "delta", "mango", "zeta"}
|
|
if len(sent) != len(want) {
|
|
t.Fatalf("sent %d notice(s), want %d: %+v", len(sent), len(want), sent)
|
|
}
|
|
for i, account := range want {
|
|
if sent[i]["summary"] != account {
|
|
t.Fatalf("sent[%d]'s summary is %v, want %q: accounts must be flushed in sorted order: %+v",
|
|
i, sent[i]["summary"], account, sent)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAFailedSendIsRetriedWithoutLosingItOrHoldingUpOtherAccounts is the review's second test for BLOCKING 2:
|
|
// a notice node-notifier.send refuses stays pending (nothing is deleted before it is shown), is tried again
|
|
// at the next Flush no sooner than RetryBackoff, and another account's notice still goes out meanwhile.
|
|
func TestAFailedSendIsRetriedWithoutLosingItOrHoldingUpOtherAccounts(t *testing.T) {
|
|
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
|
store := newFakeStore()
|
|
var sent []map[string]any
|
|
failSend := true
|
|
n := &Notifier{
|
|
Now: clock.Now,
|
|
Send: func(args map[string]any) error {
|
|
if args["summary"] == "Broken" && failSend {
|
|
return errors.New("node-notifier.send: no answer")
|
|
}
|
|
sent = append(sent, args)
|
|
return nil
|
|
},
|
|
Accounts: func() (map[string]string, error) { return map[string]string{}, nil },
|
|
Store: store,
|
|
EmitShown: func(string, string) error { return nil },
|
|
Logf: t.Logf,
|
|
}
|
|
|
|
if err := n.Add(mail("broke", 1, "b1", "Broken", "Someone")); err != nil {
|
|
t.Fatalf("Add(broke): %v", err)
|
|
}
|
|
if err := n.Add(mail("ok", 1, "o1", "Fine", "Someone")); err != nil {
|
|
t.Fatalf("Add(ok): %v", err)
|
|
}
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 1 || sent[0]["summary"] != "Fine" {
|
|
t.Fatalf("sent = %+v, want only ok's notice: broke's failure must not hold it up", sent)
|
|
}
|
|
if failures, err := store.Failures(); err != nil || failures["broke"].Count != 1 {
|
|
t.Fatalf("Store.Failures() = %+v, %v; want broke recorded once", failures, err)
|
|
}
|
|
|
|
// Before RetryBackoff has passed, broke's notice is not retried yet.
|
|
clock.Advance(30 * time.Second)
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent %d notice(s) before RetryBackoff elapsed, want 1 (broke not retried yet): %+v", len(sent), sent)
|
|
}
|
|
|
|
// Once RetryBackoff has passed, the next Flush tries it again; nothing was lost.
|
|
failSend = false
|
|
clock.Advance(RetryBackoff)
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 2 || sent[1]["summary"] != "Broken" {
|
|
t.Fatalf("sent = %+v, want broke's notice too, after RetryBackoff", sent)
|
|
}
|
|
if failures, _ := store.Failures(); len(failures) != 0 {
|
|
t.Errorf("Store.Failures() = %+v, want it cleared once broke's notice was shown", failures)
|
|
}
|
|
}
|
|
|
|
// TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd is the review's third test (BLOCKING 3,
|
|
// ADR 0378 §9, the lead's reading, the fold notice's wording updated by review round 3 of issue 800): the
|
|
// first three of an account's qualifying mail within a minute are shown one by one as Flush runs; the fourth
|
|
// 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) {
|
|
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 <= 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)
|
|
}
|
|
clock.Advance(10 * time.Second)
|
|
}
|
|
if len(sent) != 3 {
|
|
t.Fatalf("4 mails 10s apart: sent %d notice(s) before the window ends, want 3 shown one by one: %+v", len(sent), sent)
|
|
}
|
|
for i, want := range []string{"Mail 1", "Mail 2", "Mail 3"} {
|
|
if sent[i]["summary"] != want {
|
|
t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want)
|
|
}
|
|
}
|
|
|
|
// The window is over a minute after it opened (the first mail): the fourth, folded, is shown now.
|
|
clock.Advance(Window)
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
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 only what the fold holds (1), naming Sender 4", sent[3])
|
|
}
|
|
}
|
|
|
|
// TestARedeliveredEventIsNeverShownOrCountedTwice is the review's fourth test: a redelivery of a mail already
|
|
// shown is dropped (the Store knows it), and a redelivery of a mail still buffered, waiting to be folded, is
|
|
// never counted a second time toward the fold.
|
|
func TestARedeliveredEventIsNeverShownOrCountedTwice(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"
|
|
one := mail(account, 1, "m1", "Hi", "Alice")
|
|
if err := n.Add(one); err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent %d notice(s), want 1", len(sent))
|
|
}
|
|
if err := n.Add(one); err != nil { // redelivered after it was shown
|
|
t.Fatalf("Add (redelivered, already shown): %v", err)
|
|
}
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 1 {
|
|
t.Fatalf("a redelivered, already-shown event was shown again: sent now %+v", sent)
|
|
}
|
|
|
|
for i := 2; i <= 4; i++ {
|
|
if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), "Hi", fmt.Sprintf("Sender %d", i))); err != nil {
|
|
t.Fatalf("Add mail %d: %v", i, err)
|
|
}
|
|
}
|
|
fourth := mail(account, 4, "m4", "Hi", "Sender 4")
|
|
if err := n.Add(fourth); err != nil { // redelivered while still buffered, folded, not yet shown
|
|
t.Fatalf("Add (redelivered, buffered): %v", err)
|
|
}
|
|
|
|
clock.Advance(Window)
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
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 fold's 1", sent[3])
|
|
}
|
|
}
|
|
|
|
// TestDedupeKeyIsTheMessageIDAcrossAUIDVALIDITYChange is the review's fifth test: the dedupe key is the
|
|
// account and the message id wherever one is given, so a UIDVALIDITY reset — which changes every UID in a
|
|
// folder — cannot make an already-shown mail look new. Only a message with no message id falls back to its
|
|
// folder, UIDVALIDITY and UID, and that fallback still tells two different mails apart.
|
|
func TestDedupeKeyIsTheMessageIDAcrossAUIDVALIDITYChange(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"
|
|
first := EventBody{Account: account, Folder: "INBOX", UID: 5, UIDValidity: 1, MessageID: "stable-id",
|
|
Subject: "Hi", From: []Person{{Address: "a@example.com"}}}
|
|
if err := n.Add(first); err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent %d, want 1", len(sent))
|
|
}
|
|
|
|
// The same message, redelivered after a UIDVALIDITY reset changed its UID: same message id, not shown again.
|
|
resent := EventBody{Account: account, Folder: "INBOX", UID: 99, UIDValidity: 2, MessageID: "stable-id",
|
|
Subject: "Hi", From: []Person{{Address: "a@example.com"}}}
|
|
if err := n.Add(resent); err != nil {
|
|
t.Fatalf("Add (UIDVALIDITY reset, same message id): %v", err)
|
|
}
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 1 {
|
|
t.Fatalf("a mail with the same message id after a UIDVALIDITY reset was shown again: sent now %+v", sent)
|
|
}
|
|
|
|
// Two different mails with no message id, at the same UID but a different UIDVALIDITY, are not confused.
|
|
noID1 := EventBody{Account: account, Folder: "INBOX", UID: 7, UIDValidity: 1, Subject: "No id",
|
|
From: []Person{{Address: "a@example.com"}}}
|
|
noID2 := EventBody{Account: account, Folder: "INBOX", UID: 7, UIDValidity: 2, Subject: "No id, new validity",
|
|
From: []Person{{Address: "a@example.com"}}}
|
|
if err := n.Add(noID1); err != nil {
|
|
t.Fatalf("Add noID1: %v", err)
|
|
}
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if err := n.Add(noID2); err != nil {
|
|
t.Fatalf("Add noID2: %v", err)
|
|
}
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 3 {
|
|
t.Fatalf("sent %d, want 3: noID1 and noID2 are different mails, their UIDVALIDITY differs: %+v", len(sent), sent)
|
|
}
|
|
}
|
|
|
|
// TestQualifyingRulesStillHold keeps the original qualifying rules proven (an already-read mail, a mail from
|
|
// the account's own address, and a mail outside the inbox are never shown), now that buffering is per window
|
|
// rather than per quiet period.
|
|
func TestQualifyingRulesStillHold(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 := &Notifier{
|
|
Now: clock.Now,
|
|
Send: func(args map[string]any) error { sent = append(sent, args); return nil },
|
|
Accounts: func() (map[string]string, error) { return map[string]string{"work": "me@work.example"}, nil },
|
|
Store: store,
|
|
EmitShown: func(string, string) error { return nil },
|
|
Logf: t.Logf,
|
|
}
|
|
already := EventBody{Account: "work", Folder: "INBOX", UID: 2, MessageID: "m2", Subject: "Old",
|
|
From: []Person{{Address: "bob@example.com"}}, Flags: []string{"\\Seen"}}
|
|
own := EventBody{Account: "work", Folder: "INBOX", UID: 3, MessageID: "m3", Subject: "Sent",
|
|
From: []Person{{Address: "me@work.example"}}}
|
|
elsewhere := EventBody{Account: "work", Folder: "Archive", UID: 4, MessageID: "m4", Subject: "Filed",
|
|
From: []Person{{Address: "carol@example.com"}}}
|
|
for _, e := range []EventBody{already, own, elsewhere} {
|
|
if err := n.Add(e); err != nil {
|
|
t.Fatalf("Add(%+v): %v", e, err)
|
|
}
|
|
}
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 0 {
|
|
t.Fatalf("sent %+v, want nothing: an already-read mail, the account's own mail and mail outside the inbox never qualify", sent)
|
|
}
|
|
}
|
|
|
|
// TestABufferIsNeverReplacedWhileItHoldsAnUnsentMail is review round 2 of issue 800's first test (BLOCKING):
|
|
// a send that fails keeps its mail pending; once the cooldown has passed, but before the next Flush retries
|
|
// it, a new qualifying mail arrives. Neither mail is lost: both are shown once the retry succeeds.
|
|
func TestABufferIsNeverReplacedWhileItHoldsAnUnsentMail(t *testing.T) {
|
|
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
|
store := newFakeStore()
|
|
var sent []map[string]any
|
|
failSend := true
|
|
n := &Notifier{
|
|
Now: clock.Now,
|
|
Send: func(args map[string]any) error {
|
|
if failSend {
|
|
return errors.New("node-notifier.send: no answer")
|
|
}
|
|
sent = append(sent, args)
|
|
return nil
|
|
},
|
|
Accounts: func() (map[string]string, error) { return map[string]string{}, nil },
|
|
Store: store,
|
|
EmitShown: func(string, string) error { return nil },
|
|
Logf: t.Logf,
|
|
}
|
|
|
|
account := "home"
|
|
if err := n.Add(mail(account, 1, "m1", "Mail 1", "Sender 1")); err != nil {
|
|
t.Fatalf("Add mail 1: %v", err)
|
|
}
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 0 {
|
|
t.Fatalf("sent %d notice(s), want 0: the send failed", len(sent))
|
|
}
|
|
|
|
// The cooldown passes, but no retry has happened yet (Flush is not called again before the new mail
|
|
// arrives): a buffer this old must not be thrown away for a fresh one.
|
|
clock.Advance(RetryBackoff)
|
|
if err := n.Add(mail(account, 2, "m2", "Mail 2", "Sender 2")); err != nil {
|
|
t.Fatalf("Add mail 2: %v", err)
|
|
}
|
|
|
|
failSend = false
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 2 {
|
|
t.Fatalf("sent %d notice(s), want 2 (mail 1's retry and mail 2, neither lost): %+v", len(sent), sent)
|
|
}
|
|
for i, want := range []string{"Mail 1", "Mail 2"} {
|
|
if sent[i]["summary"] != want {
|
|
t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota is review round 3 of issue 800's first test,
|
|
// replacing round 2's (which contradicted round 3's rule): N, for a mail arriving at t, is every qualifying
|
|
// mail of the account — shown or folded alike — that arrived in the 60 seconds up to and including t. Mails at
|
|
// 0, 50, 55, 70, 75 and 80 seconds: at 70s the preceding 60 seconds (from 10s) hold 50, 55 and 70 themselves
|
|
// (0s has aged out), N=3, so 70 is still shown at once, not folded; at 75s the preceding 60 seconds (from 15s)
|
|
// still hold 50 too, so it, 55, 70 and 75 themselves make N=4, and 75 folds; 80 folds with it (its own
|
|
// preceding 60 seconds hold 50, 55, 70, 75 and 80, N=5). The fold is due 60 seconds after its own first mail
|
|
// (75s), at 135s, as one notice naming only what it holds (2) and the latest sender (80s's).
|
|
func TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota(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"
|
|
deltas := []time.Duration{0, 50 * time.Second, 5 * time.Second, 15 * time.Second, 5 * time.Second, 5 * time.Second}
|
|
for i, d := range deltas {
|
|
clock.Advance(d)
|
|
if err := n.Add(mail(account, i+1, fmt.Sprintf("m%d", i+1), fmt.Sprintf("Mail %d", i+1), fmt.Sprintf("Sender %d", i+1))); err != nil {
|
|
t.Fatalf("Add mail %d: %v", i+1, err)
|
|
}
|
|
if err := n.Flush(); err != nil {
|
|
t.Fatalf("Flush at mail %d: %v", i+1, err)
|
|
}
|
|
}
|
|
if len(sent) != 4 {
|
|
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", "Mail 4"} {
|
|
if sent[i]["summary"] != want {
|
|
t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want)
|
|
}
|
|
}
|
|
|
|
// The fold's own window — a minute from its first mail, 75s — ends at 135s.
|
|
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 {
|
|
t.Fatalf("Flush: %v", err)
|
|
}
|
|
if len(sent) != 4 {
|
|
t.Fatalf("sent %d notice(s), want 4 (one fold notice carrying the fourth and fifth): %+v", len(sent), sent)
|
|
}
|
|
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 only what it holds (2), naming Sender 5 (the latest)", sent[3])
|
|
}
|
|
}
|