- 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>
514 lines
21 KiB
Go
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&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)
|
|
}
|
|
}
|