Files
compass_teams_nhs/internal/hub/hub_test.go
Sam Rolfe 06f759c37b Phase 8 follow-up: resolve the child named in the question
The Hub forwards `person` equal to the asker (`PersonOrDefault`), so the spoken
question "what does Harry have on today at school?" arrives with person "sam"
and returned "no data for that person" (found by the Hub agent on AUD-116).

resolveChild now falls back to the question text when the requested person is
not one of this project's children: whole-word, case-insensitive match against
the child names this service already publishes in /capabilities. An explicit
person still wins, two named children still return answer:null, and the
permission check still runs on the resolved child. No model, no free text.

Live end-to-end through the Hub, with no person field:

  POST http://192.168.20.13:8788/ask
  {"text":"what does Harry have on today at school?","asker":"sam","source":"console"}
  200
  {"status":"ok",
   "answer":"Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.",
   "route":{"kind":"project","project":"compass_nhs"}}

Image 0.6.1-phase8; docs/phase-8-hub-notes.md records the fallback and the
end-to-end transcript.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 19:54:26 +11:00

315 lines
11 KiB
Go

package hub
import (
"context"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// fakeReader is the read-only Reader backed by fixed data. It has no write
// method, which is how the "read only" contract is enforced on /ask.
type fakeReader struct {
children []store.Child
items map[int64][]store.Item
scans map[int64]store.ScanStatus
}
func (f *fakeReader) ListChildren(context.Context) ([]store.Child, error) {
return f.children, nil
}
func (f *fakeReader) ItemsForChild(_ context.Context, childID int64) ([]store.Item, error) {
return f.items[childID], nil
}
func (f *fakeReader) ScanSummaryForChild(_ context.Context, childID int64) (store.ScanStatus, error) {
return f.scans[childID], nil
}
// testNow is a fixed Wednesday afternoon in the family timezone.
var testNow = time.Date(2026, 10, 15, 12, 0, 0, 0, mustSydney())
func mustSydney() *time.Location {
loc, err := time.LoadLocation("Australia/Sydney")
if err != nil {
panic(err)
}
return loc
}
func at(y int, mo time.Month, d, h, mi int) *time.Time {
t := time.Date(y, mo, d, h, mi, 0, 0, mustSydney())
return &t
}
func okScan(t time.Time) store.ScanStatus {
return store.ScanStatus{EverRun: true, Total: 3, Last: &store.Scan{ID: 3, StartedAt: t, OK: true}}
}
func testAnswerer(r Reader) *Answerer {
a := NewAnswerer(r, mustSydney())
a.Now = func() time.Time { return testNow }
return a
}
// harryData is one realistic day: eight lessons, one task due today, two
// overdue SACs, one task this week and one notice.
func harryData() *fakeReader {
return &fakeReader{
children: []store.Child{{ID: 1, Name: "Harry"}, {ID: 2, Name: "Finn"}},
items: map[int64][]store.Item{1: {
{ID: 1, ChildID: 1, Type: "event", Title: "8:55: 11XMUSA6 - A1 - JBR", DueAt: at(2026, 10, 15, 8, 55)},
{ID: 2, ChildID: 1, Type: "event", Title: "9:50: 11AACPA4 - C1 - BDR", DueAt: at(2026, 10, 15, 9, 50)},
{ID: 3, ChildID: 1, Type: "event", Title: "11:00: 11SBIOA1 - M4 - AFR", DueAt: at(2026, 10, 15, 11, 0)},
{ID: 4, ChildID: 1, Type: "event", Title: "14:00: 11EENGB3 - H3 - CHA", DueAt: at(2026, 10, 15, 14, 0)},
{ID: 5, ChildID: 1, Type: "event", Title: "18:00: Senior Music Concert - HALL", DueAt: at(2026, 10, 15, 18, 0)},
{ID: 6, ChildID: 1, Type: "task", Subject: "11EENG ENGLISH", Title: "SAC 3: Responding to Texts",
Body: "Status: Pending\nDue: 2026-10-15 16:00", DueAt: at(2026, 10, 15, 16, 0)},
{ID: 7, ChildID: 1, Type: "task", Subject: "11HLEG LEGAL STUDIES", Title: "SAC 6: Human Rights",
Body: "Status: Overdue", DueAt: at(2026, 10, 9, 16, 0)},
{ID: 8, ChildID: 1, Type: "task", Subject: "11XMUS MUSIC", Title: "SAC 1: Performing",
Body: "Status: Overdue", DueAt: at(2026, 10, 5, 16, 0)},
{ID: 9, ChildID: 1, Type: "task", Subject: "11MGMA MATHS", Title: "SAC 4: Networks",
Body: "Status: Pending", DueAt: at(2026, 10, 19, 16, 0)},
{ID: 10, ChildID: 1, Type: "message", Title: "Health Information", DueAt: at(2026, 10, 13, 9, 0)},
},
2: {
{ID: 20, ChildID: 2, Type: "event", Title: "9:00: 10ENGA - B2 - SMI", DueAt: at(2026, 10, 15, 9, 0)},
},
},
scans: map[int64]store.ScanStatus{1: okScan(testNow.Add(-4 * time.Hour)), 2: okScan(testNow.Add(-4 * time.Hour))},
}
}
func answerOf(t *testing.T, a *Answerer, req AskRequest) string {
t.Helper()
resp := a.Answer(context.Background(), req)
if resp.Answer == nil {
t.Fatalf("answer = nil, reason = %q", resp.Reason)
}
return *resp.Answer
}
func TestTodayQuestion(t *testing.T) {
a := testAnswerer(harryData())
got := answerOf(t, a, AskRequest{
Question: "what does Harry have on today at school?",
Asker: "sam",
Person: "harry",
Source: "esp32",
})
// 5 lessons, 1 task due today, 2 overdue.
if !strings.Contains(got, "5 school events today from 8:55am to 6:00pm") {
t.Errorf("answer = %q, want the lesson count and span", got)
}
if !strings.Contains(got, "1 task due today") {
t.Errorf("answer = %q, want 1 task due today", got)
}
if !strings.Contains(got, "2 overdue tasks") {
t.Errorf("answer = %q, want 2 overdue tasks", got)
}
if strings.Count(got, ".") != 1 {
t.Errorf("answer = %q, want one sentence", got)
}
}
func TestTodayQuestionIsCorrectForRealShape(t *testing.T) {
// The Hub lower-cases the person, and the status line uses the HTML grid
// prefix. Both must resolve the same way.
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{Question: "what has harry got on today?", Asker: "sam", Person: "Harry"})
if resp.Answer == nil {
t.Fatalf("answer = nil, reason = %q", resp.Reason)
}
if resp.Data["child"] != "Harry" {
t.Errorf("data.child = %v, want Harry", resp.Data["child"])
}
if resp.Expires == "" {
t.Error("expires is empty, want end of day")
}
}
func TestOverdueFocus(t *testing.T) {
a := testAnswerer(harryData())
got := answerOf(t, a, AskRequest{Question: "does Harry have any overdue work?", Asker: "sam", Person: "harry"})
if got != "Harry has 2 overdue tasks." {
t.Errorf("answer = %q, want the overdue count only", got)
}
}
func TestMessagesFocus(t *testing.T) {
a := testAnswerer(harryData())
got := answerOf(t, a, AskRequest{Question: "any school notices for Harry?", Asker: "sam", Person: "harry"})
if got != "Harry has 1 school notice." {
t.Errorf("answer = %q, want one notice", got)
}
}
func TestWeekFocus(t *testing.T) {
a := testAnswerer(harryData())
got := answerOf(t, a, AskRequest{Question: "what is due this week for Harry?", Asker: "sam", Person: "harry"})
if !strings.Contains(got, "1 task due this week") {
t.Errorf("answer = %q, want the week count", got)
}
}
// TestFailedScanIsLoud is the FR-12 promise: a failed scan is never an empty
// day. The answer says due work is unknown.
func TestFailedScanIsLoud(t *testing.T) {
r := harryData()
r.scans[1] = store.ScanStatus{EverRun: true, Total: 4, Failed: 1,
Last: &store.Scan{ID: 4, StartedAt: testNow.Add(-2 * time.Hour), OK: false, Error: "cloudflare challenge"}}
a := testAnswerer(r)
resp := a.Answer(context.Background(), AskRequest{Question: "what does Harry have on today?", Asker: "sam", Person: "harry"})
if resp.Answer == nil {
t.Fatal("answer = nil, want a loud answer")
}
got := *resp.Answer
if !strings.Contains(got, "failed") || !strings.Contains(got, "unknown") {
t.Errorf("answer = %q, want the failed scan stated", got)
}
if strings.Contains(got, "nothing due") {
t.Errorf("answer = %q, must not read as nothing due", got)
}
if resp.Data["scan_ok"] != false {
t.Errorf("data.scan_ok = %v, want false", resp.Data["scan_ok"])
}
}
func TestNeverScannedIsLoud(t *testing.T) {
r := harryData()
r.scans[1] = store.ScanStatus{}
a := testAnswerer(r)
got := answerOf(t, a, AskRequest{Question: "what does Harry have on today?", Asker: "sam", Person: "harry"})
if !strings.Contains(got, "has not run") {
t.Errorf("answer = %q, want the missing scan stated", got)
}
}
func TestStaleScanIsQualified(t *testing.T) {
r := harryData()
r.scans[1] = okScan(testNow.Add(-50 * time.Hour))
a := testAnswerer(r)
got := answerOf(t, a, AskRequest{Question: "what does Harry have on today?", Asker: "sam", Person: "harry"})
if !strings.Contains(got, "may be out of date") {
t.Errorf("answer = %q, want a stale warning", got)
}
}
func TestUnknownPersonCannotAnswer(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{Question: "what does Hogwarts have on today?", Asker: "sam"})
if resp.Answer != nil {
t.Fatalf("answer = %q, want nil", *resp.Answer)
}
if resp.Reason == "" {
t.Error("reason is empty, want an explanation")
}
}
func TestChildCannotAskAboutSibling(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{Question: "what does Finn have on today?", Asker: "harry", Person: "finn"})
if resp.Answer != nil {
t.Fatalf("answer = %q, want nil", *resp.Answer)
}
if !strings.Contains(resp.Reason, "not allowed") {
t.Errorf("reason = %q, want a permission refusal", resp.Reason)
}
}
func TestChildMayAskAboutThemselves(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{Question: "what do I have on today?", Asker: "harry", Person: "harry"})
if resp.Answer == nil {
t.Fatalf("answer = nil, reason = %q", resp.Reason)
}
}
func TestEmptyQuestionCannotAnswer(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{Asker: "sam"})
if resp.Answer != nil || resp.Reason == "" {
t.Errorf("resp = %+v, want cannot-answer", resp)
}
}
// TestChildNamedInQuestionResolvesThePerson covers the Hub's actual call shape:
// the Hub sends `person` equal to the asker, so the child's name is only in the
// question. The literal phase question must still answer.
func TestChildNamedInQuestionResolvesThePerson(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{
Question: "what does Harry have on today at school?",
Asker: "sam",
Person: "sam", // what the Hub forwards today (person defaults to asker)
Source: "console",
})
if resp.Answer == nil {
t.Fatalf("answer = nil, reason = %q", resp.Reason)
}
if resp.Data["child"] != "Harry" {
t.Errorf("data.child = %v, want Harry", resp.Data["child"])
}
if !strings.Contains(*resp.Answer, "school event") {
t.Errorf("answer = %q, want the lesson count", *resp.Answer)
}
}
// TestExplicitChildWinsOverQuestionName: a caller that names a child wins, even
// if the question text mentions another one.
func TestExplicitChildWinsOverQuestionName(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{
Question: "what does Harry have on today?",
Asker: "sam",
Person: "finn",
})
if resp.Answer == nil {
t.Fatalf("answer = nil, reason = %q", resp.Reason)
}
if resp.Data["child"] != "Finn" {
t.Errorf("data.child = %v, want Finn", resp.Data["child"])
}
}
// TestTwoChildrenNamedCannotAnswer: the contract answers for one person at
// most, so an ambiguous question is refused rather than guessed.
func TestTwoChildrenNamedCannotAnswer(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{
Question: "what do Harry and Finn have on today?",
Asker: "sam",
Person: "sam",
})
if resp.Answer != nil {
t.Fatalf("answer = %q, want nil for an ambiguous question", *resp.Answer)
}
}
// TestNameIsNotMatchedInsideAWord keeps the fallback to whole words.
func TestNameIsNotMatchedInsideAWord(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{
Question: "what does harrysaurus have on today?",
Asker: "sam",
Person: "sam",
})
if resp.Answer != nil {
t.Fatalf("answer = %q, want nil (no whole-word child name)", *resp.Answer)
}
}
func TestCapabilitiesNameMatchesRouting(t *testing.T) {
caps := DefaultCapabilities()
if caps.Project != "compass_nhs" {
t.Errorf("project = %q, want compass_nhs (the Hub routing.md row)", caps.Project)
}
if len(caps.Topics) == 0 || len(caps.People) == 0 {
t.Error("capabilities must name topics and people")
}
}