package parse import ( "context" "encoding/json" "strings" "testing" "time" // Embed the zone database so the tests can use Australia/Melbourne. _ "time/tzdata" ) // melbourne is the family timezone used in the tests. func melbourne(t *testing.T) *time.Location { t.Helper() loc, err := time.LoadLocation("Australia/Melbourne") if err != nil { t.Fatalf("load location: %v", err) } return loc } // tasksFixture mirrors the malformed ExtJS grid: the group header is not // closed before the group's inner table, exactly as Compass emits it. const tasksFixture = `
Assessment Essay draft
Class Task | Report: Yes
Due date: 19/10/26 04:00 PM
Submitted: Pending
-
Subject-wide Task
Due date: 09/10/26 04:00 PM
Submitted: Overdue
-
` func TestParseLearningTasks(t *testing.T) { loc := melbourne(t) items, err := parseLearningTasks("Harry", Page{ Slug: PageLearningTasks, URL: "https://nhs-vic.compass.education/Records/UserNew.aspx#learningTasks", HTML: []byte(tasksFixture), }, loc) if err != nil { t.Fatalf("parseLearningTasks: %v", err) } if len(items) != 2 { t.Fatalf("got %d items, want 2", len(items)) } first := items[0] if first.Type != TypeTask { t.Errorf("type = %q, want %q", first.Type, TypeTask) } if first.Subject != "11EENG 11 ENGLISH" { t.Errorf("subject = %q", first.Subject) } if first.Title != "Essay draft" { t.Errorf("title = %q", first.Title) } if !strings.Contains(first.Body, "Category: Assessment") || !strings.Contains(first.Body, "Task: Class Task") { t.Errorf("body = %q", first.Body) } if first.DueAt == nil || first.DueAt.Format("2006-01-02 15:04") != "2026-10-19 16:00" { t.Errorf("due = %v", first.DueAt) } if !strings.Contains(first.URL, "userId=999") || !strings.HasSuffix(first.URL, "#learningTasks") { t.Errorf("url = %q", first.URL) } if first.SourcePage != PageLearningTasks { t.Errorf("source page = %q", first.SourcePage) } if first.ExternalRef == "" { t.Error("empty external ref") } second := items[1] if second.DueAt == nil || second.DueAt.Format("2006-01-02") != "2026-10-09" { t.Errorf("second due = %v", second.DueAt) } if second.ExternalRef == first.ExternalRef { t.Error("two tasks share an external ref") } // Re-parsing must produce the same references, so the store de-duplicates. again, err := parseLearningTasks("Harry", Page{Slug: PageLearningTasks, HTML: []byte(tasksFixture)}, loc) if err != nil { t.Fatalf("re-parse: %v", err) } if again[0].ExternalRef != first.ExternalRef { t.Errorf("external ref not stable: %q vs %q", again[0].ExternalRef, first.ExternalRef) } } const newsFixture = `

News

T Smith 1 day ago Health Information

Dear families, there is a notice.

` func TestParseNews(t *testing.T) { loc := melbourne(t) items, err := parseNews("Harry", Page{Slug: PageHome, URL: "https://nhs-vic.compass.education/", HTML: []byte(newsFixture)}, loc) if err != nil { t.Fatalf("parseNews: %v", err) } if len(items) != 1 { t.Fatalf("got %d items, want 1", len(items)) } it := items[0] if it.Type != TypeMessage { t.Errorf("type = %q", it.Type) } if it.Title != "Health Information" { t.Errorf("title = %q", it.Title) } if it.Teacher != "T Smith" { t.Errorf("author = %q", it.Teacher) } if !strings.Contains(it.Body, "Dear families") { t.Errorf("body = %q", it.Body) } if it.DueAt == nil || it.DueAt.Format("2006-01-02 15:04") != "2026-10-14 09:00" { t.Errorf("posted = %v", it.DueAt) } } const calendarFixture = `
6:00: End of Year Concert - HALL - KSH
2:06: 5 - 11HLEGB5 - D8 - LHT
` func TestParseCalendar(t *testing.T) { loc := melbourne(t) items, err := parseCalendar("Harry", Page{Slug: PageCalendar, URL: "https://nhs-vic.compass.education/Organise/Calendar/", HTML: []byte(calendarFixture)}, loc) if err != nil { t.Fatalf("parseCalendar: %v", err) } if len(items) != 2 { t.Fatalf("got %d items, want 2", len(items)) } if items[0].Type != TypeEvent { t.Errorf("type = %q", items[0].Type) } if got := items[0].DueAt.Format("2006-01-02 15:04"); got != "2026-10-15 18:00" { t.Errorf("event 1 time = %s", got) } if items[0].Title != "End of Year Concert - HALL - KSH" { t.Errorf("event 1 title = %q", items[0].Title) } if items[1].Subject != "11HLEGB5" || items[1].Teacher != "LHT" { t.Errorf("event 2 subject/teacher = %q/%q", items[1].Subject, items[1].Teacher) } if got := items[1].DueAt.Format("15:04"); got != "14:06" { t.Errorf("event 2 time = %q", got) } } // TestParseFailsLoudly proves FR-12: a challenge or login page must not be read // as "nothing due". func TestParseFailsLoudly(t *testing.T) { challenge := []byte(`Just a moment...

Just a moment...

`) if _, err := parseNews("Harry", Page{Slug: PageHome, HTML: challenge}, time.UTC); err == nil { t.Error("parseNews accepted a challenge page") } if _, err := parseLearningTasks("Harry", Page{Slug: PageLearningTasks, HTML: challenge}, time.UTC); err == nil { t.Error("parseLearningTasks accepted a page with no grid") } if _, err := parseCalendar("Harry", Page{Slug: PageCalendar, HTML: challenge}, time.UTC); err == nil { t.Error("parseCalendar accepted a page with no grid") } if _, err := Parse("Harry", []Page{{Slug: "unknown", HTML: challenge}}, time.UTC); err == nil { t.Error("Parse accepted a page set with no recognised page") } } func TestParseCompassDue(t *testing.T) { loc := melbourne(t) cases := []struct { in string want string }{ {"19/10/26 04:00 PM", "2026-10-19 16:00"}, {"09/10/26 04:00 PM", "2026-10-09 16:00"}, {"No due date", ""}, {"", ""}, } for _, tc := range cases { got, err := parseCompassDue(tc.in, loc) if err != nil { t.Errorf("parseCompassDue(%q): %v", tc.in, err) continue } if tc.want == "" { if got != nil { t.Errorf("parseCompassDue(%q) = %v, want nil", tc.in, got) } continue } if got == nil || got.Format("2006-01-02 15:04") != tc.want { t.Errorf("parseCompassDue(%q) = %v, want %s", tc.in, got, tc.want) } } if _, err := parseCompassDue("tomorrow", loc); err == nil { t.Error("parseCompassDue accepted an unknown format") } } // stubCompleter returns a fixed reply. type stubCompleter struct { reply string err error calls int } func (s *stubCompleter) Complete(context.Context, string, string) (string, error) { s.calls++ if s.err != nil { return "", s.err } return s.reply, nil } func TestClassifyModel(t *testing.T) { items := []Item{ {Type: TypeTask, Title: "Essay", DueAt: ptr(time.Date(2026, 10, 9, 16, 0, 0, 0, time.UTC))}, {Type: TypeMessage, Title: "Notice"}, } reply, _ := json.Marshal([]map[string]any{ {"i": 0, "importance": "high"}, {"i": 1, "importance": "low"}, }) c := &stubCompleter{reply: "```json\n" + string(reply) + "\n```"} if err := ClassifyModel(context.Background(), c, items, "test-model", time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)); err != nil { t.Fatalf("ClassifyModel: %v", err) } if items[0].Importance != importanceHigh || items[1].Importance != importanceLow { t.Errorf("labels = %q/%q", items[0].Importance, items[1].Importance) } bad := &stubCompleter{reply: `[{"i":0,"importance":"urgent"}]`} if err := ClassifyModel(context.Background(), bad, items[:1], "test-model", time.Now()); err == nil { t.Error("ClassifyModel accepted an invalid label") } } func TestClassifyDeterministic(t *testing.T) { now := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC) overdue := now.Add(-24 * time.Hour) nextWeek := now.Add(48 * time.Hour) items := []Item{ {Type: TypeTask, Title: "overdue", DueAt: &overdue}, {Type: TypeTask, Title: "soon", DueAt: &nextWeek}, {Type: TypeMessage, Title: "plain"}, } ClassifyDeterministic(items, now) if items[0].Importance != importanceHigh || items[1].Importance != importanceHigh { t.Errorf("overdue/soon = %q/%q", items[0].Importance, items[1].Importance) } if items[2].Importance != importanceNormal { t.Errorf("plain = %q", items[2].Importance) } } func ptr(t time.Time) *time.Time { return &t } // TestClassifyDeterministicEvents proves the Phase 6 importance rule: a school // event is important, a routine class period is not. func TestClassifyDeterministicEvents(t *testing.T) { now := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC) at := now.Add(4 * time.Hour) items := []Item{ {Type: TypeEvent, Title: "End of Year Senior Music Concert - HALL - KSH", DueAt: &at}, {Type: TypeEvent, Title: "Year 11 Assembly - HALL - CMA", DueAt: &at}, {Type: TypeEvent, Title: "5 - 11HLEGB5 - D8 - LHT", DueAt: &at}, {Type: TypeEvent, Title: "C - 11TUTB - M6 - ANE", DueAt: &at}, {Type: TypeEvent, Title: "RLE GROUP D - IM1 - RLE", DueAt: &at}, } ClassifyDeterministic(items, now) for i, want := range []string{importanceHigh, importanceHigh, importanceNormal, importanceNormal, importanceNormal} { if items[i].Importance != want { t.Errorf("items[%d] %q = %q, want %q", i, items[i].Title, items[i].Importance, want) } } } // tasksJSONFixture is the shape of GetAllLearningTasksByUserId: a wrapper with // the per-child tasks. Task 1 is overdue and carries its due date at the task // level; task 2 is on time and carries it on the student; task 3 belongs to the // sibling and must be skipped. const tasksJSONFixture = `{"d":{"total":3,"data":[ {"id":12657,"name":"SAC 6: Human Rights Presentation","activityName":"","subjectName":"11HLEG 11 HUMANITIES - LEGAL STUDIES","groupName":"11HLEG","dueDateTimestamp":"2026-10-09T05:00:00Z","students":[{"userId":27038,"userName":"Harrison ROLFE","submissionStatus":2,"dueDateTimestamp":"","submittedTimestamp":""}]}, {"id":14268,"name":"Unit 1 English Exam","activityName":"11EENG","subjectName":"11EENG 11 ENGLISH - ENGLISH","groupName":"11EENG","dueDateTimestamp":"","students":[{"userId":27038,"userName":"Harrison ROLFE","submissionStatus":3,"dueDateTimestamp":"2026-06-22T05:09:00Z","submittedTimestamp":"2026-06-22T05:08:00Z"}]}, {"id":99999,"name":"Finn task","activityName":"","subjectName":"7MAT","groupName":"7MAT","dueDateTimestamp":"","students":[{"userId":31460,"userName":"Finn ROLFE","submissionStatus":2}]} ]}}` // homeOverdueFixture carries the parent home-page summary link for user 27038. const homeOverdueFixture = `
Harrison ROLFE
1 overdue learning task
1 learning task due in the next week
` func TestParseLearningTasksAll(t *testing.T) { loc := melbourne(t) got, err := parseLearningTasksAll("Harry", Page{ Slug: PageLearningTasksAll, UserID: "27038", JSON: []byte(tasksJSONFixture), }, loc) if err != nil { t.Fatalf("parseLearningTasksAll: %v", err) } if len(got.items) != 2 { t.Fatalf("got %d items, want 2 (the sibling's row is skipped)", len(got.items)) } if got.overdue != 1 { t.Errorf("overdue = %d, want 1", got.overdue) } if got.total != 3 { t.Errorf("total = %d, want 3", got.total) } if !strings.Contains(got.items[0].Body, "Status: Overdue") { t.Errorf("overdue body = %q", got.items[0].Body) } if got.items[0].DueAt == nil || got.items[0].DueAt.Format("2006-01-02 15:04") != "2026-10-09 16:00" { t.Errorf("task-level due = %v", got.items[0].DueAt) } if got.items[1].DueAt == nil || got.items[1].DueAt.Format("2006-01-02 15:04") != "2026-06-22 15:09" { t.Errorf("student-level due = %v", got.items[1].DueAt) } if got.items[0].ExternalRef == got.items[1].ExternalRef { t.Error("two tasks share an external ref") } if OverdueCount(got.items) != 1 { t.Errorf("OverdueCount = %d, want 1", OverdueCount(got.items)) } } // TestParseReconcilesOverdueWithHome proves FR-12: the parser refuses to store a // task list whose overdue count disagrees with the home page summary. func TestParseReconcilesOverdueWithHome(t *testing.T) { loc := melbourne(t) jsonPage := Page{Slug: PageLearningTasksAll, UserID: "27038", JSON: []byte(tasksJSONFixture)} homePage := Page{Slug: PageHome, HTML: []byte(homeOverdueFixture)} if _, err := Parse("Harry", []Page{homePage, jsonPage}, loc); err != nil { t.Fatalf("Parse with matching counts: %v", err) } mismatch := strings.Replace(homeOverdueFixture, "1 overdue learning task", "6 overdue learning tasks", 1) if _, err := Parse("Harry", []Page{{Slug: PageHome, HTML: []byte(mismatch)}, jsonPage}, loc); err == nil { t.Fatal("Parse accepted a task list that disagrees with the home page (FR-12)") } blank := `
` if _, err := Parse("Harry", []Page{{Slug: PageHome, HTML: []byte(blank)}, jsonPage}, loc); err == nil { t.Fatal("Parse accepted a home page with no record of the child (FR-12)") } }