Files
compass_teams_nhs/internal/parse/parse_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

514 lines
21 KiB
Go

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 <td> is not
// closed before the group's inner table, exactly as Compass emits it.
const tasksFixture = `<html><body>
<table class="x-gridview-1-table x-grid-table"><tbody>
<tr class="x-grid-row x-grid-wrap-row x-grid-group-row"><td class="x-group-hd-container" colspan="4">
<div class="x-grid-group-hd x-grid-group-hd-collapsible"><div class="x-grid-group-title" style="">
<a href="/Records/User.aspx?userId=999">Test Child</a> - <a href="/Organise/Subjects/Subject.aspx?subjectId=1&amp;openLearningTaskTab">11EENG 11 ENGLISH</a>
</div></div>
<table class="x-gridview-1-table x-grid-table"><tbody>
<tr class="x-grid-row x-grid-data-row">
<td class="x-grid-cell"><div class="x-grid-cell-inner"><div><div><div style="display:flex;">
<span class="task-details-category-label" style="background-color:#F17056">Assessment</span>
<span class="learning-task-title"><a class="task-submission-launcher" data-qtip="Essay draft">Essay draft</a></span></div>
<span data-qtip="This is a Class Learning Task."><span class="learning-task-datafield-title">Class Task</span></span> | <span data-qtip="report"><span class="learning-task-datafield-title">Report: </span><span class="learning-task-datafield-value">Yes</span></span>
</div></div></div></td>
<td class="x-grid-cell"><div class="x-grid-cell-inner">
<span data-qtip="due soon"><span class="learning-task-datafield-title">Due date: </span><span class="learning-task-datafield-value">19/10/26 04:00 PM</span></span><br>
<span data-qtip="pending"><span class="learning-task-datafield-title">Submitted: </span><span class="learning-task-datafield-value"><img src="/Assets/Pending.png"> Pending</span></span>
</div></td>
<td class="x-grid-cell"><div class="x-grid-cell-inner"><div class="learning-task-row-value-large">-</div></div></td>
<td class="x-grid-cell"><div class="x-grid-cell-inner"><img src="/Assets/tools.png"></div></td>
</tr>
<tr class="x-grid-row x-grid-row-alt x-grid-data-row">
<td class="x-grid-cell"><div class="x-grid-cell-inner"><div><div><div style="display:flex;">
<span class="task-details-category-label">Homework</span>
<span class="learning-task-title"><a class="task-submission-launcher" data-qtip="Chapter 4 questions">Chapter 4 questions</a></span></div>
<span data-qtip="Subject-wide"><span class="learning-task-datafield-title">Subject-wide Task</span></span>
</div></div></div></td>
<td class="x-grid-cell"><div class="x-grid-cell-inner">
<span data-qtip="overdue"><span class="learning-task-datafield-title">Due date: </span><span class="learning-task-datafield-value">09/10/26 04:00 PM</span></span><br>
<span data-qtip="overdue"><span class="learning-task-datafield-title">Submitted: </span><span class="learning-task-datafield-value"><img src="/Assets/Overdue.png"> Overdue</span></span>
</div></td>
<td class="x-grid-cell"><div class="x-grid-cell-inner"><div class="learning-task-row-value-large">-</div></div></td>
<td class="x-grid-cell"><div class="x-grid-cell-inner"><img src="/Assets/tools.png"></div></td>
</tr>
</tbody></table>
</td></tr>
</tbody></table>
</body></html>`
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 = `<html><body>
<div data-testid="feed-toolbar"><p>News</p></div>
<div class="MuiPaper-root"><div class="MuiCardContent-root">
<span class="MuiTypography-root MuiTypography-dialogMdStrong css-1">T Smith</span>
<span class="MuiTypography-root MuiTypography-dialogSmStrong css-2" aria-label="14/10/2026 09:00 AM">1 day ago</span>
<span class="postTitle css-3"><span class="">Health Information</span></span>
<div class="froala-viewer"><p>Dear families, there is a notice.</p></div>
</div></div>
</body></html>`
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 = `<html><body>
<table class="ext-cal"><tbody><tr><td class="ext-cal-day-col"><div class="ext-cal-day-col-inner">
<div id="calendar-manager-week-bd-day-col-20261015" class="ext-cal-day-col-gutter" style="height:1200px;">
<div class="calendar-manager-week-bd-evt-a calendar-event-1001 custom-event ext-cal-evt activity-type-2 ext-event-no-roll" style="left:0%;width:100%;top:900px;height:149px;background-color:#ccffcc;">
<span class="ext-evt-bd" lang="en-GB">6:00: End of Year Concert - HALL - KSH</span>
</div>
<div class="calendar-manager-week-bd-evt-b calendar-event-1002 custom-event ext-cal-evt activity-type-1 ext-event-no-roll" style="left:0%;width:100%;top:705px;height:48px;">
<span class="ext-evt-bd" lang="en-GB">2:06: 5 - 11HLEGB5 - D8 - LHT</span>
</div>
</div>
</div></td></tr></tbody></table>
</body></html>`
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)
}
// The week calendar is school-wide, so its lesson grid is dropped: a
// child's own periods come from that child's day widget (FR-16). Only the
// real school event survives.
if len(items) != 1 {
t.Fatalf("got %d items, want 1 (the routine lesson is not a per-child period)", 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 time = %s", got)
}
if items[0].Title != "End of Year Concert - HALL - KSH" {
t.Errorf("event title = %q", items[0].Title)
}
}
// TestParseFailsLoudly proves FR-12: a challenge or login page must not be read
// as "nothing due".
func TestParseFailsLoudly(t *testing.T) {
challenge := []byte(`<html><head><title>Just a moment...</title></head><body><h1>Just a moment...</h1></body></html>`)
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)
}
}
// TestClassifyModelLeavesEventsDeterministic proves a model cannot label a
// school event: the calendar write depends on that call (FR-8).
func TestClassifyModelLeavesEventsDeterministic(t *testing.T) {
at := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
items := []Item{
{Type: TypeEvent, Title: "End of Year Senior Music Concert - HALL - KSH", DueAt: &at},
{Type: TypeEvent, Title: "5 - 11HLEGB5 - D8 - LHT", DueAt: &at},
{Type: TypeTask, Title: "Essay", DueAt: &at},
}
reply, _ := json.Marshal([]map[string]any{
{"i": 0, "importance": "low"},
{"i": 1, "importance": "high"},
{"i": 2, "importance": "low"},
})
c := &stubCompleter{reply: string(reply)}
if err := ClassifyModel(context.Background(), c, items, "test-model", at); err != nil {
t.Fatalf("ClassifyModel: %v", err)
}
if items[0].Importance != importanceHigh {
t.Errorf("school event = %q, want %q", items[0].Importance, importanceHigh)
}
if items[1].Importance != importanceNormal {
t.Errorf("class period = %q, want %q", items[1].Importance, importanceNormal)
}
if items[2].Importance != importanceLow {
t.Errorf("task = %q, want the model's %q", items[2].Importance, importanceLow)
}
}
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 = `<html><body>
<div data-testid="feed-toolbar"></div>
<h5>Harrison ROLFE</h5>
<div class="parentChildActions-learningTaskItems">
<div><a href="Records/User.aspx?userId=27038#learningTasks">1 overdue learning task</a></div>
<div><a href="Records/User.aspx?userId=27038#learningTasks">1 learning task due in the next week</a></div>
</div>
</body></html>`
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 := `<html><body><div data-testid="feed-toolbar"></div></body></html>`
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)")
}
}
// dayWidgetFixture is the per-child "Today" widget Compass renders on a child's
// own profile page. The child is Finn (Year 7).
const dayWidgetFixture = `<html><body>
<div class="x-panel compass-day-calendar x-cal-panel" id="calendardaywidget-1055">
<div class="x-toolbar">Today</div>
<div class="calendardaywidget-1055-day-bd-evt-a x-cal-default calendar-event-93806 custom-event ext-cal-evt activity-type-1 ext-event-no-roll" style="left:0%;width:100%;top:95.8px;height:48.1px;">
<span class="ext-evt-bd" lang="en-GB">8:55: 1 - 7SCF - H14LAB - KNI</span>
</div>
<div class="calendardaywidget-1055-day-bd-evt-b x-cal-default calendar-event-93737 custom-event ext-cal-evt activity-type-1 ext-event-no-roll" style="left:50%;width:50%;top:145px;height:44px;">
<span class="ext-evt-bd" lang="en-GB">9:54: 2 - 7DESF - B1 - PSI</span>
</div>
<div class="calendardaywidget-1055-day-bd-evt-c x-cal-default calendar-event-93755 custom-event ext-cal-evt activity-type-1 ext-event-no-roll" style="left:0%;width:100%;top:321.6666666666667px;height:48px;">
<span class="ext-evt-bd" lang="en-GB">1:26: 4 - 7PEF - GYM4 - LTA</span>
</div>
</div>
</body></html>`
// TestParseDayWidgetIsPerChild proves FR-16: a child's periods come from that
// child's own day widget, and the school-wide week calendar's lesson grid is
// never stored against the child.
func TestParseDayWidgetIsPerChild(t *testing.T) {
loc := melbourne(t)
fetched := time.Date(2026, 10, 16, 7, 0, 0, 0, loc)
pages := []Page{
{Slug: PageChildProfile, URL: "https://nhs-vic.compass.education/Records/User.aspx?userId=4242", HTML: []byte(dayWidgetFixture), FetchedAt: fetched},
{Slug: PageCalendar, URL: "https://nhs-vic.compass.education/Organise/Calendar/", HTML: []byte(calendarFixture), FetchedAt: fetched},
}
items, err := Parse("Finn", pages, loc)
if err != nil {
t.Fatalf("Parse: %v", err)
}
leaked := 0
periods := 0
for _, it := range items {
if it.Subject == "11HLEGB5" {
leaked++
}
if it.SourcePage == PageChildProfile && it.Type == TypeEvent {
periods++
if it.DueAt == nil || it.DueAt.In(loc).Format("2006-01-02") != "2026-10-16" {
t.Errorf("period %q dated %v, want 2026-10-16", it.Title, it.DueAt)
}
if it.SourcePage != PageChildProfile {
t.Errorf("period %q source page = %q, want %q", it.Title, it.SourcePage, PageChildProfile)
}
}
}
if leaked != 0 {
t.Errorf("Finn's items carry %d Year 11 lesson(s) from the school-wide calendar", leaked)
}
if periods != 3 {
t.Errorf("Finn has %d periods, want 3", periods)
}
}
// TestDayWidgetParsesRoomAndTeacher proves the label is split into its parts so
// the brief can decode them.
func TestDayWidgetParsesRoomAndTeacher(t *testing.T) {
loc := melbourne(t)
fetched := time.Date(2026, 10, 16, 7, 0, 0, 0, loc)
items, found, err := parseDayWidget("Finn", Page{Slug: PageChildProfile, HTML: []byte(dayWidgetFixture), FetchedAt: fetched}, loc, fetched)
if err != nil || !found {
t.Fatalf("parseDayWidget: found=%v err=%v", found, err)
}
if len(items) != 3 {
t.Fatalf("got %d periods, want 3", len(items))
}
if items[0].Subject != "7SCF" {
t.Errorf("subject = %q", items[0].Subject)
}
if items[0].Title != "1 - 7SCF - H14LAB - KNI" {
t.Errorf("title = %q", items[0].Title)
}
if got := items[0].DueAt.In(loc).Format("15:04"); got != "08:55" {
t.Errorf("start = %q, want 08:55", got)
}
// The printed clock is 12-hour: the 1:26 period is 1:26 PM (the grid
// offset is the authority).
if got := items[2].DueAt.In(loc).Format("15:04"); got != "13:26" {
t.Errorf("afternoon start = %q, want 13:26", got)
}
}