Files
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

415 lines
14 KiB
Go

package calendar
import (
"context"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
func testStore(t *testing.T) (*store.Store, int64) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "school.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := st.SeedChildren(context.Background(), "Harry"); err != nil {
t.Fatalf("seed: %v", err)
}
child, err := st.ChildByName(context.Background(), "Harry")
if err != nil || child == nil {
t.Fatalf("child: %v %v", child, err)
}
return st, child.ID
}
// seedEvent stores one calendar event for a child.
func seedEvent(t *testing.T, st *store.Store, childID int64, title string, due time.Time, importance string, ref string) store.Item {
t.Helper()
item := store.Item{
Source: "compass",
Type: "event",
Title: title,
Body: title + "\nTeacher(s): KSH",
DueAt: &due,
URL: "https://example.invalid/Organise/Calendar/",
SourcePage: "calendar",
Importance: importance,
ExternalRef: ref,
}
if _, _, err := st.UpsertItems(context.Background(), childID, []store.Item{item}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, err := st.ItemsForChild(context.Background(), childID)
if err != nil {
t.Fatalf("items: %v", err)
}
for _, it := range items {
if it.ExternalRef == ref {
return it
}
}
t.Fatalf("seeded item %q not found", ref)
return store.Item{}
}
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func TestSyncIsIdempotent(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
seedEvent(t, st, childID, "End of Year Senior Music Concert - HALL - KSH", due, "high", "concert")
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
first, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("first Sync: %v", err)
}
if first.Candidates != 1 || first.Created != 1 || first.Failed != 0 {
t.Fatalf("first result = %+v", first)
}
if ha.created() != 1 {
t.Fatalf("first Sync made %d create calls", ha.created())
}
// The item now carries the Home Assistant event id, so it is no longer a
// candidate: a re-run writes nothing.
second, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("second Sync: %v", err)
}
if second.Candidates != 0 || second.Created != 0 {
t.Fatalf("second result = %+v", second)
}
if ha.created() != 1 {
t.Fatalf("re-run duplicated the event (%d create calls)", ha.created())
}
}
func TestSyncAdoptsAnEventByItsMarker(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
item := seedEvent(t, st, childID, "Year 11 Assembly - HALL - CMA", due, "high", "assembly")
ha := newFakeHA(t, "calendar.school")
// The event exists in Home Assistant, but the item has no id (a crashed
// run, or a Home Assistant that reports no uid). The marker adopts it.
ha.events = []map[string]any{{
"uid": "uid-existing",
"summary": "Harry — Year 11 Assembly - HALL - CMA",
"description": "source: " + Marker(item.ID),
"start": due.Format(time.RFC3339),
}}
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Created != 0 || res.Skipped != 1 {
t.Fatalf("result = %+v", res)
}
if ha.created() != 0 {
t.Fatalf("Sync wrote a duplicate (%d create calls)", ha.created())
}
ci, err := st.CalendarItemByID(ctx, item.ID)
if err != nil {
t.Fatalf("item: %v", err)
}
if ci.HAEventUID != "uid-existing" {
t.Errorf("adopted uid = %q", ci.HAEventUID)
}
}
func TestSyncRecordsALoudFailure(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
item := seedEvent(t, st, childID, "Tour to China photo - THEATRE", due, "high", "photo")
ha := newFakeHA(t, "calendar.school")
ha.failCreate = true
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Failed != 1 || len(res.Errors) != 1 {
t.Fatalf("result = %+v", res)
}
summary, err := st.CalendarPushSummary(ctx)
if err != nil {
t.Fatalf("summary: %v", err)
}
if summary.Failed != 1 || summary.Last == nil || summary.Last.OK {
t.Fatalf("push summary = %+v", summary)
}
if !strings.Contains(summary.Last.Error, "calendar is busy") {
t.Errorf("recorded error loses the reason: %q", summary.Last.Error)
}
// A failed write leaves the item as a candidate, so a later run retries it.
ci, err := st.CalendarItemByID(ctx, item.ID)
if err != nil {
t.Fatalf("item: %v", err)
}
if ci.HAEventUID != "" {
t.Errorf("failed item should not carry an event id, got %q", ci.HAEventUID)
}
}
func TestSyncDryRunWritesNothing(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
seedEvent(t, st, childID, "Senior Concert Band rehearsal - ENS", due, "high", "band")
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now(), DryRun: true}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Created != 1 || !res.DryRun {
t.Fatalf("result = %+v", res)
}
if ha.created() != 0 {
t.Fatalf("dry run wrote %d events", ha.created())
}
}
func TestSyncWindowIsTodayAndFuture(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
now := time.Now()
day := StartOfDay(now)
seedEvent(t, st, childID, "5 - 11HLEGB5 - D8 - LHT", now.Add(3*time.Hour), "normal", "lesson")
seedEvent(t, st, childID, "Yesterday excursion - CITY", day.Add(-time.Hour), "high", "yesterday")
seedEvent(t, st, childID, "Year 11 Assembly - HALL", day.Add(time.Minute), "high", "today")
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
candidates, err := st.CalendarCandidates(ctx, nil, nil, day, 0)
if err != nil {
t.Fatalf("candidates: %v", err)
}
// A routine lesson is never high, and yesterday is behind the window. The
// event earlier today stays a candidate: a scan that runs late in the day
// must still record today's school events.
if len(candidates) != 1 || candidates[0].Title != "Year 11 Assembly - HALL" {
t.Fatalf("candidates = %+v", candidates)
}
res, err := Sync(ctx, st, c, Options{Now: now}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Candidates != 1 || res.Created != 1 || ha.created() != 1 {
t.Fatalf("result = %+v (create calls %d)", res, ha.created())
}
}
func TestSyncReportsAReadFailureInsteadOfWritingBlind(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
seedEvent(t, st, childID, "Year 11 Assembly - HALL", due, "high", "assembly")
ha := newFakeHA(t, "calendar.school")
ha.Close() // the calendar is unreachable
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Failed != 1 || res.Created != 0 {
t.Fatalf("result = %+v", res)
}
if !strings.Contains(res.Errors[0], "reading existing events failed") {
t.Errorf("error = %q", res.Errors[0])
}
}
// TestSyncWritesASchoolWideEventOnce proves the shared Compass week calendar is
// not duplicated: one event stored against both children is written once, and
// both children's items record the same Home Assistant event id.
func TestSyncWritesASchoolWideEventOnce(t *testing.T) {
ctx := context.Background()
st, harryID := testStore(t)
if err := st.SeedChildren(ctx, "Finn"); err != nil {
t.Fatalf("seed finn: %v", err)
}
finn, err := st.ChildByName(ctx, "Finn")
if err != nil || finn == nil {
t.Fatalf("finn: %v %v", finn, err)
}
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
harryItem := seedEvent(t, st, harryID, "Year 11 Assembly - HALL - CMA", due, "high", "assembly")
finnItem := seedEvent(t, st, finn.ID, "Year 11 Assembly - HALL - CMA", due, "high", "assembly")
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Candidates != 1 || res.Created != 1 {
t.Fatalf("result = %+v", res)
}
if ha.created() != 1 {
t.Fatalf("school-wide event was written %d times", ha.created())
}
// A school-wide event carries just its title, not a child's name.
if got := str(ha.lastCreate["summary"]); got != "Year 11 Assembly - HALL - CMA" {
t.Errorf("summary = %q", got)
}
uid := res.Events[0].UID
if uid == "" {
t.Fatal("event written without a Home Assistant id")
}
for _, id := range []int64{harryItem.ID, finnItem.ID} {
ci, err := st.CalendarItemByID(ctx, id)
if err != nil || ci == nil {
t.Fatalf("item %d: %v %v", id, ci, err)
}
if ci.HAEventUID != uid {
t.Errorf("item %d uid = %q, want %q", id, ci.HAEventUID, uid)
}
}
}
// TestSyncWithoutEventIDsStillNeverDuplicates proves the fallback path: when
// Home Assistant reports no event id, the item keeps the description marker as
// its reference, so a re-run writes nothing.
func TestSyncWithoutEventIDsStillNeverDuplicates(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
item := seedEvent(t, st, childID, "Year 11 Assembly - HALL", due, "high", "assembly")
ha := newFakeHA(t, "calendar.school")
ha.returnUID = false
c := testClient(t, ha.URL, "calendar.school")
first, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("first Sync: %v", err)
}
if first.Created != 1 || ha.created() != 1 {
t.Fatalf("first result = %+v (create calls %d)", first, ha.created())
}
ci, err := st.CalendarItemByID(ctx, item.ID)
if err != nil || ci == nil {
t.Fatalf("item: %v %v", ci, err)
}
if ci.HAEventUID != Marker(item.ID) {
t.Errorf("reference = %q, want the description marker %q", ci.HAEventUID, Marker(item.ID))
}
second, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("second Sync: %v", err)
}
if second.Candidates != 0 || second.Created != 0 {
t.Fatalf("second result = %+v", second)
}
if ha.created() != 1 {
t.Fatalf("re-run duplicated the event (%d create calls)", ha.created())
}
}
// seedCalendarItem stores one item and returns it.
func seedCalendarItem(t *testing.T, st *store.Store, childID int64, item store.Item) store.Item {
t.Helper()
if _, _, err := st.UpsertItems(context.Background(), childID, []store.Item{item}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, err := st.ItemsForChild(context.Background(), childID)
if err != nil {
t.Fatalf("items: %v", err)
}
for _, it := range items {
if it.ExternalRef == item.ExternalRef {
return it
}
}
t.Fatalf("seeded item %q not found", item.ExternalRef)
return store.Item{}
}
// TestSyncWritesPeriodsWorkAndOverdueOnce proves FR-8: a child's own periods,
// the work due in the future and the overdue work all reach the calendar, and a
// re-run adds nothing. Submitted work is never written.
func TestSyncWritesPeriodsWorkAndOverdueOnce(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
loc := time.Local
now := StartOfDay(time.Now().In(loc)).Add(6 * time.Hour)
period := now.Add(2 * time.Hour).Truncate(time.Minute)
overdue := now.AddDate(0, 0, -3).Truncate(time.Minute)
future := now.AddDate(0, 0, 3).Truncate(time.Minute)
seedCalendarItem(t, st, childID, store.Item{
Source: "compass", Type: "event", Subject: "7SCF",
Title: "1 - 7SCF - H14LAB - KNI", DueAt: &period,
SourcePage: "child-profile", Importance: "normal", ExternalRef: "period-1",
})
seedCalendarItem(t, st, childID, store.Item{
Source: "compass", Type: "task", Subject: "Science", Title: "Lab report",
Body: "Status: Overdue", DueAt: &overdue, Importance: "high", ExternalRef: "overdue-1",
})
seedCalendarItem(t, st, childID, store.Item{
Source: "compass", Type: "task", Subject: "Maths", Title: "Exercise 4",
Body: "Status: Pending", DueAt: &future, Importance: "normal", ExternalRef: "due-1",
})
seedCalendarItem(t, st, childID, store.Item{
Source: "compass", Type: "task", Subject: "English", Title: "Done essay",
Body: "Status: On Time", DueAt: &overdue, Importance: "normal", ExternalRef: "submitted-1",
})
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: now}, silentLogger())
if err != nil {
t.Fatalf("first Sync: %v", err)
}
if res.Created != 3 || res.Failed != 0 {
t.Fatalf("first result = %+v", res)
}
if ha.created() != 3 {
t.Fatalf("first Sync made %d create calls, want 3", ha.created())
}
kinds := map[Kind]int{}
for _, ev := range res.Events {
kinds[ev.Kind]++
}
if kinds[KindPeriod] != 1 || kinds[KindOverdue] != 1 || kinds[KindDue] != 1 {
t.Errorf("kinds = %+v, want one period, one overdue and one due", kinds)
}
second, err := Sync(ctx, st, c, Options{Now: now}, silentLogger())
if err != nil {
t.Fatalf("second Sync: %v", err)
}
if second.Created != 0 || second.Candidates != 0 {
t.Fatalf("second result = %+v, want nothing new", second)
}
if ha.created() != 3 {
t.Fatalf("re-run duplicated events (%d create calls)", ha.created())
}
}