diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/signal.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/signal.go new file mode 100644 index 00000000..5318fadc --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/signal.go @@ -0,0 +1,96 @@ +package main + +// The mail signal's own words (novox/hq ADR 0461 §3, §4; issue 1429): a pure function, no store, no bus. +// Sending it — messenger_check, the safe form, the cap, marking mails told — is 1522. + +import ( + "fmt" + "sort" + "time" +) + +// SignalMail is one mail the mail signal may name (ADR 0461 §3): just enough of its entry, never a body. +// Date is the mail's own date, the "oldest first" tiebreak among mails with no due date (1429/AC1); it is +// not itself said. +type SignalMail struct { + SenderName string // "" answers "a sender without a name" (1429/AC2), never the address + Summary string + Date time.Time + DueDate time.Time // zero means none +} + +// SignalWords is what one mail signal says (ADR 0461 §1): a headline, and at most two lines. +type SignalWords struct { + Headline string + Lines []string +} + +// orderSignalMails is 1429/AC1, AC4: nearest due date first (one without a due date after all those with +// one), then oldest first among mails sharing that place — "mails carried from earlier (untold)" are simply +// part of the same list handed in, ordered exactly as any other mail that matters is. +func orderSignalMails(mails []SignalMail) []SignalMail { + sorted := append([]SignalMail(nil), mails...) + sort.SliceStable(sorted, func(i, j int) bool { + a, b := sorted[i], sorted[j] + aNone, bNone := a.DueDate.IsZero(), b.DueDate.IsZero() + if aNone != bNone { + return bNone // a has a due date, b does not: a first + } + if !aNone { + return a.DueDate.Before(b.DueDate) + } + return a.Date.Before(b.Date) // oldest first, among mails with no due date + }) + return sorted +} + +// headline is "1 mail matters" or "N mails matter" (1429/AC1, AC3: the same headline whether the words are +// said in full or the safe form). +func headline(n int) string { + if n == 1 { + return "1 mail matters" + } + return fmt.Sprintf("%d mails matter", n) +} + +// mailLine is one mail's own line (1429/AC1): " — — due ", the due part left +// out entirely when there is none; a sender with no display name is "a sender without a name" (AC2), never +// its address. +func mailLine(m SignalMail) string { + sender := m.SenderName + if sender == "" { + sender = "a sender without a name" + } + line := sender + " — " + m.Summary + if !m.DueDate.IsZero() { + line += " — due " + m.DueDate.Format("2006-01-02") + } + return line +} + +// BuildSignal is the mail signal's words in full (1429/AC1, AC2, AC4): the headline, the nearest-due mail's +// own line, and — with more than one — the next one's, ending ", and N-2 more on the mail page" once there +// are more than two in all. +func BuildSignal(mails []SignalMail) SignalWords { + ordered := orderSignalMails(mails) + n := len(ordered) + w := SignalWords{Headline: headline(n)} + if n == 0 { + return w + } + w.Lines = append(w.Lines, mailLine(ordered[0])) + if n >= 2 { + second := mailLine(ordered[1]) + if n > 2 { + second += fmt.Sprintf(", and %d more on the mail page", n-2) + } + w.Lines = append(w.Lines, second) + } + return w +} + +// SafeSignal is 1429/AC3: the safe form a refused set of words falls back to — the same headline, one line +// naming nothing of any mail. +func SafeSignal(n int) SignalWords { + return SignalWords{Headline: headline(n), Lines: []string{"open the mail page"}} +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/signal_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/signal_test.go new file mode 100644 index 00000000..c96490a3 --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/signal_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func d(day int) time.Time { return time.Date(2026, 11, day, 0, 0, 0, 0, time.UTC) } + +// 1429/AC1: one mail gives headline "1 mail matters" and one line; the first line names the mail with the +// nearest due date as " — — due ", no due part without one. +func TestBuildSignalOneMail(t *testing.T) { + w := BuildSignal([]SignalMail{{SenderName: "Alice", Summary: "pay the invoice", DueDate: d(1)}}) + if w.Headline != "1 mail matters" { + t.Fatalf("headline = %q", w.Headline) + } + if len(w.Lines) != 1 || w.Lines[0] != "Alice — pay the invoice — due 2026-11-01" { + t.Fatalf("lines = %v", w.Lines) + } +} + +// 1429/AC1: a mail with no due date carries no due part. +func TestBuildSignalNoDueDate(t *testing.T) { + w := BuildSignal([]SignalMail{{SenderName: "Alice", Summary: "fyi"}}) + if w.Lines[0] != "Alice — fyi" { + t.Fatalf("lines[0] = %q, want no due part", w.Lines[0]) + } +} + +// 1429/AC1: N mails give "N mails matter"; the first line names the nearest due date, the second the next +// one, ending with the "and N-2 more" tail once there are more than two. +func TestBuildSignalSeveralMails(t *testing.T) { + mails := []SignalMail{ + {SenderName: "Carol", Summary: "c", Date: d(1)}, // no due date, oldest of the no-due-date ones + {SenderName: "Bob", Summary: "b", DueDate: d(10)}, + {SenderName: "Alice", Summary: "a", DueDate: d(5)}, // nearest due date: first + {SenderName: "Dave", Summary: "d", Date: d(2)}, + } + w := BuildSignal(mails) + if w.Headline != "4 mails matter" { + t.Fatalf("headline = %q", w.Headline) + } + if len(w.Lines) != 2 { + t.Fatalf("lines = %v, want 2", w.Lines) + } + if !strings.HasPrefix(w.Lines[0], "Alice —") { + t.Fatalf("lines[0] = %q, want Alice first (nearest due date)", w.Lines[0]) + } + if !strings.HasPrefix(w.Lines[1], "Bob —") || !strings.HasSuffix(w.Lines[1], ", and 2 more on the mail page") { + t.Fatalf("lines[1] = %q, want Bob, and the N-2 tail", w.Lines[1]) + } +} + +// 1429/AC1: with exactly two mails, the second line carries no "more" tail. +func TestBuildSignalTwoMailsNoTail(t *testing.T) { + w := BuildSignal([]SignalMail{ + {SenderName: "Alice", Summary: "a", DueDate: d(1)}, + {SenderName: "Bob", Summary: "b", DueDate: d(2)}, + }) + if strings.Contains(w.Lines[1], "more") { + t.Fatalf("lines[1] = %q, want no 'more' tail with only two mails", w.Lines[1]) + } +} + +// 1429/AC2: a sender without a display name is "a sender without a name", never its address. +func TestBuildSignalSenderWithNoName(t *testing.T) { + w := BuildSignal([]SignalMail{{SenderName: "", Summary: "x"}}) + if !strings.HasPrefix(w.Lines[0], "a sender without a name —") { + t.Fatalf("lines[0] = %q, want it to start with 'a sender without a name'", w.Lines[0]) + } +} + +// 1429/AC3: the safe form keeps the same headline and says only "open the mail page". +func TestSafeSignal(t *testing.T) { + w := SafeSignal(3) + if w.Headline != "3 mails matter" { + t.Fatalf("headline = %q", w.Headline) + } + if len(w.Lines) != 1 || w.Lines[0] != "open the mail page" { + t.Fatalf("lines = %v", w.Lines) + } + one := SafeSignal(1) + if one.Headline != "1 mail matters" { + t.Fatalf("one mail's headline = %q", one.Headline) + } +} + +// 1429/AC4: mails carried over from earlier (untold) are counted in N and ordered with the new ones — this +// function takes no notice of which are new and which are carried, only their own due date and date. +func TestBuildSignalCountsCarriedMailsWithTheNewOnes(t *testing.T) { + carried := SignalMail{SenderName: "Old", Summary: "carried", DueDate: d(1)} // told=false from an earlier batch + newMail := SignalMail{SenderName: "New", Summary: "fresh", DueDate: d(2)} + w := BuildSignal([]SignalMail{carried, newMail}) + if w.Headline != "2 mails matter" { + t.Fatalf("headline = %q, want the carried mail counted too", w.Headline) + } + if !strings.HasPrefix(w.Lines[0], "Old —") { + t.Fatalf("lines[0] = %q, want the carried mail (nearer due date) first", w.Lines[0]) + } +}