Phase 6: fix the live Home Assistant write (Local Calendar, no event id)

The first live run against the new calendar.school entity failed loudly twice,
and both causes are fixed here.

- The Local Calendar rejects `return_response` on calendar.create_event. The
  client retries the call without it and ignores the changed-states array that
  Home Assistant returns instead.
- Home Assistant then reports no event id. The item keeps the description
  marker ([compass-nhs:<id>]) as its reference, so it leaves the candidate set
  and is never written twice. The live retry proved it: both events already in
  the calendar were adopted by their markers, with no duplicate.
- calendar.get_events reports `start` / `end` (a date-time or a bare date), not
  `start_date_time`; the reader now accepts all three shapes.

A model must no longer classify events. The calendar write depends on the
school-event rule, and the LLM labelled every event "normal"; events now keep
the deterministic rule under both classifiers.

Also fix the Phase 5 screenshot script: the calendar banner shares the `.scan`
class, so select each banner by its labelled section.

Docs record the accepted decision: Sam chose a Home Assistant Local Calendar,
so the target is calendar.school.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 19:09:06 +11:00
parent f56dbb46ac
commit da57efad73
9 changed files with 279 additions and 68 deletions

View File

@@ -106,10 +106,10 @@ func TestSyncAdoptsAnEventByItsMarker(t *testing.T) {
// 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_date_time": due.Format(time.RFC3339),
"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")
@@ -290,3 +290,43 @@ func TestSyncWritesASchoolWideEventOnce(t *testing.T) {
}
}
}
// 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())
}
}