185 lines
6.6 KiB
Go
185 lines
6.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|