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>
315 lines
11 KiB
Go
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")
|
|
}
|
|
}
|