package dashboard import ( "strings" "testing" "time" _ "time/tzdata" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" ) // sydney is the family timezone used in the tests. func sydney(t *testing.T) *time.Location { t.Helper() loc, err := time.LoadLocation("Australia/Sydney") if err != nil { t.Fatalf("load location: %v", err) } return loc } func due(t *testing.T, loc *time.Location, s string) *time.Time { t.Helper() v, err := time.ParseInLocation("2006-01-02 15:04", s, loc) if err != nil { t.Fatalf("parse %q: %v", s, err) } return &v } // TestClassifyBuckets proves FR-6/FR-7: overdue first, then due today, due this // week, no due date and messages, and that submitted work is not "due". func TestClassifyBuckets(t *testing.T) { loc := sydney(t) now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc) items := []store.Item{ {Type: "task", Title: "Old essay", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"}, {Type: "task", Title: "Past pending", DueAt: due(t, loc, "2026-10-14 09:00"), Body: "Submitted: Pending"}, {Type: "task", Title: "Today task", DueAt: due(t, loc, "2026-10-15 15:00"), Body: "Status: Pending"}, {Type: "event", Title: "Today lesson", DueAt: due(t, loc, "2026-10-15 10:00")}, {Type: "task", Title: "Friday task", DueAt: due(t, loc, "2026-10-17 09:00"), Body: "Status: Pending"}, {Type: "task", Title: "Next month", DueAt: due(t, loc, "2026-11-02 09:00"), Body: "Status: Pending"}, {Type: "task", Title: "No date", Body: "Status: Pending"}, {Type: "task", Title: "Already done", DueAt: due(t, loc, "2026-10-01 09:00"), Body: "Status: On Time"}, {Type: "task", Title: "Late but in", DueAt: due(t, loc, "2026-10-02 09:00"), Body: "Status: Received Late"}, {Type: "event", Title: "Past lesson", DueAt: due(t, loc, "2026-10-12 09:00")}, {Type: "message", Title: "Notice", DueAt: due(t, loc, "2026-10-14 07:00")}, } b := Classify(items, now, loc) want := map[string][]string{ "overdue": {"Old essay", "Past pending"}, "today": {"Today lesson", "Today task"}, "week": {"Friday task"}, "later": {"Next month"}, "nodue": {"No date"}, "done": {"Already done", "Late but in"}, "messages": {"Notice"}, } got := map[string][]string{ "overdue": titles(b.Overdue), "today": titles(b.DueToday), "week": titles(b.DueWeek), "later": titles(b.Later), "nodue": titles(b.NoDue), "done": titles(b.Done), "messages": titles(b.Messages), } for k, w := range want { if !equal(got[k], w) { t.Errorf("%s = %v, want %v", k, got[k], w) } } // A past event is history, not overdue work: it must not appear anywhere. for k, rows := range got { for _, title := range rows { if title == "Past lesson" { t.Errorf("past event appeared in %s", k) } } } if b.Actionable() != 6 { t.Errorf("Actionable = %d, want 6", b.Actionable()) } if b.Empty() { t.Error("Empty = true with items present") } } // TestOverdueFromDueDateOnly proves a task with no status line still lands in // overdue when it is past due. func TestOverdueFromDueDateOnly(t *testing.T) { loc := sydney(t) now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc) b := Classify([]store.Item{ {Type: "task", Title: "No status", DueAt: due(t, loc, "2026-10-01 09:00")}, }, now, loc) if len(b.Overdue) != 1 { t.Fatalf("overdue = %v, want the no-status past task", titles(b.Overdue)) } } func TestStatusParsing(t *testing.T) { cases := map[string]string{ "Status: Overdue\nClass: X": "overdue", "Class: X\nSubmitted: On Time": "on time", "Submitted: Received Late\nx": "received late", "Status: Pending": "pending", "no status here": "", "Submitted: Overdue.": "overdue", } for body, want := range cases { if got := Status(body); got != want { t.Errorf("Status(%q) = %q, want %q", body, got, want) } } } // TestRenderOverdueFirst proves the brief lists overdue before anything else and // stays list-shaped (rule 6). func TestRenderOverdueFirst(t *testing.T) { loc := sydney(t) now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc) b := Classify([]store.Item{ {Type: "task", Subject: "11EENG", Title: "Essay", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"}, {Type: "task", Subject: "11MGMA", Title: "Chapter 4", DueAt: due(t, loc, "2026-10-15 15:00"), Body: "Status: Pending"}, {Type: "message", Title: "Sports day"}, }, now, loc) text := Render("Harry", b, loc) if !strings.Contains(text, "Harry — 1 overdue · 1 due today") { t.Errorf("headline missing: %q", text) } oi := strings.Index(text, "OVERDUE") ti := strings.Index(text, "DUE TODAY") if oi < 0 || ti < 0 || oi > ti { t.Errorf("overdue must come first:\n%s", text) } if !strings.Contains(text, "- 11EENG: Essay (due Sat 10 Oct 09:00) [overdue]") { t.Errorf("overdue line wrong:\n%s", text) } if !strings.Contains(text, "MESSAGES\n- Sports day") { t.Errorf("messages missing:\n%s", text) } } func titles(items []store.Item) []string { out := make([]string, 0, len(items)) for _, it := range items { out = append(out, it.Title) } return out } func equal(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // TestRenderCombinedBothBoys proves FR-14: the combined brief for Jo and Sam // carries both boys, overdue first, and every line names the child. func TestRenderCombinedBothBoys(t *testing.T) { loc := sydney(t) now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc) harry := Classify([]store.Item{ {Type: "task", Title: "Essay", Subject: "English", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"}, {Type: "task", Title: "Worksheet", Subject: "Maths", DueAt: due(t, loc, "2026-10-15 15:00")}, {Type: "message", Title: "Sports day", DueAt: due(t, loc, "2026-10-14 07:00")}, }, now, loc) finn := Classify([]store.Item{ {Type: "task", Title: "Lab report", Subject: "Science", DueAt: due(t, loc, "2026-10-09 09:00"), Body: "Status: Overdue"}, }, now, loc) text := RenderCombined([]Combined{ {Child: "Harry", Buckets: harry}, {Child: "Finn", Buckets: finn}, }, loc) if !strings.HasPrefix(text, "Harry and Finn — 2 overdue · 1 due today · 0 this week · 1 message(s).\n") { t.Errorf("headline = %q", firstLineOf(text)) } overdue := strings.Index(text, "OVERDUE") today := strings.Index(text, "DUE TODAY") if overdue < 0 || today < 0 || overdue > today { t.Errorf("overdue must come first:\n%s", text) } for _, want := range []string{ "- Harry: English: Essay (due Sat 10 Oct 09:00) [overdue]", "- Finn: Science: Lab report (due Fri 09 Oct 09:00) [overdue]", "- Harry: Maths: Worksheet (due Thu 15 Oct 15:00)", "MESSAGES", "- Harry: Sports day (Wed 14 Oct 07:00)", } { if !strings.Contains(text, want) { t.Errorf("combined brief is missing %q:\n%s", want, text) } } } // TestRenderCombinedSingleChild proves one child's brief still renders a // sensible combined message. func TestRenderCombinedSingleChild(t *testing.T) { loc := sydney(t) text := RenderCombined([]Combined{{Child: "Harry"}}, loc) if !strings.HasPrefix(text, "Harry — 0 overdue") { t.Errorf("text = %q", text) } if strings.Contains(text, "OVERDUE") { t.Errorf("an empty brief must not show an OVERDUE heading: %q", text) } } func firstLineOf(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s } // TestRenderWithLimitsKeepsTheBriefBrief proves the long tail is summarised, so // the morning message stays a brief (AGENTS.md §10 rule 6) without hiding work. func TestRenderWithLimitsKeepsTheBriefBrief(t *testing.T) { loc := sydney(t) now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc) items := make([]store.Item, 0, 8) for _, title := range []string{"A", "B", "C", "D", "E", "F", "G", "H"} { items = append(items, store.Item{Type: "task", Title: "Pending " + title, Body: "Status: Pending"}) } items = append(items, store.Item{ Type: "task", Title: "Essay", Subject: "English", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue", }) b := Classify(items, now, loc) limited := RenderWithLimits("Harry", b, loc, Limits{NoDue: 3}) if !strings.Contains(limited, "… and 5 more with no due date") { t.Errorf("the long tail is not summarised:\n%s", limited) } if strings.Contains(limited, "Pending D") { t.Errorf("the cap did not apply:\n%s", limited) } // Overdue work is never hidden by a cap. if !strings.Contains(limited, "English: Essay") { t.Errorf("overdue work went missing:\n%s", limited) } if full := Render("Harry", b, loc); !strings.Contains(full, "Pending H") { t.Errorf("the full render must keep every item:\n%s", full) } }