package hub import ( "context" "strings" "testing" "time" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) // fakeReader is a read-only Reader backed by fixed data. type fakeReader struct { devices []store.Device eff map[int64]store.EffectiveState usage map[string][]store.DeviceDayUsage } func (f *fakeReader) ListDevices(context.Context) ([]store.Device, error) { return f.devices, nil } func (f *fakeReader) EffectiveState(_ context.Context, dev store.Device, _ time.Time) (store.EffectiveState, error) { return f.eff[dev.ID], nil } func (f *fakeReader) UsageByDeviceForDay(_ context.Context, day string) ([]store.DeviceDayUsage, error) { return f.usage[day], nil } func testAnswerer(r Reader, now time.Time) *Answerer { loc := time.UTC a := NewAnswerer(r, loc) a.Now = func() time.Time { return now.In(loc) } return a } func mustAnswer(t *testing.T, a *Answerer, req AskRequest) AskResponse { t.Helper() return a.Answer(context.Background(), req) } func answerTextOf(t *testing.T, resp AskResponse) string { t.Helper() if resp.Answer == nil { t.Fatalf("answer = nil, reason = %q", resp.Reason) } return *resp.Answer } func TestScreenTimeToday(t *testing.T) { now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC) r := &fakeReader{ devices: []store.Device{ {ID: 1, Name: "Harry's phone", Child: "Harry"}, {ID: 2, Name: "Finn's computer", Child: "Finn"}, }, usage: map[string][]store.DeviceDayUsage{ "2026-10-13": { {DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 3600, DomainSeconds: 900}, // app and domain for the same session must not be summed. {DeviceID: 2, Name: "Finn's computer", Child: "Finn", AppSeconds: 1800, DomainSeconds: 2000}, }, }, } a := testAnswerer(r, now) resp := mustAnswer(t, a, AskRequest{ Question: "how long have the kids been on screens today?", Asker: "sam", }) got := answerTextOf(t, resp) if !strings.Contains(got, "1h 30m") { t.Errorf("answer = %q, want 1h 30m", got) } if total, _ := resp.Data["total_seconds"].(int); total != 5400 { t.Errorf("total_seconds = %v, want 5400 (app totals, no double count)", resp.Data["total_seconds"]) } } func TestScreenTimeOneChild(t *testing.T) { now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC) r := &fakeReader{ devices: []store.Device{ {ID: 1, Name: "Harry's phone", Child: "Harry"}, {ID: 2, Name: "Finn's computer", Child: "Finn"}, }, usage: map[string][]store.DeviceDayUsage{ "2026-10-13": { {DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 2700}, {DeviceID: 2, Name: "Finn's computer", Child: "Finn", AppSeconds: 900}, }, }, } a := testAnswerer(r, now) resp := mustAnswer(t, a, AskRequest{Question: "how long has Harry been on screens?", Asker: "sam"}) if got := answerTextOf(t, resp); !strings.Contains(got, "45m") || !strings.Contains(got, "Harry") { t.Errorf("answer = %q, want Harry 45m", got) } } func TestScreenTimeYesterday(t *testing.T) { now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC) r := &fakeReader{ devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}}, usage: map[string][]store.DeviceDayUsage{ "2026-10-12": {{DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 120}}, }, } a := testAnswerer(r, now) resp := mustAnswer(t, a, AskRequest{Question: "how long were the kids on screens yesterday?", Asker: "sam"}) if got := answerTextOf(t, resp); !strings.Contains(got, "yesterday") { t.Errorf("answer = %q, want yesterday", got) } } func TestScreenTimeNoneRecorded(t *testing.T) { now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC) r := &fakeReader{ devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}}, usage: map[string][]store.DeviceDayUsage{}, } a := testAnswerer(r, now) resp := mustAnswer(t, a, AskRequest{Question: "how long have the kids been on screens today?", Asker: "sam"}) if got := answerTextOf(t, resp); !strings.Contains(got, "no recorded screen time") { t.Errorf("answer = %q", got) } } func TestBlockedAnswer(t *testing.T) { now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC) until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC) r := &fakeReader{ devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}}, eff: map[int64]store.EffectiveState{ 1: {DeviceID: 1, Locked: true, Reason: "schedule", Until: &until}, }, } a := testAnswerer(r, now) resp := mustAnswer(t, a, AskRequest{Question: "what is blocked right now?", Asker: "sam"}) if got := answerTextOf(t, resp); !strings.Contains(got, "Harry's phone") || !strings.Contains(got, "21:00") { t.Errorf("answer = %q", got) } } func TestPresenceAnswer(t *testing.T) { now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC) lastSeen := now.Add(-2*time.Hour - 10*time.Minute) r := &fakeReader{ devices: []store.Device{ {ID: 1, Name: "Harry's phone", Child: "Harry", Online: true}, {ID: 2, Name: "Finn's computer", Child: "Finn", Online: false, LastSeen: &lastSeen}, }, } a := testAnswerer(r, now) resp := mustAnswer(t, a, AskRequest{Question: "are the devices online?", Asker: "sam"}) if got := answerTextOf(t, resp); !strings.Contains(got, "Finn's computer") || !strings.Contains(got, "2h 10m") { t.Errorf("answer = %q", got) } } func TestPermissionsAndUnknowns(t *testing.T) { now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC) r := &fakeReader{ devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}}, } a := testAnswerer(r, now) cases := []struct { name string req AskRequest want string }{ {"no asker", AskRequest{Question: "screen time?"}, "no asker"}, {"not sam", AskRequest{Question: "screen time?", Asker: "harry"}, "only answer for Sam"}, {"unknown person", AskRequest{Question: "screen time?", Asker: "sam", Person: "jo"}, "no data for that person"}, {"unknown intent", AskRequest{Question: "what is the weather?", Asker: "sam"}, "I can answer"}, } for _, tc := range cases { resp := mustAnswer(t, a, tc.req) if resp.Answer != nil { t.Errorf("%s: answer = %q, want nil", tc.name, *resp.Answer) continue } if !strings.Contains(resp.Reason, tc.want) { t.Errorf("%s: reason = %q, want to contain %q", tc.name, resp.Reason, tc.want) } } if !strings.Contains(mustAnswer(t, a, AskRequest{Question: "", Asker: "sam"}).Reason, "no question") { t.Error("empty question should be refused") } } func TestDefaultCapabilities(t *testing.T) { c := DefaultCapabilities() if c.Project != "internet_control" { t.Errorf("project = %q", c.Project) } if len(c.Topics) == 0 || len(c.People) == 0 { t.Errorf("capabilities = %#v", c) } } func TestScreenSecondsPrefersApp(t *testing.T) { if got := ScreenSeconds(600, 900); got != 600 { t.Errorf("screenSeconds(600,900) = %d, want 600", got) } if got := ScreenSeconds(0, 900); got != 900 { t.Errorf("screenSeconds(0,900) = %d, want 900", got) } }