Files
compass_teams_nhs/internal/dashboard/dashboard_test.go
Sam Rolfe 047af0f31d Phase 6b/7b: per-child decoded periods, separate brief blocks, idempotent calendar (AUD-119)
- Parse each child's own "Today" day widget for the timetable (FR-16); drop the
  school-wide week calendar's lesson grid and clean the leaked routine rows.
- Add the editable codes map (FR-17): subjects, rooms, teachers and period
  labels, seeded, an unknown code falls back to the raw code; `school codes` CLI.
- Brief: Finn's block first, then Harry's, four sections always present (FR-9);
  periods decoded into plain English; the undated tail is one counted line (FR-18).
- Calendar: write periods + work due + work overdue + major events, per child,
  on every pass. Identity is the stable per-item key, so a re-run never re-inserts
  (FR-8). morning-brief.sh now runs scan -> calendar push -> notify.
- Day-widget labels are 12-hour: 1-6 read as PM, fixing afternoon periods.

Proven live on .13: image 0.6.2-phase6b7b; both children's briefs decoded and
separate; 35 events in calendar.school; runs 2 and 3 of the scan added nothing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-16 10:25:55 +11:00

347 lines
12 KiB
Go

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)
}
}
// TestRenderBriefShape proves the required Phase 7 shape (FR-9, FR-17, FR-18):
// the four headings are always present, empty sections say "none", periods are
// decoded, and the undated tail is one counted line.
func TestRenderBriefShape(t *testing.T) {
loc := sydney(t)
now := time.Date(2026, 10, 16, 6, 0, 0, 0, loc)
items := []store.Item{
{Type: "event", Title: "1 - 7SCF - H14LAB - KNI", DueAt: due(t, loc, "2026-10-16 08:55")},
{Type: "event", Title: "End of Year Concert - HALL", DueAt: due(t, loc, "2026-10-16 18:00")},
{Type: "task", Subject: "Science", Title: "Lab report", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
{Type: "task", Subject: "Maths", Title: "Exercise 4", DueAt: due(t, loc, "2026-10-20 09:00"), Body: "Status: Pending"},
}
for i := 0; i < 7; i++ {
items = append(items, store.Item{Type: "task", Subject: "English", Title: "Task", Body: "Status: Pending"})
}
b := Classify(items, now, loc)
text := RenderBrief("Finn", b, func(s string) string {
if s == "1 - 7SCF - H14LAB - KNI" {
return "Period 1 — Year 7 Science — Room H14LAB — Mr KNI"
}
return s
}, loc)
for _, want := range []string{
"=== FINN ===",
"PERIODS TODAY",
"Period 1 — Year 7 Science — Room H14LAB — Mr KNI (08:55)",
"EVENTS TODAY",
"End of Year Concert - HALL (18:00)",
"OVERDUE",
"Science: Lab report",
"DUE IN FUTURE",
"Maths: Exercise 4",
"7 task(s) with no due date (see the dashboard)",
} {
if !strings.Contains(text, want) {
t.Errorf("brief is missing %q:\n%s", want, text)
}
}
// The undated dump is not listed item by item (FR-18).
if strings.Count(text, "English: Task") != 0 {
t.Errorf("the undated tail was dumped:\n%s", text)
}
}
// TestRenderBriefEmptySectionsSayNone proves an empty section is printed, never
// omitted silently.
func TestRenderBriefEmptySectionsSayNone(t *testing.T) {
loc := sydney(t)
now := time.Date(2026, 10, 16, 6, 0, 0, 0, loc)
text := RenderBrief("Harry", Classify(nil, now, loc), nil, loc)
if strings.Count(text, "none") != 4 {
t.Errorf("want the four headings each to say none:\n%s", text)
}
}
// TestRenderCombinedBriefKeepsChildrenSeparate proves FR-9: Finn's block is
// first, Harry's second, and the two are never interleaved.
func TestRenderCombinedBriefKeepsChildrenSeparate(t *testing.T) {
loc := sydney(t)
now := time.Date(2026, 10, 16, 6, 0, 0, 0, loc)
finn := Classify([]store.Item{
{Type: "task", Subject: "Science", Title: "Lab report", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
}, now, loc)
harry := Classify([]store.Item{
{Type: "task", Subject: "English", Title: "Essay", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
}, now, loc)
text := RenderCombinedBrief([]CombinedBrief{{Child: "Finn", Buckets: finn}, {Child: "Harry", Buckets: harry}}, nil, loc)
i, j := strings.Index(text, "=== FINN ==="), strings.Index(text, "=== HARRY ===")
if i < 0 || j < 0 || i > j {
t.Fatalf("blocks are not Finn then Harry:\n%s", text)
}
finnBlock, harryBlock := text[i:j], text[j:]
if !strings.Contains(finnBlock, "Science: Lab report") || strings.Contains(finnBlock, "English: Essay") {
t.Errorf("Finn's block is wrong or interleaved:\n%s", finnBlock)
}
if !strings.Contains(harryBlock, "English: Essay") || strings.Contains(harryBlock, "Science: Lab report") {
t.Errorf("Harry's block is wrong or interleaved:\n%s", harryBlock)
}
}