205 lines
7.5 KiB
Go
205 lines
7.5 KiB
Go
package scheduler_test
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||
|
|
)
|
||
|
|
|
||
|
|
// The tests in this file run plan's rotation_check schedule on the fake
|
||
|
|
// clock against the real plan service and notification outbox. The plan of
|
||
|
|
// the week of Monday Oct 12 (w42) has poolNames, all Regulars, Monday to
|
||
|
|
// Sunday; a test that needs a Quest-log dinner swaps Lemon Orzo in for
|
||
|
|
// Monday. The household's review time is the seeded 09:00, in Chicago.
|
||
|
|
|
||
|
|
// orzoDay is the Monday Lemon Orzo is planned on.
|
||
|
|
var orzoDay = mustLocalDate("2026-10-12")
|
||
|
|
|
||
|
|
// orzoWeek plans w42 with the Quest-log dinner Lemon Orzo on Monday in
|
||
|
|
// place of Beef Chili, and returns its id.
|
||
|
|
func (p *planEnv) orzoWeek() string {
|
||
|
|
p.e.t.Helper()
|
||
|
|
id := p.questLog("Lemon Orzo", 30)
|
||
|
|
p.planWeek(w42)
|
||
|
|
if _, err := p.svc.Swap(context.Background(), w42, orzoDay.String(), id); err != nil {
|
||
|
|
p.e.t.Fatal(err)
|
||
|
|
}
|
||
|
|
return id
|
||
|
|
}
|
||
|
|
|
||
|
|
// rotationReviews returns the keys of the rotation_review notifications
|
||
|
|
// stored, oldest first. The seeded preference sends rotation_review to push
|
||
|
|
// only, so there is one row per event.
|
||
|
|
func (e *env) rotationReviews() []string {
|
||
|
|
e.t.Helper()
|
||
|
|
var keys []string
|
||
|
|
for _, r := range notifytest.Rows(e.t, e.st, notify.RotationReview) {
|
||
|
|
if r.Channel != notify.ChannelPush {
|
||
|
|
e.t.Fatalf("rotation_review row %+v, want push only", r)
|
||
|
|
}
|
||
|
|
keys = append(keys, r.Key)
|
||
|
|
}
|
||
|
|
return keys
|
||
|
|
}
|
||
|
|
|
||
|
|
// status returns the status of the dinner name.
|
||
|
|
func (p *planEnv) status(name string) string {
|
||
|
|
p.e.t.Helper()
|
||
|
|
var s string
|
||
|
|
if err := p.e.st.Reader.QueryRow(`SELECT status FROM dinners WHERE id = ?`, p.ids[name]).Scan(&s); err != nil {
|
||
|
|
p.e.t.Fatal(err)
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
// pendingReviews returns the plan dates of the pending Rotation checks of
|
||
|
|
// the dinner name.
|
||
|
|
func (p *planEnv) pendingReviews(name string) []string {
|
||
|
|
p.e.t.Helper()
|
||
|
|
rows, err := p.e.st.Reader.Query(`SELECT plan_date FROM rotation_reviews WHERE dinner_id = ? AND status = 'pending' ORDER BY plan_date`, p.ids[name])
|
||
|
|
if err != nil {
|
||
|
|
p.e.t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
var out []string
|
||
|
|
for rows.Next() {
|
||
|
|
var d string
|
||
|
|
if err := rows.Scan(&d); err != nil {
|
||
|
|
p.e.t.Fatal(err)
|
||
|
|
}
|
||
|
|
out = append(out, d)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
p.e.t.Fatal(err)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// wantOrzoTried fails unless Lemon Orzo, id, is tried with one pending
|
||
|
|
// Rotation check for orzoDay and exactly one rotation_review asking about
|
||
|
|
// it, which opens Home.
|
||
|
|
func (p *planEnv) wantOrzoTried(id string) {
|
||
|
|
p.e.t.Helper()
|
||
|
|
if got := p.status("Lemon Orzo"); got != "tried" {
|
||
|
|
p.e.t.Errorf("Lemon Orzo status = %s, want tried", got)
|
||
|
|
}
|
||
|
|
wantKeys(p.e.t, "Lemon Orzo pending Rotation checks", p.pendingReviews("Lemon Orzo"), []string{orzoDay.String()})
|
||
|
|
key := "rotation_review:" + id + ":" + orzoDay.String()
|
||
|
|
wantKeys(p.e.t, "rotation_review", p.e.rotationReviews(), []string{key})
|
||
|
|
got := notifytest.Rows(p.e.t, p.e.st, notify.RotationReview)[0].Payload
|
||
|
|
want := notify.Payload{Event: notify.RotationReview, Title: "How was Lemon Orzo? Keep it in rotation?", URL: "/", Tag: key, Week: w42.String()}
|
||
|
|
if got != want {
|
||
|
|
p.e.t.Errorf("rotation_review = %+v, want %+v", got, want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRotationCheckMarksQuestLogDinnerTried: at review_time 09:00 the day
|
||
|
|
// after a Quest-log dinner was planned, the schedule, period
|
||
|
|
// rotation_check:YYYY-MM-DD, marks it tried, opens its Rotation check and
|
||
|
|
// emits one rotation_review. It never makes the dinner a Regular.
|
||
|
|
func TestRotationCheckMarksQuestLogDinnerTried(t *testing.T) {
|
||
|
|
e := newEnv(t)
|
||
|
|
p := e.plans(e.pool())
|
||
|
|
id := p.orzoWeek()
|
||
|
|
|
||
|
|
// Tuesday 08:00: the catch-up of Monday 09:00 checks Sunday, unplanned.
|
||
|
|
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||
|
|
r.runUntil(at(2026, 10, 13, 8, 59))
|
||
|
|
wantKeys(t, "before 09:00", e.rotationReviews(), nil)
|
||
|
|
if got := p.status("Lemon Orzo"); got != "quest_log" {
|
||
|
|
t.Fatalf("Lemon Orzo status before 09:00 = %s, want quest_log", got)
|
||
|
|
}
|
||
|
|
|
||
|
|
r.runUntil(at(2026, 10, 13, 9, 0))
|
||
|
|
p.wantOrzoTried(id)
|
||
|
|
|
||
|
|
// Days later, still tried: only the household's keep makes a Regular.
|
||
|
|
r.runUntil(at(2026, 10, 16, 12, 0))
|
||
|
|
p.wantOrzoTried(id)
|
||
|
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{
|
||
|
|
"rotation_check:2026-10-12", "rotation_check:2026-10-13", "rotation_check:2026-10-14",
|
||
|
|
"rotation_check:2026-10-15", "rotation_check:2026-10-16",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRotationCheckLeavesRegulars: a day planning only Regulars marks
|
||
|
|
// nothing tried, opens no Rotation check and emits nothing.
|
||
|
|
func TestRotationCheckLeavesRegulars(t *testing.T) {
|
||
|
|
e := newEnv(t)
|
||
|
|
p := e.plans(e.pool())
|
||
|
|
p.planWeek(w42)
|
||
|
|
|
||
|
|
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||
|
|
r.runUntil(at(2026, 10, 13, 9, 0))
|
||
|
|
wantKeys(t, "rotation_review", e.rotationReviews(), nil)
|
||
|
|
if got := p.status("Beef Chili"); got != "regular" {
|
||
|
|
t.Errorf("Beef Chili status = %s, want regular", got)
|
||
|
|
}
|
||
|
|
wantKeys(t, "Beef Chili pending Rotation checks", p.pendingReviews("Beef Chili"), nil)
|
||
|
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRotationCheckNoDuplicateSameDay: a restart the same day finds the
|
||
|
|
// period claimed, and MarkTried run again for the same day finds the
|
||
|
|
// Rotation check open, so neither opens a second check or notification.
|
||
|
|
func TestRotationCheckNoDuplicateSameDay(t *testing.T) {
|
||
|
|
e := newEnv(t)
|
||
|
|
ids := e.pool()
|
||
|
|
p := e.plans(ids)
|
||
|
|
id := p.orzoWeek()
|
||
|
|
|
||
|
|
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||
|
|
r.runUntil(at(2026, 10, 13, 9, 0))
|
||
|
|
p.wantOrzoTried(id)
|
||
|
|
r.stop()
|
||
|
|
p.stop()
|
||
|
|
|
||
|
|
p2 := e.plans(ids)
|
||
|
|
r2 := e.start(at(2026, 10, 13, 9, 30), p2.svc.RotationCheckSchedule())
|
||
|
|
r2.runUntil(at(2026, 10, 13, 23, 59))
|
||
|
|
p2.wantOrzoTried(id)
|
||
|
|
|
||
|
|
if n, err := p2.svc.MarkTried(context.Background(), orzoDay); err != nil || n != 0 {
|
||
|
|
t.Fatalf("MarkTried(%s) again = %d, %v, want 0", orzoDay, n, err)
|
||
|
|
}
|
||
|
|
p2.wantOrzoTried(id)
|
||
|
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRotationCheckCatchUpAtBoot: started 23 hours after a missed 09:00,
|
||
|
|
// the scheduler runs that day's Rotation check once at boot.
|
||
|
|
func TestRotationCheckCatchUpAtBoot(t *testing.T) {
|
||
|
|
e := newEnv(t)
|
||
|
|
p := e.plans(e.pool())
|
||
|
|
id := p.orzoWeek()
|
||
|
|
|
||
|
|
e.start(at(2026, 10, 14, 8, 0), p.svc.RotationCheckSchedule())
|
||
|
|
p.wantOrzoTried(id)
|
||
|
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-13"})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRotationCheckFollowsReviewTimeWithoutRestart: a review_time change
|
||
|
|
// takes effect in the running scheduler. Moved from 09:00 to 10:30, the
|
||
|
|
// Rotation check does not run at 09:00 and runs at 10:30.
|
||
|
|
func TestRotationCheckFollowsReviewTimeWithoutRestart(t *testing.T) {
|
||
|
|
e := newEnv(t)
|
||
|
|
p := e.plans(e.pool())
|
||
|
|
id := p.orzoWeek()
|
||
|
|
|
||
|
|
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||
|
|
e.update(household.SettingsPatch{ReviewTime: ptr("10:30")})
|
||
|
|
r.changed()
|
||
|
|
r.runUntil(at(2026, 10, 13, 10, 29))
|
||
|
|
wantKeys(t, "before 10:30", e.rotationReviews(), nil)
|
||
|
|
if got := p.status("Lemon Orzo"); got != "quest_log" {
|
||
|
|
t.Fatalf("Lemon Orzo status before 10:30 = %s, want quest_log", got)
|
||
|
|
}
|
||
|
|
r.runUntil(at(2026, 10, 13, 10, 30))
|
||
|
|
p.wantOrzoTried(id)
|
||
|
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||
|
|
}
|