package main import ( "encoding/json" "strings" "testing" "time" ) func bigString(n int, ch byte) string { b := make([]byte, n) for i := range b { b[i] = ch } return string(b) } func manyMails(n int, textSize int) []QuestionMail { out := make([]QuestionMail, n) for i := range out { out[i] = QuestionMail{ ID: int64(i + 1), Account: "bob", Folder: "INBOX", Date: time.Date(2026, 10, 20, 10, 0, 0, 0, time.UTC), From: []Person{{Name: "Sender", Address: "sender@example.org"}}, Subject: bigString(2000, 's'), Text: bigString(textSize, 't'), } } return out } func manyGroups(n int) []GroupSummary { out := make([]GroupSummary, n) for i := range out { out[i] = GroupSummary{Label: bigString(200, 'g'), Count: n - i} } return out } func manyLessons(n int) []Lesson { out := make([]Lesson, n) base := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) for i := range out { out[i] = Lesson{ ID: int64(i + 1), SenderAddress: bigString(100, 'l'), Subject: bigString(300, 'x'), Change: bigString(200, 'c'), Made: base.Add(time.Duration(i) * time.Hour), } } return out } // 1427/AC1: every part of the question is bounded, with each part given deliberately overfull input. func TestBuildQuestionEnforcesEveryBudget(t *testing.T) { q := BuildQuestion(manyMails(25, 10000), manyGroups(100), manyLessons(100)) if len(q.Text) > QuestionBudget { t.Fatalf("question is %d bytes, want at most %d (QuestionBudget)", len(q.Text), QuestionBudget) } if len(Framing) > FramingBudget { t.Fatalf("Framing is %d bytes, want at most %d (FramingBudget)", len(Framing), FramingBudget) } if n := strings.Count(q.Text, "\nmail "); n > MaxMailsPerBatch { t.Fatalf("question holds %d mails, want at most %d (MaxMailsPerBatch)", n, MaxMailsPerBatch) } if g := groupsBlock(manyGroups(100)); len(g) > GroupsBudget { t.Fatalf("groups block is %d bytes, want at most %d (GroupsBudget)", len(g), GroupsBudget) } if l := lessonsBlock(manyLessons(100), manyMails(1, 10)); len(l) > LessonsBudget { t.Fatalf("lessons block is %d bytes, want at most %d (LessonsBudget)", len(l), LessonsBudget) } header := mailHeaderBlock(manyMails(1, 0)[0]) if len(header) > MailHeadersBudget { t.Fatalf("one mail's headers are %d bytes, want at most %d (MailHeadersBudget)", len(header), MailHeadersBudget) } whole := mailBlock(manyMails(1, 10000)[0]) if len(whole) > MailBudget { t.Fatalf("one whole mail is %d bytes, want at most %d (MailBudget)", len(whole), MailBudget) } } // 1427/AC1: groups come largest first, and a group that would overflow the budget is left out whole, not // cut mid-line. func TestGroupsBlockIsLargestFirstAndNeverCutMidLine(t *testing.T) { groups := []GroupSummary{{Label: "small", Count: 1}, {Label: "big", Count: 100}, {Label: "medium", Count: 10}} block := groupsBlock(groups) if i, j := strings.Index(block, "big"), strings.Index(block, "medium"); i == -1 || j == -1 || i > j { t.Fatalf("groups block %q is not largest-first", block) } for _, line := range strings.Split(block, "\n") { if line != "" && !strings.HasPrefix(line, "group ") { t.Fatalf("a line was cut mid-item: %q", line) } } } // 1427/AC1: the lessons budget keeps at most 40, and within that, a lesson matching the batch's own sender // comes before one that does not, even when the other is newer (also exercised by 1428/AC4 directly on // selectLessons). func TestLessonsBlockKeepsAtMostFortyMatchingFirst(t *testing.T) { mails := []QuestionMail{{ID: 1, From: []Person{{Address: "match@example.org"}}}} var lessons []Lesson base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for i := 0; i < 50; i++ { lessons = append(lessons, Lesson{ID: int64(i + 1), SenderAddress: "other@example.org", Made: base.Add(time.Duration(i) * time.Hour)}) } lessons = append(lessons, Lesson{ID: 999, SenderAddress: "match@example.org", Made: base}) selected := selectLessons(lessons, mails) if len(selected) != MaxLessonsInBatch { t.Fatalf("selected %d lessons, want %d (MaxLessonsInBatch)", len(selected), MaxLessonsInBatch) } if selected[0].ID != 999 { t.Fatalf("selected[0] = %+v, want the matching sender's lesson first, even though it is the oldest", selected[0]) } } // 1427/AC2: a mail in a Junk or Spam folder, or carrying \Junk, says so in its header line. func TestIsJunkAndItsLineInTheHeaderBlock(t *testing.T) { cases := []struct { folder string flags []string want bool }{ {"Junk", nil, true}, {"JUNK", nil, true}, {"spam", nil, true}, {"Spam", nil, true}, {"INBOX", []string{`\Junk`}, true}, {"INBOX", []string{`\Seen`}, false}, {"INBOX", nil, false}, } for _, c := range cases { if got := IsJunk(c.folder, c.flags); got != c.want { t.Errorf("IsJunk(%q, %v) = %v, want %v", c.folder, c.flags, got, c.want) } } m := QuestionMail{ID: 1, Account: "bob", Folder: "Junk", Date: time.Now(), Subject: "x"} if h := mailHeaderBlock(m); !strings.Contains(h, "filed this as junk") { t.Fatalf("header block for a Junk-folder mail: %q, want it to say the provider filed it as junk", h) } } func answerJSON(t *testing.T, mails ...map[string]any) []byte { t.Helper() raw, err := json.Marshal(map[string]any{"mails": mails}) if err != nil { t.Fatal(err) } return raw } // 1427/AC3: a well-shaped answer parses, naming id, group, summary, mark, why and due date. func TestParseAnswerAccepts(t *testing.T) { raw := answerJSON(t, map[string]any{ "id": 1, "group": "newsletters", "summary": "a newsletter", "mark": "neither", "why": "routine", "due-date": "2026-11-01", }) a, err := ParseAnswer(raw, []int64{1}) if err != nil { t.Fatal(err) } j, ok := a.Mails[1] if !ok || j.Group != "newsletters" || j.Mark != MarkNeither || j.DueDate != "2026-11-01" { t.Fatalf("parsed = %+v", j) } } // 1427/AC3: an answer missing a mail, naming an unknown id or mark, or a due date not a date is out of shape. func TestParseAnswerRefusesOutOfShape(t *testing.T) { cases := []struct { name string raw []byte ids []int64 }{ {"missing a mail", answerJSON(t, map[string]any{"id": 1, "mark": "neither"}), []int64{1, 2}}, {"unknown id", answerJSON(t, map[string]any{"id": 99, "mark": "neither"}), []int64{1}}, {"unknown mark", answerJSON(t, map[string]any{"id": 1, "mark": "urgent"}), []int64{1}}, {"due date not a date", answerJSON(t, map[string]any{"id": 1, "mark": "neither", "due-date": "soon"}), []int64{1}}, {"not json at all", []byte(`not json`), []int64{1}}, } for _, c := range cases { if _, err := ParseAnswer(c.raw, c.ids); err == nil { t.Errorf("%s: no error, want out of shape", c.name) } else if !strings.Contains(err.Error(), "out of shape") { t.Errorf("%s: %v, want it to say out of shape", c.name, err) } } }