Rotation check schedule (Feature 20)
Register rotation_check: daily at review_time, claimed as
rotation_check:YYYY-MM-DD, it calls plan.MarkTried for yesterday.
MarkTried now emits rotation_review:{dinnerId}:{planDate} ("How was
{dinner}? Keep it in rotation?", URL /) for each Rotation check it
opens, in the same transaction. Nothing promotes a dinner to Regular.
Task: 58095ae5-ed4b-4494-b8f0-206c4c0a3f1a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -204,6 +204,9 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := sched.Register(planSvc.RotationCheckSchedule()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil {
|
if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-11
@@ -484,15 +484,13 @@ automatic** (D-021).
|
|||||||
`changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
|
`changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
|
||||||
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
|
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
|
||||||
a dinner already on another day of the week 409 `dinner_already_planned`.
|
a dinner already on another day of the week 409 `dinner_already_planned`.
|
||||||
- Rotation check (`plan.Service`, D-021). This release provides the
|
- Rotation check (`plan.Service`, D-021). The `rotation_check` schedule
|
||||||
`MarkTried` service method and the rotation review read and decide APIs
|
calls `MarkTried` for the household-local day just past (see Scheduler).
|
||||||
below. The `rotation_check` schedule that is to call `MarkTried` for the
|
`MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
|
||||||
household-local day just past is planned (Feature 20, the scheduler) and
|
status, to `tried`, opens a `pending` `rotation_reviews` row for it and
|
||||||
not wired yet: nothing in production calls `MarkTried`, so no Rotation
|
emits its `rotation_review`, in one transaction;
|
||||||
check opens automatically. `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
|
a dinner already checked for that date is left as it is and not
|
||||||
status, to `tried` and opens a `pending` `rotation_reviews` row for it;
|
notified again, so it is idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
||||||
a dinner already checked for that date is left as it is, so it is
|
|
||||||
idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
|
||||||
pending checks whose dinner is still tried, oldest `plan_date` first,
|
pending checks whose dinner is still tried, oldest `plan_date` first,
|
||||||
each `{dinner: {id, name, photo}, plan_date}`. `POST
|
each `{dinner: {id, name, photo}, plan_date}`. `POST
|
||||||
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
|
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
|
||||||
@@ -784,7 +782,7 @@ purge are fixed and not configurable.
|
|||||||
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
|
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
|
||||||
| `shopping_chat_autosend` | Saturday 08:00 | Sends the coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). |
|
| `shopping_chat_autosend` | Saturday 08:00 | Sends the coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). |
|
||||||
| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). |
|
| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). |
|
||||||
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
|
| `rotation_check` | Daily 09:00 | Marks yesterday's Quest log dinners tried and asks "Keep it in rotation?" (`plan.Service.RotationCheckSchedule`). |
|
||||||
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). |
|
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). |
|
||||||
| `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). |
|
| `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). |
|
||||||
|
|
||||||
@@ -852,7 +850,13 @@ with the key `list_ready:chat:{weekStart}`. It never resends; a send that
|
|||||||
fails is the period's error. `shopping.Service.RolloverSchedule()` is
|
fails is the period's error. `shopping.Service.RolloverSchedule()` is
|
||||||
`week_rollover`, `Weekly(Monday, 00:05)`; its run freezes the week before
|
`week_rollover`, `Weekly(Monday, 00:05)`; its run freezes the week before
|
||||||
the fire date's, and a week with no Supply run freezes nothing and logs
|
the fire date's, and a week with no Supply run freezes nothing and logs
|
||||||
it. `harness.PurgeSchedule(store, logger)` is `ai_runs_purge`,
|
it. `plan.Service.RotationCheckSchedule()` is `rotation_check`,
|
||||||
|
`Daily(review_time)`, claimed as `rotation_check:YYYY-MM-DD` of the fire
|
||||||
|
date (`Schedule.PeriodKey`). Its run calls `MarkTried` for the day before
|
||||||
|
the fire date, which emits one `rotation_review` per Rotation check it
|
||||||
|
opens; it never makes a dinner a Regular, and a day with no Quest log
|
||||||
|
dinner planned opens none and logs the count.
|
||||||
|
`harness.PurgeSchedule(store, logger)` is `ai_runs_purge`,
|
||||||
`Daily(03:00)`, deleting rows created more than 30 days before the fire
|
`Daily(03:00)`, deleting rows created more than 30 days before the fire
|
||||||
time.
|
time.
|
||||||
Fake-clock tests of a schedule live in `internal/scheduler`, where
|
Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||||
@@ -899,6 +903,10 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
|||||||
{time}" with the dinner's name, only for a crock-pot dinner whose
|
{time}" with the dinner's name, only for a crock-pot dinner whose
|
||||||
`start_by` is earlier than `reminder_time`. The `day_of_reminder`
|
`start_by` is earlier than `reminder_time`. The `day_of_reminder`
|
||||||
schedule calls it 15 minutes before `start_by`.
|
schedule calls it 15 minutes before `start_by`.
|
||||||
|
- `rotation_review:{dinnerId}:{planDate}` (URL `/`, where the Home card
|
||||||
|
asks) through `plan.Service.MarkTried(planDate)`, "How was {dinner}?
|
||||||
|
Keep it in rotation?", once per Rotation check it opens. The
|
||||||
|
`rotation_check` schedule calls it at `review_time` for yesterday.
|
||||||
- Delivery goes through `notification_outbox`.
|
- Delivery goes through `notification_outbox`.
|
||||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||||
webhook.
|
webhook.
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ func TestSendThreadsByWeek(t *testing.T) {
|
|||||||
ListReady(week, 19, 7),
|
ListReady(week, 19, 7),
|
||||||
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
|
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
|
||||||
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
|
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
|
||||||
RotationReview("01J9DINNER", "Pho", week),
|
RotationReview("01J9DINNER", "Pho", mustDate(t, "2026-10-14")),
|
||||||
}
|
}
|
||||||
for _, ev := range events {
|
for _, ev := range events {
|
||||||
if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil {
|
if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil {
|
||||||
|
|||||||
@@ -107,17 +107,17 @@ func NewSuggestions(id string, week store.LocalDate, names []string) notify.Even
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotationReview is the rotation_review event asking whether the dinner
|
// RotationReview is the rotation_review event, keyed
|
||||||
// dinnerID, named name and tried in the week starting on week, becomes a
|
// rotation_review:{dinnerID}:{planDate}, of the Rotation check of the
|
||||||
// Regular.
|
// dinner dinnerID, named name and tried on planDate: "How was Pho? Keep it
|
||||||
func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event {
|
// in rotation?". It opens Home, where the Rotation check card asks.
|
||||||
|
func RotationReview(dinnerID, name string, planDate store.LocalDate) notify.Event {
|
||||||
return notify.Event{
|
return notify.Event{
|
||||||
Type: notify.RotationReview,
|
Type: notify.RotationReview,
|
||||||
Key: string(notify.RotationReview) + ":" + dinnerID,
|
Key: string(notify.RotationReview) + ":" + dinnerID + ":" + planDate.String(),
|
||||||
Title: "Keep it in rotation?",
|
Title: "How was " + name + "? Keep it in rotation?",
|
||||||
Body: "You tried " + name + ". Make it a Regular?",
|
|
||||||
URL: "/",
|
URL: "/",
|
||||||
Week: week,
|
Week: household.WeekStart(planDate),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,13 +137,13 @@ func TestMessages(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "rotation review",
|
name: "rotation review",
|
||||||
ev: RotationReview("01J9DINNER", "Lemon orzo", week),
|
ev: RotationReview("01J9DINNER", "Lemon orzo", mustDate(t, "2026-10-14")),
|
||||||
key: "rotation_review:01J9DINNER",
|
key: "rotation_review:01J9DINNER:2026-10-14",
|
||||||
url: "/",
|
url: "/",
|
||||||
week: "2026-10-12",
|
week: "2026-10-12",
|
||||||
title: "Keep it in rotation?",
|
title: "How was Lemon orzo? Keep it in rotation?",
|
||||||
body: "You tried Lemon orzo. Make it a Regular?",
|
body: "",
|
||||||
chatT: "Keep it in rotation? You tried Lemon orzo. Make it a Regular? Open MealQuest →",
|
chatT: "How was Lemon orzo? Keep it in rotation? Open MealQuest →",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
ev := tc.ev
|
ev := tc.ev
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,12 +82,40 @@ type Decided struct {
|
|||||||
UpdatedBy string `json:"updated_by"`
|
UpdatedBy string `json:"updated_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RotationCheckSchedule is the rotation_check schedule: every day at the
|
||||||
|
// household's review_time it runs MarkTried for the day before, so each
|
||||||
|
// Quest log dinner planned yesterday is tried and the household is asked
|
||||||
|
// "Keep it in rotation?". Its period is the fire date, claimed as
|
||||||
|
// rotation_check:YYYY-MM-DD. It never makes a dinner a Regular.
|
||||||
|
func (s *Service) RotationCheckSchedule() scheduler.Schedule {
|
||||||
|
return scheduler.Schedule{
|
||||||
|
Name: scheduler.RotationCheck,
|
||||||
|
Rule: func(settings household.Settings) scheduler.Rule {
|
||||||
|
return scheduler.Daily(settings.ReviewTime)
|
||||||
|
},
|
||||||
|
PeriodKey: func(p scheduler.Period) string {
|
||||||
|
return scheduler.RotationCheck + ":" + p.Key
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||||
|
yesterday := addDays(p.Date, -1)
|
||||||
|
n, err := s.MarkTried(ctx, yesterday)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.logger.InfoContext(ctx, "Rotation check: Quest log dinners marked tried",
|
||||||
|
"period", p.Key, "plan_date", yesterday.String(), "tried", n)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MarkTried moves every dinner in the Quest log planned on date, the
|
// MarkTried moves every dinner in the Quest log planned on date, the
|
||||||
// household-local day just past, on a plan of any status, to tried and
|
// household-local day just past, on a plan of any status, to tried, opens
|
||||||
// opens a pending Rotation check of it for date. It is idempotent: a
|
// a pending Rotation check of it for date and emits its rotation_review
|
||||||
// dinner already checked for date is left as it is, so a later run never
|
// (chat.RotationReview), all in one transaction. It is idempotent: a
|
||||||
// moves one returned to the Quest log ("We didn't make it") again. It
|
// dinner already checked for date is left as it is and not notified of
|
||||||
// returns how many dinners it moved.
|
// again, so a later run never moves one returned to the Quest log ("We
|
||||||
|
// didn't make it") again. It returns how many dinners it moved.
|
||||||
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
|
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
@@ -95,6 +126,10 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
|||||||
}
|
}
|
||||||
now := store.Now().String()
|
now := store.Now().String()
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
|
d, err := q.GetDinner(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: read dinner %s: %w", id, err)
|
||||||
|
}
|
||||||
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
|
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: mark %s tried: %w", id, err)
|
return fmt.Errorf("plan: mark %s tried: %w", id, err)
|
||||||
@@ -107,6 +142,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
|
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
|
||||||
}
|
}
|
||||||
|
if err := s.notifier.EmitTx(ctx, tx, chat.RotationReview(id, d.Name, date)); err != nil {
|
||||||
|
return fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
n = len(ids)
|
n = len(ids)
|
||||||
return nil
|
return nil
|
||||||
@@ -114,6 +152,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if n > 0 {
|
||||||
|
s.notifier.Wake()
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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"})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user