Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a183c1984 | ||
|
|
832e6b9f7d | ||
|
|
17fa82ef2b | ||
|
|
fd087e0ba3 | ||
|
|
ecf7c6be89 | ||
|
|
4dcfc3c27d | ||
|
|
dcd35e78ec | ||
|
|
4b592fdbbb | ||
|
|
14f7141e44 | ||
|
|
47a5b5cb89 |
+83
-6
@@ -23,12 +23,15 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
@@ -128,7 +131,12 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
|
||||
// The scheduler is made before the services whose changes wake it.
|
||||
sched, err := scheduler.New(db, settings, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dinnerSvc, err := dinners.NewService(db, grounder, runner, sched.Wake, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -150,7 +158,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, cfg.PublicURL, logger)
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -165,6 +173,16 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ideasSvc, err := ideas.NewService(db, grounder, ideaFinder(ai, guard), quickIdeaFinder(ai, guard), runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
homeSvc, err := home.NewService(settings, planSvc, suggestSvc, shopSvc, notifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -181,17 +199,46 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets())
|
||||
if err := sched.Register(suggestSvc.WeeklySchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sched.Register(planSvc.DraftSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sch := range planSvc.ReminderSchedules() {
|
||||
if err := sched.Register(sch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := sched.Register(planSvc.RotationCheckSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sched.Register(shopSvc.RolloverSchedule()); err != nil {
|
||||
return err
|
||||
}
|
||||
purge, err := harness.PurgeSchedule(db, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sched.Register(purge); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, homeSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start fails the jobs a previous process left running before any
|
||||
// worker runs. The job workers and the notification worker stop, and
|
||||
// are waited for, before the database closes.
|
||||
// worker runs. The job workers, the notification worker and the
|
||||
// scheduler stop, and are waited for, before the database closes.
|
||||
workersCtx, stopWorkers := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
stopWorkers()
|
||||
sched.Wait()
|
||||
runner.Wait()
|
||||
notifier.Wait()
|
||||
}()
|
||||
@@ -207,6 +254,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// The scheduler starts last: its catch-up may enqueue jobs and
|
||||
// notifications, so their workers must already run.
|
||||
if err := sched.Start(workersCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: router,
|
||||
@@ -311,7 +363,8 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
|
||||
// service registers its own kinds when it is built, before the runner
|
||||
// starts (dinners.NewService registers dinners.KindGroundDinner and
|
||||
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
|
||||
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun).
|
||||
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun,
|
||||
// ideas.NewService ideas.KindFromIngredients and ideas.KindQuick).
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
@@ -368,6 +421,30 @@ func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler
|
||||
}
|
||||
}
|
||||
|
||||
// ideaFinder runs ideas_from_ingredients with the AI settings current at
|
||||
// each run, sharing guard.
|
||||
func ideaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.Generator {
|
||||
return func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.Ideas{}, err
|
||||
}
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// quickIdeaFinder runs quick_ideas with the AI settings current at each
|
||||
// run, sharing guard.
|
||||
func quickIdeaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.QuickGenerator {
|
||||
return func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.QuickIdeaList{}, err
|
||||
}
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
+330
-44
@@ -206,6 +206,30 @@ automatic** (D-021).
|
||||
sets status `archived` and keeps the row; `POST
|
||||
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
|
||||
the session's subject as `updated_by`; an unknown id is 404.
|
||||
`GET /api/v1/dinners/{id}` returns the dinner with its recipe, read in
|
||||
one transaction: `{id, name, status, origin, method, minutes_total,
|
||||
difficulty, cuisine, is_favorite, photo_urls, ingredients, steps,
|
||||
source, grounding_status, grounding_error, why_picked}`. `photo_urls` is
|
||||
`{"400": url, "1200": url}` (`/media/photos/{photo_id}-{size}.jpg`) or
|
||||
null with no photo; `ingredients` is `[{name, qty, unit, note,
|
||||
raw_text}]` and `steps` the step texts, both in recipe position order and
|
||||
empty (`[]`) with no recipe, as for a dinner whose grounding is
|
||||
`pending` or `failed`; a recipe entered by hand has only `raw_text`.
|
||||
Two items at one position leave recipe order unknown, so such a recipe
|
||||
is 500 rather than shown out of order. `source` is `{site_name}` of the
|
||||
page the recipe was fetched from, null for a manual recipe or none; it
|
||||
has no `url`, as the page's link may carry a personal token.
|
||||
`site_name` is the name the page gives its site (publisher or
|
||||
`og:site_name`) and null otherwise: a page naming no site is stored
|
||||
under its host, which may be the pasted link's — a token in a
|
||||
subdomain — or a redirect's, so any stored name that could be a host
|
||||
(the link's host, an IP address or a dotted host name, even a real
|
||||
name like "Food.com") is withheld. The detail screen shows "Recipe
|
||||
from {site}" only when `site_name` is not null. `why_picked` is the reason of the
|
||||
suggestion that created the dinner — its pending or accepted one, as
|
||||
declining a suggestion archives its dinner — null for one no suggestion
|
||||
created; a dinner with more than one such suggestion is 500, as nothing
|
||||
records which created it. An unknown or archived dinner is 404.
|
||||
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
|
||||
job and answers with its `grounding_job_id` without waiting for it
|
||||
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
|
||||
@@ -285,20 +309,24 @@ automatic** (D-021).
|
||||
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
|
||||
grounds the survivors in the model's order with `Grounder.GroundMany`,
|
||||
as many as are still wanted at a time, until 5 are kept or none are
|
||||
left. A Side quest no recipe grounds is dropped; each round's grounded
|
||||
ones are stored before the next — a dinner with status `suggested`,
|
||||
origin `side_quest`, grounding_status `grounded` with the recipe's
|
||||
recipe_id, photo_id, method, minutes_total and difficulty and the
|
||||
model's cuisine, and a `pending` suggestion with the name it is
|
||||
suggested under, the reason and `cited_regular_ids` — unless, since the
|
||||
job read its input, a dinner it repeats was added, a suggestion it
|
||||
repeats passed, or a Regular it cites renamed or archived (checked in
|
||||
the storing transaction). The job publishes
|
||||
`{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
|
||||
left. A Side quest no recipe grounds is dropped. A `replenish` batch
|
||||
stores each round's grounded ones before the next; a `scheduled` or
|
||||
`manual` batch, which notifies, holds them and stores them all in one
|
||||
transaction once its last round is done. Each is stored as a dinner
|
||||
with status `suggested`, origin `side_quest`, grounding_status
|
||||
`grounded` with the recipe's recipe_id, photo_id, method,
|
||||
minutes_total and difficulty and the model's cuisine, and a `pending`
|
||||
suggestion with the name it is suggested under, the reason and
|
||||
`cited_regular_ids` — unless, since the job read its input, a dinner
|
||||
it repeats was added, a suggestion it repeats passed, or a Regular it
|
||||
cites renamed or archived (checked in the storing transaction). The job
|
||||
publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
|
||||
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
|
||||
skill or `normalize_recipe` failed; no Side quest is made up instead,
|
||||
D-017), `search_not_configured` or `search_failed`, and the batch is set
|
||||
`failed`; Side quests stored before the failure stay in the deck. If
|
||||
`failed`. A failed `scheduled` or `manual` batch has stored nothing; a
|
||||
failed `replenish` batch's Side quests stored before the failure stay
|
||||
in the deck. If
|
||||
recording the batch's end fails, its job fails; the job runner retries
|
||||
a job's final status write with backoff until it lands, each attempt
|
||||
bounded at 5 seconds; at shutdown an attempt that fails or times out is
|
||||
@@ -310,6 +338,89 @@ automatic** (D-021).
|
||||
still `pending` or `running` with no job queued or running (the process
|
||||
stopped, or recording its end failed) is set `failed`, so it does not
|
||||
block the next.
|
||||
- Cook from what I have (`ideas.Service`, Feature 18). `POST
|
||||
/api/v1/ideas/from-ingredients {ingredients[], quick_only}` (both
|
||||
required) checks the list with `skills.CleanIngredients` — 1 to 20
|
||||
entries, each non-blank, at most 80 characters, with a non-empty
|
||||
`harness.Normalize` form that no earlier entry shares — answering 400
|
||||
`invalid_request` otherwise, then enqueues an `ideas_from_ingredients`
|
||||
job (service-only: `POST /jobs` refuses the kind) and answers 202
|
||||
`{jobId}` without waiting. The job runs `ideas_from_ingredients` (scope:
|
||||
`skills.IdeasScope`, the flag and the normalized list in order), then
|
||||
grounds its ideas in the model's order with `Grounder.GroundEach`, as
|
||||
many as are still wanted at a time, until 5 are kept or none are left.
|
||||
An idea no recipe grounds is dropped; with `quick_only`, so is one whose
|
||||
grounded recipe is not a Quick dinner (`ideas.IsQuick`: method
|
||||
`crockpot`, or `minutes_total` ≤ 30 — the recipe's, never the model's
|
||||
`est_minutes`). Ideas are not stored as dinners. The job publishes
|
||||
`{stage, ingredients, quick_only, ideas: [{name, uses, missing, summary,
|
||||
method, minutes_total, difficulty, quick, recipe_id, photo_url,
|
||||
source_url, site_name}], dropped}` at each stage (`suggesting`,
|
||||
`grounding`, `done`) and again as each idea is kept, the moment its
|
||||
grounding finishes, so the result fills idea by idea; the ideas shown
|
||||
are always in the model's order, an earlier idea that grounds later
|
||||
taking its place before those shown. It fails with `no_ideas_found`
|
||||
(every idea was dropped), `ai_failed` (the skill or `normalize_recipe`
|
||||
failed; no idea is made up instead, D-017), `search_not_configured` or
|
||||
`search_failed`. A failed job shows no ideas, even those kept before the
|
||||
failure: the kind registers a `FailedResult` view, so `GET
|
||||
/api/v1/jobs/{id}` empties the `ideas` of any failed job's result on
|
||||
read, however it failed (including `interrupted by restart` and an
|
||||
`internal_error` whose clearing write failed). Before it fails it also
|
||||
publishes its result again with no ideas, to tidy what is stored.
|
||||
- Quick dinners (`ideas.Service`, Feature 18). `POST /api/v1/ideas/quick
|
||||
{kind, exclude[]}` (both required; `exclude` is `[]` on a first page)
|
||||
checks `kind` with `skills.CheckQuickKind` — `crockpot` or `under30`,
|
||||
no default — and `exclude` with `skills.CleanExclude` — at most 60
|
||||
names, each non-blank, at most 120 characters, with a non-empty
|
||||
`harness.Normalize` form no earlier name shares — answering 400
|
||||
`invalid_request` otherwise, then enqueues a `quick_ideas` job
|
||||
(service-only) and answers 202 `{jobId}` without waiting. The job reads
|
||||
the household context (`harness.BuildContext`, budget 16000), runs
|
||||
`quick_ideas` (scope: `skills.QuickIdeasScope`, the kind and the
|
||||
normalized excluded names in order), then grounds its ideas exactly as
|
||||
the ingredients job does (the same `Grounder.GroundEach` rounds, model
|
||||
order and failure codes) until 6 are kept or none are left. The kind is
|
||||
enforced on the grounded recipe, never the model's estimate
|
||||
(`ideas.OfQuickKind`): `crockpot` keeps method `crockpot`
|
||||
(`normalize_recipe` writes a slow cooker as `crockpot`), `under30`
|
||||
keeps a known `minutes_total` ≤ 30; every other idea is dropped. The
|
||||
model is asked for 12, more than a page, so dropped ideas can be
|
||||
replaced; when fewer than 6 survive the page shows only those, never
|
||||
padded with an idea not of the kind. The job publishes `{stage, kind,
|
||||
exclude, ideas: [{name, method, minutes_total, difficulty, recipe_id,
|
||||
photo_url, source_url, site_name}], dropped}` at each stage and as each
|
||||
idea is kept, fails with the same reasons, and a failed job shows no
|
||||
ideas through the same `FailedResult` view. "↻ Show me more" posts
|
||||
again with every name the tab has shown so far in `exclude`.
|
||||
- Idea actions (Feature 18). Both act on idea `{i}` (0, 1, …; anything
|
||||
else is 400 `invalid_request`) of a **succeeded** `ideas_from_ingredients`
|
||||
or `quick_ideas` job, read from its stored result
|
||||
(`ideas.Service.Idea`): its name and `recipe_id`, never grounded again.
|
||||
A job still queued or running is 409 `ideas_not_ready` (its ideas may
|
||||
still move); an unknown job, another kind, a failed job or an index past
|
||||
its ideas is 404 `not_found`. `POST
|
||||
/api/v1/ideas/{jobId}/items/{i}/quest-log` (no body) runs
|
||||
`dinners.Service.AddToQuestLog`: in one transaction, an unarchived
|
||||
dinner the idea repeats — the same rule that refuses a duplicate Regular
|
||||
(`harness.Similar` name, or the recipe's link) — is answered 200 as it
|
||||
is (a Regular stays a Regular); one awaiting the household's choice
|
||||
(`suggested`, `tried`) is 409 `duplicate_dinner` and changes nothing;
|
||||
otherwise it creates a dinner with status `quest_log`, origin
|
||||
`ingredients` or `quick`, grounded in the idea's recipe (its method,
|
||||
minutes, difficulty and photo), answered 201. Repeating the call
|
||||
answers the same dinner. `POST /api/v1/ideas/{jobId}/items/{i}/cook-tonight`
|
||||
(no body) takes today as the household-local date in the household
|
||||
timezone and its Monday (`plan.Service.Tonight`); when that week has no
|
||||
plan it answers 409 `no_plan` and changes nothing — no dinner, no plan
|
||||
(a plan holds all seven days; the household plans the week first).
|
||||
Otherwise it adds or finds the dinner as `quest-log` does and puts it
|
||||
on today through `plan.Service.Swap`, exactly like a Draft board swap
|
||||
(plan marked edited, revision raised, a synced plan becomes `changed`,
|
||||
the day's calendar event id cleared, `is_new` for a Quest log dinner),
|
||||
answering 200 `{dinner, plan}`. The week is always today's own, so no
|
||||
other day is written; a dinner already on another day of the week is
|
||||
409 `dinner_already_planned`.
|
||||
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
|
||||
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
|
||||
cited_regular_ids, status, dinner_status, decided_at, created_at}],
|
||||
@@ -373,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
|
||||
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`.
|
||||
- Rotation check (`plan.Service`, D-021). This release provides the
|
||||
`MarkTried` service method and the rotation review read and decide APIs
|
||||
below. The `rotation_check` schedule that is to call `MarkTried` for the
|
||||
household-local day just past is planned (Feature 20, the scheduler) and
|
||||
not wired yet: nothing in production calls `MarkTried`, so no Rotation
|
||||
check opens automatically. `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
|
||||
status, to `tried` and opens a `pending` `rotation_reviews` row for it;
|
||||
a dinner already checked for that date is left as it is, so it is
|
||||
idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
||||
- Rotation check (`plan.Service`, D-021). The `rotation_check` schedule
|
||||
calls `MarkTried` for the household-local day just past (see Scheduler).
|
||||
`MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
|
||||
status, to `tried`, opens a `pending` `rotation_reviews` row for it and
|
||||
emits its `rotation_review`, in one transaction;
|
||||
a dinner already checked for that date is left as it is and not
|
||||
notified again, so it is idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
|
||||
pending checks whose dinner is still tried, oldest `plan_date` first,
|
||||
each `{dinner: {id, name, photo}, plan_date}`. `POST
|
||||
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
|
||||
@@ -426,8 +535,8 @@ automatic** (D-021).
|
||||
received.
|
||||
`harness.Purge` deletes rows older than `harness.RunRetention` (30 days),
|
||||
clearing the `suggestion_batches` and `week_plans` references first. The
|
||||
harness does not call it: the scheduler's daily `ai_runs` purge
|
||||
(Feature 20) must call `harness.Purge` with `now - RunRetention`.
|
||||
daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with
|
||||
its fire time less `RunRetention`.
|
||||
- **Concurrency guards** (`harness.Guard`, one per process): `harness.Run`
|
||||
takes a scope key, and concurrent runs of one skill with one scope share a
|
||||
single in-flight run and its result, error included (single-flight); runs
|
||||
@@ -472,6 +581,32 @@ automatic** (D-021).
|
||||
not a Regular; and one whose reason is over 180 characters or names none
|
||||
of its cited Regulars. Cited names are rewritten as the household writes
|
||||
them. Grounding each survivor in a recipe is the caller's next step.
|
||||
- **Ideas from ingredients** (`skills.IdeasFromIngredients`, skill
|
||||
`ideas_from_ingredients` v1): from the household's ingredient list (the
|
||||
"Ingredients on hand" context section) and the Quick only flag, asks for
|
||||
8 ideas `{name, uses[], missing[], est_minutes, method, difficulty,
|
||||
search_query}` at the household temperature. Malformed replies (a
|
||||
missing field, a bad enum, an empty name, more than 8 ideas) get the one
|
||||
repair turn. The invariant is enforced in Go: a `uses` entry is kept
|
||||
only when its `harness.Normalize` form is a listed ingredient's, and is
|
||||
then written as the household wrote it, once, in the household's order;
|
||||
`missing` loses blanks, repeats and anything listed. An idea left using
|
||||
none of the listed ingredients, or repeating an earlier idea
|
||||
(`harness.Similar`), is dropped into `Dropped`. Each kept idea's summary
|
||||
line (`skills.IdeaSummary`) is computed from the corrected lists: "Uses
|
||||
all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of 5 ·
|
||||
missing: …" otherwise, with "nothing missing" in place of the list when
|
||||
nothing is.
|
||||
- **Quick ideas** (`skills.QuickIdeas`, skill `quick_ideas` v1): from the
|
||||
household context, a kind (`crockpot` or `under30`, which picks the
|
||||
prompt's rule) and the names already shown (the "Already shown" context
|
||||
section), asks for 12 ideas `{name, est_minutes, method, difficulty,
|
||||
search_query}` at the household temperature. Malformed replies (a
|
||||
missing field, a bad enum, an empty name, more than 12 ideas) get the
|
||||
one repair turn. An idea repeating an excluded name or an earlier idea
|
||||
(`harness.Similar`, on the normalized names) is dropped into `Dropped`,
|
||||
so an excluded name is never returned. Whether an idea is of the kind
|
||||
is left to the grounded recipe (see Quick dinners above).
|
||||
- **Supply run titles** (`skills.TitleArchive`, skill `archive_title`
|
||||
v1): from the names of the week's planned dinners in day order, writes
|
||||
`{title}`, the Supply run's post title in the Supply archive, at the
|
||||
@@ -516,7 +651,8 @@ job failed, the freeze finds the list still untitled and makes its own
|
||||
`archive_title` attempt before freezing. A job that starts during a freeze
|
||||
waits for it, then finds the list titled (that title stands) or frozen
|
||||
untitled (the job fails `title_failed`), and stores nothing. The
|
||||
`week_rollover` schedule is to call `Freeze` for last week.
|
||||
`week_rollover` schedule (`shopping.Service.RolloverSchedule`) calls
|
||||
`Freeze` for last week, judging the week ended by its fire time.
|
||||
|
||||
## Recipe grounding
|
||||
|
||||
@@ -627,7 +763,8 @@ untitled (the job fails `title_failed`), and stores nothing. The
|
||||
candidates `Ground` would try, without grounding any. Search failures
|
||||
wrap `recipes.ErrSearchFailed` and model provider failures
|
||||
`recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per
|
||||
item, so one failure never affects its siblings. A recipe is usable only
|
||||
item, so one failure never affects its siblings; `GroundEach` grounds
|
||||
the same way but reports each item, one at a time, as it finishes. A recipe is usable only
|
||||
with ingredients, an image and, if given, a positive total time.
|
||||
|
||||
## Scheduler
|
||||
@@ -641,16 +778,92 @@ purge are fixed and not configurable.
|
||||
|
||||
| Schedule | When | Does |
|
||||
| --- | --- | --- |
|
||||
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. |
|
||||
| `plan_draft` | Friday 09:00 | AI drafts next week's plan. |
|
||||
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
|
||||
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
|
||||
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
|
||||
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. |
|
||||
| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). |
|
||||
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
|
||||
| `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`). |
|
||||
| `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 | 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`). |
|
||||
| `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). |
|
||||
|
||||
Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
|
||||
`internal/scheduler` runs them. A schedule registers a name, a rule read
|
||||
from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`, or `DailyAt` for a time read from data) and a
|
||||
run function; settings changes reach it through `SettingsService.Subscribe`
|
||||
and move the next fire time without a restart. Each fire time has a period
|
||||
key (`Rule.PeriodKey`): the local date for a daily rule, the ISO week of the
|
||||
fire date for a weekly one, or of the week after for `WeeklyForNextWeek`
|
||||
(`plan_draft`). Inserting `schedule_runs(schedule, period_key)` is the claim;
|
||||
a period already claimed is skipped, so a period runs at most once and a
|
||||
failed run is not retried. A claim that fails is not skipped: the pass is
|
||||
repeated every `scheduler.ClaimRetry` until the claim succeeds. A settings
|
||||
change is recorded with the instant it was saved, and fire times up to that
|
||||
instant follow the old rule and later ones the new, however late the
|
||||
scheduler applies it. At boot, and whenever the scheduler wakes late,
|
||||
every unclaimed fire time within 24 hours (`scheduler.CatchUpWindow`) is
|
||||
processed oldest first, each running once. A most recent fire time that
|
||||
passed longer ago is claimed without running (logged as missed) so it
|
||||
never runs later; older ones are not looked at. DST
|
||||
follows `household.LocalTime`: a skipped time fires later by the gap, a
|
||||
repeated time fires once, at its first occurrence.
|
||||
|
||||
Each schedule is built by the service that does its work, as a
|
||||
`scheduler.Schedule` value, and registered in `cmd/server` before the
|
||||
scheduler starts: `suggest.Service.WeeklySchedule()` is `suggestions_weekly`.
|
||||
Its run calls `StartScheduledBatch(period.Week)`, which starts the same
|
||||
batch and job as `POST /api/v1/suggestions/batches` (`StartBatch(manual)`),
|
||||
with the `scheduled` trigger, and returns at once; the batch runs as a job. When a
|
||||
batch is already pending or running the period starts none and logs it.
|
||||
`plan.Service.DraftSchedule()` is `plan_draft`, keyed by the ISO week
|
||||
after the fire date (`scheduler.WeeklyForNextWeek`). Its run calls
|
||||
`StartScheduledDraft(period.Week)`, which enqueues the same `draft_week`
|
||||
job as `POST /api/v1/plans/{weekStart}/draft` without confirm, so a stored
|
||||
draft emits `plan_ready:{weekStart}` from the job and a failed one (e.g.
|
||||
`ai_failed`, recorded in `ai_runs`) emits nothing. When the week already
|
||||
has a plan, in any status, it enqueues nothing; when a draft of the week
|
||||
is already queued or running it joins it. Either way it logs it.
|
||||
`plan.Service.ReminderSchedules()` is `day_of_reminder`, two schedules
|
||||
of one name. The first is `Daily(reminder_time)`, period `YYYY-MM-DD`,
|
||||
and calls `EmitDayOfReminder(period.Date)`. The second has `Key` `start`,
|
||||
so its periods are `start:YYYY-MM-DD`, and a `scheduler.DailyAt` rule:
|
||||
for each date it reads the plan and fires 15 minutes
|
||||
(`plan.StartReminderLead`) before the day's `start_by` (the same
|
||||
computation the plan API shows) when the dinner is a crock-pot dinner
|
||||
whose `start_by` is earlier than that day's `reminder_time`; its run
|
||||
calls `EmitStartReminder(period.Date)`, which checks the same again. A
|
||||
date whose week has no plan emits neither and logs it. A `DailyAt` fire
|
||||
time is looked up from the data on every pass. A committed change that
|
||||
can move a `start_by` calls `Scheduler.Wake` (passed to `plan.NewService`
|
||||
and `dinners.NewService` as `wake`): a plan swap or stored draft, and a
|
||||
dinner's method or minutes changed by edit or grounding. The next pass
|
||||
comes at once, so a crock-pot dinner swapped in for tonight is still
|
||||
reminded 15 minutes before its `start_by`, not at the next hourly timer
|
||||
(`maxWait`); a lookup that fails is logged and repeated every
|
||||
`ClaimRetry`.
|
||||
`shopping.Service.ChatAutosendSchedule()` is `shopping_chat_autosend`,
|
||||
`WeeklyForNextWeek(chat_autosend_dow, chat_autosend_time)`, so its period
|
||||
names the coming week. Its run calls `Autosend(period.Week)`, which posts
|
||||
with `SendToChat(week, false)`, the household's own send, only when a
|
||||
webhook is stored, `list_ready`'s Chat channel is on
|
||||
(`notify.Service.ChannelOn`) and the week has a Supply run neither sent
|
||||
nor with a send unconfirmed; otherwise it posts nothing and logs why,
|
||||
with the key `list_ready:chat:{weekStart}`. It never resends; a send that
|
||||
fails is the period's error. `shopping.Service.RolloverSchedule()` is
|
||||
`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
|
||||
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
|
||||
time.
|
||||
Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
`SetClock` is visible to tests.
|
||||
|
||||
## Notifications
|
||||
|
||||
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
|
||||
@@ -659,25 +872,43 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
- Where each event is emitted, with its idempotency key. Each is stored by
|
||||
`notify.EmitTx` in the transaction of the change it is about, so the
|
||||
change and its notification commit together or not at all:
|
||||
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the
|
||||
AI's draft of a week. Only the first draft of a week notifies: a
|
||||
- `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job, asked for
|
||||
by the API or the `plan_draft` schedule, stores the AI's draft of a
|
||||
week. Only the first draft of a week notifies: a
|
||||
redraft (Rebuild week) has the same key, and a manual swap never
|
||||
drafts. Its title is "Next week's plan is ready" (or "This week's",
|
||||
or "The plan for the week of Oct 26") and its body the drafted
|
||||
dinners Monday to Sunday.
|
||||
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
|
||||
- `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply
|
||||
run is first created, by `POST /shopping/{weekStart}/generate` or
|
||||
after Push to Calendar; remaking it does not notify.
|
||||
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
|
||||
`manual` suggestion batch first adds Side quests to the deck, in the
|
||||
transaction storing them, so they are announced even when a later
|
||||
round or recording the batch's end fails; it names that round's Side
|
||||
quests, and a later round's emit stores nothing. A `replenish` batch
|
||||
never notifies.
|
||||
- `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it
|
||||
was scheduled for (carried in its job, so a batch caught up late is
|
||||
still keyed by its own week), or `new_suggestions:{batchId}` for a
|
||||
`manual` one (URL `/discover`), once, when the batch's last round is
|
||||
done, in the one transaction that stores all its Side quests, so they
|
||||
and their notification commit together or not at all. Its title
|
||||
counts every Side quest the batch stored ("5 new Side quests are in")
|
||||
and its body names them. A batch that fails at any round, as when
|
||||
`suggest_side_quests` or a later round's `normalize_recipe` fails (the
|
||||
failure is in `ai_runs`), stores no Side quest and sends nothing, so
|
||||
no stored Side quest goes unannounced. A `replenish` batch never
|
||||
notifies. This is the only place `new_suggestions` is emitted; the
|
||||
`suggestions_weekly` schedule does not emit it itself.
|
||||
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through
|
||||
`plan.Service.EmitDayOfReminder(date)`, which names the dinner planned
|
||||
on the date and emits nothing for a week with no plan. The scheduler
|
||||
calls it at `reminder_time` (Feature 20).
|
||||
`plan.Service.EmitDayOfReminder(date)`, "Tonight: {dinner} · Recipe →",
|
||||
naming the dinner planned on the date; it emits nothing for a week
|
||||
with no plan. The `day_of_reminder` schedule calls it at
|
||||
`reminder_time`.
|
||||
- `day_of_reminder:start:{date}` (URL `/dinner/{id}`) through
|
||||
`plan.Service.EmitStartReminder(date)`, "Start the crock pot by
|
||||
{time}" with the dinner's name, only for a crock-pot dinner whose
|
||||
`start_by` is earlier than `reminder_time`. The `day_of_reminder`
|
||||
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`.
|
||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||
webhook.
|
||||
@@ -934,7 +1165,7 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
request. 404 when the week has no Supply run, 409
|
||||
`archived` when it is in the Supply archive, 400 `invalid_request` for a
|
||||
bad body. `shopping.SendToChat(weekStart, resend)` is what
|
||||
`shopping_chat_autosend` calls (Feature 20).
|
||||
`shopping_chat_autosend` calls, through `Autosend`.
|
||||
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
|
||||
boolean; it sets the flag, it does not flip it) and answers `{id,
|
||||
needed, needed_count, total_count}` with the list's counts after the
|
||||
@@ -955,6 +1186,61 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
for any other `by`. A frozen list is read through `GET
|
||||
/api/v1/shopping/{weekStart}` with `status` `archived`.
|
||||
|
||||
## Home API (Feature 21)
|
||||
|
||||
- `GET /api/v1/home` (`home.Service`) aggregates the Home screen from the
|
||||
services that own each value, at "now" in the household's zone (never
|
||||
UTC), and answers:
|
||||
|
||||
```json
|
||||
{
|
||||
"dateLabel": "Friday, Oct 2",
|
||||
"timezone": "America/Chicago",
|
||||
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
|
||||
"sideQuestCount": 10,
|
||||
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
|
||||
"recentUpdates": {"unread": true}
|
||||
}
|
||||
```
|
||||
|
||||
`tonight` is today's day of the current week's plan (`plan.Get`), null
|
||||
when the week has no plan; it changes at the household's local
|
||||
midnight, so the PWA reads Home again at the next local midnight of
|
||||
`timezone`, the household's IANA zone. `photo` and `minutes_total` are
|
||||
null when unknown. `sideQuestCount` is the pending cards of the deck,
|
||||
read without writing (`suggest.PendingCount`): a Home read never fails
|
||||
orphaned batches the way a `suggest.Deck` read does.
|
||||
`shoppingList` is the upcoming week's Supply run when it has one, else
|
||||
the current week's (`shopping.Get`); with neither, it is the current
|
||||
week with `ready` false. `recentUpdates.unread` is true when a
|
||||
notification was stored in `notification_outbox` after
|
||||
`household_settings.updates_seen_at`, or at all while that is null
|
||||
(`notify.StoredSince`); an event whose channels are all switched off is
|
||||
never stored and never counts.
|
||||
- `GET /api/v1/updates` (`home.Service.Updates`, over `notify.Recent`)
|
||||
answers the Recent updates sheet: the last 10 distinct events in
|
||||
`notification_outbox`, newest first, one per event key whatever its
|
||||
channels or delivery status (an event stored for push and Google Chat is
|
||||
one update):
|
||||
|
||||
```json
|
||||
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]}
|
||||
```
|
||||
|
||||
Every field comes from the stored payload, which never holds a webhook
|
||||
URL or secret; `url` is the same-origin path the event opens.
|
||||
- `POST /api/v1/updates/seen` with `{"through": "<created_at>"}` (CSRF
|
||||
header required) records that the household has seen its updates
|
||||
through the newest one it was shown, and answers 204. The last-seen
|
||||
instant is `household_settings.updates_seen_at` — the one place it is
|
||||
stored. It only moves forward (`MarkUpdatesSeen`), so a `through` older
|
||||
than the stored one is accepted and changes nothing, and an event
|
||||
stored after `through` keeps `unread` true. It is not a settings edit:
|
||||
`updated_by` and `updated_at` are left as they are and settings
|
||||
subscribers are not told. 400 `invalid_request` for a body that is not
|
||||
exactly that object, a `through` that is not RFC 3339, or one after
|
||||
now.
|
||||
|
||||
## Frontend
|
||||
|
||||
- React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Empty, loading and error states
|
||||
|
||||
Every route the PWA serves — the handoff's routes plus the added Settings
|
||||
screens — with its empty, loading and error state. The handoff does not mock
|
||||
these states; it gives one pattern for each
|
||||
([handoff README](../design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md),
|
||||
"Empty / loading / error states"), and every screen uses the shared component
|
||||
for it:
|
||||
|
||||
| State | Component | Pattern |
|
||||
| --- | --- | --- |
|
||||
| Loading | `Skeleton` ([web/src/ui/Skeleton.tsx](../web/src/ui/Skeleton.tsx)) | Pill-shaped skeletons in `--color-surface` with a 1.2 s opacity pulse (no pulse under reduced motion). |
|
||||
| Error | `ErrorBanner` ([web/src/ui/ErrorBanner.tsx](../web/src/ui/ErrorBanner.tsx)) | `--color-neutral-100` card, grape-700 (`--color-accent-700`) text, and a **Retry** button that reads or asks again. |
|
||||
| Empty | `EmptyState` ([web/src/ui/EmptyState.tsx](../web/src/ui/EmptyState.tsx)) | Bagel title, body text and an optional primary action — the Discover deck's "All caught up / New side quests arrive Sunday." with "Get more now" is the model. |
|
||||
|
||||
[web/src/states.test.tsx](../web/src/states.test.tsx) renders the loading,
|
||||
empty and error variant of each route below against a stubbed API, asserting
|
||||
the copy and pressing Retry. Each screen's own test file covers its other
|
||||
states.
|
||||
|
||||
The loading and error rows name the screen's main read. Smaller reads on a
|
||||
screen (a Quest log count, a calendar list, a webhook card) use the same
|
||||
`Skeleton` and `ErrorBanner` in place.
|
||||
|
||||
## Handoff routes
|
||||
|
||||
| Route | Screen | Empty | Loading | Error |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `/` | Home | `EmptyState` "No quest planned tonight" with **Plan this week**; rows that do not apply are hidden. | Skeletons for the date, Tonight card and status rows. | "Couldn't load tonight's quest. …" + Retry. |
|
||||
| `/onboarding` | Onboarding — Welcome step | None: the step reads nothing; it shows the welcome and the Regulars, Side quests and Quest log terms with **Let's start**. | None: nothing to read. | None: nothing to read. |
|
||||
| `/onboarding/regulars` | Onboarding — Regulars step | "Your regulars · 0" with the Common picks to add; **Next: notifications** stays disabled until enough Regulars are saved. | Skeleton under "Your regulars". | "Your Regulars could not load: …" + Retry. |
|
||||
| `/onboarding/notifications` | Onboarding — Notifications step | None: the step reads nothing. | While finishing, **Turn on notifications** and **Maybe later** are disabled. | "Could not finish setting up: …"; the buttons come back to try again, with **Add Regulars** when fewer than three Regulars are saved. |
|
||||
| `/plan` | Weekly plan (Draft board) | `EmptyState` "Nothing planned yet" with **Draft this week**. | Seven skeleton tiles ("Plan loading"; "Drafting this week" while the AI drafts). | "Couldn't load this week's plan. …" + Retry. |
|
||||
| `/plan/shopping` | Supply run | `EmptyState` "No shopping list yet" with **Make the shopping list**; an archived week with none shows "No shopping list". A list with no ingredients shows "Nothing to buy". | Skeleton title and dinner cards. | "Couldn't load the shopping list. …" + Retry. |
|
||||
| `/plan/shopping/archive` | Supply archive | `EmptyState` "Nothing archived yet" — "Each week's Supply run becomes a post here once it's made." | Skeleton posts. | "Couldn't load the Supply archive. …" + Retry. |
|
||||
| `/discover` | Side quests deck | `EmptyState` **"All caught up"** — "New side quests arrive Sunday." with **Get more now**. | Skeleton card stack (also while a batch finds more). | "Couldn't load your side quests. …" + Retry. |
|
||||
| `/quick` | Quick dinners | No ideas found: "Couldn't find Quick dinners. No ideas came back with a recipe to match." + Retry (an `ErrorBanner`, as no idea was found to show). | Six skeleton rows while the tab's ideas are found. | "Couldn't start looking for Quick dinners. …" + Retry. |
|
||||
| `/cook-from-pantry` | Cook from what I have | Before a search: no ingredient chips and **Find ideas** disabled. No ideas found: "Couldn't find ideas. No ideas came back with a recipe to match." + Retry. | Five skeleton cards ("Ideas loading"). | "Couldn't start looking for ideas. …" + Retry. |
|
||||
| `/dinner/:id` | Dinner detail | `EmptyState` "Dinner not found" — "This dinner isn't in our kitchen — it may have been removed." | Skeleton hero, title and lines. | "Couldn't load this dinner. …" + Retry. |
|
||||
| `/settings` | Settings home | Status lines "None on rotation yet", "Not connected", "Push off · Google Chat not connected". | A skeleton per status line. | "Settings could not load: …" + Retry, which reads every failed status again. |
|
||||
| `/settings/notifications` | Notifications & Google Chat | No webhook stored: the webhook form with **Save webhook**. | Skeletons for the channel matrix and the webhook card. | "Notification settings could not load: …" + Retry ("The Google Chat webhook could not load: …" for the webhook card). A failed webhook test says "Test message not sent: …" under the form. |
|
||||
| `/settings/ai` | Admin · AI assistant | No key stored: "No key stored" with **Add key**. | Skeleton. | "AI settings could not load: …" + Retry. |
|
||||
|
||||
## Added Settings screens
|
||||
|
||||
| Route | Screen | Empty | Loading | Error |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `/settings/household` | Household | None: the household's settings always exist, seeded on first start; the screen shows the seeded values. | Skeleton. | "Household settings could not load: …" + Retry. |
|
||||
| `/settings/regulars` | Regular dinners | "On rotation · 0" — "No Regulars yet. Add the dinners your household makes on repeat." | Skeleton. | "Your Regulars could not load: …" + Retry. |
|
||||
| `/settings/calendar` | Google Calendar | "Not connected" with **Connect Google Calendar**. | Skeleton (and one for the calendar picker). | "Google Calendar status could not load: …" + Retry ("Your calendars could not load: …" + Retry for the picker). |
|
||||
@@ -34,8 +34,8 @@ const (
|
||||
StatusProviderError RunStatus = "provider_error"
|
||||
)
|
||||
|
||||
// RunRetention is how long ai_runs rows are kept. The daily purge calls
|
||||
// Purge with time.Now().Add(-RunRetention).
|
||||
// RunRetention is how long ai_runs rows are kept. The daily purge,
|
||||
// PurgeSchedule, calls Purge with its fire time less RunRetention.
|
||||
const RunRetention = 30 * 24 * time.Hour
|
||||
|
||||
// runSaveTimeout bounds writing the ai_runs row of a run that has failed,
|
||||
@@ -46,9 +46,6 @@ var runSaveTimeout = 5 * time.Second
|
||||
// Purge deletes the ai_runs rows created before olderThan and returns how
|
||||
// many it deleted. The suggestion_batches and week_plans references to
|
||||
// those rows are cleared first, in the same transaction.
|
||||
//
|
||||
// Nothing in this package calls Purge: the scheduler's daily ai_runs purge
|
||||
// (Feature 20) must call it with time.Now().Add(-RunRetention).
|
||||
func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) {
|
||||
if olderThan.IsZero() {
|
||||
return 0, errors.New("harness: purge ai_runs: zero cutoff")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// PurgeTime is when, every day in the household zone, ai_runs_purge runs.
|
||||
// It is fixed, not a setting.
|
||||
const PurgeTime = "03:00"
|
||||
|
||||
// PurgeSchedule is the ai_runs_purge schedule over st: every day at
|
||||
// PurgeTime it deletes the ai_runs rows created more than RunRetention
|
||||
// before the fire time with Purge, its period scheduler.Daily keyed
|
||||
// ai_runs_purge:YYYY-MM-DD. A purge caught up late deletes no row younger
|
||||
// than RunRetention; one that fails is the period's error and is not
|
||||
// retried, and the next day's purge deletes what it left.
|
||||
func PurgeSchedule(st *store.Store, logger *slog.Logger) (scheduler.Schedule, error) {
|
||||
if st == nil {
|
||||
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil store")
|
||||
}
|
||||
if logger == nil {
|
||||
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil logger")
|
||||
}
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.AIRunsPurge,
|
||||
Rule: func(household.Settings) scheduler.Rule {
|
||||
return scheduler.Daily(PurgeTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
cutoff := p.FireAt.Add(-RunRetention)
|
||||
n, err := Purge(ctx, st, cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.InfoContext(ctx, "ai_runs purged", "period", p.Key, "deleted", n, "created_before", cutoff.UTC().Format(time.RFC3339))
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
// IdeasRequested is how many ideas ideas_from_ingredients asks the
|
||||
// model for; validation and grounding drop some.
|
||||
IdeasRequested = 8
|
||||
// IdeasKept is the most ideas the household is shown.
|
||||
IdeasKept = 5
|
||||
// MaxIngredients is the most ingredients the household may list.
|
||||
MaxIngredients = 20
|
||||
// IngredientLimit is the most characters one listed ingredient may
|
||||
// have.
|
||||
IngredientLimit = 80
|
||||
)
|
||||
|
||||
// IdeasRequest is what ideas_from_ingredients reads.
|
||||
type IdeasRequest struct {
|
||||
// Ingredients are the ingredients the household has on hand, in its
|
||||
// order, as CleanIngredients returns them.
|
||||
Ingredients []string
|
||||
// QuickOnly asks the model for Quick dinners only. It is a hint: the
|
||||
// caller enforces it after grounding, from the grounded recipe.
|
||||
QuickOnly bool
|
||||
}
|
||||
|
||||
// Idea is one dinner idea. Method, EstMinutes and Difficulty are the
|
||||
// model's estimates until the idea is grounded in a recipe.
|
||||
type Idea struct {
|
||||
Name string `json:"name"`
|
||||
// Uses are the household's ingredients the idea uses, as the household
|
||||
// wrote them and in its order. PostProcess drops every entry that is
|
||||
// not one of them.
|
||||
Uses []string `json:"uses"`
|
||||
// Missing are the ingredients the idea needs that the household does
|
||||
// not have, in the model's order.
|
||||
Missing []string `json:"missing"`
|
||||
EstMinutes int `json:"est_minutes"`
|
||||
// Method is one of stovetop, oven, crockpot, grill and other.
|
||||
Method string `json:"method"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// SearchQuery is a web search query that finds a recipe for the dinner.
|
||||
SearchQuery string `json:"search_query"`
|
||||
// Summary is the line the household reads under the idea, computed
|
||||
// by IdeaSummary from Uses and Missing. It is never part of the
|
||||
// model's reply.
|
||||
Summary string `json:"-"`
|
||||
}
|
||||
|
||||
// Ideas is ideas_from_ingredients' output.
|
||||
type Ideas struct {
|
||||
// Ideas are the ideas that passed every rule, in the model's order.
|
||||
Ideas []Idea `json:"ideas"`
|
||||
// Dropped are the ideas the model gave that broke a rule, in the
|
||||
// model's order. It is never part of the model's reply.
|
||||
Dropped []DroppedIdea `json:"-"`
|
||||
}
|
||||
|
||||
// DroppedIdea is an idea dropped by PostProcess.
|
||||
type DroppedIdea struct {
|
||||
Name string
|
||||
// Why says which rule it broke.
|
||||
Why string
|
||||
}
|
||||
|
||||
// IdeasFromIngredients runs ideas_from_ingredients v1 on in and returns
|
||||
// ideas whose uses hold only the household's own ingredients: an entry
|
||||
// naming anything else is removed, and an idea left using none of them is
|
||||
// dropped, as is one repeating an earlier idea. Each kept idea carries its
|
||||
// summary line, computed from the corrected uses and missing lists. scope
|
||||
// names the request (see harness.Run; IdeasScope). An ingredient list
|
||||
// CleanIngredients refuses fails before any LLM call.
|
||||
func IdeasFromIngredients(ctx context.Context, cfg harness.Config, scope string, in IdeasRequest) (Ideas, error) {
|
||||
ings, err := CleanIngredients(in.Ingredients)
|
||||
if err != nil {
|
||||
return Ideas{}, fmt.Errorf("skills: ideas_from_ingredients: %w", err)
|
||||
}
|
||||
in.Ingredients = ings
|
||||
return harness.Run(ctx, cfg, ideasFromIngredients{in: in}, scope, in)
|
||||
}
|
||||
|
||||
// IdeasScope is the harness scope of a request: its normalized
|
||||
// ingredients, in order, and whether it is for Quick dinners only, so two
|
||||
// requests share a run only when the model would be asked the same.
|
||||
func IdeasScope(in IdeasRequest) string {
|
||||
parts := make([]string, len(in.Ingredients))
|
||||
for i, ing := range in.Ingredients {
|
||||
parts[i] = harness.Normalize(ing)
|
||||
}
|
||||
return "quick_only=" + strconv.FormatBool(in.QuickOnly) + ";" + strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
// CleanIngredients returns the household's ingredient list with each entry
|
||||
// on one line, or why it cannot be used: it is empty or longer than
|
||||
// MaxIngredients, or an entry is blank, longer than IngredientLimit
|
||||
// characters, has nothing left once normalized (harness.Normalize), or
|
||||
// normalizes the same as an earlier one.
|
||||
func CleanIngredients(list []string) ([]string, error) {
|
||||
if len(list) == 0 {
|
||||
return nil, errors.New("list at least one ingredient")
|
||||
}
|
||||
if len(list) > MaxIngredients {
|
||||
return nil, fmt.Errorf("%d ingredients listed; list at most %d", len(list), MaxIngredients)
|
||||
}
|
||||
out := make([]string, len(list))
|
||||
seen := map[string]string{}
|
||||
for i, ing := range list {
|
||||
ing = oneLine(ing)
|
||||
switch {
|
||||
case ing == "":
|
||||
return nil, fmt.Errorf("ingredient %d is blank", i+1)
|
||||
case utf8.RuneCountInString(ing) > IngredientLimit:
|
||||
return nil, fmt.Errorf("ingredient %d is over %d characters", i+1, IngredientLimit)
|
||||
}
|
||||
n := harness.Normalize(ing)
|
||||
if n == "" {
|
||||
return nil, fmt.Errorf("ingredient %d, %q, names no ingredient", i+1, ing)
|
||||
}
|
||||
if first, ok := seen[n]; ok {
|
||||
return nil, fmt.Errorf("ingredient %d, %q, repeats %q", i+1, ing, first)
|
||||
}
|
||||
seen[n] = ing
|
||||
out[i] = ing
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IdeaSummary is the line shown under an idea: how many of the household's
|
||||
// total ingredients it uses, then what is missing, in the order given.
|
||||
// "Uses all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of
|
||||
// 5 · missing: 1 lime, cumin" when some are. With nothing missing the
|
||||
// second part is "nothing missing", so an idea using all 5 and needing
|
||||
// nothing else prints "Uses all 5 · nothing missing".
|
||||
func IdeaSummary(uses, missing []string, total int) string {
|
||||
used := fmt.Sprintf("Uses %d of %d", len(uses), total)
|
||||
if len(uses) == total {
|
||||
used = fmt.Sprintf("Uses all %d", total)
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return used + " · nothing missing"
|
||||
}
|
||||
return used + " · missing: " + strings.Join(missing, ", ")
|
||||
}
|
||||
|
||||
// ideasFromIngredients is the skill. It holds its input because
|
||||
// post-processing compares the output with it.
|
||||
type ideasFromIngredients struct {
|
||||
in IdeasRequest
|
||||
}
|
||||
|
||||
var _ harness.Skill[IdeasRequest, Ideas] = ideasFromIngredients{}
|
||||
|
||||
func (ideasFromIngredients) Name() string { return "ideas_from_ingredients" }
|
||||
|
||||
func (ideasFromIngredients) Version() int { return 1 }
|
||||
|
||||
func (ideasFromIngredients) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// ideasPrompt is the template data of ideas_from_ingredients.v1.tmpl.
|
||||
type ideasPrompt struct {
|
||||
// Count is how many ideas to ask for.
|
||||
Count int
|
||||
QuickOnly bool
|
||||
}
|
||||
|
||||
// PromptInput puts the ingredients, already cleaned by
|
||||
// IdeasFromIngredients, in the household context block.
|
||||
func (ideasFromIngredients) PromptInput(in IdeasRequest) (harness.PromptInput, error) {
|
||||
return harness.PromptInput{
|
||||
Data: ideasPrompt{Count: IdeasRequested, QuickOnly: in.QuickOnly},
|
||||
Context: []harness.ContextSection{{Title: "Ingredients on hand", Lines: in.Ingredients}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ideasFromIngredients) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "ideas_from_ingredients", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"uses": {"type": "array", "items": {"type": "string"}},
|
||||
"missing": {"type": "array", "items": {"type": "string"}},
|
||||
"est_minutes": {"type": "integer"},
|
||||
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
|
||||
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
|
||||
"search_query": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["ideas"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes an idea, requiring each of its fields as the
|
||||
// schema does: encoding/json would otherwise read a missing or null one as
|
||||
// empty.
|
||||
func (i *Idea) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"); err != nil {
|
||||
return fmt.Errorf("idea %s", err)
|
||||
}
|
||||
type plain Idea
|
||||
return json.Unmarshal(data, (*plain)(i))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring its one field as the schema
|
||||
// does.
|
||||
func (out *Ideas) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "ideas"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain Ideas
|
||||
return json.Unmarshal(data, (*plain)(out))
|
||||
}
|
||||
|
||||
// Validate finds what makes the reply unusable as a whole: no ideas, too
|
||||
// many, or one that is malformed. A well-formed idea that breaks a rule is
|
||||
// dropped by PostProcess instead.
|
||||
func (ideasFromIngredients) Validate(out Ideas) []string {
|
||||
var problems []string
|
||||
switch n := len(out.Ideas); {
|
||||
case n == 0 && len(out.Dropped) == 0:
|
||||
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", IdeasRequested))
|
||||
case n > IdeasRequested:
|
||||
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, IdeasRequested))
|
||||
}
|
||||
for i, idea := range out.Ideas {
|
||||
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
|
||||
if strings.TrimSpace(f.value) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
|
||||
}
|
||||
if !slices.Contains(methods, idea.Method) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
|
||||
}
|
||||
if idea.EstMinutes <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
|
||||
}
|
||||
if !slices.Contains(difficulties, idea.Difficulty) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// PostProcess corrects every idea against the household's ingredients and
|
||||
// moves each that breaks a rule to Dropped. Uses keeps only entries whose
|
||||
// normalized form (harness.Normalize) is that of a listed ingredient,
|
||||
// written as the household wrote it, once each and in the household's
|
||||
// order; anything else the model claimed is removed, never trusted.
|
||||
// Missing loses blank entries, repeats and anything the household listed.
|
||||
// An idea is dropped when it repeats an earlier idea or uses none of the
|
||||
// household's ingredients. The summary line is computed from the
|
||||
// corrected lists.
|
||||
func (s ideasFromIngredients) PostProcess(out Ideas) (Ideas, error) {
|
||||
listed := map[string]int{}
|
||||
for i, ing := range s.in.Ingredients {
|
||||
listed[harness.Normalize(ing)] = i
|
||||
}
|
||||
result := Ideas{Ideas: []Idea{}}
|
||||
for _, idea := range out.Ideas {
|
||||
idea = Idea{
|
||||
Name: oneLine(idea.Name),
|
||||
Uses: s.uses(idea.Uses, listed),
|
||||
Missing: missing(idea.Missing, listed),
|
||||
EstMinutes: idea.EstMinutes,
|
||||
Method: idea.Method,
|
||||
Difficulty: idea.Difficulty,
|
||||
SearchQuery: oneLine(idea.SearchQuery),
|
||||
}
|
||||
if why := dropIdea(idea, result.Ideas); why != "" {
|
||||
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
|
||||
continue
|
||||
}
|
||||
idea.Summary = IdeaSummary(idea.Uses, idea.Missing, len(s.in.Ingredients))
|
||||
result.Ideas = append(result.Ideas, idea)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// uses returns the household's ingredients that claimed names, as the
|
||||
// household wrote them and in its order. A claimed entry that names none
|
||||
// of them is left out.
|
||||
func (s ideasFromIngredients) uses(claimed []string, listed map[string]int) []string {
|
||||
used := make([]bool, len(s.in.Ingredients))
|
||||
for _, c := range claimed {
|
||||
if i, ok := listed[harness.Normalize(c)]; ok {
|
||||
used[i] = true
|
||||
}
|
||||
}
|
||||
out := []string{}
|
||||
for i, ing := range s.in.Ingredients {
|
||||
if used[i] {
|
||||
out = append(out, ing)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// missing returns claimed on one line each, in order, without blank
|
||||
// entries, repeats or any the household listed.
|
||||
func missing(claimed []string, listed map[string]int) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range claimed {
|
||||
c = oneLine(c)
|
||||
n := harness.Normalize(c)
|
||||
if n == "" || seen[n] {
|
||||
continue
|
||||
}
|
||||
if _, ok := listed[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dropIdea returns why idea must be dropped, or "" when it may be kept.
|
||||
func dropIdea(idea Idea, kept []Idea) string {
|
||||
for _, k := range kept {
|
||||
if harness.Similar(idea.Name, k.Name) {
|
||||
return fmt.Sprintf("repeats the idea %q", k.Name)
|
||||
}
|
||||
}
|
||||
if len(idea.Uses) == 0 {
|
||||
return "uses none of the household's ingredients"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// pantry is the household's ingredient list in its order.
|
||||
var pantry = IdeasRequest{Ingredients: []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}}
|
||||
|
||||
// idea renders one idea of an ideas_from_ingredients reply.
|
||||
func idea(name, method string, minutes int, uses, missing []string) string {
|
||||
u, _ := json.Marshal(append([]string{}, uses...))
|
||||
m, _ := json.Marshal(append([]string{}, missing...))
|
||||
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":%s,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, u, m, minutes, method, strings.ToLower(name)+" recipe")
|
||||
}
|
||||
|
||||
// ideasReply renders a reply holding the ideas given.
|
||||
func ideasReply(ideas ...string) string {
|
||||
return `{"ideas":[` + strings.Join(ideas, ",") + `]}`
|
||||
}
|
||||
|
||||
// ideas runs IdeasFromIngredients on in with f and checks f was called
|
||||
// exactly calls times, each at the household temperature.
|
||||
func ideas(t *testing.T, f *llm.Fake, in IdeasRequest, calls int) (Ideas, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := IdeasFromIngredients(context.Background(), testConfig(t, f), IdeasScope(in), in)
|
||||
reqs := f.Requests()
|
||||
if len(reqs) != calls {
|
||||
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d scripted Generate results unused", g)
|
||||
}
|
||||
for i, req := range reqs {
|
||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
||||
}
|
||||
if req.Schema == nil || req.Schema.Name != "ideas_from_ingredients" {
|
||||
t.Fatalf("request %d schema = %v, want ideas_from_ingredients", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsPrompt(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"Chicken thighs", "rice"}, nil))))
|
||||
_, reqs, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, "ideas_from_ingredients_any.system.golden", system)
|
||||
golden(t, "ideas_from_ingredients_any.user.golden", user)
|
||||
for _, part := range []string{
|
||||
"## Household context",
|
||||
"### Ingredients on hand\n- Chicken thighs\n- rice\n- cilantro\n- black beans\n- red onion",
|
||||
"## Output contract",
|
||||
`"uses"`,
|
||||
`"missing"`,
|
||||
} {
|
||||
if !strings.Contains(system, part) {
|
||||
t.Errorf("system prompt lacks %q", part)
|
||||
}
|
||||
}
|
||||
if strings.Contains(system, "Quick dinners only") {
|
||||
t.Error("an Any time request asks for Quick dinners only")
|
||||
}
|
||||
if user != "Suggest 8 dinner ideas from the ingredients on hand." {
|
||||
t.Errorf("user message = %q", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsQuickOnlyPrompt(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Fried Rice", "stovetop", 25, []string{"Chicken thighs", "rice"}, nil))))
|
||||
in := pantry
|
||||
in.QuickOnly = true
|
||||
_, reqs, err := ideas(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reqs[0].System, "The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.") {
|
||||
t.Errorf("Quick only request does not ask for Quick dinners:\n%s", reqs[0].System)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsSummaryLine(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
// Every ingredient, named in another order and spelling: uses is
|
||||
// in the household's order, as the household wrote it.
|
||||
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"red onion", "Black Beans", "chicken thighs", "Rice", "cilantro!"}, []string{"1 lime"}),
|
||||
// Some of them.
|
||||
idea("Cilantro Chicken Rice", "stovetop", 30, []string{"rice", "cilantro", "Chicken thighs"}, []string{"1 lime", "cumin"}),
|
||||
// Every ingredient, nothing missing.
|
||||
idea("Black Bean Chicken Skillet", "stovetop", 30, []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, nil),
|
||||
// Some, nothing missing.
|
||||
idea("Rice and Beans", "stovetop", 25, []string{"rice", "black beans"}, []string{}),
|
||||
)))
|
||||
out, _, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Dropped) != 0 {
|
||||
t.Fatalf("dropped %+v", out.Dropped)
|
||||
}
|
||||
got := map[string][2]any{}
|
||||
for _, i := range out.Ideas {
|
||||
got[i.Name] = [2]any{i.Uses, i.Summary}
|
||||
}
|
||||
want := map[string][2]any{
|
||||
"Chicken Burrito Bowls": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · missing: 1 lime"},
|
||||
"Cilantro Chicken Rice": {[]string{"Chicken thighs", "rice", "cilantro"}, "Uses 3 of 5 · missing: 1 lime, cumin"},
|
||||
"Black Bean Chicken Skillet": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · nothing missing"},
|
||||
"Rice and Beans": {[]string{"rice", "black beans"}, "Uses 2 of 5 · nothing missing"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uses and summaries = %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsDropsHallucinatedUses(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
// The model claims parmesan and lime, which the household never
|
||||
// listed, and repeats rice.
|
||||
idea("Cheesy Chicken Rice", "oven", 40, []string{"Chicken thighs", "parmesan", "rice", "lime", "rice"}, []string{"1 cup broth"}),
|
||||
// Uses only ingredients the household never listed.
|
||||
idea("Lemon Pasta", "stovetop", 20, []string{"pasta", "lemon"}, []string{"garlic"}),
|
||||
// Lists an ingredient on hand as missing.
|
||||
idea("Chicken Tacos", "stovetop", 25, []string{"Chicken thighs"}, []string{"tortillas", "Cilantro", "tortillas"}),
|
||||
)))
|
||||
out, _, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Ideas{
|
||||
Ideas: []Idea{
|
||||
{
|
||||
Name: "Cheesy Chicken Rice", Uses: []string{"Chicken thighs", "rice"}, Missing: []string{"1 cup broth"},
|
||||
EstMinutes: 40, Method: "oven", Difficulty: "easy", SearchQuery: "cheesy chicken rice recipe",
|
||||
Summary: "Uses 2 of 5 · missing: 1 cup broth",
|
||||
},
|
||||
{
|
||||
Name: "Chicken Tacos", Uses: []string{"Chicken thighs"}, Missing: []string{"tortillas"},
|
||||
EstMinutes: 25, Method: "stovetop", Difficulty: "easy", SearchQuery: "chicken tacos recipe",
|
||||
Summary: "Uses 1 of 5 · missing: tortillas",
|
||||
},
|
||||
},
|
||||
Dropped: []DroppedIdea{{Name: "Lemon Pasta", Why: "uses none of the household's ingredients"}},
|
||||
}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Fatalf("output = %+v\nwant %+v", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsDropsRepeatedIdea(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil),
|
||||
idea("Easy Chicken Burrito Bowls", "stovetop", 30, []string{"rice"}, nil),
|
||||
)))
|
||||
out, _, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Ideas) != 1 || out.Ideas[0].Name != "Chicken Burrito Bowls" {
|
||||
t.Fatalf("ideas = %+v", out.Ideas)
|
||||
}
|
||||
if want := []DroppedIdea{{Name: "Easy Chicken Burrito Bowls", Why: `repeats the idea "Chicken Burrito Bowls"`}}; !reflect.DeepEqual(out.Dropped, want) {
|
||||
t.Fatalf("dropped = %+v, want %+v", out.Dropped, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsRepairsMalformedReply(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "microwave", 0, []string{"rice"}, nil))))
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil))))
|
||||
out, reqs, err := ideas(t, &f, pantry, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Ideas) != 1 {
|
||||
t.Fatalf("ideas = %+v", out.Ideas)
|
||||
}
|
||||
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
|
||||
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
|
||||
if !strings.Contains(repair, part) {
|
||||
t.Errorf("repair turn lacks %q:\n%s", part, repair)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsRefusesTooManyIdeas(t *testing.T) {
|
||||
var f llm.Fake
|
||||
var nine []string
|
||||
for i := range 9 {
|
||||
nine = append(nine, idea(fmt.Sprintf("Dish %d", i+1), "stovetop", 30, []string{"rice"}, nil))
|
||||
}
|
||||
f.QueueGenerate(reply(ideasReply(nine...)))
|
||||
f.QueueGenerate(reply(ideasReply(nine...)))
|
||||
if _, _, err := ideas(t, &f, pantry, 2); err == nil || !strings.Contains(err.Error(), "give at most 8 ideas") {
|
||||
t.Fatalf("err = %v, want a bad output naming the limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanIngredients(t *testing.T) {
|
||||
got, err := CleanIngredients([]string{" 1 lime ", "Chicken\tthighs"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"1 lime", "Chicken thighs"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("CleanIngredients = %q, want %q", got, want)
|
||||
}
|
||||
tooMany := make([]string, MaxIngredients+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("thing %d", i)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{"empty", nil, "list at least one ingredient"},
|
||||
{"too many", tooMany, "list at most 20"},
|
||||
{"blank", []string{"rice", " "}, "ingredient 2 is blank"},
|
||||
{"too long", []string{strings.Repeat("a", IngredientLimit+1)}, "over 80 characters"},
|
||||
{"filler only", []string{"the"}, "names no ingredient"},
|
||||
{"repeat", []string{"Red onion", "red-onion"}, `repeats "Red onion"`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := CleanIngredients(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsRefusesBadListBeforeCalling(t *testing.T) {
|
||||
var f llm.Fake
|
||||
if _, _, err := ideas(t, &f, IdeasRequest{Ingredients: []string{"rice", "Rice"}}, 0); err == nil {
|
||||
t.Fatal("a repeated ingredient was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasScopeIdentifiesTheRequest(t *testing.T) {
|
||||
a := IdeasScope(IdeasRequest{Ingredients: []string{"Rice", "black beans"}})
|
||||
if b := IdeasScope(IdeasRequest{Ingredients: []string{"rice", "Black Beans"}}); a != b {
|
||||
t.Errorf("scopes of the same list differ: %q, %q", a, b)
|
||||
}
|
||||
for _, other := range []IdeasRequest{
|
||||
{Ingredients: []string{"Rice", "black beans"}, QuickOnly: true},
|
||||
{Ingredients: []string{"black beans", "Rice"}},
|
||||
{Ingredients: []string{"Rice"}},
|
||||
} {
|
||||
if IdeasScope(other) == a {
|
||||
t.Errorf("%+v shares the scope %q", other, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,5 @@ normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752
|
||||
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
|
||||
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
|
||||
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
|
||||
ideas_from_ingredients v1 49df3e134f07fe2c0dbbda7bd3d7d1364731e5851bf6f6a526ec04b703480924
|
||||
quick_ideas v1 c78d1b9917a150bcf8df1351e0b4c2e76eaa10097819782c2c74695b0ae3cf05
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{{/* ideas_from_ingredients drafts dinner ideas the household can cook from the ingredients it has on hand. */}}
|
||||
{{define "instructions" -}}
|
||||
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
|
||||
|
||||
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
|
||||
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
{{- if .QuickOnly}}
|
||||
|
||||
The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.
|
||||
{{- end}}
|
||||
|
||||
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
|
||||
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
Suggest {{.Count}} dinner ideas from the ingredients on hand.
|
||||
{{- end}}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{/* quick_ideas drafts Quick dinner ideas of one kind, crock pot or 30 minutes or less, picked from the household's tastes. */}}
|
||||
{{define "instructions" -}}
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
{{- if .Crockpot}}
|
||||
|
||||
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
|
||||
{{- else}}
|
||||
|
||||
Every idea must take 30 minutes or less from starting prep to the table.
|
||||
{{- end}}
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{{- if .Crockpot}}
|
||||
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
|
||||
{{- else}}
|
||||
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
{{- if .Crockpot -}}
|
||||
Suggest {{.Count}} crock pot dinner ideas.
|
||||
{{- else -}}
|
||||
Suggest {{.Count}} dinner ideas ready in 30 minutes or less.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,297 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// Kinds of Quick dinner quick_ideas suggests: the two tabs of the Quick
|
||||
// dinners screen.
|
||||
const (
|
||||
// QuickCrockpot is a dinner cooked in a crock pot (slow cooker).
|
||||
QuickCrockpot = "crockpot"
|
||||
// QuickUnder30 is a dinner 30 minutes or less from prep to table.
|
||||
QuickUnder30 = "under30"
|
||||
)
|
||||
|
||||
// QuickKinds are every kind of Quick dinner, in the screen's tab order.
|
||||
var QuickKinds = []string{QuickCrockpot, QuickUnder30}
|
||||
|
||||
const (
|
||||
// QuickIdeasRequested is how many ideas quick_ideas asks the model
|
||||
// for: more than a page holds, since validation, the exclude list,
|
||||
// grounding and the kind's check drop some.
|
||||
QuickIdeasRequested = 12
|
||||
// QuickIdeasKept is the most ideas one page of Quick dinners shows.
|
||||
QuickIdeasKept = 6
|
||||
// MaxExclude is the most names already shown a request may exclude.
|
||||
MaxExclude = 60
|
||||
// ExcludeLimit is the most characters one excluded name may have.
|
||||
ExcludeLimit = 120
|
||||
)
|
||||
|
||||
// QuickIdeasRequest is what quick_ideas reads.
|
||||
type QuickIdeasRequest struct {
|
||||
// Context is the household context (see harness.BuildContext): the
|
||||
// ideas are picked from the household's tastes.
|
||||
Context harness.HouseholdContext
|
||||
// Kind is QuickCrockpot or QuickUnder30. It is a hint to the model:
|
||||
// the caller enforces it after grounding, from the grounded recipe.
|
||||
Kind string
|
||||
// Exclude are the names already shown to the household, as
|
||||
// CleanExclude returns them. No idea repeating one is returned.
|
||||
Exclude []string
|
||||
}
|
||||
|
||||
// QuickIdea is one Quick dinner idea. Method, EstMinutes and Difficulty
|
||||
// are the model's estimates until the idea is grounded in a recipe.
|
||||
type QuickIdea struct {
|
||||
Name string `json:"name"`
|
||||
EstMinutes int `json:"est_minutes"`
|
||||
// Method is one of stovetop, oven, crockpot, grill and other.
|
||||
Method string `json:"method"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// SearchQuery is a web search query that finds a recipe for the dinner.
|
||||
SearchQuery string `json:"search_query"`
|
||||
}
|
||||
|
||||
// QuickIdeaList is quick_ideas' output.
|
||||
type QuickIdeaList struct {
|
||||
// Ideas are the ideas that passed every rule, in the model's order.
|
||||
Ideas []QuickIdea `json:"ideas"`
|
||||
// Dropped are the ideas the model gave that broke a rule, in the
|
||||
// model's order. It is never part of the model's reply.
|
||||
Dropped []DroppedIdea `json:"-"`
|
||||
}
|
||||
|
||||
// QuickIdeas runs quick_ideas v1 on in and returns ideas that repeat no
|
||||
// excluded name nor each other (harness.Similar, on the normalized
|
||||
// names); the rest are in Dropped. Whether an idea is of in.Kind is not
|
||||
// decided here: the model's estimates never decide it, the grounded
|
||||
// recipe does. scope names the request (see harness.Run; QuickIdeasScope).
|
||||
// A kind CheckQuickKind refuses, or an exclude list CleanExclude refuses,
|
||||
// fails before any LLM call.
|
||||
func QuickIdeas(ctx context.Context, cfg harness.Config, scope string, in QuickIdeasRequest) (QuickIdeaList, error) {
|
||||
if err := CheckQuickKind(in.Kind); err != nil {
|
||||
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
|
||||
}
|
||||
exclude, err := CleanExclude(in.Exclude)
|
||||
if err != nil {
|
||||
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
|
||||
}
|
||||
in.Exclude = exclude
|
||||
return harness.Run(ctx, cfg, quickIdeas{in: in}, scope, in)
|
||||
}
|
||||
|
||||
// QuickIdeasScope is the harness scope of a request: its kind and its
|
||||
// normalized excluded names, in order, so two requests share a run only
|
||||
// when they ask for the same page.
|
||||
func QuickIdeasScope(in QuickIdeasRequest) string {
|
||||
parts := make([]string, len(in.Exclude))
|
||||
for i, name := range in.Exclude {
|
||||
parts[i] = harness.Normalize(name)
|
||||
}
|
||||
return "kind=" + in.Kind + ";" + strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
// CheckQuickKind returns why kind is not one of QuickKinds, or nil. There
|
||||
// is no default kind.
|
||||
func CheckQuickKind(kind string) error {
|
||||
if !slices.Contains(QuickKinds, kind) {
|
||||
return fmt.Errorf("kind %q is not one of %s", kind, strings.Join(QuickKinds, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanExclude returns the excluded names with each on one line, never
|
||||
// nil, or why they cannot be used: there are more than MaxExclude, or a
|
||||
// name is blank, longer than ExcludeLimit characters, has nothing left
|
||||
// once normalized (harness.Normalize), or normalizes the same as an
|
||||
// earlier one. An empty list, the first page's, is valid.
|
||||
func CleanExclude(list []string) ([]string, error) {
|
||||
if len(list) > MaxExclude {
|
||||
return nil, fmt.Errorf("%d names excluded; exclude at most %d", len(list), MaxExclude)
|
||||
}
|
||||
out := make([]string, len(list))
|
||||
seen := map[string]string{}
|
||||
for i, name := range list {
|
||||
name = oneLine(name)
|
||||
switch {
|
||||
case name == "":
|
||||
return nil, fmt.Errorf("excluded name %d is blank", i+1)
|
||||
case utf8.RuneCountInString(name) > ExcludeLimit:
|
||||
return nil, fmt.Errorf("excluded name %d is over %d characters", i+1, ExcludeLimit)
|
||||
}
|
||||
n := harness.Normalize(name)
|
||||
if n == "" {
|
||||
return nil, fmt.Errorf("excluded name %d, %q, names no dinner", i+1, name)
|
||||
}
|
||||
if first, ok := seen[n]; ok {
|
||||
return nil, fmt.Errorf("excluded name %d, %q, repeats %q", i+1, name, first)
|
||||
}
|
||||
seen[n] = name
|
||||
out[i] = name
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// quickIdeas is the skill. It holds its input because post-processing
|
||||
// compares the output with it.
|
||||
type quickIdeas struct {
|
||||
in QuickIdeasRequest
|
||||
}
|
||||
|
||||
var _ harness.Skill[QuickIdeasRequest, QuickIdeaList] = quickIdeas{}
|
||||
|
||||
func (quickIdeas) Name() string { return "quick_ideas" }
|
||||
|
||||
func (quickIdeas) Version() int { return 1 }
|
||||
|
||||
func (quickIdeas) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// quickIdeasPrompt is the template data of quick_ideas.v1.tmpl.
|
||||
type quickIdeasPrompt struct {
|
||||
// Count is how many ideas to ask for.
|
||||
Count int
|
||||
// Crockpot is set for QuickCrockpot, unset for QuickUnder30.
|
||||
Crockpot bool
|
||||
}
|
||||
|
||||
// PromptInput puts the household context, then the names already shown,
|
||||
// in the household context block.
|
||||
func (quickIdeas) PromptInput(in QuickIdeasRequest) (harness.PromptInput, error) {
|
||||
if err := CheckQuickKind(in.Kind); err != nil {
|
||||
return harness.PromptInput{}, err
|
||||
}
|
||||
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Already shown", Lines: in.Exclude})
|
||||
return harness.PromptInput{
|
||||
Data: quickIdeasPrompt{Count: QuickIdeasRequested, Crockpot: in.Kind == QuickCrockpot},
|
||||
Context: sections,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (quickIdeas) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "quick_ideas", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"est_minutes": {"type": "integer"},
|
||||
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
|
||||
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
|
||||
"search_query": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "est_minutes", "method", "difficulty", "search_query"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["ideas"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes an idea, requiring each of its fields as the
|
||||
// schema does: encoding/json would otherwise read a missing or null one as
|
||||
// empty.
|
||||
func (i *QuickIdea) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "name", "est_minutes", "method", "difficulty", "search_query"); err != nil {
|
||||
return fmt.Errorf("idea %s", err)
|
||||
}
|
||||
type plain QuickIdea
|
||||
return json.Unmarshal(data, (*plain)(i))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring its one field as the schema
|
||||
// does.
|
||||
func (out *QuickIdeaList) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "ideas"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain QuickIdeaList
|
||||
return json.Unmarshal(data, (*plain)(out))
|
||||
}
|
||||
|
||||
// Validate finds what makes the reply unusable as a whole: no ideas, too
|
||||
// many, or one that is malformed. A well-formed idea that breaks a rule is
|
||||
// dropped by PostProcess instead.
|
||||
func (quickIdeas) Validate(out QuickIdeaList) []string {
|
||||
var problems []string
|
||||
switch n := len(out.Ideas); {
|
||||
case n == 0 && len(out.Dropped) == 0:
|
||||
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", QuickIdeasRequested))
|
||||
case n > QuickIdeasRequested:
|
||||
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, QuickIdeasRequested))
|
||||
}
|
||||
for i, idea := range out.Ideas {
|
||||
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
|
||||
if strings.TrimSpace(f.value) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
|
||||
}
|
||||
if !slices.Contains(methods, idea.Method) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
|
||||
}
|
||||
if idea.EstMinutes <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
|
||||
}
|
||||
if !slices.Contains(difficulties, idea.Difficulty) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// PostProcess puts every idea's text on one line and moves each that
|
||||
// repeats an excluded name or an earlier idea (harness.Similar, which
|
||||
// compares normalized names) to Dropped. The model's method and minutes
|
||||
// are kept as estimates and never decide the kind.
|
||||
func (s quickIdeas) PostProcess(out QuickIdeaList) (QuickIdeaList, error) {
|
||||
result := QuickIdeaList{Ideas: []QuickIdea{}}
|
||||
for _, idea := range out.Ideas {
|
||||
idea = QuickIdea{
|
||||
Name: oneLine(idea.Name),
|
||||
EstMinutes: idea.EstMinutes,
|
||||
Method: idea.Method,
|
||||
Difficulty: idea.Difficulty,
|
||||
SearchQuery: oneLine(idea.SearchQuery),
|
||||
}
|
||||
if why := s.drop(idea, result.Ideas); why != "" {
|
||||
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
|
||||
continue
|
||||
}
|
||||
result.Ideas = append(result.Ideas, idea)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// drop returns why idea must be dropped, or "" when it may be kept.
|
||||
func (s quickIdeas) drop(idea QuickIdea, kept []QuickIdea) string {
|
||||
for _, name := range s.in.Exclude {
|
||||
if harness.Similar(idea.Name, name) {
|
||||
return fmt.Sprintf("repeats %q, already shown", name)
|
||||
}
|
||||
}
|
||||
for _, k := range kept {
|
||||
if harness.Similar(idea.Name, k.Name) {
|
||||
return fmt.Sprintf("repeats the idea %q", k.Name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// quickTastes is the household context of the quick_ideas tests.
|
||||
var quickTastes = harness.HouseholdContext{
|
||||
Today: date(2026, 10, 8),
|
||||
Timezone: "America/New_York",
|
||||
Regulars: []harness.RegularDinner{
|
||||
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240, Favorite: true},
|
||||
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30},
|
||||
},
|
||||
QuestLog: []string{"Pad Thai"},
|
||||
}
|
||||
|
||||
// quickIdea renders one idea of a quick_ideas reply.
|
||||
func quickIdea(name, method string, minutes int) string {
|
||||
return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, minutes, method, strings.ToLower(name)+" recipe")
|
||||
}
|
||||
|
||||
// quick runs QuickIdeas on in with f and checks f was called exactly
|
||||
// calls times, each at the household temperature.
|
||||
func quick(t *testing.T, f *llm.Fake, in QuickIdeasRequest, calls int) (QuickIdeaList, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := QuickIdeas(context.Background(), testConfig(t, f), QuickIdeasScope(in), in)
|
||||
reqs := f.Requests()
|
||||
if len(reqs) != calls {
|
||||
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d scripted Generate results unused", g)
|
||||
}
|
||||
for i, req := range reqs {
|
||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
||||
}
|
||||
if req.Schema == nil || req.Schema.Name != "quick_ideas" {
|
||||
t.Fatalf("request %d schema = %v, want quick_ideas", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func TestQuickIdeasPrompt(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
kind, golden, user, rule string
|
||||
}{
|
||||
{QuickCrockpot, "quick_ideas_crockpot", "Suggest 12 crock pot dinner ideas.",
|
||||
`Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".`},
|
||||
{QuickUnder30, "quick_ideas_under30", "Suggest 12 dinner ideas ready in 30 minutes or less.",
|
||||
"Every idea must take 30 minutes or less from starting prep to the table."},
|
||||
} {
|
||||
t.Run(tc.kind, func(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Chicken Fried Rice", "stovetop", 25))))
|
||||
in := QuickIdeasRequest{Context: quickTastes, Kind: tc.kind, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
|
||||
_, reqs, err := quick(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, tc.golden+".system.golden", system)
|
||||
golden(t, tc.golden+".user.golden", user)
|
||||
for _, part := range []string{
|
||||
"## Household context",
|
||||
"### Regulars\n- Beef Chili",
|
||||
"### Quest log\n- Pad Thai",
|
||||
"### Already shown\n- Beef Stew\n- Shrimp Tacos",
|
||||
tc.rule,
|
||||
"## Output contract",
|
||||
} {
|
||||
if !strings.Contains(system, part) {
|
||||
t.Errorf("system prompt lacks %q", part)
|
||||
}
|
||||
}
|
||||
if user != tc.user {
|
||||
t.Errorf("user message = %q, want %q", user, tc.user)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasNeverReturnExcludedNames(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
// The excluded names, spelled another way: the normalized name
|
||||
// decides.
|
||||
quickIdea("Easy Beef Stew!", "crockpot", 300),
|
||||
quickIdea("shrimp-tacos", "stovetop", 20),
|
||||
quickIdea("Pulled Pork", "crockpot", 480),
|
||||
// A repeat of an idea already kept.
|
||||
quickIdea("The Pulled Pork", "crockpot", 420),
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 25),
|
||||
)))
|
||||
in := QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
|
||||
out, _, err := quick(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var kept []string
|
||||
for _, i := range out.Ideas {
|
||||
kept = append(kept, i.Name)
|
||||
}
|
||||
// Chicken Fried Rice is not a crock pot dinner, but the grounded
|
||||
// recipe, not the model's estimate, decides that later.
|
||||
if want := []string{"Pulled Pork", "Chicken Fried Rice"}; !reflect.DeepEqual(kept, want) {
|
||||
t.Fatalf("ideas = %q, want %q", kept, want)
|
||||
}
|
||||
want := []DroppedIdea{
|
||||
{Name: "Easy Beef Stew!", Why: `repeats "Beef Stew", already shown`},
|
||||
{Name: "shrimp-tacos", Why: `repeats "Shrimp Tacos", already shown`},
|
||||
{Name: "The Pulled Pork", Why: `repeats the idea "Pulled Pork"`},
|
||||
}
|
||||
if !reflect.DeepEqual(out.Dropped, want) {
|
||||
t.Fatalf("dropped = %+v\nwant %+v", out.Dropped, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRepairsMalformedReply(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "microwave", 0))))
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "crockpot", 480))))
|
||||
out, reqs, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Ideas) != 1 {
|
||||
t.Fatalf("ideas = %+v", out.Ideas)
|
||||
}
|
||||
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
|
||||
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
|
||||
if !strings.Contains(repair, part) {
|
||||
t.Errorf("repair turn lacks %q:\n%s", part, repair)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRefusesTooManyIdeas(t *testing.T) {
|
||||
var f llm.Fake
|
||||
var many []string
|
||||
for i := range QuickIdeasRequested + 1 {
|
||||
many = append(many, quickIdea(fmt.Sprintf("Dish %d", i+1), "stovetop", 20))
|
||||
}
|
||||
f.QueueGenerate(reply(ideasReply(many...)))
|
||||
f.QueueGenerate(reply(ideasReply(many...)))
|
||||
if _, _, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickUnder30}, 2); err == nil || !strings.Contains(err.Error(), "give at most 12 ideas") {
|
||||
t.Fatalf("err = %v, want a bad output naming the limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRefuseBadRequestBeforeCalling(t *testing.T) {
|
||||
for _, in := range []QuickIdeasRequest{
|
||||
{Context: quickTastes, Kind: ""},
|
||||
{Context: quickTastes, Kind: "quick"},
|
||||
{Context: quickTastes, Kind: QuickUnder30, Exclude: []string{"Beef Stew", "beef-stew"}},
|
||||
} {
|
||||
var f llm.Fake
|
||||
if _, _, err := quick(t, &f, in, 0); err == nil {
|
||||
t.Fatalf("%+v was accepted", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanExclude(t *testing.T) {
|
||||
got, err := CleanExclude([]string{" Beef Stew ", "Shrimp\tTacos"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"Beef Stew", "Shrimp Tacos"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("CleanExclude = %q, want %q", got, want)
|
||||
}
|
||||
if got, err := CleanExclude(nil); err != nil || got == nil || len(got) != 0 {
|
||||
t.Fatalf("CleanExclude(nil) = %q, %v, want an empty list", got, err)
|
||||
}
|
||||
tooMany := make([]string, MaxExclude+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("dish %d", i)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{"too many", tooMany, "exclude at most 60"},
|
||||
{"blank", []string{"Beef Stew", " "}, "excluded name 2 is blank"},
|
||||
{"too long", []string{strings.Repeat("a", ExcludeLimit+1)}, "over 120 characters"},
|
||||
{"filler only", []string{"Easy Recipe"}, "names no dinner"},
|
||||
{"repeat", []string{"Beef Stew", "easy beef-stew"}, `repeats "Beef Stew"`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := CleanExclude(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuickKind(t *testing.T) {
|
||||
for _, kind := range QuickKinds {
|
||||
if err := CheckQuickKind(kind); err != nil {
|
||||
t.Errorf("CheckQuickKind(%q) = %v", kind, err)
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{"", "Crockpot", "under_30", "quick"} {
|
||||
if err := CheckQuickKind(kind); err == nil || !strings.Contains(err.Error(), "is not one of crockpot, under30") {
|
||||
t.Errorf("CheckQuickKind(%q) = %v, want a refusal naming the kinds", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasScopeIdentifiesTheRequest(t *testing.T) {
|
||||
a := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"Beef Stew"}})
|
||||
if b := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"beef-stew"}}); a != b {
|
||||
t.Errorf("scopes of the same request differ: %q, %q", a, b)
|
||||
}
|
||||
for _, other := range []QuickIdeasRequest{
|
||||
{Kind: QuickCrockpot, Exclude: []string{"Beef Stew"}},
|
||||
{Kind: QuickUnder30},
|
||||
{Kind: QuickUnder30, Exclude: []string{"Beef Stew", "Shrimp Tacos"}},
|
||||
} {
|
||||
if QuickIdeasScope(other) == a {
|
||||
t.Errorf("%+v shares the scope %q", other, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
## Instructions
|
||||
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
|
||||
|
||||
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
|
||||
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
|
||||
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
|
||||
|
||||
## Household context
|
||||
### Ingredients on hand
|
||||
- Chicken thighs
|
||||
- rice
|
||||
- cilantro
|
||||
- black beans
|
||||
- red onion
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "ideas_from_ingredients" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"uses": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"uses",
|
||||
"missing",
|
||||
"est_minutes",
|
||||
"method",
|
||||
"difficulty",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ideas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Suggest 8 dinner ideas from the ingredients on hand.
|
||||
@@ -0,0 +1,93 @@
|
||||
## Instructions
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
|
||||
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
|
||||
|
||||
## Household context
|
||||
### Today
|
||||
- Thursday 2026-10-08 (America/New_York)
|
||||
|
||||
### Regulars
|
||||
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
|
||||
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
|
||||
|
||||
### Quest log
|
||||
- Pad Thai
|
||||
|
||||
### Passed, oldest first
|
||||
(none)
|
||||
|
||||
### Recent plans
|
||||
(none)
|
||||
|
||||
### Already shown
|
||||
- Beef Stew
|
||||
- Shrimp Tacos
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"est_minutes",
|
||||
"method",
|
||||
"difficulty",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ideas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Suggest 12 crock pot dinner ideas.
|
||||
@@ -0,0 +1,93 @@
|
||||
## Instructions
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
|
||||
Every idea must take 30 minutes or less from starting prep to the table.
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
|
||||
|
||||
## Household context
|
||||
### Today
|
||||
- Thursday 2026-10-08 (America/New_York)
|
||||
|
||||
### Regulars
|
||||
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
|
||||
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
|
||||
|
||||
### Quest log
|
||||
- Pad Thai
|
||||
|
||||
### Passed, oldest first
|
||||
(none)
|
||||
|
||||
### Recent plans
|
||||
(none)
|
||||
|
||||
### Already shown
|
||||
- Beef Stew
|
||||
- Shrimp Tacos
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"est_minutes",
|
||||
"method",
|
||||
"difficulty",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ideas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Suggest 12 dinner ideas ready in 30 minutes or less.
|
||||
@@ -308,7 +308,7 @@ func TestSendThreadsByWeek(t *testing.T) {
|
||||
ListReady(week, 19, 7),
|
||||
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
|
||||
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
|
||||
RotationReview("01J9DINNER", "Pho", week),
|
||||
RotationReview("01J9DINNER", "Pho", mustDate(t, "2026-10-14")),
|
||||
}
|
||||
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 {
|
||||
@@ -342,8 +342,8 @@ func TestSendThreadsByWeek(t *testing.T) {
|
||||
t.Errorf("plan text = %q", reqs[0].Body["text"])
|
||||
}
|
||||
|
||||
// The cards' "Open" buttons link under the public URL.
|
||||
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} {
|
||||
// The cards' "Open" buttons link under the public URL to the event's week.
|
||||
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan?week=2026-10-12", 1: "https://mealquest.example/plan/shopping?week=2026-10-12"} {
|
||||
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
|
||||
var cards []cardV2
|
||||
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
|
||||
|
||||
+40
-18
@@ -2,6 +2,7 @@ package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -46,7 +47,7 @@ func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
|
||||
Key: string(notify.PlanReady) + ":" + week.String(),
|
||||
Title: planTitle(week, thisWeek),
|
||||
Body: nameList(dinners),
|
||||
URL: "/plan",
|
||||
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
@@ -59,48 +60,65 @@ func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
|
||||
Key: string(notify.ListReady) + ":" + week.String(),
|
||||
Title: "This week's Supply run is ready",
|
||||
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
|
||||
URL: "/plan/shopping",
|
||||
URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(),
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
|
||||
// name, planned on date.
|
||||
// name, planned on date: "Tonight: Tacos · Recipe →", a title carrying its
|
||||
// own call to action.
|
||||
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.DayOfReminder,
|
||||
Key: string(notify.DayOfReminder) + ":" + date.String(),
|
||||
Title: "Tonight's dinner",
|
||||
Title: "Tonight: " + name + " · Recipe →",
|
||||
URL: "/dinner/" + dinnerID,
|
||||
Week: household.WeekStart(date),
|
||||
}
|
||||
}
|
||||
|
||||
// StartReminder is the day_of_reminder event, keyed
|
||||
// day_of_reminder:start:{date}, reminding the household to start the
|
||||
// crock-pot dinner dinnerID, named name and planned on date, by startBy,
|
||||
// a time in the household zone: "Start the crock pot by 9:30 am".
|
||||
func StartReminder(date store.LocalDate, dinnerID, name string, startBy time.Time) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.DayOfReminder,
|
||||
Key: string(notify.DayOfReminder) + ":start:" + date.String(),
|
||||
Title: "Start the crock pot by " + startBy.Format("3:04 pm"),
|
||||
Body: name,
|
||||
URL: "/dinner/" + dinnerID,
|
||||
Week: household.WeekStart(date),
|
||||
}
|
||||
}
|
||||
|
||||
// NewSuggestions is the new_suggestions event of suggestion batch batchID,
|
||||
// made in the week starting on week, naming its Side quests.
|
||||
func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event {
|
||||
// NewSuggestions is the new_suggestions event keyed by id, the ISO week
|
||||
// ("2026-W41") of a scheduled suggestion batch or the batch id of a manual
|
||||
// one, of the week starting on week: how many new Side quests there are,
|
||||
// and their names.
|
||||
func NewSuggestions(id string, week store.LocalDate, names []string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.NewSuggestions,
|
||||
Key: string(notify.NewSuggestions) + ":" + batchID,
|
||||
Title: "New Side quests are in",
|
||||
Key: string(notify.NewSuggestions) + ":" + id,
|
||||
Title: count(len(names), "new Side quest is in", "new Side quests are in"),
|
||||
Body: nameList(names),
|
||||
URL: "/discover",
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// RotationReview is the rotation_review event asking whether the dinner
|
||||
// dinnerID, named name and tried in the week starting on week, becomes a
|
||||
// Regular.
|
||||
func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event {
|
||||
// RotationReview is the rotation_review event, keyed
|
||||
// rotation_review:{dinnerID}:{planDate}, of the Rotation check of the
|
||||
// dinner dinnerID, named name and tried on planDate: "How was Pho? Keep it
|
||||
// in rotation?". It opens Home, where the Rotation check card asks.
|
||||
func RotationReview(dinnerID, name string, planDate store.LocalDate) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.RotationReview,
|
||||
Key: string(notify.RotationReview) + ":" + dinnerID,
|
||||
Title: "Keep it in rotation?",
|
||||
Body: "You tried " + name + ". Make it a Regular?",
|
||||
Key: string(notify.RotationReview) + ":" + dinnerID + ":" + planDate.String(),
|
||||
Title: "How was " + name + "? Keep it in rotation?",
|
||||
URL: "/",
|
||||
Week: week,
|
||||
Week: household.WeekStart(planDate),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,9 +189,13 @@ func planTitle(week, thisWeek store.LocalDate) string {
|
||||
}
|
||||
|
||||
// Text is the Google Chat message of p: its title, its body after a colon
|
||||
// and the event's call to action with an arrow.
|
||||
// and the event's call to action with an arrow. A title ending in an arrow,
|
||||
// with no body, carries its own call to action and is the message as it is.
|
||||
func Text(p notify.Payload) string {
|
||||
text := p.Title
|
||||
if p.Body == "" && strings.HasSuffix(text, "→") {
|
||||
return text
|
||||
}
|
||||
if p.Body != "" {
|
||||
if endsSentence(text) {
|
||||
text += " "
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
@@ -38,7 +39,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready",
|
||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala + 4 more",
|
||||
@@ -48,7 +49,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready with three dinners",
|
||||
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala",
|
||||
@@ -58,7 +59,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready for next week",
|
||||
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "Next week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala + 1 more",
|
||||
@@ -68,7 +69,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "plan ready for a later week",
|
||||
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
|
||||
key: "plan_ready:2026-10-26",
|
||||
url: "/plan",
|
||||
url: "/plan?week=2026-10-26",
|
||||
week: "2026-10-26",
|
||||
title: "The plan for the week of Oct 26 is ready",
|
||||
body: "Tacos",
|
||||
@@ -78,7 +79,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "list ready",
|
||||
ev: ListReady(week, 19, 7),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
url: "/plan/shopping?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "19 items to buy for 7 dinners",
|
||||
@@ -88,7 +89,7 @@ func TestMessages(t *testing.T) {
|
||||
name: "list ready singular",
|
||||
ev: ListReady(week, 1, 1),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
url: "/plan/shopping?week=2026-10-12",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "1 item to buy for 1 dinner",
|
||||
@@ -100,9 +101,19 @@ func TestMessages(t *testing.T) {
|
||||
key: "day_of_reminder:2026-10-15",
|
||||
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
||||
week: "2026-10-12",
|
||||
title: "Tonight's dinner",
|
||||
body: "Tacos",
|
||||
chatT: "Tonight's dinner: Tacos. Open the recipe →",
|
||||
title: "Tonight: Tacos · Recipe →",
|
||||
body: "",
|
||||
chatT: "Tonight: Tacos · Recipe →",
|
||||
},
|
||||
{
|
||||
name: "crock-pot start reminder",
|
||||
ev: StartReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Beef Chili", time.Date(2026, 10, 15, 9, 30, 0, 0, time.FixedZone("CDT", -5*60*60))),
|
||||
key: "day_of_reminder:start:2026-10-15",
|
||||
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
||||
week: "2026-10-12",
|
||||
title: "Start the crock pot by 9:30 am",
|
||||
body: "Beef Chili",
|
||||
chatT: "Start the crock pot by 9:30 am: Beef Chili. Open the recipe →",
|
||||
},
|
||||
{
|
||||
name: "new suggestions",
|
||||
@@ -110,19 +121,29 @@ func TestMessages(t *testing.T) {
|
||||
key: "new_suggestions:01J9BATCH",
|
||||
url: "/discover",
|
||||
week: "2026-10-12",
|
||||
title: "New Side quests are in",
|
||||
title: "5 new Side quests are in",
|
||||
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more",
|
||||
chatT: "New Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
|
||||
chatT: "5 new Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
|
||||
},
|
||||
{
|
||||
name: "new suggestions of a scheduled week",
|
||||
ev: NewSuggestions("2026-W42", week, []string{"Pho"}),
|
||||
key: "new_suggestions:2026-W42",
|
||||
url: "/discover",
|
||||
week: "2026-10-12",
|
||||
title: "1 new Side quest is in",
|
||||
body: "Pho",
|
||||
chatT: "1 new Side quest is in: Pho. Open Discover →",
|
||||
},
|
||||
{
|
||||
name: "rotation review",
|
||||
ev: RotationReview("01J9DINNER", "Lemon orzo", week),
|
||||
key: "rotation_review:01J9DINNER",
|
||||
ev: RotationReview("01J9DINNER", "Lemon orzo", mustDate(t, "2026-10-14")),
|
||||
key: "rotation_review:01J9DINNER:2026-10-14",
|
||||
url: "/",
|
||||
week: "2026-10-12",
|
||||
title: "Keep it in rotation?",
|
||||
body: "You tried Lemon orzo. Make it a Regular?",
|
||||
chatT: "Keep it in rotation? You tried Lemon orzo. Make it a Regular? Open MealQuest →",
|
||||
title: "How was Lemon orzo? Keep it in rotation?",
|
||||
body: "",
|
||||
chatT: "How was Lemon orzo? Keep it in rotation? Open MealQuest →",
|
||||
},
|
||||
} {
|
||||
ev := tc.ev
|
||||
|
||||
@@ -97,6 +97,22 @@ func (q *Queries) GetNotificationPref(ctx context.Context, event string) (Notifi
|
||||
return i, err
|
||||
}
|
||||
|
||||
const hasNotificationSince = `-- name: HasNotificationSince :one
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notification_outbox
|
||||
WHERE CAST(?1 AS TEXT) IS NULL OR created_at > CAST(?1 AS TEXT)
|
||||
) AS found
|
||||
`
|
||||
|
||||
// Reports whether a notification was stored after since, or at all when
|
||||
// since is NULL.
|
||||
func (q *Queries) HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, hasNotificationSince, since)
|
||||
var found bool
|
||||
err := row.Scan(&found)
|
||||
return found, err
|
||||
}
|
||||
|
||||
const insertNotification = `-- name: InsertNotification :execrows
|
||||
INSERT INTO notification_outbox (
|
||||
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
||||
@@ -166,6 +182,53 @@ func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listRecentNotifications = `-- name: ListRecentNotifications :many
|
||||
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
|
||||
WHERE o.id = (
|
||||
SELECT i.id FROM notification_outbox i
|
||||
WHERE i.event_key = o.event_key
|
||||
ORDER BY i.created_at DESC, i.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY o.created_at DESC, o.event_key DESC
|
||||
LIMIT ?1
|
||||
`
|
||||
|
||||
type ListRecentNotificationsRow struct {
|
||||
EventKey string
|
||||
Payload string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// Returns the last lim distinct events stored, newest first: one row per
|
||||
// event key, the last stored for it whatever its channel, so an event
|
||||
// stored for push and Google Chat is one row. The last, because a channel
|
||||
// switched on later can store the same event key again, and
|
||||
// HasNotificationSince counts that row: an event is as new as its newest
|
||||
// row, so marking it seen also covers that row.
|
||||
func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListRecentNotificationsRow
|
||||
for rows.Next() {
|
||||
var i ListRecentNotificationsRow
|
||||
if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markNotificationSent = `-- name: MarkNotificationSent :execrows
|
||||
UPDATE notification_outbox
|
||||
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
|
||||
|
||||
@@ -24,6 +24,9 @@ type Querier interface {
|
||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
// when no job is queued.
|
||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
||||
// Claims one period of a schedule. The insert is the claim: 1 row means
|
||||
// this caller owns the period, 0 rows means it was already claimed.
|
||||
ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error)
|
||||
// Clears every day's calendar event id: none is on the chosen calendar.
|
||||
ClearCalendarEvents(ctx context.Context, updatedAt string) error
|
||||
// Records that Google refuses the stored grant: clears the refresh token and
|
||||
@@ -120,6 +123,13 @@ type Querier interface {
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetChatSettings(ctx context.Context) (ChatSetting, error)
|
||||
GetDinner(ctx context.Context, id string) (Dinner, error)
|
||||
// The reason of each suggestion of dinner_id that can have created it,
|
||||
// none for a dinner no suggestion created. A suggestion is only ever
|
||||
// created with a new dinner, and declining one archives its dinner, so a
|
||||
// live dinner's creating suggestion is pending (the dinner still
|
||||
// suggested) or accepted; a declined one did not create it. More than one
|
||||
// row means nothing records which did.
|
||||
GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
@@ -172,6 +182,9 @@ type Querier interface {
|
||||
// them only when a member edited them while it was grounding. Like
|
||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||
// Reports whether a notification was stored after since, or at all when
|
||||
// since is NULL.
|
||||
HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error)
|
||||
// Stores a pending notification for one channel, due at once. A row already
|
||||
// stored for the event key and channel, in any status, is left unchanged and
|
||||
// no row is inserted.
|
||||
@@ -202,6 +215,13 @@ type Querier interface {
|
||||
// Every stored subscription with its keys, for the push sender, oldest
|
||||
// first.
|
||||
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
|
||||
// Returns the last lim distinct events stored, newest first: one row per
|
||||
// event key, the last stored for it whatever its channel, so an event
|
||||
// stored for push and Google Chat is one row. The last, because a channel
|
||||
// switched on later can store the same event key again, and
|
||||
// HasNotificationSince counts that row: an event is as new as its newest
|
||||
// row, so marking it seen also covers that row.
|
||||
ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error)
|
||||
// Reads for the AI household context block (internal/ai/harness).
|
||||
// The most recent names the household turned down, newest first, as
|
||||
// ListPassedNames has them.
|
||||
@@ -244,6 +264,10 @@ type Querier interface {
|
||||
// Records list id sent to Google Chat at sent_to_chat_at, its send
|
||||
// confirmed, unless it is frozen into the Supply archive.
|
||||
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
|
||||
// Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||
// already at or after seen_at, so it never moves back. It is not a settings
|
||||
// edit: updated_by and updated_at are left as they are.
|
||||
MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error)
|
||||
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||
// changed, any other keeps its status.
|
||||
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: schedule_runs.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const claimScheduleRun = `-- name: ClaimScheduleRun :execrows
|
||||
INSERT INTO schedule_runs (id, schedule, period_key, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (schedule, period_key) DO NOTHING
|
||||
`
|
||||
|
||||
type ClaimScheduleRunParams struct {
|
||||
ID string
|
||||
Schedule string
|
||||
PeriodKey string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// Claims one period of a schedule. The insert is the claim: 1 row means
|
||||
// this caller owns the period, 0 rows means it was already claimed.
|
||||
func (q *Queries) ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, claimScheduleRun,
|
||||
arg.ID,
|
||||
arg.Schedule,
|
||||
arg.PeriodKey,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -161,6 +161,23 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
|
||||
return i, err
|
||||
}
|
||||
|
||||
const markUpdatesSeen = `-- name: MarkUpdatesSeen :execrows
|
||||
UPDATE household_settings
|
||||
SET updates_seen_at = CAST(?1 AS TEXT)
|
||||
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(?1 AS TEXT))
|
||||
`
|
||||
|
||||
// Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||
// already at or after seen_at, so it never moves back. It is not a settings
|
||||
// edit: updated_by and updated_at are left as they are.
|
||||
func (q *Queries) MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, markUpdatesSeen, seenAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const recordChatTest = `-- name: RecordChatTest :execrows
|
||||
UPDATE chat_settings
|
||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||
|
||||
@@ -187,6 +187,41 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDinnerSuggestionReasons = `-- name: GetDinnerSuggestionReasons :many
|
||||
SELECT reason FROM suggestions
|
||||
WHERE dinner_id = ? AND status IN ('pending', 'accepted')
|
||||
ORDER BY id
|
||||
`
|
||||
|
||||
// The reason of each suggestion of dinner_id that can have created it,
|
||||
// none for a dinner no suggestion created. A suggestion is only ever
|
||||
// created with a new dinner, and declining one archives its dinner, so a
|
||||
// live dinner's creating suggestion is pending (the dinner still
|
||||
// suggested) or accepted; a declined one did not create it. More than one
|
||||
// row means nothing records which did.
|
||||
func (q *Queries) GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getDinnerSuggestionReasons, dinnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var reason string
|
||||
if err := rows.Scan(&reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, reason)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one
|
||||
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
|
||||
ORDER BY id DESC
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
)
|
||||
|
||||
// Detail is a dinner with its recipe, as the recipe detail screen and
|
||||
// Kitchen reading mode show it.
|
||||
type Detail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Origin string `json:"origin"`
|
||||
Method *string `json:"method"`
|
||||
MinutesTotal *int64 `json:"minutes_total"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
Cuisine *string `json:"cuisine"`
|
||||
IsFavorite bool `json:"is_favorite"`
|
||||
// PhotoURLs is the cached photo at each stored width, keyed by width
|
||||
// ("400", "1200"); null when the dinner has no photo.
|
||||
PhotoURLs map[string]string `json:"photo_urls"`
|
||||
// Ingredients and Steps are the recipe's, in recipe order; empty when
|
||||
// the dinner has no recipe.
|
||||
Ingredients []Ingredient `json:"ingredients"`
|
||||
Steps []string `json:"steps"`
|
||||
// Source is the site of the recipe page the dinner is grounded in;
|
||||
// null for a recipe entered by hand or a dinner with no recipe.
|
||||
Source *Source `json:"source"`
|
||||
GroundingStatus string `json:"grounding_status"`
|
||||
// GroundingError is why grounding failed, one of the Reason codes;
|
||||
// null unless GroundingStatus is failed.
|
||||
GroundingError *string `json:"grounding_error"`
|
||||
// WhyPicked is the reason of the suggestion that created the dinner
|
||||
// (see GetDinnerSuggestionReasons); null for a dinner no suggestion
|
||||
// created.
|
||||
WhyPicked *string `json:"why_picked"`
|
||||
}
|
||||
|
||||
// Ingredient is one recipe line. RawText is the line as the recipe gives
|
||||
// it; Name, Qty, Unit and Note are normalize_recipe's reading of it, each
|
||||
// null when it has none, and Name, Qty and Unit always null for a recipe
|
||||
// entered by hand (see IsManualRecipe).
|
||||
type Ingredient struct {
|
||||
Name *string `json:"name"`
|
||||
Qty *string `json:"qty"`
|
||||
Unit *string `json:"unit"`
|
||||
Note *string `json:"note"`
|
||||
RawText string `json:"raw_text"`
|
||||
}
|
||||
|
||||
// Source is the site a recipe was fetched from. SiteName is the name the
|
||||
// page itself gives its site, and null when it gives none or the stored
|
||||
// name could have come from a link (see pageSiteName). URL is the page's
|
||||
// link only when it is verifiably free of secrets (see safeSourceURL), and
|
||||
// null otherwise: a link pasted or offered as a candidate may carry a
|
||||
// personal token, so a client links the credit only when URL is not null.
|
||||
// Source is set for every fetched recipe, so a client always credits it:
|
||||
// by SiteName, else by URL's host, else without naming the site.
|
||||
type Source struct {
|
||||
SiteName *string `json:"site_name"`
|
||||
URL *string `json:"url"`
|
||||
}
|
||||
|
||||
// Get returns dinner id with its recipe and the reason it was suggested,
|
||||
// read in one read transaction. An unknown or archived dinner is
|
||||
// ErrNotFound: an archived dinner has no detail to show.
|
||||
func (s *Service) Get(ctx context.Context, id string) (d Detail, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Detail{}, fmt.Errorf("dinners: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
row, err := q.GetDinner(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Detail{}, fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
if row.Status == StatusArchived {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
d = Detail{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Status: row.Status,
|
||||
Origin: row.Origin,
|
||||
Method: nullString(row.Method),
|
||||
Difficulty: nullString(row.Difficulty),
|
||||
Cuisine: nullString(row.Cuisine),
|
||||
IsFavorite: row.IsFavorite == 1,
|
||||
Ingredients: []Ingredient{},
|
||||
Steps: []string{},
|
||||
GroundingStatus: row.GroundingStatus,
|
||||
GroundingError: nullString(row.GroundingError),
|
||||
}
|
||||
if row.MinutesTotal.Valid {
|
||||
d.MinutesTotal = &row.MinutesTotal.Int64
|
||||
}
|
||||
if row.PhotoID.Valid {
|
||||
d.PhotoURLs = make(map[string]string, len(recipes.PhotoSizes))
|
||||
for _, size := range recipes.PhotoSizes {
|
||||
d.PhotoURLs[strconv.Itoa(size)] = photoURL(row.PhotoID.String, size)
|
||||
}
|
||||
}
|
||||
if (row.GroundingStatus == GroundingGrounded || row.GroundingStatus == GroundingManual) && !row.RecipeID.Valid {
|
||||
return Detail{}, fmt.Errorf("dinners: %s is %s with no recipe", id, row.GroundingStatus)
|
||||
}
|
||||
if row.RecipeID.Valid {
|
||||
if err := readRecipe(ctx, q, row.RecipeID.String, &d); err != nil {
|
||||
return Detail{}, fmt.Errorf("dinners: read recipe of %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
reasons, err := q.GetDinnerSuggestionReasons(ctx, id)
|
||||
if err != nil {
|
||||
return Detail{}, fmt.Errorf("dinners: read suggestion reason of %s: %w", id, err)
|
||||
}
|
||||
switch len(reasons) {
|
||||
case 0:
|
||||
case 1:
|
||||
d.WhyPicked = &reasons[0]
|
||||
default:
|
||||
// Nothing records which of them created the dinner, and any
|
||||
// one picked would be a guess.
|
||||
return Detail{}, fmt.Errorf("dinners: %s has %d suggestions that could have created it", id, len(reasons))
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// readRecipe sets d's Source, Ingredients and Steps from recipe recipeID.
|
||||
func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail) error {
|
||||
rec, err := q.GetRecipe(ctx, recipeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recipe %s: %w", recipeID, err)
|
||||
}
|
||||
if !IsManualRecipe(rec.SourceUrl) {
|
||||
src, err := SourceOf(rec.SiteName, rec.SourceUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Source = &src
|
||||
}
|
||||
ings, err := q.ListRecipeIngredients(ctx, recipeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingredients: %w", err)
|
||||
}
|
||||
for i, ing := range ings {
|
||||
if i > 0 && ing.Position == ings[i-1].Position {
|
||||
return duplicatePosition("ingredient", ing.Position)
|
||||
}
|
||||
d.Ingredients = append(d.Ingredients, Ingredient{
|
||||
Name: nullString(ing.Name),
|
||||
Qty: nullString(ing.Qty),
|
||||
Unit: nullString(ing.Unit),
|
||||
Note: nullString(ing.Note),
|
||||
RawText: ing.RawText,
|
||||
})
|
||||
}
|
||||
steps, err := q.ListRecipeSteps(ctx, recipeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("steps: %w", err)
|
||||
}
|
||||
for i, step := range steps {
|
||||
if i > 0 && step.Position == steps[i-1].Position {
|
||||
return duplicatePosition("step", step.Position)
|
||||
}
|
||||
d.Steps = append(d.Steps, step.Text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SourceOf is the Source of a recipe fetched from sourceURL whose stored
|
||||
// site name is siteName: the name only when the page gave it (see
|
||||
// pageSiteName) and the link only when it is free of secrets (see
|
||||
// safeSourceURL). It is the one way a fetched recipe's source reaches a
|
||||
// client. A sourceURL that does not parse is an error, whether or not a
|
||||
// site name is stored.
|
||||
func SourceOf(siteName sql.NullString, sourceURL string) (Source, error) {
|
||||
link, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
// Not wrapped: url.Parse's error quotes the link.
|
||||
return Source{}, errors.New("source url does not parse")
|
||||
}
|
||||
return Source{SiteName: pageSiteName(siteName, link), URL: safeSourceURL(sourceURL)}, nil
|
||||
}
|
||||
|
||||
// pageSiteName is the stored site name of a recipe fetched from link
|
||||
// when it is one the page gave, else nil. A page that names no site is
|
||||
// stored under recipes.HostSiteName of the URL it was served from: the
|
||||
// pasted link's host, which may hold a personal token (a private
|
||||
// subdomain), or that of a page the link redirected to, which is not
|
||||
// stored. Nothing records which a stored name is, so any name that could
|
||||
// be a host — the link's own host, an IP address, or a dotted host name —
|
||||
// is treated as one and withheld. A site that names itself as a domain,
|
||||
// such as "Food.com", loses its name with them: a credit that does not
|
||||
// name the site is better than one that leaks a link.
|
||||
func pageSiteName(stored sql.NullString, link *url.URL) *string {
|
||||
if !stored.Valid {
|
||||
return nil
|
||||
}
|
||||
name := stored.String
|
||||
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
|
||||
return nil
|
||||
}
|
||||
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
|
||||
return nil
|
||||
}
|
||||
return &name
|
||||
}
|
||||
|
||||
// safeSourceURL is sourceURL when it is verifiably free of secrets — an
|
||||
// https link to a host, with no user or password, no query (not even a
|
||||
// bare "?") and no fragment (not even a bare "#") — else nil. Those are
|
||||
// the places a link carries a token; a link failing any check, or not
|
||||
// parsing, is withheld rather than trimmed, since a trimmed link is not the
|
||||
// page the recipe came from. The client (sourceUrl in
|
||||
// web/src/dinners/detail.ts) parses the link with the browser's URL parser
|
||||
// and refuses the whole recipe when it fails, so the host and port must
|
||||
// also be ones it parses: see browserHost.
|
||||
func safeSourceURL(sourceURL string) *string {
|
||||
u, err := url.Parse(sourceURL)
|
||||
if err != nil || u.Scheme != "https" || u.Opaque != "" || u.Host == "" || u.User != nil ||
|
||||
u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(sourceURL, "#") {
|
||||
return nil
|
||||
}
|
||||
// url.Parse decodes a percent-encoded host; the link must spell its
|
||||
// host out as it is checked.
|
||||
authority, _, _ := strings.Cut(sourceURL[len(u.Scheme)+len("://"):], "/")
|
||||
if authority != u.Host || !browserHost(u) {
|
||||
return nil
|
||||
}
|
||||
if port := u.Port(); port != "" {
|
||||
if n, err := strconv.ParseUint(port, 10, 16); err != nil || n == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &sourceURL
|
||||
}
|
||||
|
||||
// browserHost says whether u's host is one the browser's URL parser (the
|
||||
// WHATWG URL Standard) accepts as it stands, where url.Parse accepts more,
|
||||
// such as "[bad]". The rule is narrower than the browser's: a bracketed
|
||||
// host is an IPv6 address without a zone; any other is ASCII letters,
|
||||
// digits and hyphens in dot-separated labels, none empty but a trailing
|
||||
// one, none punycode ("xn--"), since the browser refuses invalid punycode
|
||||
// and checking it is not worth a decoder; and a host whose last label is
|
||||
// a number, which the browser reads as an IPv4 address, must be a dotted
|
||||
// quad. A link to any other host is withheld.
|
||||
func browserHost(u *url.URL) bool {
|
||||
host := u.Hostname()
|
||||
if strings.HasPrefix(u.Host, "[") {
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && strings.Contains(host, ":")
|
||||
}
|
||||
labels := strings.Split(strings.TrimSuffix(host, "."), ".")
|
||||
for _, label := range labels {
|
||||
if label == "" || len(label) >= 4 && strings.EqualFold(label[:4], "xn--") {
|
||||
return false
|
||||
}
|
||||
for _, c := range label {
|
||||
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
last := labels[len(labels)-1]
|
||||
if strings.Trim(last, "0123456789") == "" || len(last) >= 2 && strings.EqualFold(last[:2], "0x") {
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.To4() != nil && !strings.Contains(host, ":")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// duplicatePosition is the error for two recipe items of kind at position.
|
||||
// Position is the only record of recipe order, and the id that breaks the
|
||||
// tie says nothing about it, so a recipe with a duplicate is refused rather
|
||||
// than shown in an order nobody can trust.
|
||||
func duplicatePosition(kind string, position int64) error {
|
||||
return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position)
|
||||
}
|
||||
+120
-9
@@ -138,6 +138,9 @@ type Service struct {
|
||||
grounder *recipes.Grounder
|
||||
logger *slog.Logger
|
||||
groundJob *jobs.Kind[GroundJob]
|
||||
// wake is called after every committed change to a dinner's method or
|
||||
// minutes, which can move its crock-pot start reminder.
|
||||
wake func()
|
||||
|
||||
// groundingsMu guards groundings: the KindGroundDinner jobs running,
|
||||
// by dinner id, so archiving or renaming a dinner can stop them.
|
||||
@@ -157,10 +160,11 @@ type Service struct {
|
||||
offerSearches map[string]map[*offerSearch]struct{}
|
||||
}
|
||||
|
||||
// NewService returns a service over st that grounds dinners with grounder
|
||||
// and registers the KindGroundDinner job on runner, which must not have
|
||||
// started yet.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
// NewService returns a service over st that grounds dinners with grounder,
|
||||
// calls wake (the scheduler's Wake) after every committed change to a
|
||||
// dinner's method or minutes, and registers the KindGroundDinner job on
|
||||
// runner, which must not have started yet.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("dinners: nil store")
|
||||
@@ -168,10 +172,12 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
|
||||
return nil, errors.New("dinners: nil grounder")
|
||||
case runner == nil:
|
||||
return nil, errors.New("dinners: nil job runner")
|
||||
case wake == nil:
|
||||
return nil, errors.New("dinners: nil wake")
|
||||
case logger == nil:
|
||||
return nil, errors.New("dinners: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
|
||||
s := &Service{st: st, grounder: grounder, wake: wake, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
|
||||
rand.Read(s.offerKey) // never fails: it crashes the program instead
|
||||
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
||||
if err != nil {
|
||||
@@ -288,6 +294,89 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// IdeaOrigins are the origins of a dinner added from an idea: ingredients
|
||||
// for Cook from what I have, quick for Quick dinners.
|
||||
var IdeaOrigins = []string{"ingredients", "quick"}
|
||||
|
||||
// NewQuestLog is a dinner to add to the Quest log from an idea: its name,
|
||||
// the recipe the idea was already grounded in, and its origin, one of
|
||||
// IdeaOrigins.
|
||||
type NewQuestLog struct {
|
||||
Name string
|
||||
RecipeID string
|
||||
Origin string
|
||||
}
|
||||
|
||||
// AddToQuestLog adds n to the Quest log, recording updatedBy: a dinner
|
||||
// with status quest_log and origin n.Origin, grounded in recipe n.RecipeID
|
||||
// (its method, minutes, difficulty and photo), and returns it with
|
||||
// created true. When an unarchived dinner already repeats it, by the same
|
||||
// rule that refuses a duplicate Regular (a harness.Similar name, or the
|
||||
// recipe's link), that dinner is returned as it is, with created false,
|
||||
// so adding the same idea again never makes a second dinner: a Regular
|
||||
// stays a Regular and a Quest log dinner stays in the Quest log. A
|
||||
// repeated dinner still awaiting the household's choice (a suggested Side
|
||||
// quest, or one tried and awaiting its Rotation check) is a
|
||||
// *DuplicateError and changes nothing: adding it would make that choice
|
||||
// for them. An unknown recipe is an error.
|
||||
func (s *Service) AddToQuestLog(ctx context.Context, n NewQuestLog, updatedBy string) (d Dinner, created bool, err error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, false, errors.New("dinners: add to Quest log: no member to record as updated_by")
|
||||
}
|
||||
if !slices.Contains(IdeaOrigins, n.Origin) {
|
||||
return Dinner{}, false, fmt.Errorf("dinners: add to Quest log: origin %q is not one of %s", n.Origin, strings.Join(IdeaOrigins, ", "))
|
||||
}
|
||||
name, err := checkName(n.Name)
|
||||
if err != nil {
|
||||
return Dinner{}, false, err
|
||||
}
|
||||
var row db.Dinner
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
recipe, err := q.GetRecipe(ctx, n.RecipeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: add to Quest log: read recipe %s: %w", n.RecipeID, err)
|
||||
}
|
||||
existing, found, err := findRepeat(ctx, q, "", name, sql.NullString{String: recipe.SourceUrl, Valid: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if existing.Status != StatusRegular && existing.Status != StatusQuestLog {
|
||||
return &DuplicateError{Existing: fromRow(existing)}
|
||||
}
|
||||
row = existing
|
||||
return nil
|
||||
}
|
||||
now := store.Now().String()
|
||||
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: StatusQuestLog,
|
||||
Origin: n.Origin,
|
||||
Method: recipe.Method,
|
||||
MinutesTotal: recipe.MinutesTotal,
|
||||
Difficulty: recipe.Difficulty,
|
||||
RecipeID: sql.NullString{String: recipe.ID, Valid: true},
|
||||
PhotoID: recipe.PhotoID,
|
||||
GroundingStatus: GroundingGrounded,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: add to Quest log: %w", err)
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Dinner{}, false, err
|
||||
}
|
||||
return fromRow(row), created, nil
|
||||
}
|
||||
|
||||
// List returns the dinners with status, which must be regular or
|
||||
// quest_log, ordered by normalized name. The Quest log is the dinners
|
||||
// accepted and not yet scheduled, so a quest_log dinner on the plan for the
|
||||
@@ -419,6 +508,9 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if p.Method != nil || p.MinutesTotal != nil {
|
||||
s.wake()
|
||||
}
|
||||
if renamed {
|
||||
updateCommitted()
|
||||
s.clearOffers(row.ID, row.GroundingRevision)
|
||||
@@ -551,9 +643,23 @@ func checkRecipeURL(raw string) (*url.URL, error) {
|
||||
// source_url of the recipe it is grounded in, else the recipe_url still
|
||||
// awaiting grounding.
|
||||
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
|
||||
row, found, err := findRepeat(ctx, q, self, name, recipeURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
return &DuplicateError{Existing: fromRow(row)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findRepeat returns the first unarchived dinner other than self that
|
||||
// name or recipeURL repeats, as checkUnique decides it, and whether there
|
||||
// is one.
|
||||
func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) (db.Dinner, bool, error) {
|
||||
rows, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: list for dedupe: %w", err)
|
||||
return db.Dinner{}, false, fmt.Errorf("dinners: list for dedupe: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Dinner.ID == self {
|
||||
@@ -564,15 +670,20 @@ func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeUR
|
||||
link = row.RecipeSourceUrl
|
||||
}
|
||||
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
|
||||
return &DuplicateError{Existing: fromRow(row.Dinner)}
|
||||
return row.Dinner, true, nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return db.Dinner{}, false, nil
|
||||
}
|
||||
|
||||
// PhotoURL is where the API serves a list-sized copy of photo id.
|
||||
func PhotoURL(id string) string {
|
||||
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
|
||||
return photoURL(id, photoListWidth)
|
||||
}
|
||||
|
||||
// photoURL is where the API serves photo id at stored width size.
|
||||
func photoURL(id string, size int) string {
|
||||
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size)
|
||||
}
|
||||
|
||||
func fromRow(r db.Dinner) Dinner {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dinners_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
@@ -74,3 +75,106 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) {
|
||||
}
|
||||
check("duplicate error", dup.Error())
|
||||
}
|
||||
|
||||
// TestPageSiteNameWithholdsHosts: a stored site name that could be a host —
|
||||
// the link's own, in any case and without "www.", an IP address or a dotted
|
||||
// host name — is withheld; a name the page gave, dotless or with spaces, is
|
||||
// kept.
|
||||
func TestPageSiteNameWithholdsHosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
const link = "https://www.TokenHost/recipes/stew"
|
||||
for _, tc := range []struct {
|
||||
stored string
|
||||
valid bool
|
||||
want *string
|
||||
}{
|
||||
{"", false, nil},
|
||||
{"tokenhost", true, nil},
|
||||
{"TOKENHOST", true, nil},
|
||||
{"private-token.example.com", true, nil},
|
||||
{"Food.com", true, nil},
|
||||
{"203.0.113.7", true, nil},
|
||||
{"2001:db8::1", true, nil},
|
||||
{"Delish", true, ptr("Delish")},
|
||||
{"Bon Appétit", true, ptr("Bon Appétit")},
|
||||
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
|
||||
} {
|
||||
src, err := dinners.SourceOf(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
|
||||
if err != nil {
|
||||
t.Fatalf("SourceOf(%q): %v", tc.stored, err)
|
||||
}
|
||||
if got := src.SiteName; (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
|
||||
t.Errorf("SourceOf(%q) site name = %v, want %v", tc.stored, deref(got), deref(tc.want))
|
||||
}
|
||||
}
|
||||
// A link that does not parse is an error with or without a site name.
|
||||
for _, site := range []sql.NullString{{String: "Delish", Valid: true}, {}} {
|
||||
if _, err := dinners.SourceOf(site, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
|
||||
t.Errorf("SourceOf(%v) of an unparseable link: err = %v, want one not quoting the link", site, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeSourceURLOnlyWithoutSecrets: a recipe link is shown only when it
|
||||
// is https to a host with no user, password, query or fragment, each of
|
||||
// which can carry a token; any other link, or one that does not parse, is
|
||||
// withheld whole.
|
||||
func TestSafeSourceURLOnlyWithoutSecrets(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, link := range []string{
|
||||
"https://www.example.com/recipes/chicken-curry",
|
||||
"https://example.com",
|
||||
"https://example.com:8443/stew/",
|
||||
"https://[2001:db8::1]/curry",
|
||||
"https://1.2.3.4/curry",
|
||||
"https://Kitchen-2.Example.com./curry",
|
||||
} {
|
||||
if got := dinners.SafeSourceURL(link); got == nil || *got != link {
|
||||
t.Errorf("SafeSourceURL(%q) = %v, want the link", link, deref(got))
|
||||
}
|
||||
}
|
||||
for _, link := range []string{
|
||||
"https://example.com/curry?access_token=query-token",
|
||||
"https://example.com/curry?",
|
||||
"https://user:password@example.com/curry",
|
||||
"https://token@example.com/curry",
|
||||
"https://@example.com/curry",
|
||||
"http://example.com/curry",
|
||||
"https://example.com/curry#token=fragment-token",
|
||||
"https://example.com/curry#",
|
||||
"ftp://example.com/curry",
|
||||
"https:example.com/curry",
|
||||
"https:///curry",
|
||||
"https://example.com:99999/curry",
|
||||
"https://example.com:0/curry",
|
||||
"//example.com/curry",
|
||||
"https://secret-token\x7f.example/%zz",
|
||||
"",
|
||||
// Hosts url.Parse accepts but the browser's URL parser refuses
|
||||
// (or reads as another host), which would fail the whole recipe
|
||||
// screen.
|
||||
"https://[bad]/curry",
|
||||
"https://[fe80::1%25en0]/curry",
|
||||
"https://[1.2.3.4]/curry",
|
||||
"https://1.2.3.999/curry",
|
||||
"https://1.2.3.4.5/curry",
|
||||
"https://999/curry",
|
||||
"https://example.0x/curry",
|
||||
"https://xn--a/curry",
|
||||
"https://ex%61mple.com/curry",
|
||||
"https://ex%2fample.com/curry",
|
||||
"https://a..b/curry",
|
||||
"https://café.example/curry",
|
||||
} {
|
||||
if got := dinners.SafeSourceURL(link); got != nil {
|
||||
t.Errorf("SafeSourceURL(%q) = %q, want nil", link, *got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deref(s *string) any {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
@@ -71,3 +71,6 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
|
||||
r, err := s.runGroundJob(ctx, p, nil)
|
||||
return r.GroundingStatus, err
|
||||
}
|
||||
|
||||
// SafeSourceURL is safeSourceURL.
|
||||
var SafeSourceURL = safeSourceURL
|
||||
|
||||
@@ -196,6 +196,7 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
|
||||
if n == 0 {
|
||||
return s.currentGrounding(ctx, row.ID)
|
||||
}
|
||||
s.wake()
|
||||
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
|
||||
}
|
||||
|
||||
@@ -577,6 +578,7 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
s.wake()
|
||||
s.clearOffers(cur.ID, math.MaxInt64)
|
||||
return fromRow(row), nil
|
||||
}
|
||||
@@ -700,6 +702,7 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
s.wake()
|
||||
s.clearOffers(row.ID, math.MaxInt64)
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package home
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock makes now the clock s reads "now" and "tonight" on.
|
||||
func SetClock(s *Service, now func() time.Time) { s.now = now }
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package home reads the Home screen: tonight's dinner, the Side quests to
|
||||
// swipe, the Supply run and whether there are recent updates. It owns no
|
||||
// data: every value is read through the service that owns it, and "now"
|
||||
// and "tonight" are in the household's zone, never UTC.
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
// Service reads the Home screen.
|
||||
type Service struct {
|
||||
settings *household.SettingsService
|
||||
plans *plan.Service
|
||||
suggest *suggest.Service
|
||||
shopping *shopping.Service
|
||||
notifier *notify.Service
|
||||
// now is the clock "now" and "tonight" are read on; tests replace it.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService returns a service reading through the given services.
|
||||
func NewService(settings *household.SettingsService, plans *plan.Service, suggestSvc *suggest.Service, shop *shopping.Service, notifier *notify.Service) (*Service, error) {
|
||||
switch {
|
||||
case settings == nil:
|
||||
return nil, errors.New("home: nil household settings service")
|
||||
case plans == nil:
|
||||
return nil, errors.New("home: nil plan service")
|
||||
case suggestSvc == nil:
|
||||
return nil, errors.New("home: nil suggestions service")
|
||||
case shop == nil:
|
||||
return nil, errors.New("home: nil shopping service")
|
||||
case notifier == nil:
|
||||
return nil, errors.New("home: nil notifier")
|
||||
}
|
||||
return &Service{settings: settings, plans: plans, suggest: suggestSvc, shopping: shop, notifier: notifier, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Home is the Home screen as the API shows it.
|
||||
type Home struct {
|
||||
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
|
||||
DateLabel string `json:"dateLabel"`
|
||||
// Timezone is the household's IANA zone, whose next local midnight
|
||||
// is when Home is read again for the new day.
|
||||
Timezone string `json:"timezone"`
|
||||
// Tonight is the dinner planned for today on the current week's plan;
|
||||
// nil when the week has no plan.
|
||||
Tonight *Dinner `json:"tonight"`
|
||||
// SideQuestCount is how many Side quests are pending on the deck.
|
||||
SideQuestCount int `json:"sideQuestCount"`
|
||||
// ShoppingList is the Supply run of the upcoming week when it has one,
|
||||
// else of the current week.
|
||||
ShoppingList ShoppingStatus `json:"shoppingList"`
|
||||
RecentUpdates RecentUpdates `json:"recentUpdates"`
|
||||
}
|
||||
|
||||
// Dinner is tonight's dinner.
|
||||
type Dinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Photo *string `json:"photo"`
|
||||
// Origin is regular or side_quest.
|
||||
Origin string `json:"origin"`
|
||||
MinutesTotal *int64 `json:"minutes_total"`
|
||||
}
|
||||
|
||||
// ShoppingStatus is whether a week's Supply run is ready.
|
||||
type ShoppingStatus struct {
|
||||
// WeekStart is the Monday of the week, YYYY-MM-DD.
|
||||
WeekStart string `json:"weekStart"`
|
||||
// Label is the week label, e.g. "Week 41 · Oct 5 – 11".
|
||||
Label string `json:"label"`
|
||||
// Ready is true when the week has a Supply run.
|
||||
Ready bool `json:"ready"`
|
||||
// SentToChatAt is when the Supply run was sent to Google Chat; nil
|
||||
// until it is, and when there is no Supply run.
|
||||
SentToChatAt *string `json:"sentToChatAt"`
|
||||
}
|
||||
|
||||
// RecentUpdates says whether there is something new for the bell.
|
||||
type RecentUpdates struct {
|
||||
// Unread is true when a notification was stored after the household
|
||||
// last saw its updates (household_settings.updates_seen_at), or at all
|
||||
// when it never has.
|
||||
Unread bool `json:"unread"`
|
||||
}
|
||||
|
||||
// Get reads the Home screen at the service's "now". It only reads: it
|
||||
// writes nothing, not even the batch bookkeeping a Deck read does.
|
||||
func (s *Service) Get(ctx context.Context) (Home, error) {
|
||||
settings, err := s.settings.Get(ctx)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: household settings: %w", err)
|
||||
}
|
||||
now := s.now().In(zone)
|
||||
today := household.Today(now, zone)
|
||||
thisWeek := household.WeekStart(today)
|
||||
|
||||
tonight, err := s.tonight(ctx, thisWeek, today)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
sideQuests, err := s.suggest.PendingCount(ctx)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: read deck: %w", err)
|
||||
}
|
||||
list, err := s.shoppingStatus(ctx, thisWeek)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
unread, err := s.unread(ctx, settings.UpdatesSeenAt)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
return Home{
|
||||
DateLabel: now.Format("Monday, Jan 2"),
|
||||
Timezone: settings.Timezone,
|
||||
Tonight: tonight,
|
||||
SideQuestCount: sideQuests,
|
||||
ShoppingList: list,
|
||||
RecentUpdates: RecentUpdates{Unread: unread},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tonight returns the dinner planned on today in the plan of the week
|
||||
// starting thisWeek, or nil when the week has no plan.
|
||||
func (s *Service) tonight(ctx context.Context, thisWeek, today store.LocalDate) (*Dinner, error) {
|
||||
p, err := s.plans.Get(ctx, thisWeek)
|
||||
if errors.Is(err, plan.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("home: read plan: %w", err)
|
||||
}
|
||||
for _, d := range p.Days {
|
||||
if d.Date == today.String() {
|
||||
return &Dinner{
|
||||
ID: d.Dinner.ID, Name: d.Dinner.Name, Photo: d.Dinner.Photo,
|
||||
Origin: d.Dinner.Origin, MinutesTotal: d.Dinner.Minutes,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("home: plan of week %s has no day %s", thisWeek, today)
|
||||
}
|
||||
|
||||
// shoppingStatus returns the status of the upcoming week's Supply run when
|
||||
// it has one, else of the current week's, ready or not.
|
||||
func (s *Service) shoppingStatus(ctx context.Context, thisWeek store.LocalDate) (ShoppingStatus, error) {
|
||||
y, m, d := thisWeek.Date()
|
||||
nextWeek := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
|
||||
for _, week := range []store.LocalDate{nextWeek, thisWeek} {
|
||||
l, err := s.shopping.Get(ctx, week)
|
||||
if errors.Is(err, shopping.ErrListNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return ShoppingStatus{}, fmt.Errorf("home: read Supply run: %w", err)
|
||||
}
|
||||
return weekStatus(week, true, l.SentToChatAt)
|
||||
}
|
||||
return weekStatus(thisWeek, false, nil)
|
||||
}
|
||||
|
||||
func weekStatus(week store.LocalDate, ready bool, sentToChatAt *string) (ShoppingStatus, error) {
|
||||
label, err := household.WeekLabel(week)
|
||||
if err != nil {
|
||||
return ShoppingStatus{}, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return ShoppingStatus{WeekStart: week.String(), Label: label, Ready: ready, SentToChatAt: sentToChatAt}, nil
|
||||
}
|
||||
|
||||
// unread reports whether a notification was stored after seenAt, the
|
||||
// stored updates_seen_at, or at all when it is nil.
|
||||
func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
|
||||
var since *time.Time
|
||||
if seenAt != nil {
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(*seenAt); err != nil {
|
||||
return false, fmt.Errorf("home: household settings updates_seen_at: %w", err)
|
||||
}
|
||||
since = &ts.Time
|
||||
}
|
||||
unread, err := s.notifier.StoredSince(ctx, since)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
|
||||
// RecentLimit is how many events the Recent updates sheet lists.
|
||||
const RecentLimit = 10
|
||||
|
||||
// Update is one event in the Recent updates sheet.
|
||||
type Update struct {
|
||||
// Type is the event type, e.g. plan_ready.
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
// URL is the path-absolute, same-origin path a tap on the update opens.
|
||||
URL string `json:"url"`
|
||||
// CreatedAt is when the event was stored, UTC RFC 3339 with
|
||||
// milliseconds; it is what POST /updates/seen takes back.
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Updates returns the last RecentLimit distinct notification events, newest
|
||||
// first, whatever channels they went to.
|
||||
func (s *Service) Updates(ctx context.Context) ([]Update, error) {
|
||||
events, err := s.notifier.Recent(ctx, RecentLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
out := make([]Update, len(events))
|
||||
for i, ev := range events {
|
||||
out[i] = Update{
|
||||
Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL,
|
||||
CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now.
|
||||
var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now")
|
||||
|
||||
// MarkUpdatesSeen records that the household has seen its updates through
|
||||
// through, the created_at of the newest update it was shown, so
|
||||
// recentUpdates.unread is false until an event is stored after it. A time
|
||||
// before the one already recorded changes nothing; one after now is
|
||||
// ErrSeenInFuture, since it would hide events not yet stored.
|
||||
func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error {
|
||||
if through.After(s.now()) {
|
||||
return ErrSeenInFuture
|
||||
}
|
||||
if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil {
|
||||
return fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
package home_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"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/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"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/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// zone is the household's zone in these tests: not UTC, so a late local
|
||||
// evening is already the next day in UTC.
|
||||
const zone = "America/Chicago"
|
||||
|
||||
// weekDates is the week of 2026-10-12, Monday first.
|
||||
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||
|
||||
type env struct {
|
||||
svc *home.Service
|
||||
st *store.Store
|
||||
settings *household.SettingsService
|
||||
notifier *notify.Service
|
||||
loc *time.Location
|
||||
}
|
||||
|
||||
// newEnv returns a home service over a migrated temp database whose
|
||||
// household is in zone, reading "now" from a clock set to at.
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
|
||||
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(make([]byte, secretbox.KeySize)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath)
|
||||
googleSvc, err := gcal.NewService(st, box, gcal.Config{
|
||||
ClientID: gcaltest.ClientID, ClientSecret: gcaltest.ClientSecret, PublicURL: pub,
|
||||
SecretKey: []byte("server-secret-key"), Endpoint: google.Endpoint(), RevokeURL: google.RevokeURL(),
|
||||
CalendarAPI: google.CalendarAPI(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chatSvc, err := chat.NewService(st, box, pub, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
title := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
return skills.ArchiveTitle{}, errors.New("no model in this test")
|
||||
}
|
||||
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := household.NewSettingsService(st)
|
||||
svc, err := home.NewService(settings, plans, suggestSvc, shop, notifier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loc, err := time.LoadLocation(zone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &env{svc: svc, st: st, settings: settings, notifier: notifier, loc: loc}
|
||||
}
|
||||
|
||||
// at reads the Home screen with the clock at the household-local wall
|
||||
// time "YYYY-MM-DD HH:MM".
|
||||
func (e *env) at(t *testing.T, local string) home.Home {
|
||||
t.Helper()
|
||||
now, err := time.ParseInLocation("2006-01-02 15:04", local, e.loc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home.SetClock(e.svc, func() time.Time { return now })
|
||||
h, err := e.svc.Get(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// dinner stores a dinner and returns its id.
|
||||
func (e *env) dinner(t *testing.T, name, status, origin string, minutes int64) string {
|
||||
t.Helper()
|
||||
now := store.Now().String()
|
||||
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: origin,
|
||||
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
|
||||
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// planWeek stores the plan of the week of 2026-10-12: the dinner named for
|
||||
// its date each day, a Regular of 30 minutes, but Wednesday a Side quest of
|
||||
// unknown minutes. It returns the plan's id.
|
||||
func (e *env) planWeek(t *testing.T) string {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: weekDates[0], CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, date := range weekDates {
|
||||
id := e.dinner(t, "Dinner "+date, "regular", "regular", 30)
|
||||
if date == "2026-10-14" {
|
||||
id = e.dinner(t, "Dinner "+date, "quest_log", "side_quest", 0)
|
||||
}
|
||||
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date,
|
||||
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return planID
|
||||
}
|
||||
|
||||
// Tonight's dinner and the date label follow the household's local
|
||||
// midnight, not UTC's: late on Tuesday evening in Chicago it is already
|
||||
// Wednesday in UTC, and Home still shows Tuesday's dinner.
|
||||
func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.planWeek(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
local, label, dinner string
|
||||
}{
|
||||
// 23:59 CDT is 04:59 UTC the next day.
|
||||
{"2026-10-13 23:59", "Tuesday, Oct 13", "Dinner 2026-10-13"},
|
||||
{"2026-10-14 00:00", "Wednesday, Oct 14", "Dinner 2026-10-14"},
|
||||
// 19:00 CDT on Sunday is Monday 00:00 UTC, a week with no plan.
|
||||
{"2026-10-18 19:00", "Sunday, Oct 18", "Dinner 2026-10-18"},
|
||||
} {
|
||||
h := e.at(t, tc.local)
|
||||
if h.DateLabel != tc.label {
|
||||
t.Errorf("at %s: dateLabel = %q, want %q", tc.local, h.DateLabel, tc.label)
|
||||
}
|
||||
if h.Tonight == nil || h.Tonight.Name != tc.dinner {
|
||||
t.Errorf("at %s: tonight = %+v, want %s", tc.local, h.Tonight, tc.dinner)
|
||||
}
|
||||
}
|
||||
|
||||
// Late Sunday evening of the week before, the plan's week has not
|
||||
// begun locally, though in UTC it has.
|
||||
if h := e.at(t, "2026-10-11 23:30"); h.Tonight != nil || h.DateLabel != "Sunday, Oct 11" {
|
||||
t.Errorf("at 2026-10-11 23:30: tonight = %+v, dateLabel = %q, want no dinner on Sunday, Oct 11", h.Tonight, h.DateLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// Tonight carries the dinner's id, name, photo, origin and minutes.
|
||||
func TestTonightDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.planWeek(t)
|
||||
|
||||
h := e.at(t, "2026-10-13 18:00")
|
||||
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
|
||||
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
|
||||
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
|
||||
}
|
||||
h = e.at(t, "2026-10-14 18:00")
|
||||
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
|
||||
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
|
||||
}
|
||||
}
|
||||
|
||||
// With no plan, no suggestions, no Supply run and no notifications, Home
|
||||
// says so: no dinner tonight, nothing to swipe, the current week's Supply
|
||||
// run not ready and nothing unread.
|
||||
func TestHomeEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
|
||||
h := e.at(t, "2026-10-13 23:59")
|
||||
want := home.Home{
|
||||
DateLabel: "Tuesday, Oct 13",
|
||||
Timezone: zone,
|
||||
ShoppingList: home.ShoppingStatus{
|
||||
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
|
||||
},
|
||||
}
|
||||
if h != want {
|
||||
t.Errorf("home = %+v, want %+v", h, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The Side quest count is the pending cards of the deck: a decided
|
||||
// suggestion is not counted.
|
||||
func TestSideQuestCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"Pho", "Ramen", "Tagine"} {
|
||||
id := e.dinner(t, name, "suggested", "side_quest", 0)
|
||||
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
||||
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: name, Reason: "Because", CitedRegularIds: "[]",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 3 {
|
||||
t.Errorf("sideQuestCount = %d, want 3", h.SideQuestCount)
|
||||
}
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE suggestions SET status = 'declined', decided_at = ? WHERE name = 'Pho'`, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 2 {
|
||||
t.Errorf("sideQuestCount after a Pass = %d, want 2", h.SideQuestCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Reading Home writes nothing: a batch still pending whose job is gone,
|
||||
// which a Deck read would fail, is left as it was, and the read succeeds.
|
||||
func TestGetDoesNotWriteBatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
created := "2026-10-13T12:00:00.000Z"
|
||||
batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch.Status != "pending" {
|
||||
t.Fatalf("new batch status = %q, want pending", batch.Status)
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 {
|
||||
t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount)
|
||||
}
|
||||
var status, updatedAt string
|
||||
if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "pending" || updatedAt != created {
|
||||
t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created)
|
||||
}
|
||||
}
|
||||
|
||||
// The Supply run shown is the upcoming week's when it has one, else the
|
||||
// current week's; the weeks are the household's local weeks.
|
||||
func TestShoppingStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
planID := e.planWeek(t)
|
||||
list := func(weekStart string, sent sql.NullString) {
|
||||
t.Helper()
|
||||
now := store.Now().String()
|
||||
if _, err := e.st.Writer.ExecContext(ctx,
|
||||
`INSERT INTO shopping_lists (id, week_start, plan_id, status, sent_to_chat_at, created_at, updated_at) VALUES (?, ?, ?, 'current', ?, ?, ?)`,
|
||||
store.NewID(), weekStart, planID, sent, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
list("2026-10-12", sql.NullString{})
|
||||
want := home.ShoppingStatus{WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", Ready: true}
|
||||
if h := e.at(t, "2026-10-16 09:00"); h.ShoppingList != want {
|
||||
t.Errorf("shoppingList = %+v, want %+v", h.ShoppingList, want)
|
||||
}
|
||||
|
||||
sent := "2026-10-17T13:00:00.000Z"
|
||||
list("2026-10-19", sql.NullString{String: sent, Valid: true})
|
||||
h := e.at(t, "2026-10-16 09:00")
|
||||
if s := h.ShoppingList; s.WeekStart != "2026-10-19" || s.Label != "Week 43 · Oct 19 – 25" || !s.Ready || s.SentToChatAt == nil || *s.SentToChatAt != sent {
|
||||
t.Errorf("shoppingList = %+v, want the upcoming week's, sent at %s", s, sent)
|
||||
}
|
||||
|
||||
// Late Sunday evening locally is Monday in UTC: the current week is
|
||||
// still the week of 2026-10-12, so its upcoming week's list shows.
|
||||
if s := e.at(t, "2026-10-18 23:30").ShoppingList; s.WeekStart != "2026-10-19" {
|
||||
t.Errorf("late Sunday: shoppingList week = %s, want 2026-10-19", s.WeekStart)
|
||||
}
|
||||
// On Monday the week of 2026-10-19 is current and has no upcoming
|
||||
// list.
|
||||
if s := e.at(t, "2026-10-19 08:00").ShoppingList; s.WeekStart != "2026-10-19" || !s.Ready {
|
||||
t.Errorf("Monday: shoppingList = %+v, want the current week's", s)
|
||||
}
|
||||
// A week later neither the current nor the upcoming week has a list.
|
||||
want = home.ShoppingStatus{WeekStart: "2026-10-26", Label: "Week 44 · Oct 26 – Nov 1"}
|
||||
if s := e.at(t, "2026-10-26 08:00").ShoppingList; s != want {
|
||||
t.Errorf("no list: shoppingList = %+v, want %+v", s, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Recent updates are unread when a notification was stored after the
|
||||
// household last saw its updates, or ever when it never has.
|
||||
func TestRecentUpdatesUnread(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
|
||||
t.Error("unread with no notifications")
|
||||
}
|
||||
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.NewSuggestions, Key: "new_suggestions:1", Title: "New Side quests", URL: "/discover"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
|
||||
t.Error("not unread with a notification never seen")
|
||||
}
|
||||
seen := func(at time.Time) {
|
||||
t.Helper()
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updates_seen_at = ?`, store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
seen(time.Now().Add(time.Hour))
|
||||
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
|
||||
t.Error("unread with every notification seen")
|
||||
}
|
||||
seen(time.Now().Add(-time.Hour))
|
||||
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
|
||||
t.Error("not unread with a notification stored after updates were seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresServices(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := home.NewService(nil, nil, nil, nil, nil); err == nil {
|
||||
t.Fatal("NewService accepted nil services")
|
||||
}
|
||||
}
|
||||
|
||||
// store stores event key of type typ in the outbox at instant at, for each
|
||||
// of channels, as notify stores an event.
|
||||
func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
|
||||
for _, ch := range channels {
|
||||
if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
|
||||
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
|
||||
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Updates are the last ten distinct events, newest first: an event stored
|
||||
// for push and Google Chat is listed once, whatever became of its delivery.
|
||||
func TestUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Errorf("updates with no notifications = %#v, want an empty list", got)
|
||||
}
|
||||
|
||||
base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
for i := range 12 {
|
||||
key := fmt.Sprintf("plan_ready:%02d", i)
|
||||
e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
}
|
||||
// A delivered and a failed row of one event are still one event.
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err = e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != home.RecentLimit {
|
||||
t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got)
|
||||
}
|
||||
for i, u := range got {
|
||||
n := 11 - i
|
||||
key := fmt.Sprintf("plan_ready:%02d", n)
|
||||
want := home.Update{
|
||||
Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key,
|
||||
CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(),
|
||||
}
|
||||
if u != want {
|
||||
t.Errorf("updates[%d] = %+v, want %+v", i, u, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An event emitted to push and Google Chat is one update.
|
||||
func TestUpdatesEmittedToTwoChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows int
|
||||
if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 2 {
|
||||
t.Fatalf("outbox rows = %d, want push and chat", rows)
|
||||
}
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" {
|
||||
t.Errorf("updates = %+v, want the one plan_ready event", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Marking updates seen through the newest clears unread until a newer event
|
||||
// is stored; marking them seen through an older time does not move the
|
||||
// mark back, and a time after now is refused.
|
||||
func TestMarkUpdatesSeen(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
unread := func() bool {
|
||||
t.Helper()
|
||||
return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread
|
||||
}
|
||||
first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC)
|
||||
e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush)
|
||||
if !unread() {
|
||||
t.Fatal("not unread with an event never seen")
|
||||
}
|
||||
// The settings were last edited well before: marking updates seen must
|
||||
// keep that time and tell no settings subscriber.
|
||||
edited := (store.Timestamp{Time: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)}).String()
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updated_at = ?`, edited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var notified int
|
||||
unsubscribe := e.settings.Subscribe(func(household.Settings) { notified++ })
|
||||
defer unsubscribe()
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unread() {
|
||||
t.Error("unread after marking the newest event seen")
|
||||
}
|
||||
|
||||
second := first.Add(time.Millisecond)
|
||||
e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat)
|
||||
if !unread() {
|
||||
t.Error("not unread with an event stored after the mark")
|
||||
}
|
||||
// Seen again through the older event, as a sheet opened before the
|
||||
// newer one arrived would: nothing moves, so it stays unread.
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !unread() {
|
||||
t.Error("an older mark hid the newer event")
|
||||
}
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unread() {
|
||||
t.Error("unread after marking the newer event seen")
|
||||
}
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := e.settings.Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want {
|
||||
t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want)
|
||||
}
|
||||
if settings.UpdatedBy != nil {
|
||||
t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy)
|
||||
}
|
||||
if settings.UpdatedAt != edited {
|
||||
t.Errorf("updated_at = %s, want %s: marking updates seen is not a settings edit", settings.UpdatedAt, edited)
|
||||
}
|
||||
if notified != 0 {
|
||||
t.Errorf("settings subscribers told %d times, want none: marking updates seen is not a settings edit", notified)
|
||||
}
|
||||
|
||||
// The clock reads 17:00 UTC.
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) {
|
||||
t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An event stored again for a channel switched on later is as new as that
|
||||
// row: the update shows its time, and marking it seen clears unread.
|
||||
func TestUpdateStoredAgainForAnotherChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||
later := first.Add(30 * time.Minute)
|
||||
e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush)
|
||||
e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat)
|
||||
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want {
|
||||
t.Fatalf("updates = %+v, want the one event at %s", got, want)
|
||||
}
|
||||
e.at(t, "2026-10-13 12:00")
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread {
|
||||
t.Error("unread after the event was marked seen through its listed time")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
@@ -200,6 +201,21 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// MarkUpdatesSeen records that the household has seen its Recent updates
|
||||
// through seenAt: updates_seen_at moves forward to seenAt, and is left as it
|
||||
// is when it is already at or after it. It is not a settings edit, so
|
||||
// updated_by and updated_at are kept and subscribers are not told.
|
||||
func (s *SettingsService) MarkUpdatesSeen(ctx context.Context, seenAt time.Time) error {
|
||||
if seenAt.IsZero() {
|
||||
return errors.New("household: mark updates seen: zero time")
|
||||
}
|
||||
_, err := db.New(s.st.Writer).MarkUpdatesSeen(ctx, store.Timestamp{Time: seenAt.UTC().Truncate(time.Millisecond)}.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: mark updates seen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyPatch applies a validated patch to the household settings row in q's
|
||||
// transaction, recording updatedBy, and returns the new row.
|
||||
func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) {
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
// Package ideas finds dinner ideas for Cook from what I have and Quick
|
||||
// dinners: from a list of ingredients on hand, ideas_from_ingredients
|
||||
// proposes dinners, and for a kind of Quick dinner, quick_ideas does; each
|
||||
// is grounded with the recipe grounding pipeline (dropping those no recipe
|
||||
// grounds, and Quick dinners whose grounded recipe is not of the kind), and
|
||||
// the ideas kept are reported as a job's result, which fills as each idea
|
||||
// grounds. A job that keeps no idea, or fails, shows no idea: nothing is
|
||||
// made up instead (D-017). Ideas are not stored as dinners: the household
|
||||
// acts on one later.
|
||||
package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Job kinds.
|
||||
const (
|
||||
// KindFromIngredients is the job that finds ideas from ingredients.
|
||||
KindFromIngredients = "ideas_from_ingredients"
|
||||
// KindQuick is the job that finds a page of Quick dinner ideas.
|
||||
KindQuick = "quick_ideas"
|
||||
)
|
||||
|
||||
// ContextBudget is the character budget of the household context
|
||||
// quick_ideas is given (see harness.BuildContext).
|
||||
const ContextBudget = 16000
|
||||
|
||||
// QuickMinutes is the most minutes from prep to table of a Quick dinner
|
||||
// not cooked in a crock pot.
|
||||
const QuickMinutes = 30
|
||||
|
||||
// Job stages, as the job's result reports them.
|
||||
const (
|
||||
StageSuggesting = "suggesting"
|
||||
StageGrounding = "grounding"
|
||||
StageDone = "done"
|
||||
)
|
||||
|
||||
// Reasons the job failed, stored as its failure code. Any other failure,
|
||||
// such as the database's, is the runner's internal_error.
|
||||
const (
|
||||
// ReasonAIFailed: ideas_from_ingredients or quick_ideas, or
|
||||
// normalize_recipe while grounding, failed; no idea is made up instead
|
||||
// (D-017).
|
||||
ReasonAIFailed = "ai_failed"
|
||||
// ReasonSearchNotConfigured: recipe search is not set up.
|
||||
ReasonSearchNotConfigured = "search_not_configured"
|
||||
// ReasonSearchFailed: the search provider failed.
|
||||
ReasonSearchFailed = "search_failed"
|
||||
// ReasonNoIdeasFound: every idea was dropped, by the skill's rules,
|
||||
// because no recipe grounds it, or because its grounded recipe is not
|
||||
// a Quick dinner (a Quick only request) or not of the kind asked for (a
|
||||
// Quick dinners request).
|
||||
ReasonNoIdeasFound = "no_ideas_found"
|
||||
)
|
||||
|
||||
// Reasons are every failure reason of both jobs.
|
||||
var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed, ReasonNoIdeasFound}
|
||||
|
||||
// InvalidError is a request the household must correct, such as an empty
|
||||
// ingredient list or an unknown kind of Quick dinner.
|
||||
type InvalidError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *InvalidError) Error() string { return e.Message }
|
||||
|
||||
// Generator runs ideas_from_ingredients on in; scope is
|
||||
// skills.IdeasScope(in). In production it is skills.IdeasFromIngredients
|
||||
// with the household's harness.Config.
|
||||
type Generator func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error)
|
||||
|
||||
// QuickGenerator runs quick_ideas on in; scope is skills.QuickIdeasScope(in).
|
||||
// In production it is skills.QuickIdeas with the household's
|
||||
// harness.Config.
|
||||
type QuickGenerator func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error)
|
||||
|
||||
// Service starts and runs idea jobs.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
grounder *recipes.Grounder
|
||||
runner *jobs.Runner
|
||||
generate Generator
|
||||
quick QuickGenerator
|
||||
logger *slog.Logger
|
||||
job *jobs.Kind[FromIngredientsJob]
|
||||
quickJob *jobs.Kind[QuickJob]
|
||||
}
|
||||
|
||||
// NewService returns a service that reads the household context from st,
|
||||
// finds ideas with generate and Quick dinner ideas with quick, grounds
|
||||
// them with grounder and registers the KindFromIngredients and KindQuick
|
||||
// jobs on runner, which must not have started yet. The jobs are started
|
||||
// only by StartFromIngredients and StartQuick, which check the request
|
||||
// first.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator, quick QuickGenerator, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("ideas: nil store")
|
||||
case grounder == nil:
|
||||
return nil, errors.New("ideas: nil grounder")
|
||||
case generate == nil:
|
||||
return nil, errors.New("ideas: nil generator")
|
||||
case quick == nil:
|
||||
return nil, errors.New("ideas: nil quick generator")
|
||||
case runner == nil:
|
||||
return nil, errors.New("ideas: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("ideas: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, runner: runner, generate: generate, quick: quick, logger: logger}
|
||||
var err error
|
||||
if s.job, err = register(runner, KindFromIngredients, s.run, shownJobResult[Result]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.quickJob, err = register(runner, KindQuick, s.runQuick, shownJobResult[QuickResult]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// register registers the service-only job name, run by run, whose jobs
|
||||
// show their result through show.
|
||||
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], show func(jobs.Status, json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
|
||||
kind, err := jobs.Register(runner, name, run, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.ServiceOnly(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.ShownResult(show); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kind, nil
|
||||
}
|
||||
|
||||
// FromIngredientsJob is the KindFromIngredients payload: the ingredients
|
||||
// on hand, as skills.CleanIngredients returns them, and whether only
|
||||
// Quick dinners are wanted.
|
||||
type FromIngredientsJob struct {
|
||||
Ingredients []string `json:"ingredients"`
|
||||
QuickOnly bool `json:"quick_only"`
|
||||
}
|
||||
|
||||
// Started is the job a request started.
|
||||
type Started struct {
|
||||
JobID string `json:"jobId"`
|
||||
}
|
||||
|
||||
// StartFromIngredients enqueues a KindFromIngredients job for ingredients
|
||||
// and returns its id. An ingredient list skills.CleanIngredients refuses
|
||||
// is an *InvalidError, and starts nothing.
|
||||
func (s *Service) StartFromIngredients(ctx context.Context, ingredients []string, quickOnly bool) (Started, error) {
|
||||
ings, err := skills.CleanIngredients(ingredients)
|
||||
if err != nil {
|
||||
return Started{}, &InvalidError{Message: "ingredients: " + err.Error()}
|
||||
}
|
||||
id, err := s.job.Enqueue(ctx, FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly})
|
||||
if err != nil {
|
||||
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
|
||||
}
|
||||
return Started{JobID: id}, nil
|
||||
}
|
||||
|
||||
// Idea is a grounded idea as the job's result shows it. Method,
|
||||
// MinutesTotal and Difficulty are the grounded recipe's, never the
|
||||
// model's estimates.
|
||||
type Idea struct {
|
||||
Name string `json:"name"`
|
||||
// Uses are the household's ingredients the idea uses, as the household
|
||||
// wrote them and in its order.
|
||||
Uses []string `json:"uses"`
|
||||
// Missing are the ingredients the idea needs that the household does
|
||||
// not have.
|
||||
Missing []string `json:"missing"`
|
||||
// Summary is the line shown under the idea, e.g. "Uses 3 of 5 ·
|
||||
// missing: 1 lime" (see skills.IdeaSummary).
|
||||
Summary string `json:"summary"`
|
||||
// Method is stovetop, oven, crockpot, grill or other.
|
||||
Method string `json:"method"`
|
||||
// MinutesTotal is prep to table, null when the recipe does not say.
|
||||
MinutesTotal *int `json:"minutes_total"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// Quick says whether the idea is a Quick dinner (see IsQuick).
|
||||
Quick bool `json:"quick"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
// SourceURL and SiteName are the grounded recipe's source as
|
||||
// dinners.SourceOf gives it: each null when it could leak a secret.
|
||||
SourceURL *string `json:"source_url"`
|
||||
SiteName *string `json:"site_name"`
|
||||
}
|
||||
|
||||
// Result is the KindFromIngredients result, published as the job goes:
|
||||
// its stage, the request, the ideas grounded so far in the model's order
|
||||
// (an idea that grounds sooner than one before it is shown first, and the
|
||||
// earlier one is put back in its place when it grounds),
|
||||
// and how many ideas were dropped, by ideas_from_ingredients' rules,
|
||||
// because no recipe grounds them, or, for a Quick only request, because
|
||||
// the grounded recipe is not a Quick dinner.
|
||||
type Result struct {
|
||||
Stage string `json:"stage"`
|
||||
Ingredients []string `json:"ingredients"`
|
||||
QuickOnly bool `json:"quick_only"`
|
||||
Ideas []Idea `json:"ideas"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
func (r Result) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// IsQuick reports whether a recipe cooked by method in minutes (nil when
|
||||
// unknown) is a Quick dinner: cooked in a crock pot, or 30 minutes or less
|
||||
// from prep to table.
|
||||
func IsQuick(method string, minutes *int) bool {
|
||||
return method == "crockpot" || (minutes != nil && *minutes <= QuickMinutes)
|
||||
}
|
||||
|
||||
// run runs a KindFromIngredients job: it asks ideas_from_ingredients for
|
||||
// skills.IdeasRequested ideas, then grounds them in the model's order, at
|
||||
// most the number still wanted at a time, until skills.IdeasKept are kept
|
||||
// or none are left. An idea no recipe grounds is dropped, and so, for a
|
||||
// Quick only request, is one whose grounded recipe is not a Quick dinner;
|
||||
// when every idea is dropped the job fails with ReasonNoIdeasFound. Any
|
||||
// other grounding failure fails the job, as does ideas_from_ingredients
|
||||
// failing, with no idea made up instead. The result is published at each
|
||||
// stage and again as each idea is kept, so it fills as the ideas ground.
|
||||
// Before the job fails, its result is published again with no ideas (see
|
||||
// finish).
|
||||
func (s *Service) run(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
|
||||
res, err := s.fill(ctx, p, publish)
|
||||
return finish(ctx, s.logger, "ideas from ingredients", res, err, publish)
|
||||
}
|
||||
|
||||
// shownResult is a job result that shows ideas.
|
||||
type shownResult[R any] interface {
|
||||
jobs.Public
|
||||
// shown is how many ideas the result shows.
|
||||
shown() int
|
||||
// cleared is the result showing no ideas.
|
||||
cleared() R
|
||||
// withSafeSources is the result with each idea's source as
|
||||
// shownSource gives it.
|
||||
withSafeSources() (R, error)
|
||||
}
|
||||
|
||||
func (r Result) shown() int { return len(r.Ideas) }
|
||||
|
||||
func (r Result) cleared() Result {
|
||||
r.Ideas = []Idea{}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r Result) withSafeSources() (Result, error) {
|
||||
ideas := make([]Idea, len(r.Ideas))
|
||||
for i, idea := range r.Ideas {
|
||||
src, err := shownSource(idea.SourceURL, idea.SiteName)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("ideas: idea %d: %w", i, err)
|
||||
}
|
||||
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
|
||||
ideas[i] = idea
|
||||
}
|
||||
r.Ideas = ideas
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// finish ends a job whose work returned res and err. A job that failed
|
||||
// has its result published again with no ideas, to tidy the stored
|
||||
// result. That write is not what keeps a failed job from showing ideas,
|
||||
// since it may fail and a job interrupted by restart never makes it:
|
||||
// shownJobResult, applied whenever a failed job is read, is.
|
||||
func finish[R shownResult[R]](ctx context.Context, logger *slog.Logger, what string, res R, err error, publish func(R) error) (R, error) {
|
||||
var zero R
|
||||
if err == nil {
|
||||
return res, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return zero, err
|
||||
}
|
||||
var failure *jobs.Failure
|
||||
if errors.As(err, &failure) {
|
||||
// Only the code is logged: the error may quote a model's or
|
||||
// search provider's response.
|
||||
logger.InfoContext(ctx, what+" failed", "reason", failure.Code)
|
||||
}
|
||||
if res.shown() > 0 {
|
||||
if perr := publish(res.cleared()); perr != nil {
|
||||
return zero, fmt.Errorf("ideas: clear failed job's ideas: %w", perr)
|
||||
}
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
|
||||
// fill does run's work, returning a *jobs.Failure for a failure the
|
||||
// household may see. On failure it returns the result last published with
|
||||
// the error, so run can clear its ideas.
|
||||
func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
|
||||
res := Result{Stage: StageSuggesting, Ingredients: p.Ingredients, QuickOnly: p.QuickOnly, Ideas: []Idea{}}
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
in := skills.IdeasRequest{Ingredients: p.Ingredients, QuickOnly: p.QuickOnly}
|
||||
out, err := s.generate(ctx, skills.IdeasScope(in), in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return res, ctx.Err()
|
||||
}
|
||||
return res, &jobs.Failure{Code: ReasonAIFailed}
|
||||
}
|
||||
res.Stage = StageGrounding
|
||||
res.Dropped = len(out.Dropped)
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
reqs := make([]recipes.GroundRequest, len(out.Ideas))
|
||||
for i, idea := range out.Ideas {
|
||||
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
|
||||
}
|
||||
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool, error) {
|
||||
idea, err := ideaOf(out.Ideas[i], gr)
|
||||
return idea, !p.QuickOnly || idea.Quick, err
|
||||
}, func() error { return publish(res) })
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if len(res.Ideas) == 0 {
|
||||
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
|
||||
}
|
||||
res.Stage = StageDone
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// keepGrounded grounds reqs with g in their order, at most the number
|
||||
// still wanted at a time, until want ideas are shown or none are left. As
|
||||
// each grounds, keep makes its idea from request i's grounding and says
|
||||
// whether it is kept: a kept idea is put in *shown in its place in reqs'
|
||||
// order (an idea that grounds sooner than one before it is shown first,
|
||||
// and the earlier one is put back in its place when it grounds) and
|
||||
// publish is called. An idea no recipe grounds, or that keep refuses,
|
||||
// counts in *dropped. Any other grounding failure, or keep or publish
|
||||
// failing, is returned (see ground).
|
||||
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool, error), publish func() error) error {
|
||||
// order[k] is the index in reqs of (*shown)[k], so each idea is put in
|
||||
// its place in reqs' order as it grounds.
|
||||
var order []int
|
||||
for next := 0; len(*shown) < want && next < len(reqs); {
|
||||
first := next
|
||||
round := reqs[first:min(first+want-len(*shown), len(reqs))]
|
||||
next += len(round)
|
||||
err := ground(ctx, g, round, func(i int, gr *recipes.Grounded) error {
|
||||
if gr == nil {
|
||||
*dropped++
|
||||
return nil
|
||||
}
|
||||
idea, ok, err := keep(first+i, *gr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
*dropped++
|
||||
return nil
|
||||
}
|
||||
at := len(order)
|
||||
for at > 0 && order[at-1] > first+i {
|
||||
at--
|
||||
}
|
||||
order = slices.Insert(order, at, first+i)
|
||||
*shown = slices.Insert(*shown, at, idea)
|
||||
return publish()
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ground grounds reqs with g and calls kept, one call at a time, as each
|
||||
// request grounds, with its index in reqs and its grounding, or nil when
|
||||
// no recipe grounds it. Any other grounding failure, or kept failing,
|
||||
// stops the groundings still running and is returned, as a *jobs.Failure
|
||||
// when the household may see it.
|
||||
func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, kept func(i int, gr *recipes.Grounded) error) error {
|
||||
groundCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
var failed error
|
||||
g.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) {
|
||||
if failed != nil {
|
||||
return
|
||||
}
|
||||
var ungroundable *recipes.ErrUngroundable
|
||||
switch {
|
||||
case r.Err == nil:
|
||||
failed = kept(i, &r.Grounded)
|
||||
case errors.As(r.Err, &ungroundable):
|
||||
failed = kept(i, nil)
|
||||
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
|
||||
failed = &jobs.Failure{Code: ReasonSearchNotConfigured}
|
||||
case errors.Is(r.Err, recipes.ErrSearchFailed):
|
||||
failed = &jobs.Failure{Code: ReasonSearchFailed}
|
||||
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
|
||||
failed = &jobs.Failure{Code: ReasonAIFailed}
|
||||
default:
|
||||
failed = fmt.Errorf("ideas: ground %q: %w", reqs[i].DishName, r.Err)
|
||||
}
|
||||
if failed != nil {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// shownJobResult is a job's stored result as the job shows it. A failed
|
||||
// job's shows no ideas, however the job failed, so a failed job never
|
||||
// shows ideas kept before the failure. Any other job's shows each idea's
|
||||
// source as shownSource gives it, so a result stored before sources were
|
||||
// made safe never shows a link or name that could leak a secret.
|
||||
func shownJobResult[R shownResult[R]](status jobs.Status, stored json.RawMessage) (json.RawMessage, error) {
|
||||
var res R
|
||||
if err := json.Unmarshal(stored, &res); err != nil {
|
||||
return nil, fmt.Errorf("ideas: decode %s job's result: %w", status, err)
|
||||
}
|
||||
if status == jobs.StatusFailed {
|
||||
return res.cleared().PublicJSON()
|
||||
}
|
||||
safe, err := res.withSafeSources()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return safe.PublicJSON()
|
||||
}
|
||||
|
||||
// ideaOf is idea as grounded by gr.
|
||||
func ideaOf(idea skills.Idea, gr recipes.Grounded) (Idea, error) {
|
||||
src, err := sourceOf(gr)
|
||||
if err != nil {
|
||||
return Idea{}, err
|
||||
}
|
||||
return Idea{
|
||||
Name: idea.Name,
|
||||
Uses: idea.Uses,
|
||||
Missing: idea.Missing,
|
||||
Summary: idea.Summary,
|
||||
Method: gr.Method,
|
||||
MinutesTotal: gr.MinutesTotal,
|
||||
Difficulty: gr.Difficulty,
|
||||
Quick: IsQuick(gr.Method, gr.MinutesTotal),
|
||||
RecipeID: gr.RecipeID,
|
||||
PhotoURL: dinners.PhotoURL(gr.PhotoID),
|
||||
SourceURL: src.URL,
|
||||
SiteName: src.SiteName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sourceOf is gr's recipe source as a client may see it (see
|
||||
// dinners.SourceOf): a grounded recipe's site name is stored only when
|
||||
// not empty.
|
||||
func sourceOf(gr recipes.Grounded) (dinners.Source, error) {
|
||||
src, err := dinners.SourceOf(sql.NullString{String: gr.SiteName, Valid: gr.SiteName != ""}, gr.SourceURL)
|
||||
if err != nil {
|
||||
return dinners.Source{}, fmt.Errorf("ideas: recipe %s: %w", gr.RecipeID, err)
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// shownSource is the source of an idea as stored in a job's result, with
|
||||
// link sourceURL and site name siteName, as a client may see it (see
|
||||
// dinners.SourceOf). A result stored now already holds it, and one stored
|
||||
// before sources were made safe holds the grounded recipe's own, with ""
|
||||
// for no site name, as sourceOf reads it; a null link, which only a result
|
||||
// stored now holds, is checked as "".
|
||||
func shownSource(sourceURL, siteName *string) (dinners.Source, error) {
|
||||
var link string
|
||||
if sourceURL != nil {
|
||||
link = *sourceURL
|
||||
}
|
||||
var site sql.NullString
|
||||
if siteName != nil {
|
||||
site = sql.NullString{String: *siteName, Valid: *siteName != ""}
|
||||
}
|
||||
return dinners.SourceOf(site, link)
|
||||
}
|
||||
|
||||
// QuickJob is the KindQuick payload: the kind of Quick dinner wanted,
|
||||
// skills.QuickCrockpot or skills.QuickUnder30, and the names already shown
|
||||
// to the household, as skills.CleanExclude returns them.
|
||||
type QuickJob struct {
|
||||
Kind string `json:"kind"`
|
||||
Exclude []string `json:"exclude"`
|
||||
}
|
||||
|
||||
// StartQuick enqueues a KindQuick job for a page of Quick dinner ideas of
|
||||
// kind, none repeating a name in exclude, and returns its id. A kind
|
||||
// skills.CheckQuickKind refuses, with no default, or an exclude list
|
||||
// skills.CleanExclude refuses, is an *InvalidError, and starts nothing.
|
||||
func (s *Service) StartQuick(ctx context.Context, kind string, exclude []string) (Started, error) {
|
||||
if err := skills.CheckQuickKind(kind); err != nil {
|
||||
return Started{}, &InvalidError{Message: "kind: " + err.Error()}
|
||||
}
|
||||
names, err := skills.CleanExclude(exclude)
|
||||
if err != nil {
|
||||
return Started{}, &InvalidError{Message: "exclude: " + err.Error()}
|
||||
}
|
||||
id, err := s.quickJob.Enqueue(ctx, QuickJob{Kind: kind, Exclude: names})
|
||||
if err != nil {
|
||||
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
|
||||
}
|
||||
return Started{JobID: id}, nil
|
||||
}
|
||||
|
||||
// QuickIdea is a grounded Quick dinner idea as the job's result shows it.
|
||||
// Method, MinutesTotal and Difficulty are the grounded recipe's, never the
|
||||
// model's estimates, and always make it of the kind asked for (see
|
||||
// OfQuickKind).
|
||||
type QuickIdea struct {
|
||||
Name string `json:"name"`
|
||||
// Method is stovetop, oven, crockpot, grill or other: crockpot for
|
||||
// skills.QuickCrockpot.
|
||||
Method string `json:"method"`
|
||||
// MinutesTotal is prep to table, null when the recipe does not say:
|
||||
// never null, and at most QuickMinutes, for skills.QuickUnder30.
|
||||
MinutesTotal *int `json:"minutes_total"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
// SourceURL and SiteName are as in Idea.
|
||||
SourceURL *string `json:"source_url"`
|
||||
SiteName *string `json:"site_name"`
|
||||
}
|
||||
|
||||
// QuickResult is the KindQuick result, published as the job goes: its
|
||||
// stage, the request, the ideas grounded so far in the model's order (as
|
||||
// in Result), and how many ideas were dropped, by quick_ideas' rules
|
||||
// (repeating a name already shown or an earlier idea), because no recipe
|
||||
// grounds them, or because the grounded recipe is not of the kind.
|
||||
type QuickResult struct {
|
||||
Stage string `json:"stage"`
|
||||
Kind string `json:"kind"`
|
||||
Exclude []string `json:"exclude"`
|
||||
Ideas []QuickIdea `json:"ideas"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
func (r QuickResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
func (r QuickResult) shown() int { return len(r.Ideas) }
|
||||
|
||||
func (r QuickResult) cleared() QuickResult {
|
||||
r.Ideas = []QuickIdea{}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r QuickResult) withSafeSources() (QuickResult, error) {
|
||||
ideas := make([]QuickIdea, len(r.Ideas))
|
||||
for i, idea := range r.Ideas {
|
||||
src, err := shownSource(idea.SourceURL, idea.SiteName)
|
||||
if err != nil {
|
||||
return QuickResult{}, fmt.Errorf("ideas: idea %d: %w", i, err)
|
||||
}
|
||||
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
|
||||
ideas[i] = idea
|
||||
}
|
||||
r.Ideas = ideas
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// OfQuickKind reports whether a recipe cooked by method in minutes (nil
|
||||
// when unknown) is a Quick dinner of kind: for skills.QuickCrockpot,
|
||||
// cooked in a crock pot (normalize_recipe writes a slow cooker as
|
||||
// crockpot); for skills.QuickUnder30, known to be 30 minutes or less from
|
||||
// prep to table. Any other kind is none.
|
||||
func OfQuickKind(kind, method string, minutes *int) bool {
|
||||
switch kind {
|
||||
case skills.QuickCrockpot:
|
||||
return method == "crockpot"
|
||||
case skills.QuickUnder30:
|
||||
return minutes != nil && *minutes <= QuickMinutes
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runQuick runs a KindQuick job: from the household context, it asks
|
||||
// quick_ideas for skills.QuickIdeasRequested ideas of the kind, more than
|
||||
// a page holds, then grounds them in the model's order, at most the number
|
||||
// still wanted at a time, until skills.QuickIdeasKept are kept or none are
|
||||
// left. An idea no recipe grounds is dropped, and so is one whose grounded
|
||||
// recipe is not of the kind (OfQuickKind): the model's estimate never
|
||||
// keeps an idea. When fewer than skills.QuickIdeasKept survive, the page
|
||||
// shows only those: fewer is honest, and it is never padded with an idea
|
||||
// not of the kind. When every idea is dropped the job fails with
|
||||
// ReasonNoIdeasFound. Any other grounding failure fails the job, as does
|
||||
// quick_ideas failing, with no idea made up instead. The result is
|
||||
// published at each stage and again as each idea is kept, and a failed job
|
||||
// shows no ideas, as for KindFromIngredients (see finish).
|
||||
func (s *Service) runQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
|
||||
res, err := s.fillQuick(ctx, p, publish)
|
||||
return finish(ctx, s.logger, "quick ideas", res, err, publish)
|
||||
}
|
||||
|
||||
// fillQuick does runQuick's work as fill does run's.
|
||||
func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
|
||||
res := QuickResult{Stage: StageSuggesting, Kind: p.Kind, Exclude: p.Exclude, Ideas: []QuickIdea{}}
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("ideas: %w", err)
|
||||
}
|
||||
in := skills.QuickIdeasRequest{Context: hctx, Kind: p.Kind, Exclude: p.Exclude}
|
||||
out, err := s.quick(ctx, skills.QuickIdeasScope(in), in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return res, ctx.Err()
|
||||
}
|
||||
return res, &jobs.Failure{Code: ReasonAIFailed}
|
||||
}
|
||||
res.Stage = StageGrounding
|
||||
res.Dropped = len(out.Dropped)
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
reqs := make([]recipes.GroundRequest, len(out.Ideas))
|
||||
for i, idea := range out.Ideas {
|
||||
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
|
||||
}
|
||||
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool, error) {
|
||||
idea, err := quickIdeaOf(out.Ideas[i], gr)
|
||||
return idea, OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal), err
|
||||
}, func() error { return publish(res) })
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if len(res.Ideas) == 0 {
|
||||
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
|
||||
}
|
||||
res.Stage = StageDone
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// quickIdeaOf is idea as grounded by gr.
|
||||
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) (QuickIdea, error) {
|
||||
src, err := sourceOf(gr)
|
||||
if err != nil {
|
||||
return QuickIdea{}, err
|
||||
}
|
||||
return QuickIdea{
|
||||
Name: idea.Name,
|
||||
Method: gr.Method,
|
||||
MinutesTotal: gr.MinutesTotal,
|
||||
Difficulty: gr.Difficulty,
|
||||
RecipeID: gr.RecipeID,
|
||||
PhotoURL: dinners.PhotoURL(gr.PhotoID),
|
||||
SourceURL: src.URL,
|
||||
SiteName: src.SiteName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Origins of a dinner made from an idea (the dinners.origin column).
|
||||
const (
|
||||
// OriginIngredients is an idea from Cook from what I have.
|
||||
OriginIngredients = "ingredients"
|
||||
// OriginQuick is an idea from Quick dinners.
|
||||
OriginQuick = "quick"
|
||||
)
|
||||
|
||||
// ErrIdeaNotFound is an idea the household cannot act on: an unknown job,
|
||||
// a job that does not find ideas, a failed job (which shows no ideas), or
|
||||
// an index past the job's ideas.
|
||||
var ErrIdeaNotFound = errors.New("idea not found")
|
||||
|
||||
// ErrIdeasNotReady is an idea of a job still queued or running. Its ideas
|
||||
// may still move: an idea that grounds later is put back in its place
|
||||
// before those shown already, so an index names an idea only once the job
|
||||
// has succeeded.
|
||||
var ErrIdeasNotReady = errors.New("the ideas are still being found; act on one when the job has succeeded")
|
||||
|
||||
// Chosen is the idea the household acts on: its name, the recipe it was
|
||||
// grounded in, and the origin of a dinner made from it.
|
||||
type Chosen struct {
|
||||
Name string
|
||||
RecipeID string
|
||||
Origin string
|
||||
}
|
||||
|
||||
// Idea returns idea i, counted from 0, of the succeeded job jobID, as its
|
||||
// stored result shows it: a dinner made from it uses the recipe it was
|
||||
// grounded in, never grounding it again. A job still queued or running is
|
||||
// ErrIdeasNotReady; any idea not shown by a succeeded KindFromIngredients
|
||||
// or KindQuick job is ErrIdeaNotFound.
|
||||
func (s *Service) Idea(ctx context.Context, jobID string, i int) (Chosen, error) {
|
||||
job, err := s.runner.Get(ctx, jobID)
|
||||
if errors.Is(err, jobs.ErrNotFound) {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Chosen{}, fmt.Errorf("ideas: read job: %w", err)
|
||||
}
|
||||
if job.Kind != KindFromIngredients && job.Kind != KindQuick {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
switch job.Status {
|
||||
case jobs.StatusQueued, jobs.StatusRunning:
|
||||
return Chosen{}, ErrIdeasNotReady
|
||||
case jobs.StatusSucceeded:
|
||||
default:
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
if job.Kind == KindQuick {
|
||||
var res QuickResult
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
|
||||
}
|
||||
if i < 0 || i >= len(res.Ideas) {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginQuick}, nil
|
||||
}
|
||||
var res Result
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
|
||||
}
|
||||
if i < 0 || i >= len(res.Ideas) {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginIngredients}, nil
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// pantry is the household's ingredient list.
|
||||
var pantry = []string{"Chicken thighs", "rice", "black beans", "red onion", "cilantro"}
|
||||
|
||||
type env struct {
|
||||
svc *Service
|
||||
runner *jobs.Runner
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
// model answers ideas_from_ingredients and quick_ideas; normalize
|
||||
// answers normalize_recipe while grounding.
|
||||
model *llm.Fake
|
||||
normalize *llm.Fake
|
||||
// fail, when set, fails ideas_from_ingredients and quick_ideas without
|
||||
// a model call.
|
||||
fail error
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
// newEnv returns an ideas service over a migrated store with the
|
||||
// household's settings seeded and an unstarted
|
||||
// job runner. It finds ideas through the real harness and
|
||||
// ideas_from_ingredients or quick_ideas with the scripted e.model, and
|
||||
// grounds with fake
|
||||
// search, pages and images and the scripted e.normalize.
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
dataDir := t.TempDir()
|
||||
st, err := store.Open(ctx, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &env{
|
||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
||||
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
||||
model: &llm.Fake{},
|
||||
normalize: &llm.Fake{},
|
||||
st: st,
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.normalize)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
if e.fail != nil {
|
||||
return skills.Ideas{}, e.fail
|
||||
}
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
if e.fail != nil {
|
||||
return skills.QuickIdeaList{}, e.fail
|
||||
}
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
e.runner, err = jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = NewService(st, g, generate, quick, e.runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// recipe makes the search for name find one page that grounds it, titled
|
||||
// title, with a total time of minutes, and scripts the normalize_recipe
|
||||
// reply grounding it takes. The page's total time is the grounded
|
||||
// minutes, and "Slow Cooker" in title makes the grounded method crockpot,
|
||||
// whatever the reply says.
|
||||
func (e *env) recipe(t *testing.T, name, title string, minutes int) {
|
||||
t.Helper()
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
e.search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
e.pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
|
||||
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
|
||||
e.normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
|
||||
}
|
||||
|
||||
// idea renders one idea of an ideas_from_ingredients reply whose search
|
||||
// query is its name.
|
||||
func idea(name, method string, minutes int, uses ...string) string {
|
||||
u, _ := json.Marshal(append([]string{}, uses...))
|
||||
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":["1 lime"],"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, u, minutes, method, name)
|
||||
}
|
||||
|
||||
// reply scripts one ideas_from_ingredients reply holding ideas.
|
||||
func (e *env) reply(ideas ...string) {
|
||||
js := `{"ideas":[` + strings.Join(ideas, ",") + `]}`
|
||||
e.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
// recorder collects every result a job publishes.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
published []Result
|
||||
}
|
||||
|
||||
// latest returns the last result published.
|
||||
func (r *recorder) latest() (Result, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.published) == 0 {
|
||||
return Result{}, false
|
||||
}
|
||||
return r.published[len(r.published)-1], true
|
||||
}
|
||||
|
||||
func (r *recorder) publish(res Result) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
// The result's slices are copied, as the runner stores each publish
|
||||
// as JSON at once.
|
||||
res.Ideas = append([]Idea(nil), res.Ideas...)
|
||||
r.published = append(r.published, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// run runs the job's handler directly on ingredients, recording each
|
||||
// publish.
|
||||
func (e *env) run(t *testing.T, ingredients []string, quickOnly bool) (Result, []Result, error) {
|
||||
t.Helper()
|
||||
ings, err := skills.CleanIngredients(ingredients)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec recorder
|
||||
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly}, rec.publish)
|
||||
return res, rec.published, err
|
||||
}
|
||||
|
||||
// within reports whether every name in sub appears in list, in list's
|
||||
// order.
|
||||
func within(sub, list []string) bool {
|
||||
for _, name := range list {
|
||||
if len(sub) > 0 && sub[0] == name {
|
||||
sub = sub[1:]
|
||||
}
|
||||
}
|
||||
return len(sub) == 0
|
||||
}
|
||||
|
||||
func names(list []Idea) []string {
|
||||
out := []string{}
|
||||
for _, i := range list {
|
||||
out = append(out, i.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestResultFillsAsEachIdeaGrounds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf", "Bean Salad"} {
|
||||
e.recipe(t, name, name, 35)
|
||||
}
|
||||
// Eight ideas: two no recipe grounds, so the sixth and seventh are
|
||||
// grounded in a second round, and the eighth never is.
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
|
||||
idea("Mystery Stew", "stovetop", 35, "rice"),
|
||||
idea("Fried Rice", "stovetop", 20, "rice", "Chicken thighs"),
|
||||
idea("Black Bean Soup", "stovetop", 40, "black beans", "red onion"),
|
||||
idea("Unknown Casserole", "oven", 50, "rice"),
|
||||
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs", "cilantro"),
|
||||
idea("Rice Pilaf", "stovetop", 30, "rice"),
|
||||
idea("Bean Salad", "other", 15, "black beans"),
|
||||
)
|
||||
res, published, err := e.run(t, pantry, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf"}
|
||||
if got := names(res.Ideas); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ideas = %q, want %q", got, want)
|
||||
}
|
||||
if res.Stage != StageDone || res.Dropped != 2 {
|
||||
t.Fatalf("result stage %s dropped %d, want done with 2 dropped", res.Stage, res.Dropped)
|
||||
}
|
||||
if !reflect.DeepEqual(res.Ingredients, pantry) || res.QuickOnly {
|
||||
t.Fatalf("result request = %q quick %v", res.Ingredients, res.QuickOnly)
|
||||
}
|
||||
if got := e.search.Queries(); len(got) != 7 || strings.Contains(strings.Join(got, ","), "Bean Salad") {
|
||||
t.Errorf("searched %q, want the first seven ideas only", got)
|
||||
}
|
||||
|
||||
// Each publish: the stage, then one more idea at a time, each idea
|
||||
// grounded when it appears.
|
||||
var stages []string
|
||||
var counts []int
|
||||
for _, p := range published {
|
||||
stages = append(stages, p.Stage)
|
||||
counts = append(counts, len(p.Ideas))
|
||||
for _, i := range p.Ideas {
|
||||
if i.RecipeID == "" || i.PhotoURL == "" || i.Method == "" {
|
||||
t.Errorf("published idea %q is not grounded: %+v", i.Name, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
wantStages := []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding}
|
||||
if !reflect.DeepEqual(stages, wantStages) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3, 4, 5}) {
|
||||
t.Fatalf("published stages %q with %v ideas, want %q with 0, 0, 1, 2, 3, 4, 5", stages, counts, wantStages)
|
||||
}
|
||||
// Ideas ground concurrently, so one may appear before an earlier one,
|
||||
// but each publish keeps every idea already shown, in the model's
|
||||
// order.
|
||||
for i := 3; i < len(published); i++ {
|
||||
prev, cur := names(published[i-1].Ideas), names(published[i].Ideas)
|
||||
if !within(prev, cur) || !within(cur, want) {
|
||||
t.Errorf("publish %d shows %q after %q, want earlier ideas kept in the model's order %q", i+1, cur, prev, want)
|
||||
}
|
||||
}
|
||||
|
||||
first := res.Ideas[0]
|
||||
if first.Summary != "Uses 2 of 5 · missing: 1 lime" || !reflect.DeepEqual(first.Uses, []string{"rice", "black beans"}) {
|
||||
t.Errorf("first idea = %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickOnlyChecksTheGroundedRecipe(t *testing.T) {
|
||||
for _, quickOnly := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("quick_only=%v", quickOnly), func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The model estimates every idea quick; the grounded recipes
|
||||
// decide.
|
||||
e.recipe(t, "Fried Rice", "Fried Rice", 25)
|
||||
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
e.recipe(t, "Bean Enchiladas", "Bean Enchiladas", 55)
|
||||
e.recipe(t, "Rice Bowls", "Rice Bowls", 30)
|
||||
e.recipe(t, "Bean Bake", "Bean Bake", 31)
|
||||
e.reply(
|
||||
idea("Fried Rice", "stovetop", 20, "rice"),
|
||||
idea("Chicken Chili", "stovetop", 25, "Chicken thighs", "black beans"),
|
||||
idea("Bean Enchiladas", "oven", 25, "black beans"),
|
||||
idea("Rice Bowls", "stovetop", 15, "rice"),
|
||||
idea("Bean Bake", "oven", 30, "black beans"),
|
||||
)
|
||||
res, _, err := e.run(t, pantry, quickOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quick := map[string]bool{}
|
||||
for _, i := range res.Ideas {
|
||||
quick[i.Name] = i.Quick
|
||||
}
|
||||
want := map[string]bool{"Fried Rice": true, "Chicken Chili": true, "Rice Bowls": true}
|
||||
wantDropped := 2
|
||||
if !quickOnly {
|
||||
want["Bean Enchiladas"], want["Bean Bake"] = false, false
|
||||
wantDropped = 0
|
||||
}
|
||||
if !reflect.DeepEqual(quick, want) || res.Dropped != wantDropped {
|
||||
t.Fatalf("ideas quick = %v dropped %d, want %v dropped %d", quick, res.Dropped, want, wantDropped)
|
||||
}
|
||||
for _, i := range res.Ideas {
|
||||
if i.Name == "Chicken Chili" && (i.Method != "crockpot" || i.MinutesTotal == nil || *i.MinutesTotal != 240) {
|
||||
t.Errorf("Chicken Chili = %s %v, want the grounded crockpot 240", i.Method, i.MinutesTotal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickOnlyGroundsMoreToReplaceSlowIdeas(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
slow := []string{"Roast One", "Roast Two", "Roast Three", "Roast Four", "Roast Five"}
|
||||
var reply []string
|
||||
for _, name := range slow {
|
||||
e.recipe(t, name, name, 90)
|
||||
reply = append(reply, idea(name, "oven", 20, "Chicken thighs"))
|
||||
}
|
||||
e.recipe(t, "Fried Rice", "Fried Rice", 20)
|
||||
reply = append(reply, idea("Fried Rice", "stovetop", 20, "rice"))
|
||||
e.reply(reply...)
|
||||
res, _, err := e.run(t, pantry, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(res.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice"}) || res.Dropped != 5 {
|
||||
t.Fatalf("ideas = %q dropped %d, want Fried Rice with 5 dropped", got, res.Dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHallucinatedUsesNeverReachTheResult(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.recipe(t, "Burrito Bowls", "Burrito Bowls", 35)
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice", "sour cream", "black beans", "cheddar"),
|
||||
idea("Parmesan Pasta", "stovetop", 20, "pasta", "parmesan"),
|
||||
)
|
||||
res, _, err := e.run(t, pantry, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Ideas) != 1 || !reflect.DeepEqual(res.Ideas[0].Uses, []string{"rice", "black beans"}) {
|
||||
t.Fatalf("ideas = %+v, want Burrito Bowls using rice and black beans", res.Ideas)
|
||||
}
|
||||
if res.Ideas[0].Summary != "Uses 2 of 5 · missing: 1 lime" || res.Dropped != 1 {
|
||||
t.Fatalf("summary %q dropped %d", res.Ideas[0].Summary, res.Dropped)
|
||||
}
|
||||
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Burrito Bowls"}) {
|
||||
t.Errorf("searched %q, want only the idea kept", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(e *env)
|
||||
code string
|
||||
}{
|
||||
{"ai fails", func(e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
|
||||
{"bad output twice", func(e *env) { e.reply(); e.reply() }, ReasonAIFailed},
|
||||
{"search fails", func(e *env) {
|
||||
e.search.Err = errors.New("boom")
|
||||
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
|
||||
}, ReasonSearchFailed},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
tc.setup(e)
|
||||
_, published, err := e.run(t, pantry, false)
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != tc.code {
|
||||
t.Fatalf("err = %v, want failure %s", err, tc.code)
|
||||
}
|
||||
for _, p := range published {
|
||||
if len(p.Ideas) != 0 {
|
||||
t.Fatalf("a failed job published ideas: %+v", p.Ideas)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachIdeaShowsAsSoonAsItGrounds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"} {
|
||||
e.recipe(t, name, name, 35)
|
||||
}
|
||||
// The first idea's search waits, so the two after it ground first.
|
||||
gate := make(chan struct{})
|
||||
e.search.Gates = map[string]chan struct{}{"Burrito Bowls": gate}
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
|
||||
idea("Fried Rice", "stovetop", 20, "rice"),
|
||||
idea("Black Bean Soup", "stovetop", 40, "black beans"),
|
||||
)
|
||||
ings, err := skills.CleanIngredients(pantry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec recorder
|
||||
type outcome struct {
|
||||
res Result
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings}, rec.publish)
|
||||
done <- outcome{res, err}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 {
|
||||
if got := names(latest.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice", "Black Bean Soup"}) {
|
||||
t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got)
|
||||
}
|
||||
if latest.Stage != StageGrounding {
|
||||
t.Fatalf("stage = %s, want grounding", latest.Stage)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the ideas that grounded were not shown while the first idea was still grounding")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(gate)
|
||||
out := <-done
|
||||
if out.err != nil {
|
||||
t.Fatal(out.err)
|
||||
}
|
||||
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"}
|
||||
if got := names(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone {
|
||||
t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoIdeaKeptFailsTheJob(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
quickOnly bool
|
||||
setup func(t *testing.T, e *env)
|
||||
}{
|
||||
{"no recipe grounds any", false, func(t *testing.T, e *env) {
|
||||
e.reply(idea("Mystery Stew", "stovetop", 30, "rice"), idea("Unknown Casserole", "oven", 50, "rice"))
|
||||
}},
|
||||
{"no grounded recipe is quick", true, func(t *testing.T, e *env) {
|
||||
e.recipe(t, "Roast Chicken", "Roast Chicken", 90)
|
||||
e.reply(idea("Roast Chicken", "oven", 25, "Chicken thighs"))
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
tc.setup(t, e)
|
||||
_, published, err := e.run(t, pantry, tc.quickOnly)
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != ReasonNoIdeasFound {
|
||||
t.Fatalf("err = %v, want failure %s", err, ReasonNoIdeasFound)
|
||||
}
|
||||
for _, p := range published {
|
||||
if len(p.Ideas) != 0 || p.Stage == StageDone {
|
||||
t.Fatalf("a job that kept no idea published %+v", p)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaterRoundFailureClearsPublishedIdeas(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
kept := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos"}
|
||||
for _, name := range kept {
|
||||
e.recipe(t, name, name, 35)
|
||||
}
|
||||
// The first round keeps four ideas; the second round's search fails.
|
||||
e.search.Fail = map[string]error{"Rice Pilaf": errors.New("boom")}
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice"),
|
||||
idea("Mystery Stew", "stovetop", 35, "rice"),
|
||||
idea("Fried Rice", "stovetop", 20, "rice"),
|
||||
idea("Black Bean Soup", "stovetop", 40, "black beans"),
|
||||
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs"),
|
||||
idea("Rice Pilaf", "stovetop", 30, "rice"),
|
||||
)
|
||||
_, published, err := e.run(t, pantry, false)
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != ReasonSearchFailed {
|
||||
t.Fatalf("err = %v, want failure %s", err, ReasonSearchFailed)
|
||||
}
|
||||
most := 0
|
||||
for _, p := range published {
|
||||
most = max(most, len(p.Ideas))
|
||||
}
|
||||
if most != len(kept) {
|
||||
t.Fatalf("at most %d ideas published, want the first round's %d before the failure", most, len(kept))
|
||||
}
|
||||
if last := published[len(published)-1]; len(last.Ideas) != 0 {
|
||||
t.Fatalf("last publish before the failure shows %q, want no ideas", names(last.Ideas))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartFromIngredientsRunsTheJob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.recipe(t, "Fried Rice", "Fried Rice", 20)
|
||||
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
|
||||
if _, err := e.svc.StartFromIngredients(t.Context(), []string{"rice", "Rice"}, false); err == nil {
|
||||
t.Fatal("a repeated ingredient was accepted")
|
||||
} else if invalid := (*InvalidError)(nil); !errors.As(err, &invalid) {
|
||||
t.Fatalf("err = %v, want an *InvalidError", err)
|
||||
}
|
||||
|
||||
started, err := e.svc.StartFromIngredients(t.Context(), []string{" rice ", "Chicken thighs"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
var job jobs.Job
|
||||
for {
|
||||
job, err = e.runner.Get(t.Context(), started.JobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job still %s", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if job.Status != jobs.StatusSucceeded || job.Kind != KindFromIngredients {
|
||||
t.Fatalf("job = %+v", job)
|
||||
}
|
||||
var res Result
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Stage != StageDone || !reflect.DeepEqual(res.Ingredients, []string{"rice", "Chicken thighs"}) || !res.QuickOnly || len(res.Ideas) != 1 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if got := res.Ideas[0]; got.Summary != "Uses 1 of 2 · missing: 1 lime" || !got.Quick {
|
||||
t.Errorf("idea = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQuick(t *testing.T) {
|
||||
m := func(n int) *int { return &n }
|
||||
for _, tc := range []struct {
|
||||
method string
|
||||
minutes *int
|
||||
want bool
|
||||
}{
|
||||
{"crockpot", m(480), true},
|
||||
{"crockpot", nil, true},
|
||||
{"stovetop", m(30), true},
|
||||
{"oven", m(31), false},
|
||||
{"stovetop", nil, false},
|
||||
} {
|
||||
if got := IsQuick(tc.method, tc.minutes); got != tc.want {
|
||||
t.Errorf("IsQuick(%s, %v) = %v, want %v", tc.method, tc.minutes, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// quickIdea renders one idea of a quick_ideas reply whose search query is
|
||||
// its name.
|
||||
func quickIdea(name, method string, minutes int) string {
|
||||
return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, minutes, method, name)
|
||||
}
|
||||
|
||||
// quickRecorder collects every result a quick job publishes.
|
||||
type quickRecorder struct {
|
||||
mu sync.Mutex
|
||||
published []QuickResult
|
||||
}
|
||||
|
||||
func (r *quickRecorder) latest() (QuickResult, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.published) == 0 {
|
||||
return QuickResult{}, false
|
||||
}
|
||||
return r.published[len(r.published)-1], true
|
||||
}
|
||||
|
||||
func (r *quickRecorder) publish(res QuickResult) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
res.Ideas = append([]QuickIdea(nil), res.Ideas...)
|
||||
r.published = append(r.published, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runQuick runs the quick job's handler directly, recording each publish.
|
||||
func (e *env) runQuick(t *testing.T, kind string, exclude ...string) (QuickResult, []QuickResult, error) {
|
||||
t.Helper()
|
||||
names, err := skills.CleanExclude(exclude)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec quickRecorder
|
||||
res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: kind, Exclude: names}, rec.publish)
|
||||
return res, rec.published, err
|
||||
}
|
||||
|
||||
func quickNames(list []QuickIdea) []string {
|
||||
out := []string{}
|
||||
for _, i := range list {
|
||||
out = append(out, i.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestUnder30DropsIdeasOverThirtyMinutes(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The model estimates every idea under 30 minutes; the grounded
|
||||
// recipes decide.
|
||||
e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
e.recipe(t, "Beef Stroganoff", "Beef Stroganoff", 45)
|
||||
e.recipe(t, "Shrimp Scampi", "Shrimp Scampi", 30)
|
||||
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
e.reply(
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("Beef Stroganoff", "stovetop", 25),
|
||||
quickIdea("Shrimp Scampi", "stovetop", 20),
|
||||
quickIdea("Chicken Chili", "crockpot", 25),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := quickNames(res.Ideas), []string{"Chicken Fried Rice", "Shrimp Scampi"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ideas = %q, want %q: the 45-minute and the crock pot ideas dropped", got, want)
|
||||
}
|
||||
if res.Dropped != 2 || res.Stage != StageDone || res.Kind != skills.QuickUnder30 {
|
||||
t.Fatalf("result = %+v, want done with 2 dropped", res)
|
||||
}
|
||||
for _, i := range res.Ideas {
|
||||
if i.MinutesTotal == nil || *i.MinutesTotal > QuickMinutes || i.RecipeID == "" || i.PhotoURL == "" {
|
||||
t.Errorf("idea %+v is not a grounded dinner of 30 minutes or less", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrockpotDropsStovetopIdeas(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The model calls every idea a crock pot dinner; only the recipes
|
||||
// cooked in a slow cooker are.
|
||||
e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480)
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.recipe(t, "Chicken Tortilla Soup", "Crock Pot Chicken Tortilla Soup", 375)
|
||||
e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
e.reply(
|
||||
quickIdea("Pulled Pork", "crockpot", 480),
|
||||
quickIdea("Beef Stew", "crockpot", 360),
|
||||
quickIdea("Chicken Tortilla Soup", "crockpot", 375),
|
||||
quickIdea("Chicken Fried Rice", "crockpot", 240),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickCrockpot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := quickNames(res.Ideas), []string{"Pulled Pork", "Chicken Tortilla Soup"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ideas = %q, want %q: the stovetop ideas dropped", got, want)
|
||||
}
|
||||
if res.Dropped != 2 {
|
||||
t.Fatalf("dropped %d, want 2", res.Dropped)
|
||||
}
|
||||
for _, i := range res.Ideas {
|
||||
if i.Method != "crockpot" {
|
||||
t.Errorf("idea %q method %s, want the grounded crockpot", i.Name, i.Method)
|
||||
}
|
||||
}
|
||||
if m := res.Ideas[0].MinutesTotal; m == nil || *m != 480 {
|
||||
t.Errorf("Pulled Pork minutes_total = %v, want the recipe's 480", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcludedNamesAreNeverReturned(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// Every idea has a recipe that would ground it as a Quick dinner.
|
||||
for _, name := range []string{"Easy Chicken Fried Rice", "shrimp-tacos", "Garlic Shrimp Pasta"} {
|
||||
e.recipe(t, name, name, 20)
|
||||
}
|
||||
e.reply(
|
||||
quickIdea("Easy Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("shrimp-tacos", "stovetop", 15),
|
||||
quickIdea("Garlic Shrimp Pasta", "stovetop", 25),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30, "Chicken Fried Rice", "Shrimp Tacos")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := quickNames(res.Ideas); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) || res.Dropped != 2 {
|
||||
t.Fatalf("ideas = %q dropped %d, want only Garlic Shrimp Pasta with 2 dropped", got, res.Dropped)
|
||||
}
|
||||
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) {
|
||||
t.Errorf("searched %q, want the excluded names never grounded", got)
|
||||
}
|
||||
if !reflect.DeepEqual(res.Exclude, []string{"Chicken Fried Rice", "Shrimp Tacos"}) {
|
||||
t.Errorf("result exclude = %q", res.Exclude)
|
||||
}
|
||||
// The model was told what was already shown.
|
||||
if system := e.model.Requests()[0].System; !strings.Contains(system, "### Already shown\n- Chicken Fried Rice\n- Shrimp Tacos") {
|
||||
t.Errorf("prompt does not list the names already shown:\n%s", system)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickPageStopsAtSixAndShowsFewerHonestly(t *testing.T) {
|
||||
t.Run("twelve ideas, six shown", func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var reply []string
|
||||
for i := range skills.QuickIdeasRequested {
|
||||
name := fmt.Sprintf("Skillet Dish %c", 'A'+i)
|
||||
e.recipe(t, name, name, 20)
|
||||
reply = append(reply, quickIdea(name, "stovetop", 20))
|
||||
}
|
||||
e.reply(reply...)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Ideas) != skills.QuickIdeasKept || res.Dropped != 0 {
|
||||
t.Fatalf("ideas = %q dropped %d, want %d", quickNames(res.Ideas), res.Dropped, skills.QuickIdeasKept)
|
||||
}
|
||||
if got := e.search.Queries(); len(got) != skills.QuickIdeasKept {
|
||||
t.Errorf("searched %d ideas, want only the %d a page holds", len(got), skills.QuickIdeasKept)
|
||||
}
|
||||
})
|
||||
t.Run("over-generated ideas replace dropped ones", func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var reply []string
|
||||
// The first six ground too slow, so the next six are grounded to
|
||||
// fill the page; only three of those are quick.
|
||||
for i := range skills.QuickIdeasRequested {
|
||||
name := fmt.Sprintf("Skillet Dish %c", 'A'+i)
|
||||
minutes := 20
|
||||
if i < 6 || i%2 == 1 {
|
||||
minutes = 50
|
||||
}
|
||||
e.recipe(t, name, name, minutes)
|
||||
reply = append(reply, quickIdea(name, "stovetop", 20))
|
||||
}
|
||||
e.reply(reply...)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Fewer than six survive: the page shows three, never padded.
|
||||
if got, want := quickNames(res.Ideas), []string{"Skillet Dish G", "Skillet Dish I", "Skillet Dish K"}; !reflect.DeepEqual(got, want) || res.Dropped != 9 || res.Stage != StageDone {
|
||||
t.Fatalf("result %s ideas %q dropped %d, want done with %q and 9 dropped", res.Stage, got, res.Dropped, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestQuickResultFillsAsEachIdeaGrounds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, name := range []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"} {
|
||||
e.recipe(t, name, name, 25)
|
||||
}
|
||||
// The first idea's search waits, so the two after it ground first.
|
||||
gate := make(chan struct{})
|
||||
e.search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate}
|
||||
e.reply(
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("Shrimp Scampi", "stovetop", 20),
|
||||
quickIdea("Garlic Shrimp Pasta", "stovetop", 25),
|
||||
)
|
||||
var rec quickRecorder
|
||||
type outcome struct {
|
||||
res QuickResult
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: skills.QuickUnder30, Exclude: []string{}}, rec.publish)
|
||||
done <- outcome{res, err}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 {
|
||||
if got := quickNames(latest.Ideas); !reflect.DeepEqual(got, []string{"Shrimp Scampi", "Garlic Shrimp Pasta"}) {
|
||||
t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got)
|
||||
}
|
||||
if latest.Stage != StageGrounding {
|
||||
t.Fatalf("stage = %s, want grounding", latest.Stage)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the ideas that grounded were not shown while the first idea was still grounding")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(gate)
|
||||
out := <-done
|
||||
if out.err != nil {
|
||||
t.Fatal(out.err)
|
||||
}
|
||||
want := []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"}
|
||||
if got := quickNames(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone {
|
||||
t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want)
|
||||
}
|
||||
|
||||
var stages []string
|
||||
var counts []int
|
||||
for _, p := range rec.published {
|
||||
stages = append(stages, p.Stage)
|
||||
counts = append(counts, len(p.Ideas))
|
||||
}
|
||||
if !reflect.DeepEqual(stages, []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding}) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3}) {
|
||||
t.Fatalf("published stages %q with %v ideas, want suggesting, then grounding with 0, 1, 2, 3", stages, counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(t *testing.T, e *env)
|
||||
code string
|
||||
}{
|
||||
{"ai fails", func(t *testing.T, e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
|
||||
{"no idea is of the kind", func(t *testing.T, e *env) {
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.reply(quickIdea("Beef Stew", "crockpot", 360))
|
||||
}, ReasonNoIdeasFound},
|
||||
{"every idea already shown", func(t *testing.T, e *env) {
|
||||
e.reply(quickIdea("Pulled Pork", "crockpot", 480))
|
||||
}, ReasonNoIdeasFound},
|
||||
{"a later round fails after ideas were shown", func(t *testing.T, e *env) {
|
||||
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
|
||||
reply := []string{quickIdea("Chicken Chili", "crockpot", 240)}
|
||||
for i := range 5 {
|
||||
name := fmt.Sprintf("Mystery Stew %d", i+1)
|
||||
reply = append(reply, quickIdea(name, "crockpot", 300))
|
||||
}
|
||||
e.reply(append(reply, quickIdea("Lentil Soup", "crockpot", 300))...)
|
||||
}, ReasonSearchFailed},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
tc.setup(t, e)
|
||||
_, published, err := e.runQuick(t, skills.QuickCrockpot, "Pulled Pork")
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != tc.code {
|
||||
t.Fatalf("err = %v, want failure %s", err, tc.code)
|
||||
}
|
||||
if last := published[len(published)-1]; len(last.Ideas) != 0 || last.Stage == StageDone {
|
||||
t.Fatalf("last publish of a failed job = %+v, want no ideas", last)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartQuickRunsTheJob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480)
|
||||
e.reply(quickIdea("Pulled Pork", "crockpot", 480))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
kind string
|
||||
exclude []string
|
||||
want string
|
||||
}{
|
||||
{"", nil, `kind: kind "" is not one of crockpot, under30`},
|
||||
{"quick", nil, `kind: kind "quick" is not one of crockpot, under30`},
|
||||
{skills.QuickCrockpot, []string{"Beef Stew", "beef stew"}, `exclude: excluded name 2, "beef stew", repeats "Beef Stew"`},
|
||||
} {
|
||||
_, err := e.svc.StartQuick(t.Context(), tc.kind, tc.exclude)
|
||||
var invalid *InvalidError
|
||||
if !errors.As(err, &invalid) || invalid.Message != tc.want {
|
||||
t.Errorf("StartQuick(%q, %q) = %v, want %q", tc.kind, tc.exclude, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
started, err := e.svc.StartQuick(t.Context(), skills.QuickCrockpot, []string{" Beef Stew "})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
var job jobs.Job
|
||||
for {
|
||||
job, err = e.runner.Get(t.Context(), started.JobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job still %s", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if job.Status != jobs.StatusSucceeded || job.Kind != KindQuick {
|
||||
t.Fatalf("job = %+v", job)
|
||||
}
|
||||
var res QuickResult
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Stage != StageDone || res.Kind != skills.QuickCrockpot || !reflect.DeepEqual(res.Exclude, []string{"Beef Stew"}) || len(res.Ideas) != 1 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName == nil || *got.SiteName != "R Kitchen" {
|
||||
t.Errorf("idea = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfQuickKind(t *testing.T) {
|
||||
m := func(n int) *int { return &n }
|
||||
for _, tc := range []struct {
|
||||
kind, method string
|
||||
minutes *int
|
||||
want bool
|
||||
}{
|
||||
{skills.QuickCrockpot, "crockpot", m(480), true},
|
||||
{skills.QuickCrockpot, "crockpot", nil, true},
|
||||
{skills.QuickCrockpot, "stovetop", m(20), false},
|
||||
{skills.QuickUnder30, "stovetop", m(30), true},
|
||||
{skills.QuickUnder30, "oven", m(31), false},
|
||||
{skills.QuickUnder30, "stovetop", nil, false},
|
||||
{skills.QuickUnder30, "crockpot", m(240), false},
|
||||
{"quick", "crockpot", m(20), false},
|
||||
} {
|
||||
if got := OfQuickKind(tc.kind, tc.method, tc.minutes); got != tc.want {
|
||||
t.Errorf("OfQuickKind(%s, %s, %v) = %v, want %v", tc.kind, tc.method, tc.minutes, got, tc.want)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(skills.QuickKinds, []string{skills.QuickCrockpot, skills.QuickUnder30}) {
|
||||
t.Errorf("kinds = %q", skills.QuickKinds)
|
||||
}
|
||||
}
|
||||
+43
-3
@@ -135,12 +135,14 @@ type Job struct {
|
||||
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||
|
||||
// handler is a registered kind: run runs a job, check reports whether a
|
||||
// payload decodes into the kind's payload type, and serviceOnly is set when
|
||||
// Runner.Enqueue refuses the kind.
|
||||
// payload decodes into the kind's payload type, serviceOnly is set when
|
||||
// Runner.Enqueue refuses the kind, and shownResult, when set, is what Get
|
||||
// shows of a job's stored result (see Kind.ShownResult).
|
||||
type handler struct {
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
serviceOnly bool
|
||||
shownResult func(status Status, stored json.RawMessage) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// Runner owns the job handlers and the worker pool.
|
||||
@@ -307,6 +309,28 @@ func (k *Kind[P]) ServiceOnly() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShownResult makes Get return, for a job of the kind with a stored
|
||||
// result, show's view of that result, given the job's status, instead of
|
||||
// the result itself. It is applied on every read, so it holds for every
|
||||
// stored result: however a job failed (by its handler, as interrupted by
|
||||
// restart, or after a write meant to tidy the result failed) and whatever
|
||||
// version of the kind stored it. A show error fails Get. A kind that never
|
||||
// calls it shows a job's result as stored. It must be called before Start.
|
||||
func (k *Kind[P]) ShownResult(show func(status Status, stored json.RawMessage) (json.RawMessage, error)) error {
|
||||
if show == nil {
|
||||
return fmt.Errorf("jobs: shown result %q: nil func", k.name)
|
||||
}
|
||||
k.r.mu.Lock()
|
||||
defer k.r.mu.Unlock()
|
||||
if k.r.started {
|
||||
return fmt.Errorf("jobs: shown result %q: runner already started", k.name)
|
||||
}
|
||||
h := k.r.handlers[k.name]
|
||||
h.shownResult = show
|
||||
k.r.handlers[k.name] = h
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wake tells an idle worker to look for a job, as Enqueue does itself:
|
||||
// call it after committing the transaction a job was enqueued in.
|
||||
func (k *Kind[P]) Wake() { k.r.wakeOne() }
|
||||
@@ -402,7 +426,8 @@ func (r *Runner) Wait() {
|
||||
r.wg.Wait()
|
||||
}
|
||||
|
||||
// Get returns the job with id, or ErrNotFound.
|
||||
// Get returns the job with id, or ErrNotFound. A job's result is shown
|
||||
// through its kind's ShownResult, when the kind set one.
|
||||
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
row, err := db.New(r.st.Reader).GetJob(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -418,6 +443,21 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
if row.Error.Valid {
|
||||
job.Error = &row.Error.String
|
||||
}
|
||||
if job.Result != nil {
|
||||
r.mu.Lock()
|
||||
show := r.handlers[job.Kind].shownResult
|
||||
r.mu.Unlock()
|
||||
if show != nil {
|
||||
shown, err := show(job.Status, job.Result)
|
||||
if err != nil {
|
||||
return Job{}, fmt.Errorf("jobs: show %s %s result: %w", job.Status, job.Kind, err)
|
||||
}
|
||||
if !json.Valid(shown) {
|
||||
return Job{}, fmt.Errorf("jobs: show %s %s result: not one JSON value", job.Status, job.Kind)
|
||||
}
|
||||
job.Result = shown
|
||||
}
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -772,3 +772,55 @@ func TestServiceOnlyKind(t *testing.T) {
|
||||
t.Error("ServiceOnly after Start succeeded, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShownResultAppliesOnlyToItsKindsJobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
h := func(_ context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
if err := publish(countResult{Done: 5}); err != nil {
|
||||
return countResult{}, err
|
||||
}
|
||||
if p.Steps == 0 {
|
||||
return countResult{}, &Failure{Code: "stopped"}
|
||||
}
|
||||
return countResult{Done: p.Steps}, nil
|
||||
}
|
||||
hidden := register(t, r, "hidden", h, "stopped")
|
||||
plain := register(t, r, "plain", h, "stopped")
|
||||
if err := hidden.ShownResult(func(status Status, stored json.RawMessage) (json.RawMessage, error) {
|
||||
if status == StatusFailed {
|
||||
return json.RawMessage(`{"done":0}`), nil
|
||||
}
|
||||
var res countResult
|
||||
if err := json.Unmarshal(stored, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(countResult{Done: res.Done * 10})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := hidden.ShownResult(nil); err == nil {
|
||||
t.Error("ShownResult(nil) succeeded, want an error")
|
||||
}
|
||||
start(t, r)
|
||||
if err := hidden.ShownResult(func(_ Status, s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil {
|
||||
t.Error("ShownResult after Start succeeded, want an error")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
kind *Kind[countPayload]
|
||||
steps int
|
||||
want string
|
||||
}{
|
||||
{hidden, 0, `{"done":0}`}, // failed: shown through ShownResult
|
||||
{hidden, 3, `{"done":30}`}, // succeeded: shown through ShownResult
|
||||
{plain, 0, `{"done":5}`}, // failed, kind without ShownResult: as stored
|
||||
{plain, 3, `{"done":3}`}, // succeeded, kind without ShownResult: as stored
|
||||
} {
|
||||
job := waitFor(t, r, enqueue(t, tc.kind, countPayload{Steps: tc.steps}), finished)
|
||||
if string(job.Result) != tc.want {
|
||||
t.Errorf("%s job with %d steps: %s result = %s, want %s", job.Kind, tc.steps, job.Status, job.Result, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -189,6 +190,61 @@ func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error {
|
||||
})
|
||||
}
|
||||
|
||||
// StoredSince reports whether a notification was stored after since, or
|
||||
// at all when since is nil. An event counts once it is stored for any
|
||||
// channel, whatever became of its delivery; one whose preference switches
|
||||
// every channel off is never stored and never counts.
|
||||
func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, error) {
|
||||
var after sql.NullString
|
||||
if since != nil {
|
||||
after = sql.NullString{String: timestamp(*since), Valid: true}
|
||||
}
|
||||
found, err := db.New(s.st.Reader).HasNotificationSince(ctx, after)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("notify: read notifications since %s: %w", after.String, err)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// Stored is one event as it was stored, whatever channels it went to.
|
||||
type Stored struct {
|
||||
Type EventType
|
||||
Title string
|
||||
Body string
|
||||
// URL is the path-absolute, same-origin path the event opens.
|
||||
URL string
|
||||
// CreatedAt is when the event was last stored, for any channel: the
|
||||
// instant StoredSince must be given for it not to count.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Recent returns the last n distinct events stored, newest first. An event
|
||||
// stored for more than one channel is one event, as new as its newest row;
|
||||
// one whose preference switches every channel off is never stored and
|
||||
// never listed.
|
||||
func (s *Service) Recent(ctx context.Context, n int) ([]Stored, error) {
|
||||
if n <= 0 {
|
||||
return nil, fmt.Errorf("notify: list %d recent events: want a positive count", n)
|
||||
}
|
||||
rows, err := db.New(s.st.Reader).ListRecentNotifications(ctx, int64(n))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify: list recent events: %w", err)
|
||||
}
|
||||
out := make([]Stored, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var p Payload
|
||||
if err := json.Unmarshal([]byte(r.Payload), &p); err != nil {
|
||||
return nil, fmt.Errorf("notify: decode payload of %s: %w", r.EventKey, err)
|
||||
}
|
||||
created, err := parseTimestamp(r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify: created_at of %s: %w", r.EventKey, err)
|
||||
}
|
||||
out = append(out, Stored{Type: p.Event, Title: p.Title, Body: p.Body, URL: p.URL, CreatedAt: created})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// emitTx stores ev in tx for each channel that channels chooses from the
|
||||
// preference of ev's type.
|
||||
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
|
||||
@@ -238,6 +294,19 @@ func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels fun
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChannelOn reports whether the preference of event type ev switches
|
||||
// channel ch on. A type with no preference row is an error.
|
||||
func (s *Service) ChannelOn(ctx context.Context, ev EventType, ch Channel) (bool, error) {
|
||||
pref, err := db.New(s.st.Reader).GetNotificationPref(ctx, string(ev))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, fmt.Errorf("notify: no notification preference for %s", ev)
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("notify: read notification preference for %s: %w", ev, err)
|
||||
}
|
||||
return slices.Contains(enabledChannels(pref), ch), nil
|
||||
}
|
||||
|
||||
// enabledChannels returns the channels pref switches on.
|
||||
func enabledChannels(pref db.NotificationPref) []Channel {
|
||||
var chs []Channel
|
||||
|
||||
@@ -234,6 +234,7 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
|
||||
return "", err
|
||||
}
|
||||
s.notifier.Wake()
|
||||
s.wake()
|
||||
return planID, nil
|
||||
}
|
||||
|
||||
|
||||
+80
-20
@@ -141,6 +141,9 @@ type Service struct {
|
||||
draft Drafter
|
||||
cal Calendar
|
||||
notifier *notify.Service
|
||||
// wake is called after every committed change to a plan's days, which
|
||||
// can move a crock-pot dinner's start reminder.
|
||||
wake func()
|
||||
logger *slog.Logger
|
||||
draftJob *jobs.Kind[DraftJob]
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
|
||||
@@ -150,11 +153,12 @@ type Service struct {
|
||||
|
||||
// NewService returns a service over st that drafts with draft, pushes to
|
||||
// cal with links under publicURL, tells the household through notifier,
|
||||
// which must be over st, and registers the KindDraftWeek job on
|
||||
// which must be over st, calls wake (the scheduler's Wake) after every
|
||||
// committed change to a plan's days, and registers the KindDraftWeek job on
|
||||
// runner, which must not have started yet. The kind is service-only:
|
||||
// StartDraft, which keeps one draft in flight per week, is the only way to
|
||||
// enqueue it.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||
// StartDraft and StartScheduledDraft, which keep one draft in flight per
|
||||
// week, are the only way to enqueue it.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("plan: nil store")
|
||||
@@ -166,12 +170,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
|
||||
return nil, errors.New("plan: nil calendar")
|
||||
case notifier == nil:
|
||||
return nil, errors.New("plan: nil notifier")
|
||||
case wake == nil:
|
||||
return nil, errors.New("plan: nil wake")
|
||||
case publicURL == nil:
|
||||
return nil, errors.New("plan: nil public URL")
|
||||
case logger == nil:
|
||||
return nil, errors.New("plan: nil logger")
|
||||
}
|
||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, logger: logger, publicURL: publicURL}
|
||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL}
|
||||
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -233,10 +239,10 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, e
|
||||
}
|
||||
|
||||
// EmitDayOfReminder emits day_of_reminder for date, keyed by the date,
|
||||
// naming the dinner planned on it, and reports whether it did: a date whose
|
||||
// week has no plan emits nothing. The scheduler calls it at the
|
||||
// household's reminder_time (Feature 20); emitting a date again stores no
|
||||
// second notification.
|
||||
// "Tonight: {dinner} · Recipe →" for the dinner planned on it, and reports
|
||||
// whether it did: a date whose week has no plan emits nothing. The
|
||||
// day_of_reminder schedule calls it at the household's reminder_time
|
||||
// (ReminderSchedules); emitting a date again stores no second notification.
|
||||
func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) {
|
||||
p, err := s.Get(ctx, household.WeekStart(date))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
@@ -245,15 +251,14 @@ func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, d := range p.Days {
|
||||
if d.Date == date.String() {
|
||||
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
|
||||
return false, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
d, err := p.day(date)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
|
||||
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
|
||||
return false, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// readPlan reads the plan of the week starting weekStart through q, with
|
||||
@@ -342,10 +347,27 @@ type Draft struct {
|
||||
// enqueues nothing. Drafting over a synced or changed plan needs confirm,
|
||||
// else it fails with ErrConfirmRequired and enqueues nothing.
|
||||
func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) {
|
||||
d, _, err := s.startDraft(ctx, weekStart, confirm, false)
|
||||
return d, err
|
||||
}
|
||||
|
||||
// StartScheduledDraft is StartDraft, without confirm, for the plan_draft
|
||||
// schedule: it enqueues the same KindDraftWeek job, or returns the one
|
||||
// queued or running, only while the week starting weekStart has no plan.
|
||||
// A week that has a plan, in any status, is left as it is: nothing is
|
||||
// enqueued and planned is true.
|
||||
func (s *Service) StartScheduledDraft(ctx context.Context, weekStart store.LocalDate) (d Draft, planned bool, err error) {
|
||||
return s.startDraft(ctx, weekStart, false, true)
|
||||
}
|
||||
|
||||
// startDraft is StartDraft, and when onlyUnplanned is set it enqueues
|
||||
// nothing for a week that has a plan and reports planned true.
|
||||
func (s *Service) startDraft(ctx context.Context, weekStart store.LocalDate, confirm, onlyUnplanned bool) (Draft, bool, error) {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
|
||||
return Draft{}, false, &InvalidWeekError{Value: weekStart.String()}
|
||||
}
|
||||
var d Draft
|
||||
planned := false
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
@@ -353,6 +375,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
case err != nil:
|
||||
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||
case onlyUnplanned:
|
||||
planned = true
|
||||
return nil
|
||||
case cur.Status != StatusDraft && !confirm:
|
||||
return ErrConfirmRequired
|
||||
}
|
||||
@@ -375,12 +400,12 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
return Draft{}, false, err
|
||||
}
|
||||
if d.Created {
|
||||
s.draftJob.Wake()
|
||||
}
|
||||
return d, nil
|
||||
return d, planned, nil
|
||||
}
|
||||
|
||||
// Swap plans dinnerID on date of the week starting weekStart, marking the
|
||||
@@ -450,9 +475,44 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
s.wake()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Tonight returns today, the household-local date now in the household's
|
||||
// zone, and the Monday starting its week, when that week has a plan: Swap
|
||||
// of that week and day sets tonight's dinner. A week with no plan is
|
||||
// ErrNotFound; no plan is made for it, since a plan holds all seven days.
|
||||
// The week is always today's own, so Swap never writes a different day.
|
||||
func (s *Service) Tonight(ctx context.Context) (week, today store.LocalDate, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: household settings: %w", err)
|
||||
}
|
||||
today = household.Today(time.Now(), zone)
|
||||
week = household.WeekStart(today)
|
||||
_, err = q.GetWeekPlan(ctx, week.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return store.LocalDate{}, store.LocalDate{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read week %s: %w", week, err)
|
||||
}
|
||||
return week, today, nil
|
||||
}
|
||||
|
||||
// isNew is plan_days.is_new for a dinner with status: 1 for the Quest log.
|
||||
func isNew(status string) int64 {
|
||||
if status == dinners.StatusQuestLog {
|
||||
|
||||
@@ -146,7 +146,7 @@ func newEnv(t *testing.T) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), publicURL, logger)
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -375,9 +375,9 @@ func TestDraftEmitsPlanReady(t *testing.T) {
|
||||
}
|
||||
for _, r := range rows {
|
||||
p := r.Payload
|
||||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
|
||||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan?week=2026-10-12" || p.Week != "2026-10-12" ||
|
||||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
|
||||
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title)
|
||||
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan?week=2026-10-12", r.Channel, r, want.Title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,8 +428,8 @@ func TestEmitDayOfReminder(t *testing.T) {
|
||||
t.Fatalf("day_of_reminder rows = %+v, want one on push", rows)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight's dinner" ||
|
||||
r.Payload.Body != "Pad Thai" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
|
||||
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight: Pad Thai · Recipe →" ||
|
||||
r.Payload.Body != "" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
|
||||
t.Errorf("day_of_reminder row = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// StartReminderLead is how long before a crock-pot dinner's start_by its
|
||||
// start reminder fires.
|
||||
const StartReminderLead = 15 * time.Minute
|
||||
|
||||
// StartReminderKey is the period key prefix of the crock-pot start
|
||||
// reminder: its periods are start:YYYY-MM-DD of the day_of_reminder
|
||||
// schedule.
|
||||
const StartReminderKey = "start"
|
||||
|
||||
// ReminderSchedules are the day_of_reminder schedules. The first fires
|
||||
// every day at the household's reminder_time, period YYYY-MM-DD, and emits
|
||||
// day_of_reminder:YYYY-MM-DD for tonight's dinner (EmitDayOfReminder). The
|
||||
// second fires StartReminderLead before the start_by of a crock-pot dinner
|
||||
// whose start_by is earlier than that day's reminder_time, period
|
||||
// start:YYYY-MM-DD of the dinner's date, and emits
|
||||
// day_of_reminder:start:YYYY-MM-DD (EmitStartReminder). A date whose week
|
||||
// has no plan emits neither.
|
||||
func (s *Service) ReminderSchedules() []scheduler.Schedule {
|
||||
return []scheduler.Schedule{
|
||||
{
|
||||
Name: scheduler.DayOfReminder,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.Daily(settings.ReminderTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
sent, err := s.EmitDayOfReminder(ctx, p.Date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sent {
|
||||
s.logger.InfoContext(ctx, "day-of reminder not sent: the week has no plan",
|
||||
"period", p.Key, "date", p.Date.String())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: scheduler.DayOfReminder,
|
||||
Key: StartReminderKey,
|
||||
Rule: func(household.Settings) scheduler.Rule {
|
||||
return scheduler.DailyAt(s.startReminderAt)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
sent, err := s.EmitStartReminder(ctx, p.Date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !sent {
|
||||
s.logger.InfoContext(ctx, "start reminder not sent: no crock-pot dinner to start before the reminder",
|
||||
"period", p.Key, "date", p.Date.String())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EmitStartReminder emits day_of_reminder:start:{date}, "Start the crock
|
||||
// pot by {time}", for the crock-pot dinner planned on date, and reports
|
||||
// whether it did: it emits nothing unless date's dinner has a start_by
|
||||
// earlier than date's reminder_time. Emitting a date again stores no
|
||||
// second notification.
|
||||
func (s *Service) EmitStartReminder(ctx context.Context, date store.LocalDate) (bool, error) {
|
||||
d, ok, err := s.startReminder(ctx, date)
|
||||
if err != nil || !ok {
|
||||
return false, err
|
||||
}
|
||||
if err := s.notifier.Emit(ctx, chat.StartReminder(date, d.Dinner.ID, d.Dinner.Name, *d.StartBy)); err != nil {
|
||||
return false, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// startReminderAt is the start reminder's scheduler.FireAt: the
|
||||
// StartReminderLead before start_by of the dinner date's start reminder,
|
||||
// if it has one.
|
||||
func (s *Service) startReminderAt(ctx context.Context, date store.LocalDate) (time.Time, bool, error) {
|
||||
d, ok, err := s.startReminder(ctx, date)
|
||||
if err != nil || !ok {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
return d.StartBy.Add(-StartReminderLead), true, nil
|
||||
}
|
||||
|
||||
// startReminder returns the plan day of date when it has a start reminder:
|
||||
// its dinner has a start_by (a crock-pot dinner of known minutes) earlier
|
||||
// than the household's reminder_time on date. The plan and the settings
|
||||
// come from one read transaction.
|
||||
func (s *Service) startReminder(ctx context.Context, date store.LocalDate) (d Day, ok bool, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Day{}, false, fmt.Errorf("plan: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
p, err := readPlan(ctx, q, household.WeekStart(date))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Day{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Day{}, false, err
|
||||
}
|
||||
d, err = p.day(date)
|
||||
if err != nil {
|
||||
return Day{}, false, err
|
||||
}
|
||||
if d.StartBy == nil {
|
||||
return Day{}, false, nil
|
||||
}
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return Day{}, false, fmt.Errorf("plan: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return Day{}, false, fmt.Errorf("plan: household settings: %w", err)
|
||||
}
|
||||
remind, err := household.LocalTime(zone, date, settings.ReminderTime)
|
||||
if err != nil {
|
||||
return Day{}, false, fmt.Errorf("plan: reminder time of %s: %w", date, err)
|
||||
}
|
||||
return d, d.StartBy.Before(remind), nil
|
||||
}
|
||||
|
||||
// day returns p's day on date; a plan holds all seven days of its week.
|
||||
func (p Plan) day(date store.LocalDate) (Day, error) {
|
||||
for _, d := range p.Days {
|
||||
if d.Date == date.String() {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return Day{}, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"errors"
|
||||
"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/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"
|
||||
)
|
||||
|
||||
@@ -79,12 +82,40 @@ type Decided struct {
|
||||
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
|
||||
// household-local day just past, on a plan of any status, to tried and
|
||||
// opens a pending Rotation check of it for date. It is idempotent: a
|
||||
// dinner already checked for date is left as it is, 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.
|
||||
// household-local day just past, on a plan of any status, to tried, opens
|
||||
// a pending Rotation check of it for date and emits its rotation_review
|
||||
// (chat.RotationReview), all in one transaction. It is idempotent: a
|
||||
// dinner already checked for date is left as it is and not notified of
|
||||
// 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) {
|
||||
var n int
|
||||
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()
|
||||
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})
|
||||
if err != nil {
|
||||
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 {
|
||||
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)
|
||||
return nil
|
||||
@@ -114,6 +152,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n > 0 {
|
||||
s.notifier.Wake()
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ func TestOnlyKeepPromotesToRegular(t *testing.T) {
|
||||
|
||||
// Each non-test Go file naming the regular status, and how often.
|
||||
uses := map[string]int{
|
||||
"dinners/dinners.go": 5, // the constant (2), listable, CreateRegular's status and origin
|
||||
"dinners/dinners.go": 6, // the constant (2), listable, CreateRegular's status and origin, AddToQuestLog reusing a Regular as it is
|
||||
"plan/plan.go": 1, // plannable
|
||||
"plan/rotation.go": 1, // decisionStatus[DecisionKeep]
|
||||
"suggest/batch.go": 1, // reads which dinners are Regulars to cite
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
)
|
||||
|
||||
// DraftSchedule is the plan_draft schedule: at the household's
|
||||
// plan_draft_dow and plan_draft_time it drafts the week after the fire
|
||||
// date's, its period scheduler.WeeklyForNextWeek keys plan_draft:YYYY-Www
|
||||
// for that week. The draft is the KindDraftWeek job a manual draft runs
|
||||
// (StartScheduledDraft), so a stored draft emits plan_ready keyed
|
||||
// plan_ready:{weekStart} and a draft that fails, as when the AI fails,
|
||||
// stores no plan and emits nothing. A week that already has a plan is left
|
||||
// as it is, and so is a draft of it already queued or running; either way
|
||||
// the period says so.
|
||||
func (s *Service) DraftSchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.PlanDraft,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.WeeklyForNextWeek(time.Weekday(settings.PlanDraftDow), settings.PlanDraftTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
d, planned, err := s.StartScheduledDraft(ctx, p.Week)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case planned:
|
||||
s.logger.InfoContext(ctx, "scheduled week draft not started: the week already has a plan",
|
||||
"period", p.Key, "week_start", p.Week.String())
|
||||
case !d.Created:
|
||||
s.logger.WarnContext(ctx, "scheduled week draft not started: a draft of the week is already under way",
|
||||
"period", p.Key, "week_start", p.Week.String(), "job_id", d.JobID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ type Recipe struct {
|
||||
// Title is the recipe's name.
|
||||
Title string
|
||||
// SiteName is the publisher's name, else the page's og:site_name, else
|
||||
// its host without a leading "www.".
|
||||
// HostSiteName of the page's URL.
|
||||
SiteName string
|
||||
// ImageURL is the largest image, absolute; empty when the recipe has
|
||||
// none.
|
||||
@@ -232,7 +232,13 @@ func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string,
|
||||
if ogSiteName != "" {
|
||||
return ogSiteName, nil
|
||||
}
|
||||
return strings.TrimPrefix(base.Hostname(), "www."), nil
|
||||
return HostSiteName(base), nil
|
||||
}
|
||||
|
||||
// HostSiteName is the site name of a page that names none: its host
|
||||
// without a leading "www.".
|
||||
func HostSiteName(u *url.URL) string {
|
||||
return strings.TrimPrefix(u.Hostname(), "www.")
|
||||
}
|
||||
|
||||
// imageURL picks the largest of the recipe's images by width × height;
|
||||
|
||||
@@ -292,7 +292,22 @@ type GroundResult struct {
|
||||
// affect the others.
|
||||
func (g *Grounder) GroundMany(ctx context.Context, reqs []GroundRequest) []GroundResult {
|
||||
results := make([]GroundResult, len(reqs))
|
||||
g.GroundEach(ctx, reqs, func(i int, r GroundResult) { results[i] = r })
|
||||
return results
|
||||
}
|
||||
|
||||
// GroundEach grounds each request as GroundMany does, but calls done with
|
||||
// each item's index and result as soon as that item finishes, one call at
|
||||
// a time. It returns once every item has finished and every call has
|
||||
// returned.
|
||||
func (g *Grounder) GroundEach(ctx context.Context, reqs []GroundRequest, done func(i int, r GroundResult)) {
|
||||
slots := make(chan struct{}, GroundConcurrency)
|
||||
var mu sync.Mutex
|
||||
report := func(i int, r GroundResult) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
done(i, r)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i, req := range reqs {
|
||||
wg.Add(1)
|
||||
@@ -301,16 +316,15 @@ func (g *Grounder) GroundMany(ctx context.Context, reqs []GroundRequest) []Groun
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
results[i] = GroundResult{Err: ctx.Err()}
|
||||
report(i, GroundResult{Err: ctx.Err()})
|
||||
return
|
||||
}
|
||||
defer func() { <-slots }()
|
||||
gr, err := g.Ground(ctx, req.DishName, req.Query)
|
||||
results[i] = GroundResult{Grounded: gr, Err: err}
|
||||
<-slots
|
||||
report(i, GroundResult{Grounded: gr, Err: err})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// stored returns the grounding of the recipe stored for sourceURL, and
|
||||
|
||||
@@ -23,12 +23,16 @@ import (
|
||||
)
|
||||
|
||||
// Search is a recipes.SearchSource and recipes.SearchProvider answering
|
||||
// each query from Results. Err, when set, fails every search. Gate, when
|
||||
// set, makes every search wait for it to close.
|
||||
// each query from Results. Err, when set, fails every search, and Fail
|
||||
// fails the searches for its queries. Gate, when set, makes every search
|
||||
// wait for it to close, and Gates makes the searches for its queries wait
|
||||
// for theirs.
|
||||
type Search struct {
|
||||
Results map[string][]recipes.Candidate
|
||||
Err error
|
||||
Fail map[string]error
|
||||
Gate chan struct{}
|
||||
Gates map[string]chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
queries []string
|
||||
@@ -40,9 +44,12 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can
|
||||
s.mu.Lock()
|
||||
s.queries = append(s.queries, query)
|
||||
s.mu.Unlock()
|
||||
if s.Gate != nil {
|
||||
for _, gate := range []chan struct{}{s.Gate, s.Gates[query]} {
|
||||
if gate == nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-s.Gate:
|
||||
case <-gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
@@ -50,6 +57,9 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
if err := s.Fail[query]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := s.Results[query]
|
||||
return c[:min(n, len(c))], nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"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"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// The tests in this file run plan's day_of_reminder schedules on the fake
|
||||
// clock against the real plan service and notification outbox. The plan of
|
||||
// the week of Monday Oct 12 (w42) has poolNames Monday to Sunday: Beef
|
||||
// Chili on Monday, Pot Roast on Tuesday. The household's dinner time is the
|
||||
// seeded 17:30 and its reminder time the seeded 16:30, in Chicago.
|
||||
|
||||
// w42 is the Monday of 2026-W42.
|
||||
var w42 = mustLocalDate("2026-10-12")
|
||||
|
||||
// planWeek drafts the plan of the week starting weekStart and waits for it.
|
||||
func (p *planEnv) planWeek(weekStart store.LocalDate) {
|
||||
p.e.t.Helper()
|
||||
p.reply(weekStart)
|
||||
if _, err := p.svc.StartDraft(context.Background(), weekStart, false); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
if jobs := p.e.drafts(); jobs[len(jobs)-1] != succeeded {
|
||||
p.e.t.Fatalf("draft of %s = %+v, want succeeded", weekStart, jobs)
|
||||
}
|
||||
}
|
||||
|
||||
// crockPot makes the dinner name a crock-pot dinner of minutes.
|
||||
func (p *planEnv) crockPot(name string, minutes int) {
|
||||
p.e.t.Helper()
|
||||
if _, err := p.e.st.Writer.Exec(`UPDATE dinners SET method = 'crockpot', minutes_total = ? WHERE id = ?`, minutes, p.ids[name]); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// reminders returns the day_of_reminder notifications stored, oldest
|
||||
// first. The seeded preference sends day_of_reminder to push only, so there
|
||||
// is one row per event.
|
||||
func (e *env) reminders() []notifytest.Row {
|
||||
e.t.Helper()
|
||||
rows := notifytest.Rows(e.t, e.st, notify.DayOfReminder)
|
||||
for _, r := range rows {
|
||||
if r.Channel != notify.ChannelPush {
|
||||
e.t.Fatalf("day_of_reminder row %+v, want push only", r)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// reminderKeys returns the keys of e.reminders().
|
||||
func (e *env) reminderKeys() []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
for _, r := range e.reminders() {
|
||||
keys = append(keys, r.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// wantReminder fails unless the reminder keyed key was stored with title,
|
||||
// body and the link to the dinner name.
|
||||
func (p *planEnv) wantReminder(key, title, body, name string) {
|
||||
p.e.t.Helper()
|
||||
for _, r := range p.e.reminders() {
|
||||
if r.Key != key {
|
||||
continue
|
||||
}
|
||||
week := household.WeekStart(mustLocalDate(key[len(key)-len("YYYY-MM-DD"):]))
|
||||
want := notify.Payload{Event: notify.DayOfReminder, Title: title, Body: body, URL: "/dinner/" + p.ids[name], Tag: key, Week: week.String()}
|
||||
if r.Payload != want {
|
||||
p.e.t.Errorf("reminder %s = %+v, want %+v", key, r.Payload, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
p.e.t.Errorf("no reminder %s", key)
|
||||
}
|
||||
|
||||
// TestDayOfReminderNormalNight: a night whose dinner is not a crock-pot
|
||||
// dinner gets exactly the daily reminder, at reminder_time 16:30, keyed
|
||||
// day_of_reminder:YYYY-MM-DD and linking to the dinner.
|
||||
func TestDayOfReminderNormalNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.planWeek(w42)
|
||||
|
||||
// Monday 08:00: the catch-up of Sunday 16:30 finds no plan for W41.
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 16, 29))
|
||||
wantKeys(t, "before 16:30", e.reminderKeys(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 16, 30))
|
||||
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||
p.wantReminder("day_of_reminder:2026-10-13", "Tonight: Pot Roast · Recipe →", "", "Pot Roast")
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderCrockPotNight: a crock-pot night whose start_by is
|
||||
// earlier than reminder_time gets the start reminder at start_by minus 15
|
||||
// minutes, keyed day_of_reminder:start:YYYY-MM-DD, and the daily reminder
|
||||
// at reminder_time. A crock-pot night whose start_by is not earlier than
|
||||
// reminder_time gets only the daily one.
|
||||
func TestDayOfReminderCrockPotNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.crockPot("Beef Chili", 480) // Monday: start by 09:30
|
||||
p.crockPot("Pot Roast", 30) // Tuesday: start by 17:00, after 16:30
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 9, 14))
|
||||
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 16, 29))
|
||||
wantKeys(t, "before 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r.runUntil(at(2026, 10, 12, 16, 30))
|
||||
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderEmptyNight: with no plan for tonight's week, here none
|
||||
// at all and then only next week's, nothing is emitted and nothing fails;
|
||||
// next week's crock-pot Monday is reminded on its own day.
|
||||
func TestDayOfReminderEmptyNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "with no plan", e.reminderKeys(), nil)
|
||||
|
||||
p.crockPot("Beef Chili", 480)
|
||||
p.planWeek(w43)
|
||||
r.runUntil(at(2026, 10, 18, 23, 59))
|
||||
wantKeys(t, "with only next week's plan", e.reminderKeys(), nil)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder),
|
||||
[]string{"2026-10-11", "2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"})
|
||||
if logs := p.logs.String(); !strings.Contains(logs, "day-of reminder not sent: the week has no plan") || strings.Contains(logs, "level=ERROR") {
|
||||
t.Errorf("logs = %s, want the empty nights logged and no error", logs)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 19, 9, 15))
|
||||
wantKeys(t, "next Monday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-19"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-19", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
|
||||
}
|
||||
|
||||
// TestDayOfReminderFollowsReminderTimeWithoutRestart: a reminder_time
|
||||
// change takes effect in the running scheduler. Moved from 16:30 to 18:00,
|
||||
// the daily reminder fires at 18:00, not 16:30, and a crock-pot start_by
|
||||
// of 17:00, now earlier than reminder_time, gets its start reminder at
|
||||
// 16:45.
|
||||
func TestDayOfReminderFollowsReminderTimeWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.crockPot("Beef Chili", 30) // start by 17:00
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("18:00")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 12, 16, 44))
|
||||
wantKeys(t, "before 16:45", e.reminderKeys(), nil)
|
||||
r.runUntil(at(2026, 10, 12, 16, 45))
|
||||
wantKeys(t, "at 16:45", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 5:00 pm", "Beef Chili", "Beef Chili")
|
||||
r.runUntil(at(2026, 10, 12, 17, 59))
|
||||
wantKeys(t, "before 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r.runUntil(at(2026, 10, 12, 18, 0))
|
||||
wantKeys(t, "at 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderNoDuplicateAfterRestart: restarts after each reminder
|
||||
// of a crock-pot night, within the catch-up window, emit neither again:
|
||||
// each period is already claimed.
|
||||
func TestDayOfReminderNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p.crockPot("Beef Chili", 480)
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
p2 := e.plans(ids)
|
||||
r2 := e.start(at(2026, 10, 12, 9, 20), p2.svc.ReminderSchedules()...)
|
||||
wantKeys(t, "after the first restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r2.runUntil(at(2026, 10, 12, 16, 30))
|
||||
r2.stop()
|
||||
p2.stop()
|
||||
|
||||
p3 := e.plans(ids)
|
||||
r3 := e.start(at(2026, 10, 12, 16, 40), p3.svc.ReminderSchedules()...)
|
||||
r3.runUntil(at(2026, 10, 13, 9, 0))
|
||||
wantKeys(t, "after the second restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "start:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDailyAtLookupFailureIsRetried: a DailyAt rule fires at the instant
|
||||
// its lookup gives, its period keyed by the schedule's Key and the date;
|
||||
// a lookup that fails fires nothing and is looked up again after
|
||||
// ClaimRetry, then fires its period once.
|
||||
func TestDailyAtLookupFailureIsRetried(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
var mu sync.Mutex
|
||||
failing := false
|
||||
at915 := func(ctx context.Context, d store.LocalDate) (time.Time, bool, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if failing {
|
||||
return time.Time{}, false, errors.New("database down")
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
return at(y, m, day, 9, 15), true, nil
|
||||
}
|
||||
sch := scheduler.Schedule{
|
||||
Name: scheduler.DayOfReminder,
|
||||
Key: "start",
|
||||
Rule: func(household.Settings) scheduler.Rule { return scheduler.DailyAt(at915) },
|
||||
Run: rec.run,
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), sch)
|
||||
wantKeys(t, "catch-up", rec.keys(), []string{"start:2026-10-11"})
|
||||
mu.Lock()
|
||||
failing = true
|
||||
mu.Unlock()
|
||||
r.runUntil(at(2026, 10, 12, 9, 16))
|
||||
wantKeys(t, "while the lookup fails", rec.keys(), []string{"start:2026-10-11"})
|
||||
|
||||
mu.Lock()
|
||||
failing = false
|
||||
mu.Unlock()
|
||||
r.runUntil(at(2026, 10, 12, 9, 17))
|
||||
wantKeys(t, "after the lookup recovers", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12"})
|
||||
if p := rec.periods()[1]; !p.FireAt.Equal(at(2026, 10, 12, 9, 15)) || p.Date.String() != "2026-10-12" {
|
||||
t.Errorf("period = %+v, want fired at 09:15 for 2026-10-12", p)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 13, 12, 0))
|
||||
wantKeys(t, "next day", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12", "start:2026-10-13"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
|
||||
}
|
||||
|
||||
// questLog stores name as a Quest-log dinner, a crock-pot dinner of
|
||||
// minutes, and returns its id. It is not a Regular.
|
||||
func (p *planEnv) questLog(name string, minutes int) string {
|
||||
p.e.t.Helper()
|
||||
now := store.Now().String()
|
||||
d, err := db.New(p.e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: "quest_log",
|
||||
Origin: "side_quest",
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
p.ids[name] = d.ID
|
||||
p.crockPot(name, minutes)
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// TestStartReminderAfterSwapFiresOnTime: swapping a crock-pot dinner in
|
||||
// for tonight wakes the scheduler, so its start reminder fires at start_by
|
||||
// minus 15 minutes, 09:15, and not at the next hourly check, 10:00. At
|
||||
// 09:00 tonight is Beef Chili, not a crock-pot dinner, and the scheduler's
|
||||
// next timer is 10:00; at 09:01 the household swaps in an eight-hour
|
||||
// crock-pot Quest-log dinner, start by 09:30. Each reminder fires once.
|
||||
func TestStartReminderAfterSwapFiresOnTime(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.wakingPlans(e.pool())
|
||||
stew := p.questLog("Crock Pot Stew", 480) // start by 09:30
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 9, 0))
|
||||
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 12, 10, 0)) {
|
||||
t.Fatalf("next wake at 09:00 = %s, want the hourly 10:00", next)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 9, 1))
|
||||
if _, err := p.svc.Swap(context.Background(), w42, "2026-10-12", stew); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 12, 9, 14))
|
||||
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Crock Pot Stew", "Crock Pot Stew")
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Crock Pot Stew · Recipe →", "", "Crock Pot Stew")
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package scheduler
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock makes now the clock and after the timer s fires by.
|
||||
func SetClock(s *Scheduler, now func() time.Time, after func(time.Duration) <-chan time.Time) {
|
||||
s.now = now
|
||||
s.after = after
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"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/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// The tests in this file run plan's plan_draft schedule on the fake clock
|
||||
// against the real plan service, job runner, harness, draft_week and
|
||||
// notification outbox, with the AI faked.
|
||||
|
||||
// planEnv is a plan service over a scheduler env's store, the "process" a
|
||||
// scheduler runs it in. model answers draft_week.
|
||||
type planEnv struct {
|
||||
e *env
|
||||
svc *plan.Service
|
||||
model *llm.Fake
|
||||
ids map[string]string
|
||||
logs *syncBuffer
|
||||
stop func()
|
||||
}
|
||||
|
||||
// poolNames are the seven Regulars pool stores, enough for a week.
|
||||
var poolNames = []string{"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Tacos", "Pancakes"}
|
||||
|
||||
// pool stores poolNames as Regulars and returns their ids by name.
|
||||
func (e *env) pool() map[string]string {
|
||||
e.t.Helper()
|
||||
ids := map[string]string{}
|
||||
for _, name := range poolNames {
|
||||
now := store.Now().String()
|
||||
d, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: "regular",
|
||||
Origin: "regular",
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ids[name] = d.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// publicURL is MEALQUEST_PUBLIC_URL in these tests.
|
||||
var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
|
||||
|
||||
// noCalendar is a Calendar the schedule must never push to.
|
||||
type noCalendar struct{ t *testing.T }
|
||||
|
||||
func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error {
|
||||
c.t.Error("plan_draft pushed to Calendar")
|
||||
return errors.New("no calendar")
|
||||
}
|
||||
|
||||
// plans builds a plan service drafting from the dinners ids over e with a
|
||||
// started job runner, which stop stops (and the test's end, if not before).
|
||||
// Its changes do not wake the scheduler: a draft job's wake would add a
|
||||
// pass the fake clock's waits do not expect.
|
||||
func (e *env) plans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, func() {})
|
||||
}
|
||||
|
||||
// wakingPlans is plans whose changes wake the scheduler, as in the server.
|
||||
// A test using it changes plans only from its own goroutine and waits for
|
||||
// the wake's pass with changed.
|
||||
func (e *env) wakingPlans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, e.wake)
|
||||
}
|
||||
|
||||
// newPlans is plans whose service calls wake after a plan change.
|
||||
func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
|
||||
e.t.Helper()
|
||||
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return p.model, nil },
|
||||
Store: e.st,
|
||||
Guard: guard,
|
||||
}
|
||||
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.DraftWeek(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(p.logs, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
var once sync.Once
|
||||
p.stop = func() {
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
}
|
||||
e.t.Cleanup(p.stop)
|
||||
return p
|
||||
}
|
||||
|
||||
// reply scripts the next draft_week answer: poolNames on the days of the
|
||||
// week starting weekStart, Monday to Sunday.
|
||||
func (p *planEnv) reply(weekStart store.LocalDate) {
|
||||
p.e.t.Helper()
|
||||
dates, err := household.WeekDays(weekStart)
|
||||
if err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
var days []string
|
||||
for i, name := range poolNames {
|
||||
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], p.ids[name]))
|
||||
}
|
||||
js := fmt.Sprintf(`{"days":[%s],"note":""}`, strings.Join(days, ","))
|
||||
p.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
// draftJob is a draft_week jobs row.
|
||||
type draftJob struct {
|
||||
Status, Error string
|
||||
}
|
||||
|
||||
// drafts waits until no draft_week job is queued or running and returns
|
||||
// them all, oldest first.
|
||||
func (e *env) drafts() []draftJob {
|
||||
e.t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
rows, err := e.st.Reader.Query(`SELECT status, COALESCE(error, '') FROM jobs WHERE kind = ? ORDER BY created_at, id`, plan.KindDraftWeek)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
var out []draftJob
|
||||
busy := false
|
||||
for rows.Next() {
|
||||
var j draftJob
|
||||
if err := rows.Scan(&j.Status, &j.Error); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
busy = busy || j.Status == string(jobs.StatusQueued) || j.Status == string(jobs.StatusRunning)
|
||||
out = append(out, j)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
rows.Close()
|
||||
if !busy {
|
||||
return out
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
e.t.Fatalf("draft jobs still under way: %+v", out)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// wantDrafts fails unless the draft_week jobs are got.
|
||||
func wantDrafts(t *testing.T, what string, got []draftJob, want ...draftJob) {
|
||||
t.Helper()
|
||||
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Fatalf("%s: draft jobs = %+v, want %+v", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// weekPlans returns the week_start of every plan, oldest first.
|
||||
func (e *env) weekPlans() []string {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT week_start FROM week_plans ORDER BY week_start`)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var w string
|
||||
if err := rows.Scan(&w); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// planReady returns the distinct plan_ready keys stored, oldest first.
|
||||
func (e *env) planReady() []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
|
||||
if len(keys) == 0 || keys[len(keys)-1] != r.Key {
|
||||
keys = append(keys, r.Key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
var (
|
||||
succeeded = draftJob{Status: string(jobs.StatusSucceeded)}
|
||||
aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed}
|
||||
// w43 is the Monday of 2026-W43, the week after Friday Oct 16.
|
||||
w43 = mustLocalDate("2026-10-19")
|
||||
w44 = mustLocalDate("2026-10-26")
|
||||
)
|
||||
|
||||
func mustLocalDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// TestPlanDraftDraftsNextWeek: at the seeded Friday 09:00 the schedule
|
||||
// drafts the next ISO week through the plan service's draft job, period
|
||||
// plan_draft:YYYY-Www of that week, and the draft emits plan_ready keyed
|
||||
// by its Monday once.
|
||||
func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w44)
|
||||
|
||||
// Wednesday Oct 14: the Friday Oct 9 09:00 fire is days ago, missed.
|
||||
r := e.start(at(2026, 10, 14, 12, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 8, 59))
|
||||
wantDrafts(t, "before Friday 09:00", e.drafts())
|
||||
wantKeys(t, "plans before Friday 09:00", e.weekPlans(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
got, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Days) != 7 || got.Days[0].Dinner.Name != "Beef Chili" || got.Status != plan.StatusDraft {
|
||||
t.Errorf("plan of W43 = %+v, want the drafted week", got)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 23, 9, 0))
|
||||
wantDrafts(t, "W44", e.drafts(), succeeded, succeeded)
|
||||
wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"})
|
||||
wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"})
|
||||
}
|
||||
|
||||
// TestPlanDraftLeavesAnExistingPlan: when next week already has a plan,
|
||||
// here one the household drafted by hand, the period drafts nothing,
|
||||
// notifies no one and says so.
|
||||
func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w43) // a second draft would succeed too
|
||||
if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDrafts(t, "manual draft", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
before, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "with a plan", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
after, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(after) != fmt.Sprint(before) {
|
||||
t.Errorf("plan of W43 = %+v, want it unchanged from %+v", after, before)
|
||||
}
|
||||
if logs := p.logs.String(); !strings.Contains(logs, "the week already has a plan") || !strings.Contains(logs, "period=2026-W43") {
|
||||
t.Errorf("logs = %s, want the skipped period logged", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanDraftNoDuplicateAfterRestart: a restart after the week was
|
||||
// drafted, still within the catch-up window, drafts nothing more and sends
|
||||
// no second plan_ready: the period is already claimed.
|
||||
func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p.reply(w43)
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "first process", e.drafts(), succeeded)
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
// The restarted process has a new job runner, plan service and
|
||||
// scheduler; it would answer a second draft if one were started.
|
||||
p2 := e.plans(ids)
|
||||
p2.reply(w43)
|
||||
r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
r2.runUntil(at(2026, 10, 23, 8, 59))
|
||||
wantDrafts(t, "after restart", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
}
|
||||
|
||||
// TestPlanDraftAIFailureEmitsNothing: when draft_week fails the draft job
|
||||
// fails as ai_failed with the failure recorded in ai_runs, no plan is made
|
||||
// up and no one is notified; a restart within the catch-up window does not
|
||||
// draft the claimed period again.
|
||||
func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "after the AI failed", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plans after the AI failed", e.weekPlans(), nil)
|
||||
wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
|
||||
var status, msg string
|
||||
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'draft_week'`).Scan(&status, &msg); err != nil {
|
||||
t.Fatalf("ai_runs row of draft_week: %v", err)
|
||||
}
|
||||
if status != string(harness.StatusProviderError) || msg == "" {
|
||||
t.Errorf("ai_runs draft_week = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
|
||||
}
|
||||
if logs := p.logs.String(); !strings.Contains(logs, "week draft failed") || !strings.Contains(logs, "reason="+plan.ReasonAIFailed) {
|
||||
t.Errorf("logs = %s, want the draft failure with reason %s", logs, plan.ReasonAIFailed)
|
||||
}
|
||||
|
||||
r.stop()
|
||||
p.stop()
|
||||
p2 := e.plans(ids)
|
||||
p2.reply(w43)
|
||||
e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
wantDrafts(t, "after restart", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), nil)
|
||||
}
|
||||
|
||||
// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and
|
||||
// plan_draft_time change takes effect in the running scheduler; the old
|
||||
// Friday 09:00 no longer fires and the new Wednesday 18:30 drafts the next
|
||||
// week.
|
||||
func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday
|
||||
e.update(household.SettingsPatch{PlanDraftDow: ptr(3), PlanDraftTime: ptr("18:30")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
|
||||
wantDrafts(t, "before Wednesday 18:30", e.drafts())
|
||||
r.runUntil(at(2026, 10, 14, 18, 30))
|
||||
wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
|
||||
// Friday 09:00, the old fire time, drafts nothing more.
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
// Package scheduler runs the household's timed work: each registered
|
||||
// schedule fires once per period at a household-local time read from the
|
||||
// household settings.
|
||||
//
|
||||
// # Periods and the claim
|
||||
//
|
||||
// Every fire time belongs to one period, named by a period key derived in
|
||||
// the household zone from the schedule's cadence (see Rule.PeriodKey): the
|
||||
// local date "2026-10-09" for a daily rule, and the ISO week "2026-W41" of
|
||||
// the fire date, or of the week after it, for a weekly rule. Before running,
|
||||
// the scheduler inserts schedule_runs(schedule, period_key); the insert is
|
||||
// the claim, and a period already claimed, by an earlier run, an earlier
|
||||
// process or another instance, is skipped. A period therefore runs at most
|
||||
// once: a Run that fails is logged and its period is not retried.
|
||||
//
|
||||
// # Catch-up
|
||||
//
|
||||
// When the scheduler starts, and whenever it wakes late, it handles, oldest
|
||||
// first, every fire time of each schedule that passed within CatchUpWindow,
|
||||
// and the most recent fire time even when it passed longer ago. Each
|
||||
// unclaimed period within CatchUpWindow runs once. A most recent one that
|
||||
// passed longer ago is claimed without running and logged as missed, so it
|
||||
// can never run later, not after a restart and not after a settings change
|
||||
// moves the fire time. Other fire times older than CatchUpWindow are never
|
||||
// looked at.
|
||||
//
|
||||
// # Claim failures
|
||||
//
|
||||
// A claim that fails (the database refused the insert) is never skipped:
|
||||
// the scheduler logs it and keeps that period's fire time, and every
|
||||
// ClaimRetry claims that same period again until the claim succeeds. Only
|
||||
// then does it apply the catch-up rule to it, running it if its fire time
|
||||
// is within CatchUpWindow of the successful claim and recording it as
|
||||
// missed otherwise, and only then does it go on to later fire times, even
|
||||
// when one has passed meanwhile. Each period is claimed by its own fire
|
||||
// time, so the later period runs once as well.
|
||||
//
|
||||
// # Time zone and DST
|
||||
//
|
||||
// Fire times are wall-clock times in the household zone, never the process
|
||||
// zone, turned into instants by household.LocalTime. Its rule is the
|
||||
// scheduler's DST rule: a time that does not exist on a spring-forward day
|
||||
// fires later by the length of the gap (02:30 fires at 03:30 local), and a
|
||||
// time that occurs twice on a fall-back day fires once, at its first
|
||||
// occurrence. Either way the period key is that local date's, so the
|
||||
// period still runs exactly once.
|
||||
//
|
||||
// # Fire times from data
|
||||
//
|
||||
// A DailyAt rule fires at a time read from the household's data rather
|
||||
// than the settings: its function gives, for each household-local date,
|
||||
// the instant the date's period fires, or none. The period is that date
|
||||
// even when the instant falls on another day. The scheduler asks again on
|
||||
// every pass, and whoever changes that data calls Wake once the change is
|
||||
// committed, so the next pass comes at once and a fire time the change
|
||||
// moved fires on time rather than up to maxWait late; the claim still runs
|
||||
// a period at most once. A lookup that fails is logged and repeated every
|
||||
// ClaimRetry, as a failed claim is.
|
||||
//
|
||||
// # Settings changes
|
||||
//
|
||||
// The scheduler subscribes to the household settings and records each change
|
||||
// with the instant it was saved. A change takes effect at once without a
|
||||
// restart: fire times up to that instant follow the old settings and later
|
||||
// ones the new, even when the scheduler gets to the change late. So moving
|
||||
// a fire time to earlier the same day does not fire the passed time, and a
|
||||
// new fire time that was still ahead when the change was saved fires.
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Schedule names, as the schedule_runs.schedule CHECK constraint allows.
|
||||
const (
|
||||
SuggestionsWeekly = "suggestions_weekly"
|
||||
PlanDraft = "plan_draft"
|
||||
ShoppingChatAutosend = "shopping_chat_autosend"
|
||||
DayOfReminder = "day_of_reminder"
|
||||
RotationCheck = "rotation_check"
|
||||
WeekRollover = "week_rollover"
|
||||
AIRunsPurge = "ai_runs_purge"
|
||||
)
|
||||
|
||||
// names are the schedule names Register accepts.
|
||||
var names = map[string]bool{
|
||||
SuggestionsWeekly: true,
|
||||
PlanDraft: true,
|
||||
ShoppingChatAutosend: true,
|
||||
DayOfReminder: true,
|
||||
RotationCheck: true,
|
||||
WeekRollover: true,
|
||||
AIRunsPurge: true,
|
||||
}
|
||||
|
||||
// CatchUpWindow is how long after its fire time a period that has not run
|
||||
// still runs. A period found later than that is recorded as missed.
|
||||
const CatchUpWindow = 24 * time.Hour
|
||||
|
||||
// ClaimRetry is how long the scheduler waits before repeating a pass in
|
||||
// which a claim failed.
|
||||
const ClaimRetry = time.Minute
|
||||
|
||||
// maxWait bounds one sleep, so the scheduler checks the wall clock at least
|
||||
// this often even if the host was suspended or its clock stepped.
|
||||
const maxWait = time.Hour
|
||||
|
||||
// Rule is when a schedule fires: at household-local Time ("HH:MM") every
|
||||
// day, or on Weekday only when Weekly is set, or every day at the instant
|
||||
// At gives. Build one with Daily, Weekly, WeeklyForNextWeek or DailyAt.
|
||||
type Rule struct {
|
||||
Weekly bool
|
||||
Weekday time.Weekday
|
||||
Time string
|
||||
// NextWeek keys a weekly period by the ISO week after the fire date,
|
||||
// for work done ahead for the coming week.
|
||||
NextWeek bool
|
||||
// At, instead of Time, gives the instant the period of a
|
||||
// household-local date fires.
|
||||
At FireAt
|
||||
}
|
||||
|
||||
// FireAt gives the instant the period of household-local date fires, or
|
||||
// false when that date's period does not fire.
|
||||
type FireAt func(ctx context.Context, date store.LocalDate) (time.Time, bool, error)
|
||||
|
||||
// Daily fires every day at hhmm; its period is the local date.
|
||||
func Daily(hhmm string) Rule {
|
||||
return Rule{Time: hhmm}
|
||||
}
|
||||
|
||||
// Weekly fires on day at hhmm; its period is the ISO week of the fire date.
|
||||
func Weekly(day time.Weekday, hhmm string) Rule {
|
||||
return Rule{Weekly: true, Weekday: day, Time: hhmm}
|
||||
}
|
||||
|
||||
// WeeklyForNextWeek fires on day at hhmm; its period is the ISO week after
|
||||
// the fire date's.
|
||||
func WeeklyForNextWeek(day time.Weekday, hhmm string) Rule {
|
||||
return Rule{Weekly: true, Weekday: day, Time: hhmm, NextWeek: true}
|
||||
}
|
||||
|
||||
// DailyAt fires at most once a day, at the instant at gives for the date;
|
||||
// its period is that date, wherever the instant falls.
|
||||
func DailyAt(at FireAt) Rule {
|
||||
return Rule{At: at}
|
||||
}
|
||||
|
||||
// PeriodKey returns the period key of a fire on household-local date
|
||||
// fireDate: the date ("2026-10-09") for a daily rule, the ISO week
|
||||
// ("2026-W53") for a weekly one, and the ISO week after that ("2027-W01")
|
||||
// when NextWeek is set. It panics if fireDate is the zero LocalDate.
|
||||
func (r Rule) PeriodKey(fireDate store.LocalDate) string {
|
||||
if !r.Weekly {
|
||||
return fireDate.String()
|
||||
}
|
||||
if r.NextWeek {
|
||||
return household.PeriodKey(addDays(fireDate, 7))
|
||||
}
|
||||
return household.PeriodKey(fireDate)
|
||||
}
|
||||
|
||||
// validate reports whether r can fire.
|
||||
func (r Rule) validate() error {
|
||||
if r.Weekly && (r.Weekday < time.Sunday || r.Weekday > time.Saturday) {
|
||||
return fmt.Errorf("invalid weekday %d (want 0-6, Sunday = 0)", r.Weekday)
|
||||
}
|
||||
if !r.Weekly && (r.Weekday != time.Sunday || r.NextWeek) {
|
||||
return errors.New("a daily rule has no weekday or next week")
|
||||
}
|
||||
if r.At != nil {
|
||||
if r.Weekly || r.Time != "" {
|
||||
return errors.New("a DailyAt rule has no weekday or time")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Any date checks the time's form.
|
||||
_, err := household.LocalTime(time.UTC, store.LocalDateOf(time.Unix(0, 0).UTC()), r.Time)
|
||||
return err
|
||||
}
|
||||
|
||||
// Period is one period of a schedule, as given to its Run.
|
||||
type Period struct {
|
||||
// Key is the period key claimed in schedule_runs.
|
||||
Key string
|
||||
// FireAt is the instant the period fired, in the household zone.
|
||||
FireAt time.Time
|
||||
// Date is the household-local date of FireAt.
|
||||
Date store.LocalDate
|
||||
// Week is the ISO Monday of the week Key names; for a daily rule, of
|
||||
// the week containing Date.
|
||||
Week store.LocalDate
|
||||
}
|
||||
|
||||
// Schedule is registered work that runs once per period.
|
||||
type Schedule struct {
|
||||
// Name is one of the schedule name constants.
|
||||
Name string
|
||||
// Key, when set, prefixes the schedule's period keys with Key and a
|
||||
// colon ("start:2026-10-14"), so two schedules of one Name claim
|
||||
// different periods. Name and Key together are unique.
|
||||
Key string
|
||||
// PeriodKey, when set, gives the period key claimed for p in place of
|
||||
// the rule's, such as "list_ready:chat:2026-10-19" for the week p.Week;
|
||||
// p.Key is the rule's key. It must give each period its own key. A
|
||||
// schedule sets Key or PeriodKey, not both.
|
||||
PeriodKey func(p Period) string
|
||||
// Rule returns when the schedule fires under settings. It is called
|
||||
// again after every settings change.
|
||||
Rule func(settings household.Settings) Rule
|
||||
// Run does the period's work. It runs on the scheduler's goroutine,
|
||||
// one schedule at a time, so it must return promptly: long work is
|
||||
// enqueued as a job. ctx is cancelled when the scheduler stops. An
|
||||
// error is logged; the period stays claimed and is not retried.
|
||||
Run func(ctx context.Context, p Period) error
|
||||
}
|
||||
|
||||
// label names sch in errors and logs: its Name, and its Key if set.
|
||||
func (sch Schedule) label() string {
|
||||
if sch.Key == "" {
|
||||
return sch.Name
|
||||
}
|
||||
return sch.Name + ":" + sch.Key
|
||||
}
|
||||
|
||||
// Settings is the household settings source: *household.SettingsService.
|
||||
type Settings interface {
|
||||
Get(ctx context.Context) (household.Settings, error)
|
||||
Subscribe(fn func(household.Settings)) (unsubscribe func())
|
||||
}
|
||||
|
||||
// Scheduler runs the registered schedules.
|
||||
type Scheduler struct {
|
||||
st *store.Store
|
||||
settings Settings
|
||||
logger *slog.Logger
|
||||
|
||||
// now is the clock and after the timer; tests replace both.
|
||||
now func() time.Time
|
||||
after func(d time.Duration) <-chan time.Time
|
||||
|
||||
// wake holds one token when the settings or the data a DailyAt rule
|
||||
// reads changed since the loop last looked; pending is the settings
|
||||
// changes to apply, oldest first.
|
||||
wake chan struct{}
|
||||
pendingMu sync.Mutex
|
||||
pending []change
|
||||
|
||||
mu sync.Mutex
|
||||
schedules []Schedule
|
||||
started bool
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New returns a scheduler over st reading the household settings from
|
||||
// settings.
|
||||
func New(st *store.Store, settings Settings, logger *slog.Logger) (*Scheduler, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("scheduler: nil store")
|
||||
}
|
||||
if settings == nil {
|
||||
return nil, errors.New("scheduler: nil settings")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("scheduler: nil logger")
|
||||
}
|
||||
return &Scheduler{
|
||||
st: st,
|
||||
settings: settings,
|
||||
logger: logger,
|
||||
now: time.Now,
|
||||
after: time.After,
|
||||
wake: make(chan struct{}, 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register adds sch. It must be called before Start, once per name.
|
||||
func (s *Scheduler) Register(sch Schedule) error {
|
||||
if !names[sch.Name] {
|
||||
return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name)
|
||||
}
|
||||
if sch.Rule == nil {
|
||||
return fmt.Errorf("scheduler: register %s: nil rule", sch.label())
|
||||
}
|
||||
if sch.Run == nil {
|
||||
return fmt.Errorf("scheduler: register %s: nil run", sch.label())
|
||||
}
|
||||
if strings.Contains(sch.Key, ":") {
|
||||
return fmt.Errorf("scheduler: register %s: key has a colon", sch.label())
|
||||
}
|
||||
if sch.Key != "" && sch.PeriodKey != nil {
|
||||
return fmt.Errorf("scheduler: register %s: both key and period key", sch.label())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
return fmt.Errorf("scheduler: register %s: already started", sch.label())
|
||||
}
|
||||
for _, have := range s.schedules {
|
||||
if have.Name == sch.Name && have.Key == sch.Key {
|
||||
return fmt.Errorf("scheduler: register %s: already registered", sch.label())
|
||||
}
|
||||
}
|
||||
s.schedules = append(s.schedules, sch)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start reads the settings and starts the scheduler, which first catches
|
||||
// up on each schedule's recent periods and then fires schedules as
|
||||
// their times come. Settings whose zone or rules are invalid are an error
|
||||
// and nothing starts. The scheduler stops when ctx is cancelled; Wait waits
|
||||
// for it.
|
||||
func (s *Scheduler) Start(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
return errors.New("scheduler: already started")
|
||||
}
|
||||
// Subscribing first means a change saved after the read below still
|
||||
// wakes the loop.
|
||||
unsubscribe := s.settings.Subscribe(s.changed)
|
||||
settings, err := s.settings.Get(ctx)
|
||||
if err != nil {
|
||||
unsubscribe()
|
||||
return fmt.Errorf("scheduler: %w", err)
|
||||
}
|
||||
p, err := compile(s.schedules, settings)
|
||||
if err != nil {
|
||||
unsubscribe()
|
||||
return err
|
||||
}
|
||||
s.started = true
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
defer unsubscribe()
|
||||
s.loop(ctx, p)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until the scheduler has stopped.
|
||||
func (s *Scheduler) Wait() {
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// change is a settings change and the instant it was saved.
|
||||
type change struct {
|
||||
at time.Time
|
||||
settings household.Settings
|
||||
}
|
||||
|
||||
// changed is the settings subscriber. It runs on the updating goroutine, so
|
||||
// it only records the change and wakes the loop.
|
||||
func (s *Scheduler) changed(settings household.Settings) {
|
||||
s.pendingMu.Lock()
|
||||
s.pending = append(s.pending, change{at: s.now(), settings: settings})
|
||||
s.pendingMu.Unlock()
|
||||
s.signal()
|
||||
}
|
||||
|
||||
// Wake makes the scheduler look up its fire times again now rather than at
|
||||
// its next timer. Call it after committing a change to the data a DailyAt
|
||||
// rule reads, such as tonight's planned dinner. It never blocks.
|
||||
func (s *Scheduler) Wake() {
|
||||
s.signal()
|
||||
}
|
||||
|
||||
// signal leaves one token in wake for the loop.
|
||||
func (s *Scheduler) signal() {
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default: // the loop already has a token and looks again then
|
||||
}
|
||||
}
|
||||
|
||||
// takeChanges returns and clears the pending changes.
|
||||
func (s *Scheduler) takeChanges() []change {
|
||||
s.pendingMu.Lock()
|
||||
defer s.pendingMu.Unlock()
|
||||
out := s.pending
|
||||
s.pending = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// plan is the schedules compiled against one version of the settings.
|
||||
type plan struct {
|
||||
loc *time.Location
|
||||
items []item
|
||||
}
|
||||
|
||||
type item struct {
|
||||
sch Schedule
|
||||
rule Rule
|
||||
}
|
||||
|
||||
// compile builds the plan for settings.
|
||||
func compile(schedules []Schedule, settings household.Settings) (*plan, error) {
|
||||
loc, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scheduler: household zone: %w", err)
|
||||
}
|
||||
p := &plan{loc: loc, items: make([]item, len(schedules))}
|
||||
for i, sch := range schedules {
|
||||
rule := sch.Rule(settings)
|
||||
if err := rule.validate(); err != nil {
|
||||
return nil, fmt.Errorf("scheduler: %s: %w", sch.label(), err)
|
||||
}
|
||||
p.items[i] = item{sch: sch, rule: rule}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// loop fires schedules until ctx is cancelled. done is the instant up to
|
||||
// which fire times have been handled; it starts at the zero time so the
|
||||
// first pass catches up. retry is the periods whose claim failed, each
|
||||
// with its own fire time. Each pass first claims retry again; while any
|
||||
// claim there still fails, the pass stops and is repeated after
|
||||
// ClaimRetry. Otherwise it applies the pending settings changes in order,
|
||||
// handling fire times up to a change under the plan it replaces, then
|
||||
// handles fire times up to now under the current plan, stopping after the
|
||||
// first stretch in which a claim failed.
|
||||
func (s *Scheduler) loop(ctx context.Context, p *plan) {
|
||||
var done time.Time
|
||||
var pending []change
|
||||
var retry []target
|
||||
for {
|
||||
now := s.now()
|
||||
pending = append(pending, s.takeChanges()...)
|
||||
retry = s.retry(ctx, retry, now)
|
||||
for len(retry) == 0 && len(pending) > 0 {
|
||||
c := pending[0]
|
||||
if c.at.After(done) {
|
||||
if p != nil {
|
||||
retry = s.fire(ctx, p, done, c.at, now)
|
||||
}
|
||||
done = c.at
|
||||
}
|
||||
p = s.compileChange(ctx, c.settings)
|
||||
pending = pending[1:]
|
||||
}
|
||||
if len(retry) == 0 {
|
||||
if p != nil {
|
||||
retry = s.fire(ctx, p, done, now, now)
|
||||
}
|
||||
done = now
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var timeout <-chan time.Time
|
||||
switch {
|
||||
case len(retry) > 0:
|
||||
timeout = s.after(ClaimRetry)
|
||||
case p != nil:
|
||||
next, ok, err := p.next(ctx, now)
|
||||
switch {
|
||||
case err != nil:
|
||||
s.logger.ErrorContext(ctx, "scheduler: look up next fire time failed; retrying", "error", err, "retry_in", ClaimRetry.String())
|
||||
timeout = s.after(ClaimRetry)
|
||||
case ok:
|
||||
timeout = s.after(min(next.Sub(now), maxWait))
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timeout:
|
||||
case <-s.wake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compileChange returns the plan for changed settings, or nil when they
|
||||
// are unusable.
|
||||
func (s *Scheduler) compileChange(ctx context.Context, settings household.Settings) *plan {
|
||||
p, err := compile(s.schedules, settings)
|
||||
if err != nil {
|
||||
// Settings are validated before they are saved, so this is a
|
||||
// bug; firing at a guessed time would be worse than not firing
|
||||
// until the settings are fixed.
|
||||
s.logger.ErrorContext(ctx, "scheduler: settings unusable; no schedule fires until they change", "error", err)
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// target is one schedule's periods still to handle, oldest first, each
|
||||
// named by its fire time and the household-local date of its period. A
|
||||
// target whose fire times could not be looked up has lookup set instead,
|
||||
// with the stretch (done, until] to look them up in again.
|
||||
type target struct {
|
||||
it item
|
||||
loc *time.Location
|
||||
fires []fireTime
|
||||
|
||||
lookup bool
|
||||
done, until time.Time
|
||||
}
|
||||
|
||||
// fire handles, for every schedule, its catch-up fire times after done and
|
||||
// at or before until; now is the current instant. It returns the
|
||||
// targets whose lookup or claim failed, to be handled again.
|
||||
func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target {
|
||||
var failed []target
|
||||
for _, it := range p.items {
|
||||
if ctx.Err() != nil {
|
||||
return failed
|
||||
}
|
||||
if t, ok := s.fireItem(ctx, it, p.loc, done, until, now); !ok {
|
||||
failed = append(failed, t)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// fireItem handles, oldest first, it's fire times after done and at or
|
||||
// before until that passed within CatchUpWindow of now, and the most recent
|
||||
// of them even when it passed longer ago. It reports false, with the target
|
||||
// to handle again, when the fire times could not be looked up or a period's
|
||||
// claim failed.
|
||||
func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) {
|
||||
fs, err := fireBetween(ctx, it.rule, loc, done, until)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "scheduler: look up fire time failed; retrying",
|
||||
"schedule", it.sch.label(), "error", err, "retry_in", ClaimRetry.String())
|
||||
return target{it: it, loc: loc, lookup: true, done: done, until: until}, false
|
||||
}
|
||||
i := 0
|
||||
for i < len(fs)-1 && now.Sub(fs[i].at) > CatchUpWindow {
|
||||
i++
|
||||
}
|
||||
return s.handleAll(ctx, target{it: it, loc: loc, fires: fs[i:]}, now)
|
||||
}
|
||||
|
||||
// handleAll handles the periods of t in order, stopping at the first whose
|
||||
// claim failed. It reports false, with that period and those after it,
|
||||
// when one did.
|
||||
func (s *Scheduler) handleAll(ctx context.Context, t target, now time.Time) (target, bool) {
|
||||
for i, f := range t.fires {
|
||||
if !s.handle(ctx, t.it, f, now) {
|
||||
t.fires = t.fires[i:]
|
||||
return t, false
|
||||
}
|
||||
}
|
||||
return target{}, true
|
||||
}
|
||||
|
||||
// retry handles targets again at now and returns those whose lookup or
|
||||
// claim still failed.
|
||||
func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time) []target {
|
||||
var failed []target
|
||||
for i, t := range targets {
|
||||
if ctx.Err() != nil {
|
||||
return append(failed, targets[i:]...)
|
||||
}
|
||||
if t.lookup {
|
||||
if again, ok := s.fireItem(ctx, t.it, t.loc, t.done, t.until, now); !ok {
|
||||
failed = append(failed, again)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if again, ok := s.handleAll(ctx, t, now); !ok {
|
||||
failed = append(failed, again)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// handle claims the period of it's fire time f and runs it, or records it
|
||||
// as missed when f is more than CatchUpWindow before now. It reports false
|
||||
// when the claim failed and the period is still unclaimed.
|
||||
func (s *Scheduler) handle(ctx context.Context, it item, f fireTime, now time.Time) bool {
|
||||
at, date := f.at, f.date
|
||||
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
|
||||
if it.rule.NextWeek {
|
||||
period.Week = household.WeekStart(addDays(date, 7))
|
||||
} else {
|
||||
period.Week = household.WeekStart(date)
|
||||
}
|
||||
switch {
|
||||
case it.sch.Key != "":
|
||||
period.Key = it.sch.Key + ":" + period.Key
|
||||
case it.sch.PeriodKey != nil:
|
||||
period.Key = it.sch.PeriodKey(period)
|
||||
}
|
||||
log := s.logger.With("schedule", it.sch.Name, "period", period.Key, "fire_at", at.Format(time.RFC3339))
|
||||
|
||||
claimed, err := s.claim(ctx, it.sch.Name, period.Key, now)
|
||||
if err != nil {
|
||||
log.ErrorContext(ctx, "scheduler: claim period failed; retrying", "error", err, "retry_in", ClaimRetry.String())
|
||||
return false
|
||||
}
|
||||
if !claimed {
|
||||
return true
|
||||
}
|
||||
if late := now.Sub(at); late > CatchUpWindow {
|
||||
log.WarnContext(ctx, "scheduler: period missed; recorded without running", "late", late.String())
|
||||
return true
|
||||
}
|
||||
if err := it.sch.Run(ctx, period); err != nil {
|
||||
log.ErrorContext(ctx, "scheduler: run failed", "error", err)
|
||||
return true
|
||||
}
|
||||
log.InfoContext(ctx, "scheduler: ran")
|
||||
return true
|
||||
}
|
||||
|
||||
// claim inserts schedule_runs(name, key) and reports whether this call
|
||||
// inserted it.
|
||||
func (s *Scheduler) claim(ctx context.Context, name, key string, now time.Time) (bool, error) {
|
||||
n, err := db.New(s.st.Writer).ClaimScheduleRun(ctx, db.ClaimScheduleRunParams{
|
||||
ID: store.NewID(),
|
||||
Schedule: name,
|
||||
PeriodKey: key,
|
||||
CreatedAt: store.Timestamp{Time: now.UTC().Truncate(time.Millisecond)}.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// next returns the earliest fire time of any schedule after now, or false
|
||||
// when there is none.
|
||||
func (p *plan) next(ctx context.Context, now time.Time) (time.Time, bool, error) {
|
||||
var next time.Time
|
||||
for _, it := range p.items {
|
||||
f, ok, err := fireAfter(ctx, it.rule, p.loc, now)
|
||||
if err != nil {
|
||||
return time.Time{}, false, fmt.Errorf("%s: %w", it.sch.label(), err)
|
||||
}
|
||||
if ok && (next.IsZero() || f.at.Before(next)) {
|
||||
next = f.at
|
||||
}
|
||||
}
|
||||
return next, !next.IsZero(), nil
|
||||
}
|
||||
|
||||
// window is how many days either side of a date fireBetween and fireAfter
|
||||
// search: more than a week, so a weekly rule always has a fire time on
|
||||
// each side.
|
||||
const window = 8
|
||||
|
||||
// atWindow is how many days either side of a date fireBetween and fireAfter
|
||||
// look up a DailyAt rule's fire times. A period firing further off is
|
||||
// looked up on a later pass, which comes within maxWait.
|
||||
const atWindow = 2
|
||||
|
||||
// fireTime is a fire time and the household-local date of its period.
|
||||
type fireTime struct {
|
||||
at time.Time
|
||||
date store.LocalDate
|
||||
}
|
||||
|
||||
// fireBetween returns the fire times of r in zone loc after from and at or
|
||||
// before t, oldest first.
|
||||
func fireBetween(ctx context.Context, r Rule, loc *time.Location, from, t time.Time) ([]fireTime, error) {
|
||||
fs, err := fireTimes(ctx, r, loc, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []fireTime
|
||||
for _, f := range fs {
|
||||
if f.at.After(from) && !f.at.After(t) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b fireTime) int { return a.at.Compare(b.at) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fireAfter returns the earliest fire time of r in zone loc after t.
|
||||
func fireAfter(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) {
|
||||
fs, err := fireTimes(ctx, r, loc, t)
|
||||
if err != nil {
|
||||
return fireTime{}, false, err
|
||||
}
|
||||
var best fireTime
|
||||
for _, f := range fs {
|
||||
if f.at.After(t) && (best.at.IsZero() || f.at.Before(best.at)) {
|
||||
best = f
|
||||
}
|
||||
}
|
||||
return best, !best.at.IsZero(), nil
|
||||
}
|
||||
|
||||
// fireTimes returns r's fire times for the local dates within window days
|
||||
// of t's, or atWindow days for a DailyAt rule. A Time that fails to
|
||||
// resolve was validated at compile, so it cannot; a DailyAt lookup that
|
||||
// fails is an error.
|
||||
func fireTimes(ctx context.Context, r Rule, loc *time.Location, t time.Time) ([]fireTime, error) {
|
||||
today := household.Today(t, loc)
|
||||
span := window
|
||||
if r.At != nil {
|
||||
span = atWindow
|
||||
}
|
||||
var out []fireTime
|
||||
for i := -span; i <= span; i++ {
|
||||
d := addDays(today, i)
|
||||
if r.Weekly && weekday(d) != r.Weekday {
|
||||
continue
|
||||
}
|
||||
if r.At != nil {
|
||||
at, ok, err := r.At(ctx, d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fire time of %s: %w", d, err)
|
||||
}
|
||||
if ok {
|
||||
out = append(out, fireTime{at: at, date: d})
|
||||
}
|
||||
continue
|
||||
}
|
||||
at, err := household.LocalTime(loc, d, r.Time)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("scheduler: validated rule %+v: %v", r, err))
|
||||
}
|
||||
out = append(out, fireTime{at: at, date: d})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addDays returns d plus n calendar days.
|
||||
func addDays(d store.LocalDate, n int) store.LocalDate {
|
||||
y, m, day := d.Date()
|
||||
return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
// weekday returns the day of the week of d.
|
||||
func weekday(d store.LocalDate) time.Weekday {
|
||||
y, m, day := d.Date()
|
||||
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Weekday()
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/migrations"
|
||||
)
|
||||
|
||||
const zone = "America/Chicago"
|
||||
|
||||
// chicago is the household zone of every test.
|
||||
var chicago = mustZone(zone)
|
||||
|
||||
func mustZone(name string) *time.Location {
|
||||
loc, err := household.LoadZone(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// at returns the instant clocks in Chicago read the given time.
|
||||
func at(year int, month time.Month, day, hour, minute int) time.Time {
|
||||
return time.Date(year, month, day, hour, minute, 0, 0, chicago)
|
||||
}
|
||||
|
||||
// fakeClock is a clock whose time moves only when the test moves it.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
timers []fakeTimer
|
||||
// waits receives one value per After call, so the test knows the
|
||||
// scheduler finished a pass and is waiting.
|
||||
waits chan struct{}
|
||||
}
|
||||
|
||||
type fakeTimer struct {
|
||||
deadline time.Time
|
||||
ch chan time.Time
|
||||
}
|
||||
|
||||
func newFakeClock(now time.Time) *fakeClock {
|
||||
return &fakeClock{now: now, waits: make(chan struct{}, 100)}
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
// After returns a timer that fires d from now. The scheduler waits on only
|
||||
// its latest timer, so this one replaces any still pending: a timer the
|
||||
// scheduler gave up on when a wake came first never fires.
|
||||
func (c *fakeClock) After(d time.Duration) <-chan time.Time {
|
||||
c.mu.Lock()
|
||||
ch := make(chan time.Time, 1)
|
||||
c.timers = []fakeTimer{{deadline: c.now.Add(d), ch: ch}}
|
||||
c.mu.Unlock()
|
||||
c.waits <- struct{}{}
|
||||
return ch
|
||||
}
|
||||
|
||||
// set moves the clock to t and fires the timers due by then, reporting
|
||||
// whether any fired.
|
||||
func (c *fakeClock) set(t time.Time) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = t
|
||||
fired := false
|
||||
kept := c.timers[:0]
|
||||
for _, tm := range c.timers {
|
||||
if tm.deadline.After(t) {
|
||||
kept = append(kept, tm)
|
||||
continue
|
||||
}
|
||||
tm.ch <- t
|
||||
fired = true
|
||||
}
|
||||
c.timers = kept
|
||||
return fired
|
||||
}
|
||||
|
||||
// nextDeadline returns the earliest pending timer deadline.
|
||||
func (c *fakeClock) nextDeadline() (time.Time, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var next time.Time
|
||||
for _, tm := range c.timers {
|
||||
if next.IsZero() || tm.deadline.Before(next) {
|
||||
next = tm.deadline
|
||||
}
|
||||
}
|
||||
return next, !next.IsZero()
|
||||
}
|
||||
|
||||
// recorder records the periods a schedule ran.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
runs []scheduler.Period
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recorder) run(_ context.Context, p scheduler.Period) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.runs = append(r.runs, p)
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r *recorder) periods() []scheduler.Period {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return slices.Clone(r.runs)
|
||||
}
|
||||
|
||||
func (r *recorder) keys() []string {
|
||||
var keys []string
|
||||
for _, p := range r.periods() {
|
||||
keys = append(keys, p.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// env is a migrated database with household settings in Chicago.
|
||||
type env struct {
|
||||
t *testing.T
|
||||
st *store.Store
|
||||
settings *household.SettingsService
|
||||
// sched is the scheduler start last started, which wake wakes.
|
||||
sched atomic.Pointer[scheduler.Scheduler]
|
||||
}
|
||||
|
||||
// wake wakes the scheduler start last started, as the services' wake does
|
||||
// in the server; before the first start it does nothing.
|
||||
func (e *env) wake() {
|
||||
if s := e.sched.Load(); s != nil {
|
||||
s.Wake()
|
||||
}
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &env{t: t, st: st, settings: household.NewSettingsService(st)}
|
||||
}
|
||||
|
||||
// update changes the household settings.
|
||||
func (e *env) update(patch household.SettingsPatch) {
|
||||
e.t.Helper()
|
||||
if _, err := e.settings.Update(context.Background(), patch, "member"); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// claimed returns the period keys schedule_runs holds for name.
|
||||
func (e *env) claimed(name string) []string {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT period_key FROM schedule_runs WHERE schedule = ? ORDER BY period_key`, name)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// running is a started scheduler on a fake clock.
|
||||
type running struct {
|
||||
t *testing.T
|
||||
clock *fakeClock
|
||||
stop func()
|
||||
}
|
||||
|
||||
// start starts a scheduler on e at now with the schedules, and stops it
|
||||
// when the test ends. It returns once the first pass, the catch-up, is done.
|
||||
func (e *env) start(now time.Time, schedules ...scheduler.Schedule) *running {
|
||||
e.t.Helper()
|
||||
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
clock := newFakeClock(now)
|
||||
scheduler.SetClock(s, clock.Now, clock.After)
|
||||
for _, sch := range schedules {
|
||||
if err := s.Register(sch); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := s.Start(ctx); err != nil {
|
||||
cancel()
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
e.sched.Store(s)
|
||||
var once sync.Once
|
||||
stop := func() {
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
s.Wait()
|
||||
})
|
||||
}
|
||||
e.t.Cleanup(stop)
|
||||
r := &running{t: e.t, clock: clock, stop: stop}
|
||||
r.idle()
|
||||
return r
|
||||
}
|
||||
|
||||
// idle waits until the scheduler finished a pass and is waiting.
|
||||
func (r *running) idle() {
|
||||
r.t.Helper()
|
||||
select {
|
||||
case <-r.clock.waits:
|
||||
case <-time.After(10 * time.Second):
|
||||
r.t.Fatal("scheduler did not finish its pass")
|
||||
}
|
||||
}
|
||||
|
||||
// runUntil moves the clock to to, stopping at every instant the scheduler
|
||||
// asked to wake at on the way, as a real clock would.
|
||||
func (r *running) runUntil(to time.Time) {
|
||||
r.t.Helper()
|
||||
for {
|
||||
next, ok := r.clock.nextDeadline()
|
||||
if !ok || next.After(to) {
|
||||
if r.clock.set(to) {
|
||||
r.idle()
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.clock.set(next) {
|
||||
r.idle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// changed waits for the scheduler to apply a settings change, or to look
|
||||
// again after a wake.
|
||||
func (r *running) changed() {
|
||||
r.t.Helper()
|
||||
r.idle()
|
||||
}
|
||||
|
||||
func reminder(rec *recorder) scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.DayOfReminder,
|
||||
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReminderTime) },
|
||||
Run: rec.run,
|
||||
}
|
||||
}
|
||||
|
||||
func suggestions(rec *recorder) scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.SuggestionsWeekly,
|
||||
Rule: func(s household.Settings) scheduler.Rule {
|
||||
return scheduler.Weekly(time.Weekday(s.SuggestionsDow), s.SuggestionsTime)
|
||||
},
|
||||
Run: rec.run,
|
||||
}
|
||||
}
|
||||
|
||||
func planDraft(rec *recorder) scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.PlanDraft,
|
||||
Rule: func(s household.Settings) scheduler.Rule {
|
||||
return scheduler.WeeklyForNextWeek(time.Weekday(s.PlanDraftDow), s.PlanDraftTime)
|
||||
},
|
||||
Run: rec.run,
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func wantKeys(t *testing.T, what string, got, want []string) {
|
||||
t.Helper()
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("%s = %q, want %q", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFiresOncePerPeriod(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
// Seeded reminder time is 16:30; boot just after it on Oct 6 runs it.
|
||||
r := e.start(at(2026, 10, 6, 16, 31), reminder(&rec))
|
||||
wantKeys(t, "after boot", rec.keys(), []string{"2026-10-06"})
|
||||
|
||||
r.runUntil(at(2026, 10, 7, 16, 29))
|
||||
wantKeys(t, "before the fire time", rec.keys(), []string{"2026-10-06"})
|
||||
r.runUntil(at(2026, 10, 9, 23, 59))
|
||||
wantKeys(t, "after three days", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09"})
|
||||
|
||||
for i, p := range rec.periods()[1:] {
|
||||
want := at(2026, 10, 7+i, 16, 30)
|
||||
if !p.FireAt.Equal(want) {
|
||||
t.Errorf("run %s fired at %s, want %s", p.Key, p.FireAt, want)
|
||||
}
|
||||
if p.FireAt.Location().String() != zone {
|
||||
t.Errorf("run %s fired in zone %s, want %s", p.Key, p.FireAt.Location(), zone)
|
||||
}
|
||||
}
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
|
||||
}
|
||||
|
||||
func TestRestartMidPeriodDoesNotRerun(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var first recorder
|
||||
r := e.start(at(2026, 10, 7, 12, 0), suggestions(&first)) // Wednesday
|
||||
// The seeded Sunday 08:00 of Oct 4 is days ago: missed, not run.
|
||||
wantKeys(t, "first boot", first.keys(), nil)
|
||||
r.runUntil(at(2026, 10, 11, 9, 0))
|
||||
wantKeys(t, "first process", first.keys(), []string{"2026-W41"})
|
||||
r.stop()
|
||||
|
||||
// The restart is an hour after the Sunday 08:00 fire, mid-period.
|
||||
var second recorder
|
||||
r2 := e.start(at(2026, 10, 11, 9, 0), suggestions(&second))
|
||||
wantKeys(t, "after restart", second.keys(), nil)
|
||||
r2.runUntil(at(2026, 10, 18, 7, 59))
|
||||
wantKeys(t, "rest of the period", second.keys(), nil)
|
||||
r2.runUntil(at(2026, 10, 18, 8, 0))
|
||||
wantKeys(t, "next period", second.keys(), []string{"2026-W42"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"})
|
||||
}
|
||||
|
||||
func TestCatchUpWithin24Hours(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
// Sunday Oct 11 08:00 passed 23h59m before boot.
|
||||
e.start(at(2026, 10, 12, 7, 59), suggestions(&rec))
|
||||
wantKeys(t, "catch-up", rec.keys(), []string{"2026-W41"})
|
||||
if p := rec.periods()[0]; !p.FireAt.Equal(at(2026, 10, 11, 8, 0)) {
|
||||
t.Errorf("caught-up period fired at %s, want its fire time", p.FireAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCatchUpBeyond24Hours(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
// Sunday Oct 11 08:00 passed 24h01m before boot.
|
||||
r := e.start(at(2026, 10, 12, 8, 1), suggestions(&rec))
|
||||
wantKeys(t, "boot", rec.keys(), nil)
|
||||
// The missed period is recorded, so it never runs later.
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W41"})
|
||||
|
||||
// Moving the fire time to Sunday 23:00 puts that week's fire time
|
||||
// within 24 h of now. The change itself never fires a passed time...
|
||||
e.update(household.SettingsPatch{SuggestionsTime: ptr("23:00")})
|
||||
r.changed()
|
||||
wantKeys(t, "after a settings change", rec.keys(), nil)
|
||||
r.stop()
|
||||
|
||||
// ...and the restart's catch-up finds the period already recorded.
|
||||
var again recorder
|
||||
r2 := e.start(at(2026, 10, 12, 8, 2), suggestions(&again))
|
||||
wantKeys(t, "after restart", again.keys(), nil)
|
||||
r2.runUntil(at(2026, 10, 18, 23, 0))
|
||||
wantKeys(t, "next period", again.keys(), []string{"2026-W42"})
|
||||
}
|
||||
|
||||
func TestCatchUpOnlyMostRecentPeriod(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
// Days of downtime: only Oct 9 16:30, 30 minutes ago, runs.
|
||||
e.start(at(2026, 10, 9, 17, 0), reminder(&rec))
|
||||
wantKeys(t, "catch-up", rec.keys(), []string{"2026-10-09"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-09"})
|
||||
}
|
||||
|
||||
func TestCatchUpEveryPeriodWithin24HoursAcrossSpringForward(t *testing.T) {
|
||||
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08,
|
||||
// so 01:30 on Mar 8 and 01:30 on Mar 9 are only 23 hours apart.
|
||||
e := newEnv(t)
|
||||
e.update(household.SettingsPatch{ReviewTime: ptr("01:30")})
|
||||
var rec recorder
|
||||
review := scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
|
||||
Run: rec.run,
|
||||
}
|
||||
// Boot at Mar 9 01:35: Mar 9 01:30 passed 5 minutes ago and Mar 8
|
||||
// 01:30 23h05m ago, so both run, oldest first; Mar 7 01:30, 47h05m
|
||||
// ago, is not looked at.
|
||||
r := e.start(at(2026, 3, 9, 1, 35), review)
|
||||
wantKeys(t, "catch-up", rec.keys(), []string{"2026-03-08", "2026-03-09"})
|
||||
wantFired(t, rec.periods(),
|
||||
time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC), // 01:30 CST
|
||||
time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC), // 01:30 CDT
|
||||
)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"2026-03-08", "2026-03-09"})
|
||||
r.stop()
|
||||
|
||||
// A restart within the window finds both periods claimed.
|
||||
var again recorder
|
||||
review.Run = again.run
|
||||
r2 := e.start(at(2026, 3, 9, 1, 40), review)
|
||||
r2.runUntil(at(2026, 3, 9, 23, 0))
|
||||
wantKeys(t, "after restart", again.keys(), nil)
|
||||
}
|
||||
|
||||
func TestSpringForward(t *testing.T) {
|
||||
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08.
|
||||
e := newEnv(t)
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("02:30")})
|
||||
var daily, skipped recorder
|
||||
review := scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
|
||||
Run: skipped.run,
|
||||
}
|
||||
r := e.start(at(2026, 3, 7, 9, 0), reminder(&daily), review)
|
||||
r.runUntil(at(2026, 3, 9, 12, 0))
|
||||
|
||||
wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"})
|
||||
wantFired(t, daily.periods(),
|
||||
time.Date(2026, 3, 7, 14, 0, 0, 0, time.UTC), // 08:00 CST
|
||||
time.Date(2026, 3, 8, 13, 0, 0, 0, time.UTC), // 08:00 CDT
|
||||
time.Date(2026, 3, 9, 13, 0, 0, 0, time.UTC),
|
||||
)
|
||||
|
||||
// 02:30 does not exist on Mar 8: it fires once, an hour later at
|
||||
// 03:30 CDT, keyed by Mar 8.
|
||||
wantKeys(t, "02:30 runs", skipped.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"})
|
||||
wantFired(t, skipped.periods(),
|
||||
time.Date(2026, 3, 7, 8, 30, 0, 0, time.UTC), // 02:30 CST
|
||||
time.Date(2026, 3, 8, 8, 30, 0, 0, time.UTC), // 03:30 CDT
|
||||
time.Date(2026, 3, 9, 7, 30, 0, 0, time.UTC), // 02:30 CDT
|
||||
)
|
||||
if got := skipped.periods()[1].FireAt.Format("15:04 MST"); got != "03:30 CDT" {
|
||||
t.Errorf("skipped 02:30 fired at %s local, want 03:30 CDT", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallBack(t *testing.T) {
|
||||
// Clocks in Chicago go back 02:00 CDT → 01:00 CST on Sunday 2026-11-01.
|
||||
e := newEnv(t)
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("01:30")})
|
||||
var daily, repeated recorder
|
||||
review := scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
|
||||
Run: repeated.run,
|
||||
}
|
||||
r := e.start(at(2026, 10, 31, 9, 0), reminder(&daily), review)
|
||||
r.runUntil(at(2026, 11, 2, 12, 0))
|
||||
|
||||
wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"})
|
||||
wantFired(t, daily.periods(),
|
||||
time.Date(2026, 10, 31, 13, 0, 0, 0, time.UTC), // 08:00 CDT
|
||||
time.Date(2026, 11, 1, 14, 0, 0, 0, time.UTC), // 08:00 CST
|
||||
time.Date(2026, 11, 2, 14, 0, 0, 0, time.UTC),
|
||||
)
|
||||
|
||||
// 01:30 happens twice on Nov 1: it fires once, at the first (CDT).
|
||||
wantKeys(t, "01:30 runs", repeated.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"})
|
||||
wantFired(t, repeated.periods(),
|
||||
time.Date(2026, 10, 31, 6, 30, 0, 0, time.UTC), // 01:30 CDT
|
||||
time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), // first 01:30, CDT
|
||||
time.Date(2026, 11, 2, 7, 30, 0, 0, time.UTC), // 01:30 CST
|
||||
)
|
||||
}
|
||||
|
||||
func TestFallBackSecondOccurrenceDoesNotRerun(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.update(household.SettingsPatch{ReviewTime: ptr("01:30")})
|
||||
var rec recorder
|
||||
review := scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
|
||||
Run: rec.run,
|
||||
}
|
||||
// Boot between the two 01:30s of Nov 1: the first ran in an earlier
|
||||
// process, so the second is not a new period.
|
||||
first := time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC)
|
||||
r := e.start(first.Add(-time.Minute), review)
|
||||
r.runUntil(first)
|
||||
wantKeys(t, "first 01:30", rec.keys(), []string{"2026-10-31", "2026-11-01"})
|
||||
r.stop()
|
||||
|
||||
var again recorder
|
||||
review.Run = again.run
|
||||
r2 := e.start(first.Add(30*time.Minute), review)
|
||||
r2.runUntil(first.Add(2 * time.Hour)) // past the second 01:30, CST
|
||||
wantKeys(t, "second 01:30", again.keys(), nil)
|
||||
}
|
||||
|
||||
func wantFired(t *testing.T, got []scheduler.Period, want ...time.Time) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%d runs, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if !got[i].FireAt.Equal(want[i]) {
|
||||
t.Errorf("run %s fired at %s (%s), want %s", got[i].Key, got[i].FireAt.UTC(), got[i].FireAt, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestISOWeek53Boundary(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var sugg, draft recorder
|
||||
// Suggestions Sunday 08:00 keys the ISO week ending that Sunday; the
|
||||
// plan draft Friday 09:00 keys the week after.
|
||||
r := e.start(at(2026, 12, 20, 9, 0), suggestions(&sugg), planDraft(&draft))
|
||||
r.runUntil(at(2027, 1, 11, 0, 0))
|
||||
|
||||
wantKeys(t, "suggestions", sugg.keys(), []string{"2026-W51", "2026-W52", "2026-W53", "2027-W01"})
|
||||
// Sunday 2027-01-03 is the last day of 2026-W53.
|
||||
if p := sugg.periods()[2]; p.Date.String() != "2027-01-03" || p.Week.String() != "2026-12-28" {
|
||||
t.Errorf("2026-W53 suggestions ran on %s for week %s, want 2027-01-03 for 2026-12-28", p.Date, p.Week)
|
||||
}
|
||||
// Friday Dec 18, two days before boot, was missed.
|
||||
wantKeys(t, "plan drafts", draft.keys(), []string{"2026-W53", "2027-W01", "2027-W02"})
|
||||
// Friday 2026-12-25 drafts 2026-W53; Friday 2027-01-01 drafts 2027-W01.
|
||||
if p := draft.periods()[0]; p.Date.String() != "2026-12-25" || p.Week.String() != "2026-12-28" {
|
||||
t.Errorf("2026-W53 draft ran on %s for week %s, want 2026-12-25 for 2026-12-28", p.Date, p.Week)
|
||||
}
|
||||
if p := draft.periods()[1]; p.Date.String() != "2027-01-01" || p.Week.String() != "2027-01-04" {
|
||||
t.Errorf("2027-W01 draft ran on %s for week %s, want 2027-01-01 for 2027-01-04", p.Date, p.Week)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeriodKey(t *testing.T) {
|
||||
date := func(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
tests := []struct {
|
||||
rule scheduler.Rule
|
||||
date string
|
||||
want string
|
||||
}{
|
||||
{scheduler.Daily("16:30"), "2026-12-31", "2026-12-31"},
|
||||
{scheduler.Weekly(time.Monday, "00:00"), "2026-12-28", "2026-W53"},
|
||||
{scheduler.Weekly(time.Sunday, "08:00"), "2027-01-03", "2026-W53"},
|
||||
{scheduler.Weekly(time.Monday, "00:00"), "2027-01-04", "2027-W01"},
|
||||
{scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2026-12-25", "2026-W53"},
|
||||
{scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2027-01-01", "2027-W01"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.rule.PeriodKey(date(tt.date)); got != tt.want {
|
||||
t.Errorf("%+v.PeriodKey(%s) = %s, want %s", tt.rule, tt.date, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsChangeMovesNextFire(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
|
||||
wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"})
|
||||
|
||||
// Earlier but still ahead: fires at the new time, not the old.
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("13:00")})
|
||||
r.changed()
|
||||
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 13, 0)) {
|
||||
t.Errorf("next wake %s, want the new 13:00", next)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 7, 13, 0))
|
||||
wantKeys(t, "at the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
r.runUntil(at(2026, 10, 7, 23, 0))
|
||||
wantKeys(t, "at the old time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
|
||||
// Moved to a time already passed today: nothing fires now, and
|
||||
// tomorrow fires at the new time.
|
||||
r.runUntil(at(2026, 10, 8, 12, 0))
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("11:00")})
|
||||
r.changed()
|
||||
wantKeys(t, "moved into the past", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
r.runUntil(at(2026, 10, 9, 10, 59))
|
||||
wantKeys(t, "before the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
r.runUntil(at(2026, 10, 9, 11, 0))
|
||||
wantKeys(t, "the new time", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-09"})
|
||||
}
|
||||
|
||||
// A change saved at 12:00 moving the 16:30 reminder to 12:01 must fire it
|
||||
// even when the scheduler only gets to the change at 12:02, after the new
|
||||
// fire time has passed.
|
||||
func TestSettingsChangeAppliedLateStillFires(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
// blocker holds the scheduler in a Run at 12:00 on 2026-10-07 while
|
||||
// the settings change and the clock moves on.
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
blocker := scheduler.Schedule{
|
||||
Name: scheduler.RotationCheck,
|
||||
Rule: func(household.Settings) scheduler.Rule { return scheduler.Daily("12:00") },
|
||||
Run: func(_ context.Context, p scheduler.Period) error {
|
||||
if p.Key == "2026-10-07" {
|
||||
close(started)
|
||||
<-release
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
r := e.start(at(2026, 10, 7, 11, 0), reminder(&rec), blocker)
|
||||
wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"})
|
||||
|
||||
r.clock.set(at(2026, 10, 7, 12, 0))
|
||||
<-started
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("12:01")})
|
||||
r.clock.set(at(2026, 10, 7, 12, 2))
|
||||
close(release)
|
||||
r.idle() // the pass the blocker held
|
||||
r.idle() // the pass that applies the change
|
||||
wantKeys(t, "after the late change", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
|
||||
r.runUntil(at(2026, 10, 7, 23, 0))
|
||||
wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"})
|
||||
}
|
||||
|
||||
// failClaims makes every claim fail until recover is called.
|
||||
func (e *env) failClaims() (recover func()) {
|
||||
e.t.Helper()
|
||||
for _, q := range []string{
|
||||
`CREATE TABLE fail_claims (on_ INTEGER NOT NULL)`,
|
||||
`INSERT INTO fail_claims VALUES (1)`,
|
||||
`CREATE TRIGGER fail_claim BEFORE INSERT ON schedule_runs
|
||||
WHEN (SELECT on_ FROM fail_claims) = 1
|
||||
BEGIN SELECT RAISE(ABORT, 'injected claim failure'); END`,
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(q); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
e.t.Helper()
|
||||
if _, err := e.st.Writer.Exec(`UPDATE fail_claims SET on_ = 0`); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A claim that fails is retried, not skipped, and the period then runs once.
|
||||
func TestClaimFailureIsRetried(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
recoverClaims := e.failClaims()
|
||||
var rec recorder
|
||||
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
|
||||
wantKeys(t, "boot with failing claims", rec.keys(), nil)
|
||||
wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil)
|
||||
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry)) {
|
||||
t.Errorf("next wake %s, want a retry after %s", next, scheduler.ClaimRetry)
|
||||
}
|
||||
|
||||
recoverClaims()
|
||||
r.runUntil(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry))
|
||||
wantKeys(t, "after the retry", rec.keys(), []string{"2026-10-06"})
|
||||
r.runUntil(at(2026, 10, 7, 23, 0))
|
||||
wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"})
|
||||
}
|
||||
|
||||
// A claim that keeps failing past the next daily fire is retried for its
|
||||
// own period, not the most recent one: that period, now more than
|
||||
// CatchUpWindow old, is recorded as missed, and the later period runs once.
|
||||
func TestClaimFailureRecoversAfterNextFire(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
recoverClaims := e.failClaims()
|
||||
var rec recorder
|
||||
// The 7 October 16:30 reminder is due and its claim fails, past the
|
||||
// 8 October 16:30 fire.
|
||||
r := e.start(at(2026, 10, 7, 16, 45), reminder(&rec))
|
||||
r.runUntil(at(2026, 10, 8, 17, 0))
|
||||
wantKeys(t, "runs while failing", rec.keys(), nil)
|
||||
wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil)
|
||||
|
||||
// Claims recover 24h31m after the 7 October fire.
|
||||
recoverClaims()
|
||||
r.runUntil(at(2026, 10, 8, 17, 1))
|
||||
wantKeys(t, "after recovery", rec.keys(), []string{"2026-10-08"})
|
||||
wantKeys(t, "claims after recovery", e.claimed(scheduler.DayOfReminder), []string{"2026-10-07", "2026-10-08"})
|
||||
|
||||
r.runUntil(at(2026, 10, 9, 16, 0))
|
||||
wantKeys(t, "next day", rec.keys(), []string{"2026-10-08"})
|
||||
}
|
||||
|
||||
func TestSettingsChangeTimezone(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
|
||||
e.update(household.SettingsPatch{Timezone: ptr("America/New_York")})
|
||||
r.changed()
|
||||
// 16:30 in New York is 15:30 in Chicago.
|
||||
r.runUntil(at(2026, 10, 7, 15, 30))
|
||||
wantKeys(t, "after the zone change", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
if got := rec.periods()[1].FireAt.Location().String(); got != "America/New_York" {
|
||||
t.Errorf("fired in zone %s, want America/New_York", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunErrorKeepsPeriodClaimed(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
rec := recorder{err: errors.New("boom")}
|
||||
r := e.start(at(2026, 10, 7, 16, 0), reminder(&rec))
|
||||
r.runUntil(at(2026, 10, 7, 23, 0))
|
||||
wantKeys(t, "runs", rec.keys(), []string{"2026-10-06", "2026-10-07"})
|
||||
r.stop()
|
||||
var again recorder
|
||||
e.start(at(2026, 10, 7, 23, 0), reminder(&again))
|
||||
wantKeys(t, "after restart", again.keys(), nil)
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec recorder
|
||||
bad := []scheduler.Schedule{
|
||||
{Name: "hourly", Rule: reminder(&rec).Rule, Run: rec.run},
|
||||
{Name: scheduler.DayOfReminder, Run: rec.run},
|
||||
{Name: scheduler.DayOfReminder, Rule: reminder(&rec).Rule},
|
||||
{Name: scheduler.DayOfReminder, Key: "start", PeriodKey: func(p scheduler.Period) string { return p.Key }, Rule: reminder(&rec).Rule, Run: rec.run},
|
||||
}
|
||||
for _, sch := range bad {
|
||||
if err := s.Register(sch); err == nil {
|
||||
t.Errorf("Register(%q) accepted an invalid schedule", sch.Name)
|
||||
}
|
||||
}
|
||||
if err := s.Register(reminder(&rec)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Register(reminder(&rec)); err == nil {
|
||||
t.Error("Register accepted a second schedule with the same name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRejectsInvalidRule(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec recorder
|
||||
if err := s.Register(scheduler.Schedule{
|
||||
Name: scheduler.WeekRollover,
|
||||
Rule: func(household.Settings) scheduler.Rule { return scheduler.Weekly(7, "00:00") },
|
||||
Run: rec.run,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Start(context.Background()); err == nil {
|
||||
s.Wait()
|
||||
t.Fatal("Start accepted weekday 7")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopEndsTheLoop(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
r.stop()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("scheduler did not stop")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"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/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"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/suggest"
|
||||
)
|
||||
|
||||
// The tests in this file run suggest's suggestions_weekly schedule on the
|
||||
// fake clock against the real suggestion service, job runner, harness and
|
||||
// notification outbox, with the AI and recipe sources faked.
|
||||
|
||||
// suggestEnv is a suggestion service over a scheduler env's store, the
|
||||
// "process" a scheduler runs it in. side answers suggest_side_quests and
|
||||
// model normalize_recipe while grounding.
|
||||
type suggestEnv struct {
|
||||
e *env
|
||||
svc *suggest.Service
|
||||
side *llm.Fake
|
||||
model *llm.Fake
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
logs *syncBuffer
|
||||
stop func()
|
||||
}
|
||||
|
||||
// syncBuffer collects log output written by concurrent goroutines.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// regulars stores the Regulars Beef Chili and Chicken Fajitas, so a batch
|
||||
// has Regulars to suggest from.
|
||||
func (e *env) regulars() {
|
||||
e.t.Helper()
|
||||
for _, name := range []string{"Beef Chili", "Chicken Fajitas"} {
|
||||
now := store.Now().String()
|
||||
if _, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: "regular",
|
||||
Origin: "regular",
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// suggestions builds a suggestion service over e with a started job
|
||||
// runner, which stop stops (and the test's end, if not before).
|
||||
func (e *env) suggestions() *suggestEnv {
|
||||
e.t.Helper()
|
||||
s := &suggestEnv{
|
||||
e: e,
|
||||
side: &llm.Fake{},
|
||||
model: &llm.Fake{},
|
||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
||||
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
||||
logs: &syncBuffer{},
|
||||
}
|
||||
g := recipestest.NewGrounder(e.t, e.st, e.t.TempDir(), s.search, s.pages, s.images, s.model)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return s.side, nil },
|
||||
Store: e.st,
|
||||
Guard: guard,
|
||||
}
|
||||
suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SuggestSideQuests(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(s.logs, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
s.svc, err = suggest.NewService(e.st, g, suggester, runner, notifytest.New(e.t, e.st), logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
var once sync.Once
|
||||
s.stop = func() {
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
}
|
||||
e.t.Cleanup(s.stop)
|
||||
return s
|
||||
}
|
||||
|
||||
// week scripts the next batch: suggest_side_quests answers with the
|
||||
// Side quests named names, each grounded by its own recipe.
|
||||
func (s *suggestEnv) week(names ...string) {
|
||||
s.e.t.Helper()
|
||||
var quests []string
|
||||
for _, name := range names {
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
s.search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
s.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
|
||||
s.images.JPEGs[photo] = recipestest.JPEG(s.e.t, 400, 320, uint8(len(s.images.JPEGs)+1))
|
||||
s.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 35))
|
||||
quests = append(quests, fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":"Like your Beef Chili.","cited_regulars":["Beef Chili"],"search_query":%q}`, name, name))
|
||||
}
|
||||
js := `{"suggestions":[` + strings.Join(quests, ",") + `]}`
|
||||
s.side.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
// batch is a suggestion_batches row.
|
||||
type batch struct {
|
||||
ID, Trigger, Status string
|
||||
}
|
||||
|
||||
// batches returns every suggestion batch, oldest first.
|
||||
func (e *env) batches() []batch {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT id, trigger, status FROM suggestion_batches ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []batch
|
||||
for rows.Next() {
|
||||
var b batch
|
||||
if err := rows.Scan(&b.ID, &b.Trigger, &b.Status); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// settled waits until no batch is pending or running and returns the
|
||||
// batches.
|
||||
func (e *env) settled() []batch {
|
||||
e.t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
bs := e.batches()
|
||||
busy := false
|
||||
for _, b := range bs {
|
||||
busy = busy || b.Status == suggest.BatchPending || b.Status == suggest.BatchRunning
|
||||
}
|
||||
if !busy {
|
||||
return bs
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
e.t.Fatalf("batches still under way: %+v", bs)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// wantBatches fails unless the batches are scheduled with statuses want.
|
||||
func wantBatches(t *testing.T, what string, got []batch, want ...string) {
|
||||
t.Helper()
|
||||
ok := len(got) == len(want)
|
||||
for i := 0; ok && i < len(got); i++ {
|
||||
ok = got[i].Trigger == suggest.TriggerScheduled && got[i].Status == want[i]
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("%s: batches = %+v, want scheduled %q", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// notified returns the new_suggestions keys stored, oldest first, and
|
||||
// reports an error for any whose title is not the one titles gives it.
|
||||
func (e *env) notified(titles ...string) []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
rows := notifytest.Rows(e.t, e.st, notify.NewSuggestions)
|
||||
for i, r := range rows {
|
||||
keys = append(keys, r.Key)
|
||||
if i < len(titles) && r.Payload.Title != titles[i] {
|
||||
e.t.Errorf("new_suggestions %s title = %q, want %q", r.Key, r.Payload.Title, titles[i])
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyOneBatchAndNotificationPerWeek: at the seeded
|
||||
// Sunday 08:00 the schedule starts one scheduled batch a week, period
|
||||
// suggestions_weekly:YYYY-Www, and each tells the household once, with
|
||||
// new_suggestions:YYYY-Www counting its new Side quests.
|
||||
func TestSuggestionsWeeklyOneBatchAndNotificationPerWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde", "Chicken Shawarma Bowls")
|
||||
s.week("Lamb Kofta", "Black Bean Tostadas", "Pork Carnitas")
|
||||
|
||||
// Wednesday Oct 7: the Sunday Oct 4 08:00 fire is days ago, missed.
|
||||
r := e.start(at(2026, 10, 7, 12, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 7, 59))
|
||||
wantBatches(t, "before Sunday 08:00", e.settled())
|
||||
wantKeys(t, "new_suggestions before Sunday 08:00", e.notified(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
wantBatches(t, "at Sunday 08:00", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions of W41", e.notified("2 new Side quests are in"), []string{"new_suggestions:2026-W41"})
|
||||
|
||||
r.runUntil(at(2026, 10, 18, 7, 59))
|
||||
wantBatches(t, "rest of W41", e.settled(), suggest.BatchDone)
|
||||
r.runUntil(at(2026, 10, 18, 9, 0))
|
||||
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions of W41 and W42", e.notified("2 new Side quests are in", "3 new Side quests are in"),
|
||||
[]string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyFollowsSettings: the schedule fires at the
|
||||
// household's suggestions_dow and suggestions_time.
|
||||
func TestSuggestionsWeeklyFollowsSettings(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
e.update(household.SettingsPatch{SuggestionsDow: ptr(3), SuggestionsTime: ptr("18:30")})
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde")
|
||||
|
||||
r := e.start(at(2026, 10, 12, 12, 0), s.svc.WeeklySchedule()) // Monday
|
||||
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
|
||||
wantBatches(t, "before Wednesday 18:30", e.settled())
|
||||
r.runUntil(at(2026, 10, 14, 18, 30))
|
||||
wantBatches(t, "at Wednesday 18:30", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions", e.notified("1 new Side quest is in"), []string{"new_suggestions:2026-W42"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyAIFailureSendsNothing: when suggest_side_quests
|
||||
// fails the batch fails with the failure logged and recorded in ai_runs,
|
||||
// and the household is not notified.
|
||||
func TestSuggestionsWeeklyAIFailureSendsNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
|
||||
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
wantBatches(t, "after the AI failed", e.settled(), suggest.BatchFailed)
|
||||
wantKeys(t, "new_suggestions after the AI failed", e.notified(), nil)
|
||||
// W40 is the boot's missed period, recorded without running.
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
|
||||
|
||||
var status, msg string
|
||||
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'suggest_side_quests'`).Scan(&status, &msg); err != nil {
|
||||
t.Fatalf("ai_runs row of suggest_side_quests: %v", err)
|
||||
}
|
||||
if status != string(harness.StatusProviderError) || msg == "" {
|
||||
t.Errorf("ai_runs suggest_side_quests = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
|
||||
}
|
||||
if logs := s.logs.String(); !strings.Contains(logs, "suggestion batch failed") || !strings.Contains(logs, "reason="+suggest.ReasonAIFailed) {
|
||||
t.Errorf("logs = %s, want the batch failure with reason %s", logs, suggest.ReasonAIFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyNoDuplicateAfterRestart: a restart after the week's
|
||||
// batch ran, still within the catch-up window, starts no second batch and
|
||||
// sends no second notification: the period is already claimed.
|
||||
func TestSuggestionsWeeklyNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde", "Chicken Shawarma Bowls")
|
||||
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
wantBatches(t, "first process", e.settled(), suggest.BatchDone)
|
||||
r.stop()
|
||||
s.stop()
|
||||
|
||||
// The restarted process has a new job runner, suggestion service and
|
||||
// scheduler; it would answer a second batch if one were started.
|
||||
s2 := e.suggestions()
|
||||
s2.week("Lamb Kofta")
|
||||
r2 := e.start(at(2026, 10, 11, 9, 0), s2.svc.WeeklySchedule())
|
||||
r2.runUntil(at(2026, 10, 18, 7, 59))
|
||||
wantBatches(t, "after restart", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions after restart", e.notified(), []string{"new_suggestions:2026-W41"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyCatchUpAfterTheWeekEnds: a Sunday fire caught up on
|
||||
// Monday, in the next ISO week, still runs W41's batch keyed by W41, and
|
||||
// the next Sunday's batch notifies for W42.
|
||||
func TestSuggestionsWeeklyCatchUpAfterTheWeekEnds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde")
|
||||
s.week("Lamb Kofta")
|
||||
|
||||
r := e.start(at(2026, 10, 12, 7, 0), s.svc.WeeklySchedule()) // Monday, 23h late
|
||||
wantBatches(t, "after catch-up", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions after catch-up", e.notified(), []string{"new_suggestions:2026-W41"})
|
||||
r.runUntil(at(2026, 10, 18, 8, 0))
|
||||
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions", e.notified(), []string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay: a period that
|
||||
// fires while a batch is pending starts no second one and says so.
|
||||
func TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.stop() // the manual batch stays pending
|
||||
manual, err := s.svc.StartBatch(context.Background(), suggest.TriggerManual)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
if bs := e.batches(); len(bs) != 1 || bs[0].ID != manual.ID {
|
||||
t.Fatalf("batches = %+v, want only the manual batch %s", bs, manual.ID)
|
||||
}
|
||||
if logs := s.logs.String(); !strings.Contains(logs, "scheduled suggestion batch not started") || !strings.Contains(logs, "period=2026-W41") {
|
||||
t.Errorf("logs = %s, want the skipped period logged", logs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// The tests in this file run shopping's shopping_chat_autosend and
|
||||
// week_rollover schedules, and harness's ai_runs_purge, on the fake clock
|
||||
// against the real shopping service, Google Chat client (posting to a fake
|
||||
// webhook), notification preferences and ai_runs purge. The seeded
|
||||
// autosend is Saturday 08:00 in Chicago.
|
||||
|
||||
// w41 is the Monday of 2026-W41.
|
||||
var w41 = mustLocalDate("2026-10-05")
|
||||
|
||||
// shopEnv is a shopping service over a scheduler env's store. Its job
|
||||
// runner never starts, so Generate's title jobs stay queued and every
|
||||
// Supply run stays untitled until Freeze titles it, which fails.
|
||||
type shopEnv struct {
|
||||
e *env
|
||||
svc *shopping.Service
|
||||
chat *chattest.Chat
|
||||
cs *chat.Service
|
||||
plans *planEnv
|
||||
logs *syncBuffer
|
||||
}
|
||||
|
||||
// shopping builds a shopping service over e, with Regulars to plan from.
|
||||
func (e *env) shopping() *shopEnv {
|
||||
e.t.Helper()
|
||||
s := &shopEnv{e: e, chat: chattest.New(e.t), plans: e.plans(e.pool()), logs: &syncBuffer{}}
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
s.cs, err = chat.NewService(e.st, box, publicURL, s.chat.Transport())
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(s.logs, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
noTitle := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
return skills.ArchiveTitle{}, errors.New("no AI in this test")
|
||||
}
|
||||
s.svc, err = shopping.NewService(e.st, noTitle, runner, notifytest.New(e.t, e.st), s.cs, logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// list plans the week starting weekStart and generates its Supply run.
|
||||
func (s *shopEnv) list(weekStart store.LocalDate) {
|
||||
s.e.t.Helper()
|
||||
s.plans.planWeek(weekStart)
|
||||
if _, err := s.svc.Generate(context.Background(), weekStart); err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// webhook stores the fake Google Chat webhook.
|
||||
func (s *shopEnv) webhook() {
|
||||
s.e.t.Helper()
|
||||
if _, err := s.cs.Put(context.Background(), chattest.Webhook, "Family"); err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// get returns the Supply run of the week starting weekStart.
|
||||
func (s *shopEnv) get(weekStart store.LocalDate) shopping.List {
|
||||
s.e.t.Helper()
|
||||
l, err := s.svc.Get(context.Background(), weekStart)
|
||||
if err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// wantLogged fails unless the service logged want and logged no error.
|
||||
func (s *shopEnv) wantLogged(want string) {
|
||||
s.e.t.Helper()
|
||||
if logs := s.logs.String(); !strings.Contains(logs, want) || strings.Contains(logs, "level=ERROR") {
|
||||
s.e.t.Errorf("logs = %s, want %q and no error", logs, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendSendsComingWeek: at the seeded Saturday 08:00 the
|
||||
// schedule posts the coming week's Supply run, W43's, to Google Chat
|
||||
// through SendToChat, in that week's thread, and records sent_to_chat_at;
|
||||
// its period is claimed in schedule_runs as list_ready:chat:2026-10-19,
|
||||
// the coming week's start. It posts once.
|
||||
func TestChatAutosendSendsComingWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
|
||||
// Saturday 07:00: the previous Saturday's period is past the
|
||||
// catch-up window, so it is recorded as missed.
|
||||
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
|
||||
r.runUntil(at(2026, 10, 17, 7, 59))
|
||||
if n := len(s.chat.Posts()); n != 0 || s.get(w43).SentToChatAt != nil {
|
||||
t.Fatalf("before 08:00: %d posts, sent_to_chat_at %v, want none", n, s.get(w43).SentToChatAt)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 17, 8, 0))
|
||||
posts := s.chat.Posts()
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("at 08:00: %d posts, want 1", len(posts))
|
||||
}
|
||||
if !strings.HasPrefix(posts[0].Text(), "Supply run · Week 43 · Oct 19 – 25") {
|
||||
t.Errorf("posted %q, want the W43 Supply run", posts[0].Text())
|
||||
}
|
||||
if got := posts[0].Query.Get("threadKey"); got != chat.ThreadKey(w43) {
|
||||
t.Errorf("posted in thread %q, want %q", got, chat.ThreadKey(w43))
|
||||
}
|
||||
if s.get(w43).SentToChatAt == nil {
|
||||
t.Error("sent_to_chat_at not recorded")
|
||||
}
|
||||
s.wantLogged("Supply run sent to Google Chat")
|
||||
s.wantLogged("period=list_ready:chat:2026-10-19")
|
||||
|
||||
r.runUntil(at(2026, 10, 24, 7, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("a week on: %d posts, want 1", n)
|
||||
}
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
}
|
||||
|
||||
// TestChatAutosendFollowsSettings: chat_autosend_dow and
|
||||
// chat_autosend_time move the send without a restart, here to Friday
|
||||
// 18:00, still for the coming week.
|
||||
func TestChatAutosendFollowsSettings(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
|
||||
r := e.start(at(2026, 10, 16, 12, 0), s.svc.ChatAutosendSchedule())
|
||||
e.update(household.SettingsPatch{ChatAutosendDow: ptr(5), ChatAutosendTime: ptr("18:00")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 16, 17, 59))
|
||||
if n := len(s.chat.Posts()); n != 0 {
|
||||
t.Fatalf("before Friday 18:00: %d posts, want 0", n)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 16, 18, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 || s.get(w43).SentToChatAt == nil {
|
||||
t.Fatalf("at Friday 18:00: %d posts, sent_to_chat_at %v, want 1 and set", n, s.get(w43).SentToChatAt)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("Saturday: %d posts, want still 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendSkips: the schedule posts nothing, records no send and
|
||||
// fails nothing when the coming week's Supply run was already sent, when
|
||||
// the week has no Supply run, when list_ready is switched off for Google
|
||||
// Chat, and when no webhook is stored. Each period is still claimed and
|
||||
// the reason logged.
|
||||
func TestChatAutosendSkips(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, s *shopEnv)
|
||||
// posts is how many posts the setup itself made.
|
||||
posts int
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "already sent",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
if _, err := s.svc.SendToChat(context.Background(), w43, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
posts: 1,
|
||||
reason: shopping.AutosendAlreadySent,
|
||||
},
|
||||
{
|
||||
name: "no list",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w42)
|
||||
s.webhook()
|
||||
},
|
||||
reason: shopping.AutosendNoList,
|
||||
},
|
||||
{
|
||||
name: "chat off for list_ready",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
if _, err := s.e.st.Writer.Exec(`UPDATE notification_prefs SET chat = 0 WHERE event = 'list_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
reason: shopping.AutosendChatOff,
|
||||
},
|
||||
{
|
||||
name: "no webhook",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
},
|
||||
reason: shopping.AutosendNotConfigured,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
c.setup(t, s)
|
||||
var sentBefore *string
|
||||
if l, err := s.svc.Get(context.Background(), w43); err == nil {
|
||||
sentBefore = l.SentToChatAt
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
if n := len(s.chat.Posts()); n != c.posts {
|
||||
t.Errorf("posts = %d, want %d", n, c.posts)
|
||||
}
|
||||
if l, err := s.svc.Get(context.Background(), w43); err == nil && !equalPtr(l.SentToChatAt, sentBefore) {
|
||||
t.Errorf("sent_to_chat_at = %v, want %v", l.SentToChatAt, sentBefore)
|
||||
}
|
||||
s.wantLogged("Supply run not sent to Google Chat: " + c.reason)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendFailureSurfaces: a send Google Chat refuses is the
|
||||
// period's error, logged as such; the period stays claimed and is not
|
||||
// retried, and sent_to_chat_at is not recorded.
|
||||
func TestChatAutosendFailureSurfaces(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
s.chat.SetStatus(500)
|
||||
|
||||
var logs syncBuffer
|
||||
sched, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := newFakeClock(at(2026, 10, 17, 7, 0))
|
||||
scheduler.SetClock(sched, clock.Now, clock.After)
|
||||
if err := sched.Register(s.svc.ChatAutosendSchedule()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
sched.Wait()
|
||||
})
|
||||
if err := sched.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &running{t: t, clock: clock, stop: cancel}
|
||||
r.idle()
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("posts = %d, want the one refused", n)
|
||||
}
|
||||
if s.get(w43).SentToChatAt != nil {
|
||||
t.Error("sent_to_chat_at recorded for a refused send")
|
||||
}
|
||||
if got := logs.String(); !strings.Contains(got, "level=ERROR") || !strings.Contains(got, "scheduler: run failed") || !strings.Contains(got, "schedule=shopping_chat_autosend") {
|
||||
t.Errorf("scheduler logs = %s, want the refused send logged as a failed run", got)
|
||||
}
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
}
|
||||
|
||||
func equalPtr(a, b *string) bool {
|
||||
return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
|
||||
}
|
||||
|
||||
// TestWeekRolloverFreezesPreviousWeek: at Monday 00:05 the schedule
|
||||
// freezes exactly the week that has just ended into the Supply archive,
|
||||
// not the week before it nor the new week; the next Monday freezes the
|
||||
// next week; a Monday whose previous week has no Supply run freezes
|
||||
// nothing and fails nothing. Its periods are week_rollover:YYYY-Www of the
|
||||
// Monday.
|
||||
func TestWeekRolloverFreezesPreviousWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w41)
|
||||
s.list(w42)
|
||||
s.list(w43)
|
||||
|
||||
status := func() []string {
|
||||
return []string{s.get(w41).Status, s.get(w42).Status, s.get(w43).Status}
|
||||
}
|
||||
cur, arc := shopping.StatusCurrent, shopping.StatusArchived
|
||||
|
||||
// Sunday 23:00: last Monday's rollover is past the catch-up window and
|
||||
// recorded as missed, so W41 is never frozen by it.
|
||||
r := e.start(at(2026, 10, 18, 23, 0), s.svc.RolloverSchedule())
|
||||
r.runUntil(at(2026, 10, 19, 0, 4))
|
||||
wantKeys(t, "before Monday 00:05", status(), []string{cur, cur, cur})
|
||||
|
||||
r.runUntil(at(2026, 10, 19, 0, 5))
|
||||
wantKeys(t, "at Monday 00:05", status(), []string{cur, arc, cur})
|
||||
if s.get(w42).Title != nil {
|
||||
t.Errorf("W42 title = %q, want frozen untitled", *s.get(w42).Title)
|
||||
}
|
||||
if _, err := s.svc.Generate(context.Background(), w42); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("generate W42 after the rollover: %v, want ErrFrozen", err)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 26, 0, 5))
|
||||
wantKeys(t, "next Monday", status(), []string{cur, arc, arc})
|
||||
|
||||
r.runUntil(at(2026, 11, 2, 0, 5))
|
||||
s.wantLogged("week rollover froze nothing: the week has no Supply run")
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.WeekRollover), []string{"2026-W42", "2026-W43", "2026-W44", "2026-W45"})
|
||||
}
|
||||
|
||||
// aiRun stores an ai_runs row created at created and returns its id.
|
||||
func (e *env) aiRun(created time.Time) string {
|
||||
e.t.Helper()
|
||||
id := store.NewID()
|
||||
ts := store.Timestamp{Time: created.UTC()}.String()
|
||||
if err := db.New(e.st.Writer).CreateAIRun(context.Background(), db.CreateAIRunParams{
|
||||
ID: id, Skill: "draft_week", SkillVersion: "1", Provider: "anthropic", Model: "claude-test",
|
||||
Temperature: 0.7, Status: "ok", RequestRedacted: "{}", ResponseRedacted: sql.NullString{String: "{}", Valid: true},
|
||||
CreatedAt: ts, UpdatedAt: ts,
|
||||
}); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// aiRuns returns the ids of the ai_runs rows, oldest first.
|
||||
func (e *env) aiRuns() []string {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT id FROM ai_runs ORDER BY created_at`)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// TestAIRunsPurgeDaily: every day at 03:00 the schedule deletes the
|
||||
// ai_runs rows created more than 30 days before, and no younger row; its
|
||||
// periods are ai_runs_purge:YYYY-MM-DD.
|
||||
func TestAIRunsPurgeDaily(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.aiRun(at(2026, 9, 10, 12, 0)) // older than Sep 11 03:00
|
||||
edge := e.aiRun(at(2026, 9, 12, 2, 59))
|
||||
young := e.aiRun(at(2026, 9, 12, 3, 1))
|
||||
recent := e.aiRun(at(2026, 10, 11, 12, 0))
|
||||
|
||||
sch, err := harness.PurgeSchedule(e.st, slog.New(slog.NewTextHandler(&syncBuffer{}, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Monday 02:00: the catch-up of Sunday 03:00 purges before Sep 11 03:00.
|
||||
r := e.start(at(2026, 10, 12, 2, 0), sch)
|
||||
wantKeys(t, "after the catch-up", e.aiRuns(), []string{edge, young, recent})
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 2, 59))
|
||||
wantKeys(t, "before 03:00", e.aiRuns(), []string{edge, young, recent})
|
||||
r.runUntil(at(2026, 10, 12, 3, 0))
|
||||
wantKeys(t, "at 03:00", e.aiRuns(), []string{young, recent})
|
||||
r.runUntil(at(2026, 10, 13, 3, 0))
|
||||
wantKeys(t, "next day", e.aiRuns(), []string{recent})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.AIRunsPurge), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
|
||||
}
|
||||
|
||||
// TestPurgeScheduleRejectsNil: the purge schedule needs a store and a
|
||||
// logger.
|
||||
func TestPurgeScheduleRejectsNil(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := harness.PurgeSchedule(nil, slog.Default()); err == nil {
|
||||
t.Error("nil store accepted")
|
||||
}
|
||||
if _, err := harness.PurgeSchedule(e.st, nil); err == nil {
|
||||
t.Error("nil logger accepted")
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func (b *lockedBuffer) String() string {
|
||||
func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -233,7 +233,7 @@ func TestChatWebhookNeedsCSRF(t *testing.T) {
|
||||
func TestNewRouterRequiresChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Chat service")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and
|
||||
// returns the body both raw (to tell null from absent) and decoded.
|
||||
func getDetail(t *testing.T, h http.Handler, id string) (map[string]any, dinners.Detail) {
|
||||
t.Helper()
|
||||
rec, raw := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var d dinners.Detail
|
||||
dec := json.NewDecoder(rec.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&d); err != nil {
|
||||
t.Fatalf("decode detail: %v", err)
|
||||
}
|
||||
return raw, d
|
||||
}
|
||||
|
||||
// wantNull fails unless each key is present in raw and null.
|
||||
func wantNull(t *testing.T, raw map[string]any, keys ...string) {
|
||||
t.Helper()
|
||||
for _, k := range keys {
|
||||
if v, ok := raw[k]; !ok || v != nil {
|
||||
t.Errorf("%s = %v (present %v), want null", k, v, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerGroundedSideQuest: a Side quest grounded in a fetched page
|
||||
// shows its photo at both stored sizes, its recipe's ingredients and steps
|
||||
// in recipe order (not row order), its source site and the reason it was
|
||||
// suggested.
|
||||
func TestGetDinnerGroundedSideQuest(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
||||
e.start(t)
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "succeeded" {
|
||||
t.Fatalf("grounding job = %v", job)
|
||||
}
|
||||
id := created["id"].(string)
|
||||
|
||||
// Make it an accepted Side quest with the suggestion that created it,
|
||||
// and reverse its recipe's positions so row order disagrees with
|
||||
// recipe order.
|
||||
var recipeID, photoID string
|
||||
if err := e.st.Reader.QueryRow(`SELECT recipe_id, photo_id FROM dinners WHERE id = ?`, id).Scan(&recipeID, &photoID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`,
|
||||
`UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
`UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(stmt, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const reason = "Like your Tikka masala, but quicker."
|
||||
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
|
||||
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
|
||||
Reason: reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, got := getDetail(t, e.h, id)
|
||||
str := func(s string) *string { return &s }
|
||||
minutes := int64(40)
|
||||
want := dinners.Detail{
|
||||
ID: id,
|
||||
Name: "Chicken curry",
|
||||
Status: "quest_log",
|
||||
Origin: "side_quest",
|
||||
Method: str("stovetop"),
|
||||
MinutesTotal: &minutes,
|
||||
Difficulty: str("easy"),
|
||||
IsFavorite: true,
|
||||
PhotoURLs: map[string]string{
|
||||
"400": "/media/photos/" + photoID + "-400.jpg",
|
||||
"1200": "/media/photos/" + photoID + "-1200.jpg",
|
||||
},
|
||||
Ingredients: []dinners.Ingredient{
|
||||
{Name: str("garlic"), Qty: str("2"), Unit: str("cloves"), Note: str("minced"), RawText: "2 cloves garlic, minced"},
|
||||
{Name: str("onion"), Qty: str("1"), RawText: "1 onion"},
|
||||
},
|
||||
Steps: []string{"Fry with the garlic.", "Chop the onion."},
|
||||
Source: &dinners.Source{SiteName: str("Curry House"), URL: str(curryPage)},
|
||||
GroundingStatus: "grounded",
|
||||
WhyPicked: str(reason),
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gj, _ := json.Marshal(got)
|
||||
wj, _ := json.Marshal(want)
|
||||
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
|
||||
}
|
||||
// Each photo URL is served.
|
||||
for _, u := range got.PhotoURLs {
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, signedIn(http.MethodGet, u, nil))
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/jpeg" {
|
||||
t.Errorf("GET %s = %d %s", u, rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerManualRegular: a Regular added by hand and given a recipe
|
||||
// typed in has no photo, no source and no reason, and its ingredients are
|
||||
// the typed lines with nothing normalized.
|
||||
func TestGetDinnerManualRegular(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Family chili"}`)
|
||||
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
||||
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
raw, got := getDetail(t, e.h, id)
|
||||
wantNull(t, raw, "photo_urls", "source", "why_picked", "difficulty", "grounding_error")
|
||||
str := func(s string) *string { return &s }
|
||||
minutes := int64(240)
|
||||
want := dinners.Detail{
|
||||
ID: id,
|
||||
Name: "Family chili",
|
||||
Status: "regular",
|
||||
Origin: "regular",
|
||||
Method: str("crockpot"),
|
||||
MinutesTotal: &minutes,
|
||||
Ingredients: []dinners.Ingredient{{RawText: "1 lb beef"}, {RawText: "1 can beans"}},
|
||||
Steps: []string{"Brown.", "Simmer."},
|
||||
GroundingStatus: "manual",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gj, _ := json.Marshal(got)
|
||||
wj, _ := json.Marshal(want)
|
||||
t.Errorf("detail =\n %s\nwant\n %s", gj, wj)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerFailedGrounding: a dinner whose grounding failed shows its
|
||||
// real state — failed with its reason, no photo, no source and empty
|
||||
// ingredients and steps — never a pretend recipe.
|
||||
func TestGetDinnerFailedGrounding(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
e.start(t)
|
||||
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "failed" {
|
||||
t.Fatalf("grounding job = %v, want failed", job)
|
||||
}
|
||||
|
||||
raw, got := getDetail(t, e.h, created["id"].(string))
|
||||
wantNull(t, raw, "photo_urls", "source", "why_picked", "method", "minutes_total", "difficulty")
|
||||
if ings, ok := raw["ingredients"].([]any); !ok || len(ings) != 0 {
|
||||
t.Errorf("ingredients = %v, want []", raw["ingredients"])
|
||||
}
|
||||
if steps, ok := raw["steps"].([]any); !ok || len(steps) != 0 {
|
||||
t.Errorf("steps = %v, want []", raw["steps"])
|
||||
}
|
||||
if got.GroundingStatus != "failed" || got.GroundingError == nil || *got.GroundingError != dinners.ReasonNoRecipe ||
|
||||
got.Name != "Nothing matches this" || got.Status != "regular" || got.Origin != "regular" {
|
||||
t.Errorf("detail = %+v, want the failed Regular with no_recipe_found", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerNotFound: an unknown dinner and an archived one are both
|
||||
// 404, and the detail needs a session like every /api/v1 route.
|
||||
func TestGetDinnerNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Old favourite"}`)
|
||||
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, target := range []string{id, store.NewID()} {
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+target, "")
|
||||
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Errorf("GET %s = %d %v, want 404 not_found", target, rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
live := createDinner(t, e.h, `{"name":"Taco night"}`)
|
||||
req := httptest.NewRequest(http.MethodGet, dinnersPath+"/"+live, nil)
|
||||
if rec, body := serve(t, e.h, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" {
|
||||
t.Errorf("GET without a session = %d %v, want 401", rec.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerSourceURLOnlyWhenSafe: a recipe grounded in a clean https
|
||||
// link shows it as source.url; one whose stored link carries a token in its
|
||||
// query or userinfo, is http, or has a fragment shows its site name but
|
||||
// source.url null and nothing of the link.
|
||||
func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
const (
|
||||
clean = "https://kitchen.example/recipes/chicken-curry"
|
||||
queryToken = "query-token-def456"
|
||||
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
|
||||
)
|
||||
e.pages.Bodies[clean] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
|
||||
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
|
||||
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
||||
ground := func(dish, page string) (id string) {
|
||||
t.Helper()
|
||||
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
|
||||
}
|
||||
var stored string
|
||||
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != page {
|
||||
t.Fatalf("stored source_url = %q, want %q", stored, page)
|
||||
}
|
||||
return id
|
||||
}
|
||||
source := func(what, id string) map[string]any {
|
||||
t.Helper()
|
||||
raw, _ := getDetail(t, e.h, id)
|
||||
src, ok := raw["source"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("source of %s = %v, want an object", what, raw["source"])
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
cleanID := ground("Chicken curry", clean)
|
||||
if src := source("a clean link", cleanID); !reflect.DeepEqual(src, map[string]any{"site_name": "Curry House", "url": clean}) {
|
||||
t.Errorf("source of a clean link = %v, want site_name Curry House and url %s", src, clean)
|
||||
}
|
||||
|
||||
queryID := ground("Weeknight curry", queryPage)
|
||||
withheld := func(what, id, site string, secrets ...string) {
|
||||
t.Helper()
|
||||
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
|
||||
}
|
||||
for _, secret := range secrets {
|
||||
if strings.Contains(rec.Body.String(), secret) {
|
||||
t.Errorf("detail of %s contains %q: %s", what, secret, rec.Body)
|
||||
}
|
||||
}
|
||||
if src := source(what, id); !reflect.DeepEqual(src, map[string]any{"site_name": site, "url": nil}) {
|
||||
t.Errorf("source of %s = %v, want site_name %q and url null", what, src, site)
|
||||
}
|
||||
}
|
||||
withheld("a link with a query token", queryID, "B Kitchen", queryPage, "secret-query.example", queryToken, "access_token")
|
||||
|
||||
// The other unsafe shapes, stored as a link grounded elsewhere would
|
||||
// leave them.
|
||||
for _, tc := range []struct{ what, link string }{
|
||||
{"a link with a userinfo token", "https://user-token-ghi789@kitchen.example/recipes/chicken-curry"},
|
||||
{"a link with a userinfo password", "https://cook:password-jkl012@kitchen.example/recipes/chicken-curry"},
|
||||
{"an http link", "http://kitchen.example/recipes/chicken-curry"},
|
||||
{"a link with a fragment", "https://kitchen.example/recipes/chicken-curry#token=fragment-mno345"},
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, tc.link, cleanID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withheld(tc.what, cleanID, "Curry House", tc.link, "kitchen.example", "-token-", "password", "fragment")
|
||||
}
|
||||
|
||||
// web/src/dinners/testdata/source_url_cases.json pins source as this
|
||||
// handler sends it for each stored link; the recipe screen's tests
|
||||
// render each one, so a link the server sends and the client refuses
|
||||
// fails one side or the other.
|
||||
fixture, err := os.ReadFile("../../web/src/dinners/testdata/source_url_cases.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var shared struct {
|
||||
Cases []struct {
|
||||
Stored string
|
||||
Source map[string]any
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(fixture, &shared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(shared.Cases) == 0 {
|
||||
t.Fatal("source_url_cases.json has no cases")
|
||||
}
|
||||
for _, c := range shared.Cases {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, c.Stored, cleanID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src := source(c.Stored, cleanID); !reflect.DeepEqual(src, c.Source) {
|
||||
got, _ := json.Marshal(src)
|
||||
want, _ := json.Marshal(c.Source)
|
||||
t.Errorf("source of %s = %s, want %s", c.Stored, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored
|
||||
// under its host, so a recipe grounded in a pasted link whose host carries a
|
||||
// token has site_name null and nothing from the link in its detail; a page
|
||||
// that names its site keeps that name, even on a token-bearing host; and a
|
||||
// stored name shaped like another host, as a redirect leaves, is withheld.
|
||||
// The links carry a query, so source.url is withheld too and nothing of the
|
||||
// link may appear.
|
||||
func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
const (
|
||||
bareToken = "private-token-abc123"
|
||||
namedToken = "named-token-def456"
|
||||
bare = "https://" + bareToken + ".example.com/recipe?print=1"
|
||||
named = "https://www." + namedToken + ".example.com/recipe?print=1"
|
||||
)
|
||||
e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25)
|
||||
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
|
||||
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
||||
ground := func(dish, page string) (id, recipeID string) {
|
||||
t.Helper()
|
||||
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body)
|
||||
}
|
||||
if err := e.st.Reader.QueryRow(`SELECT recipe_id FROM dinners WHERE id = ?`, id).Scan(&recipeID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id, recipeID
|
||||
}
|
||||
storedSite := func(recipeID string) string {
|
||||
t.Helper()
|
||||
var site string
|
||||
if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return site
|
||||
}
|
||||
check := func(what, id string, leaks []string, want any) {
|
||||
t.Helper()
|
||||
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body)
|
||||
}
|
||||
for _, leak := range leaks {
|
||||
if strings.Contains(strings.ToLower(rec.Body.String()), leak) {
|
||||
t.Errorf("detail of %s contains %q: %s", what, leak, rec.Body)
|
||||
}
|
||||
}
|
||||
raw, _ := getDetail(t, e.h, id)
|
||||
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want, "url": nil}) {
|
||||
t.Errorf("source of %s = %v, want site_name %v and url null", what, raw["source"], want)
|
||||
}
|
||||
}
|
||||
|
||||
bareID, bareRecipe := ground("Chicken curry", bare)
|
||||
if got := storedSite(bareRecipe); got != bareToken+".example.com" {
|
||||
t.Fatalf("stored site_name = %q, want the link's host", got)
|
||||
}
|
||||
check("a page naming no site", bareID, []string{bare, bareToken, "example.com"}, nil)
|
||||
|
||||
namedID, namedRecipe := ground("Weeknight curry", named)
|
||||
if got := storedSite(namedRecipe); got != "Curry House" {
|
||||
t.Fatalf("stored site_name = %q, want the page's", got)
|
||||
}
|
||||
check("a page naming its site", namedID, []string{named, namedToken, "example.com"}, "Curry House")
|
||||
|
||||
const redirectHost = "redirect-token-ghi789.example.net"
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil)
|
||||
}
|
||||
|
||||
// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of
|
||||
// the suggestion that created the dinner, never that of an earlier
|
||||
// suggestion row for it that was declined.
|
||||
func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const reason = "Like your Tikka masala, but quicker."
|
||||
// The declined row is created first, so it is the earliest by both
|
||||
// created_at and id.
|
||||
for _, s := range []struct{ reason, status string }{
|
||||
{"Passed on last week.", "declined"},
|
||||
{reason, "accepted"},
|
||||
} {
|
||||
sid := store.NewID()
|
||||
if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{
|
||||
ID: sid, DinnerID: id, BatchID: batch.ID, Name: "Chicken curry",
|
||||
Reason: s.reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := q.DecideSuggestion(t.Context(), db.DecideSuggestionParams{
|
||||
Status: s.status, Now: sql.NullString{String: now, Valid: true}, ID: sid,
|
||||
}); err != nil || n != 1 {
|
||||
t.Fatalf("decide %s = %d, %v", s.status, n, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, got := getDetail(t, e.h, id); got.WhyPicked == nil || *got.WhyPicked != reason {
|
||||
t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two
|
||||
// steps at one position has no order anyone can trust, so its detail is a
|
||||
// 500 rather than the recipe in some order.
|
||||
func TestGetDinnerDuplicatePositionFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
for _, table := range []string{"recipe_ingredients", "recipe_steps"} {
|
||||
id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`)
|
||||
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
||||
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") {
|
||||
t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,24 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// getDinner answers GET /dinners/{id} with the dinner, its recipe and the
|
||||
// reason it was suggested. An archived dinner is 404, like an unknown one,
|
||||
// not a 200 with its status: it has no detail to show. source never has the
|
||||
// recipe page's link, and source.site_name is null unless the page itself
|
||||
// named its site — a name that could be a host, the link's or a redirect's,
|
||||
// is withheld (dinners.Source) — so the detail screen shows "Recipe from
|
||||
// {site}" only when site_name is not null.
|
||||
func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := svc.Get(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "get dinner", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// postDinner adds a Regular, recording the session's actor as updated_by,
|
||||
// and answers 201 with it and its grounding_job_id without waiting for the
|
||||
// job. When the job cannot be enqueued it still answers 201, with the dinner
|
||||
|
||||
@@ -86,13 +86,19 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.runner = runner
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
// The router serves the photos the grounder caches.
|
||||
dataDir := t.TempDir()
|
||||
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
|
||||
photos, err := recipes.NewPhotoCache(e.images, st, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestRunner(t), newTestPhotos(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
)
|
||||
|
||||
// getHome answers GET /home with the Home screen at the household's local
|
||||
// "now".
|
||||
func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "read home", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, h)
|
||||
}
|
||||
}
|
||||
|
||||
// updatesBody is the GET /updates answer.
|
||||
type updatesBody struct {
|
||||
Updates []home.Update `json:"updates"`
|
||||
}
|
||||
|
||||
// getUpdates answers GET /updates with the Recent updates: the last
|
||||
// home.RecentLimit distinct notification events, newest first.
|
||||
func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updates, err := svc.Updates(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "read recent updates", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates})
|
||||
}
|
||||
}
|
||||
|
||||
// maxUpdatesSeenBody bounds a POST /updates/seen request body.
|
||||
const maxUpdatesSeenBody = 1 << 10
|
||||
|
||||
// updatesSeenRequest is the POST /updates/seen body: through is the
|
||||
// created_at of the newest update the household was shown.
|
||||
type updatesSeenRequest struct {
|
||||
Through *string `json:"through"`
|
||||
}
|
||||
|
||||
// postUpdatesSeen answers POST /updates/seen by recording that the
|
||||
// household has seen its updates through the body's through, and answers
|
||||
// 204. A through before the one already recorded changes nothing.
|
||||
func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if req.Through == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required")
|
||||
return
|
||||
}
|
||||
through, err := time.Parse(time.RFC3339Nano, *req.Through)
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp")
|
||||
return
|
||||
}
|
||||
err = svc.MarkUpdatesSeen(r.Context(), through)
|
||||
if errors.Is(err, home.ErrSeenInFuture) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"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/store"
|
||||
)
|
||||
|
||||
// GET /home answers the Home screen in its JSON shape; newTestHome's
|
||||
// household (America/New_York) has no plan, no suggestions, no Supply run
|
||||
// and no notifications.
|
||||
func TestGetHome(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestRouter(t, "dev")
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// want is the answer at instant now; the request is read between two
|
||||
// instants, so it matches the answer at one of them.
|
||||
want := func(now time.Time) string {
|
||||
week := household.WeekStart(household.Today(now, loc))
|
||||
label, err := household.WeekLabel(week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
|
||||
"timezone": loc.String(),
|
||||
"tonight": nil,
|
||||
"sideQuestCount": 0,
|
||||
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
|
||||
"recentUpdates": map[string]any{"unread": false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
before := time.Now()
|
||||
rec, _ := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
||||
after := time.Now()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var got any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(canonical) != want(before) && string(canonical) != want(after) {
|
||||
t.Errorf("GET /api/v1/home = %s, want %s", canonical, want(after))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHomeNeedsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/home", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// homeRouter returns a router whose Home reads st, and st.
|
||||
func homeRouter(t *testing.T) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
svc := newTestHomeOn(t, st)
|
||||
return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st
|
||||
}
|
||||
|
||||
// storeEvent stores event key in st's outbox at instant at for each of
|
||||
// channels, as notify stores an event, and returns at as stored.
|
||||
func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", Tag: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
|
||||
for _, ch := range channels {
|
||||
if _, err := db.New(st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
|
||||
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
|
||||
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// unread reads recentUpdates.unread from GET /home.
|
||||
func unread(t *testing.T, h http.Handler) bool {
|
||||
t.Helper()
|
||||
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
updates, ok := body["recentUpdates"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body)
|
||||
}
|
||||
return updates["unread"] == true
|
||||
}
|
||||
|
||||
// markSeen posts body to /updates/seen and returns the answer.
|
||||
func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)))
|
||||
return rec
|
||||
}
|
||||
|
||||
// GET /updates lists the last distinct events newest first, one per event
|
||||
// whatever its channels, in their JSON shape.
|
||||
func TestGetUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` {
|
||||
t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush)
|
||||
newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||
want := `{"updates":[` +
|
||||
`{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` +
|
||||
`{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}`
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
||||
t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Unread is true while an event newer than the last seen one exists,
|
||||
// clears once POST /updates/seen marks the newest seen, and returns when a
|
||||
// newer event is stored; marking an older one seen again moves nothing.
|
||||
func TestUpdatesSeen(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
now := time.Now()
|
||||
|
||||
if unread(t, h) {
|
||||
t.Error("unread with no events")
|
||||
}
|
||||
first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
if !unread(t, h) {
|
||||
t.Error("not unread with an event never seen")
|
||||
}
|
||||
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if unread(t, h) {
|
||||
t.Error("unread after the newest event was marked seen")
|
||||
}
|
||||
|
||||
second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush)
|
||||
if !unread(t, h) {
|
||||
t.Error("not unread with an event newer than the last seen")
|
||||
}
|
||||
// Already seen: marking the older event seen again is accepted and
|
||||
// changes nothing.
|
||||
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if !unread(t, h) {
|
||||
t.Error("marking an older event seen hid a newer one")
|
||||
}
|
||||
if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if unread(t, h) {
|
||||
t.Error("unread after the newer event was marked seen")
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed POST /updates/seen is 400 and records nothing.
|
||||
func TestUpdatesSeenRejectsMalformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush)
|
||||
|
||||
for _, body := range []string{
|
||||
``,
|
||||
`[]`,
|
||||
`{}`,
|
||||
`{"through":null}`,
|
||||
`{"through":42}`,
|
||||
`{"through":"yesterday"}`,
|
||||
`{"through":"2026-10-13 12:00:00"}`,
|
||||
`{"through":"2026-10-13T12:00:00.000Z","extra":true}`,
|
||||
`{"through":"2026-10-13T12:00:00.000Z"} {}`,
|
||||
`{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`,
|
||||
} {
|
||||
rec := markSeen(t, h, body)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) {
|
||||
t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
if !unread(t, h) {
|
||||
t.Error("a malformed POST /updates/seen marked updates seen")
|
||||
}
|
||||
}
|
||||
|
||||
// Both endpoints need a session, and POST /updates/seen the CSRF token.
|
||||
func TestUpdatesNeedSessionAndCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _ := homeRouter(t)
|
||||
body := `{"through":"2026-10-01T12:00:00.000Z"}`
|
||||
for _, req := range []*http.Request{
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil),
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)),
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"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/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// actionsZone is the household zone of newIdeaActionsRouter.
|
||||
const actionsZone = "America/New_York"
|
||||
|
||||
// newIdeaActionsRouter returns a started router whose ideas, dinners and
|
||||
// plan services share one store, whose household settings are seeded, so
|
||||
// an idea's actions read the job, make the dinner and swap the plan of the
|
||||
// same household. The ideas service runs both skills through the real
|
||||
// harness with the scripted model and grounds with the scripted fakes.
|
||||
func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(t.Context(), st, actionsZone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, normalize)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
ideasSvc, err := ideas.NewService(st, g, generate, quick, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dinnerSvc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
t.Error("draft_week ran: an idea action must never draft a plan")
|
||||
return skills.Week{}, context.Canceled
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startRunner(t, runner)
|
||||
return h, st
|
||||
}
|
||||
|
||||
// finishedIdeasJob runs an ideas from ingredients job that keeps names, in
|
||||
// order, and returns the router, store, job id and its ideas' recipe ids.
|
||||
func finishedIdeasJob(t *testing.T, names ...string) (http.Handler, *store.Store, string, []string) {
|
||||
t.Helper()
|
||||
model, normalize, search, pages, images := scriptIdeas(t, names, names, nil)
|
||||
h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images)
|
||||
id := startIdeasJob(t, h)
|
||||
job := waitJob(t, h, id)
|
||||
if job["status"] != "succeeded" {
|
||||
t.Fatalf("ideas job = %v", job)
|
||||
}
|
||||
var recipeIDs []string
|
||||
for _, i := range job["result"].(map[string]any)["ideas"].([]any) {
|
||||
recipeIDs = append(recipeIDs, i.(map[string]any)["recipe_id"].(string))
|
||||
}
|
||||
if len(recipeIDs) != len(names) {
|
||||
t.Fatalf("ideas job kept %d ideas, want %d", len(recipeIDs), len(names))
|
||||
}
|
||||
return h, st, id, recipeIDs
|
||||
}
|
||||
|
||||
func ideaPath(jobID, i, action string) string {
|
||||
return "/api/v1/ideas/" + jobID + "/items/" + i + "/" + action
|
||||
}
|
||||
|
||||
// dinnerCount is how many dinners the store holds, archived or not.
|
||||
func dinnerCount(t *testing.T, st *store.Store) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM dinners`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// thisWeek is today in the household's zone and the Monday of its week.
|
||||
func thisWeek(t *testing.T) (week, today store.LocalDate) {
|
||||
t.Helper()
|
||||
zone, err := household.LoadZone(actionsZone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
today = household.Today(time.Now(), zone)
|
||||
return household.WeekStart(today), today
|
||||
}
|
||||
|
||||
// planThisWeek stores a plan of this week with a Regular on every day and
|
||||
// the given status, and returns the week's Monday and today.
|
||||
func planThisWeek(t *testing.T, st *store.Store, status string) (week, today store.LocalDate) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
week, today = thisWeek(t)
|
||||
days, err := household.WeekDays(week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := db.New(st.Writer)
|
||||
now := store.Now().String()
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, date := range days {
|
||||
name := "Regular " + date.String()
|
||||
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular",
|
||||
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date.String(),
|
||||
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := st.Writer.ExecContext(ctx, `UPDATE week_plans SET status = ? WHERE id = ?`, status, planID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return week, today
|
||||
}
|
||||
|
||||
// planRevision is the revision of the plan of week.
|
||||
func planRevision(t *testing.T, st *store.Store, week store.LocalDate) int64 {
|
||||
t.Helper()
|
||||
row, err := db.New(st.Reader).GetWeekPlan(t.Context(), week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row.Revision
|
||||
}
|
||||
|
||||
// Adding an idea to the Quest log makes a quest_log dinner grounded in the
|
||||
// idea's own recipe, with origin ingredients, without grounding it again;
|
||||
// asking again answers the same dinner and makes no second one.
|
||||
func TestPostIdeaQuestLogAddsGroundedDinnerOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding")
|
||||
|
||||
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST quest-log = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if d["name"] != "Rice Pudding" || d["status"] != "quest_log" || d["origin"] != "ingredients" ||
|
||||
d["grounding_status"] != "grounded" || d["recipe_id"] != recipeIDs[1] || d["photo_url"] == nil ||
|
||||
d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["updated_by"] != testSession.Subject {
|
||||
t.Fatalf("dinner = %v, want Rice Pudding in the Quest log grounded in %s", d, recipeIDs[1])
|
||||
}
|
||||
|
||||
rec, again := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
|
||||
if rec.Code != http.StatusOK || again["id"] != d["id"] || again["status"] != "quest_log" {
|
||||
t.Fatalf("POST quest-log again = %d %v, want 200 with dinner %v", rec.Code, again, d["id"])
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 1 {
|
||||
t.Fatalf("dinners = %d, want 1", n)
|
||||
}
|
||||
if names := listNames(t, h, "quest_log"); len(names) != 1 || names[0] != "Rice Pudding" {
|
||||
t.Fatalf("Quest log = %q", names)
|
||||
}
|
||||
}
|
||||
|
||||
// A Quick dinners idea makes a dinner with origin quick.
|
||||
func TestPostIdeaQuestLogQuickOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
quickReply(model, [3]any{"Chicken Fried Rice", "stovetop", 20})
|
||||
h, _ := newIdeaActionsRouter(t, model, normalize, search, pages, images)
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST quick = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id := body["jobId"].(string)
|
||||
if job := waitJob(t, h, id); job["status"] != "succeeded" {
|
||||
t.Fatalf("quick job = %v", job)
|
||||
}
|
||||
|
||||
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "")
|
||||
if rec.Code != http.StatusCreated || d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "quick" {
|
||||
t.Fatalf("POST quest-log = %d %v, want a quick Quest log dinner", rec.Code, d)
|
||||
}
|
||||
}
|
||||
|
||||
// An idea repeating a dinner the household already has is that dinner, as
|
||||
// it is: a Regular stays a Regular. One awaiting the household's choice
|
||||
// is refused and nothing changes.
|
||||
func TestPostIdeaQuestLogReusesExistingDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding")
|
||||
regular := createDinner(t, h, `{"name": "chicken fried rice"}`)
|
||||
|
||||
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "")
|
||||
if rec.Code != http.StatusOK || d["id"] != regular || d["status"] != "regular" || d["origin"] != "regular" {
|
||||
t.Fatalf("POST quest-log = %d %v, want 200 with Regular %s as it is", rec.Code, d, regular)
|
||||
}
|
||||
|
||||
now := store.Now().String()
|
||||
if _, err := db.New(st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: "Rice pudding", NormalizedName: harness.Normalize("Rice pudding"), Status: "suggested",
|
||||
Origin: "side_quest", GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
||||
t.Fatalf("POST quest-log of a suggested Side quest = %d %v, want 409 duplicate_dinner", rec.Code, body)
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 2 {
|
||||
t.Fatalf("dinners = %d, want the Regular and the Side quest only", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Cook tonight puts the idea's dinner on today of this week's plan through
|
||||
// the plan's swap: the day is new, the plan's revision rises and a synced
|
||||
// plan becomes changed. Asking again plans the same dinner.
|
||||
func TestPostIdeaCookTonightSwapsToday(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
week, today := planThisWeek(t, st, plan.StatusSynced)
|
||||
before := planRevision(t, st, week)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST cook-tonight = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
d := body["dinner"].(map[string]any)
|
||||
if d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "ingredients" || d["recipe_id"] != recipeIDs[0] {
|
||||
t.Fatalf("dinner = %v", d)
|
||||
}
|
||||
assertTonight := func(body map[string]any) {
|
||||
t.Helper()
|
||||
p := body["plan"].(map[string]any)
|
||||
if p["week_start"] != week.String() || p["status"] != plan.StatusChanged {
|
||||
t.Fatalf("plan = %v, want week %s changed", p, week)
|
||||
}
|
||||
found := false
|
||||
for _, day := range p["days"].([]any) {
|
||||
day := day.(map[string]any)
|
||||
planned := day["dinner"].(map[string]any)["id"] == d["id"]
|
||||
if day["date"] == today.String() {
|
||||
found = true
|
||||
if !planned || day["is_new"] != true {
|
||||
t.Fatalf("today %s = %v, want %v new", today, day, d["id"])
|
||||
}
|
||||
} else if planned {
|
||||
t.Fatalf("dinner also on %v", day["date"])
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("plan has no day %s", today)
|
||||
}
|
||||
}
|
||||
assertTonight(body)
|
||||
if after := planRevision(t, st, week); after != before+1 {
|
||||
t.Fatalf("plan revision = %d, want %d", after, before+1)
|
||||
}
|
||||
|
||||
rec, body = callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != d["id"] {
|
||||
t.Fatalf("POST cook-tonight again = %d %v", rec.Code, body)
|
||||
}
|
||||
assertTonight(body)
|
||||
if n := dinnerCount(t, st); n != 8 {
|
||||
t.Fatalf("dinners = %d, want the seven Regulars and one idea", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Cook tonight reuses a dinner the household already has.
|
||||
func TestPostIdeaCookTonightReusesExistingDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
_, today := planThisWeek(t, st, plan.StatusDraft)
|
||||
regular := createDinner(t, h, `{"name": "Chicken fried rice"}`)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != regular {
|
||||
t.Fatalf("POST cook-tonight = %d %v, want Regular %s", rec.Code, body, regular)
|
||||
}
|
||||
for _, day := range body["plan"].(map[string]any)["days"].([]any) {
|
||||
day := day.(map[string]any)
|
||||
if day["date"] == today.String() && (day["dinner"].(map[string]any)["id"] != regular || day["is_new"] != false) {
|
||||
t.Fatalf("today = %v, want Regular %s", day, regular)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With no plan for this week, cook tonight answers 409 no_plan and changes
|
||||
// nothing: no dinner is made and no plan is drafted or stored.
|
||||
func TestPostIdeaCookTonightWithoutPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "no_plan" {
|
||||
t.Fatalf("POST cook-tonight = %d %v, want 409 no_plan", rec.Code, body)
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 0 {
|
||||
t.Fatalf("dinners = %d, want none", n)
|
||||
}
|
||||
var plans, drafts int
|
||||
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM week_plans`).Scan(&plans); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&drafts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plans != 0 || drafts != 0 {
|
||||
t.Fatalf("plans = %d, drafts = %d, want none", plans, drafts)
|
||||
}
|
||||
}
|
||||
|
||||
// An idea that is not an index of a succeeded ideas job is refused.
|
||||
func TestPostIdeaActionsRefuse(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
planThisWeek(t, st, plan.StatusDraft)
|
||||
// A Regular's grounding job is a job, but finds no ideas.
|
||||
rec, body := callDinners(t, h, http.MethodPost, dinnersPath, `{"name": "Beef Stew"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST /dinners = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
other := body["grounding_job_id"].(string)
|
||||
|
||||
for _, action := range []string{"quest-log", "cook-tonight"} {
|
||||
for _, tc := range []struct {
|
||||
job, i string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{id, "x", http.StatusBadRequest, "invalid_request"},
|
||||
{id, "-1", http.StatusBadRequest, "invalid_request"},
|
||||
{id, "01", http.StatusBadRequest, "invalid_request"},
|
||||
{id, "1", http.StatusNotFound, "not_found"},
|
||||
{"unknown", "0", http.StatusNotFound, "not_found"},
|
||||
{other, "0", http.StatusNotFound, "not_found"},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(tc.job, tc.i, action), "")
|
||||
if rec.Code != tc.status || errorCode(body) != tc.code {
|
||||
t.Errorf("POST %s %s/%s = %d %v, want %d %s", action, tc.job, tc.i, rec.Code, body, tc.status, tc.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 8 {
|
||||
t.Fatalf("dinners = %d, want the plan's seven Regulars and Beef Stew only", n)
|
||||
}
|
||||
}
|
||||
|
||||
// While the job still finds ideas, an idea is not acted on: its index may
|
||||
// still move.
|
||||
func TestPostIdeaActionsWhileJobRuns(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := scriptIdeas(t, []string{"Chicken Fried Rice"}, []string{"Chicken Fried Rice"}, nil)
|
||||
gate := make(chan struct{})
|
||||
search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate}
|
||||
h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images)
|
||||
id := startIdeasJob(t, h)
|
||||
|
||||
for _, action := range []string{"quest-log", "cook-tonight"} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", action), "")
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "ideas_not_ready" {
|
||||
t.Errorf("POST %s = %d %v, want 409 ideas_not_ready", action, rec.Code, body)
|
||||
}
|
||||
}
|
||||
close(gate)
|
||||
waitJob(t, h, id)
|
||||
if n := dinnerCount(t, st); n != 0 {
|
||||
t.Fatalf("dinners = %d, want none", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
)
|
||||
|
||||
// maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body.
|
||||
const maxIdeasBody = 16 << 10
|
||||
|
||||
// ideasFromIngredients is the POST /ideas/from-ingredients body. Both
|
||||
// fields are required.
|
||||
type ideasFromIngredients struct {
|
||||
Ingredients []string `json:"ingredients"`
|
||||
QuickOnly *bool `json:"quick_only"`
|
||||
}
|
||||
|
||||
// postIdeasFromIngredients starts finding ideas from the ingredients on
|
||||
// hand and answers 202 with the jobId that GET /jobs/{id} reports on,
|
||||
// without waiting for the job. An ingredient list the household must
|
||||
// correct is 400.
|
||||
func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[ideasFromIngredients](http.MaxBytesReader(w, r.Body, maxIdeasBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case body.Ingredients == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "ingredients: required")
|
||||
return
|
||||
case body.QuickOnly == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "quick_only: required")
|
||||
return
|
||||
}
|
||||
started, err := svc.StartFromIngredients(r.Context(), body.Ingredients, *body.QuickOnly)
|
||||
var invalid *ideas.InvalidError
|
||||
switch {
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
|
||||
return
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "start ideas from ingredients", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, started)
|
||||
}
|
||||
}
|
||||
|
||||
// ideasQuick is the POST /ideas/quick body. Both fields are required:
|
||||
// exclude is [] on a first page.
|
||||
type ideasQuick struct {
|
||||
Kind *string `json:"kind"`
|
||||
Exclude []string `json:"exclude"`
|
||||
}
|
||||
|
||||
// postIdeasQuick starts finding a page of Quick dinner ideas of a kind,
|
||||
// crockpot or under30, none named in exclude, and answers 202 with the
|
||||
// jobId that GET /jobs/{id} reports on, without waiting for the job. A
|
||||
// missing or unknown kind, or an exclude list the household must correct,
|
||||
// is 400.
|
||||
func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[ideasQuick](http.MaxBytesReader(w, r.Body, maxIdeasBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case body.Kind == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "kind: required")
|
||||
return
|
||||
case body.Exclude == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "exclude: required")
|
||||
return
|
||||
}
|
||||
started, err := svc.StartQuick(r.Context(), *body.Kind, body.Exclude)
|
||||
var invalid *ideas.InvalidError
|
||||
switch {
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
|
||||
return
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "start quick ideas", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, started)
|
||||
}
|
||||
}
|
||||
|
||||
// chosenIdeaOrFail returns the idea {i} of ideas job {jobId}, or answers
|
||||
// 400 invalid_request when i is not an index (0, 1, ...), 404 not_found
|
||||
// for an idea no succeeded ideas job shows, or 409 ideas_not_ready while
|
||||
// the job is still finding ideas.
|
||||
func chosenIdeaOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger, svc *ideas.Service) (ideas.Chosen, bool) {
|
||||
raw := chi.URLParam(r, "i")
|
||||
i, err := strconv.Atoi(raw)
|
||||
if err != nil || i < 0 || strconv.Itoa(i) != raw {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "i: must be an idea's index, 0 or more")
|
||||
return ideas.Chosen{}, false
|
||||
}
|
||||
idea, err := svc.Idea(r.Context(), chi.URLParam(r, "jobId"), i)
|
||||
switch {
|
||||
case errors.Is(err, ideas.ErrIdeaNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "idea not found")
|
||||
return ideas.Chosen{}, false
|
||||
case errors.Is(err, ideas.ErrIdeasNotReady):
|
||||
writeError(w, r, logger, http.StatusConflict, "ideas_not_ready", err.Error())
|
||||
return ideas.Chosen{}, false
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "read idea", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return ideas.Chosen{}, false
|
||||
}
|
||||
return idea, true
|
||||
}
|
||||
|
||||
// postIdeaQuestLog adds idea {i} of ideas job {jobId} to the Quest log,
|
||||
// recording the session's actor as updated_by, as a dinner grounded in
|
||||
// the recipe the idea was grounded in, with origin ingredients or quick.
|
||||
// It answers 201 with the dinner made, or 200 with the dinner the idea
|
||||
// repeats (see dinners.Service.AddToQuestLog), so asking again answers the
|
||||
// same dinner. A repeated dinner awaiting the household's choice is 409
|
||||
// duplicate_dinner.
|
||||
func postIdeaQuestLog(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, created, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "add idea to Quest log", err)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if created {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, r, logger, status, d)
|
||||
}
|
||||
}
|
||||
|
||||
// cookedTonight is the POST /ideas/{jobId}/items/{i}/cook-tonight answer:
|
||||
// the idea's dinner and the week's plan with it tonight.
|
||||
type cookedTonight struct {
|
||||
Dinner dinners.Dinner `json:"dinner"`
|
||||
Plan plan.Plan `json:"plan"`
|
||||
}
|
||||
|
||||
// postIdeaCookTonight plans idea {i} of ideas job {jobId} tonight: today,
|
||||
// in the household's zone, of the plan of today's week. The idea's dinner
|
||||
// is added or found as postIdeaQuestLog does, then swapped onto today
|
||||
// through plan.Service.Swap, exactly as a swap on the Draft board, so the
|
||||
// plan is marked edited and a synced plan becomes changed. It answers 200
|
||||
// with the dinner and the plan as swapped; asking again plans the same
|
||||
// dinner again.
|
||||
//
|
||||
// When today's week has no plan it answers 409 no_plan and changes
|
||||
// nothing: no dinner is added and no plan is made, since a plan holds all
|
||||
// seven days; the household plans the week first. The week is always
|
||||
// today's own, so no other day is ever written. A dinner already on
|
||||
// another day of the week is 409 dinner_already_planned, after the dinner
|
||||
// is added or found.
|
||||
func postIdeaCookTonight(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service, planSvc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
week, today, err := planSvc.Tonight(r.Context())
|
||||
if errors.Is(err, plan.ErrNotFound) {
|
||||
writeNoPlan(w, r, logger)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "find tonight's plan", err)
|
||||
return
|
||||
}
|
||||
d, _, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "add idea to cook tonight", err)
|
||||
return
|
||||
}
|
||||
p, err := planSvc.Swap(r.Context(), week, today.String(), d.ID)
|
||||
if errors.Is(err, plan.ErrNotFound) {
|
||||
// Plans are never deleted, so the plan Tonight found is still
|
||||
// there; this is said loudly rather than answered as success.
|
||||
logger.ErrorContext(r.Context(), "plan of tonight's week gone before the swap", "week", week.String())
|
||||
writeNoPlan(w, r, logger)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "cook idea tonight", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, cookedTonight{Dinner: d, Plan: p})
|
||||
}
|
||||
}
|
||||
|
||||
// writeNoPlan answers 409 no_plan: today's week has no plan to put
|
||||
// tonight's dinner on, so the household plans the week first.
|
||||
func writeNoPlan(w http.ResponseWriter, r *http.Request, logger *slog.Logger) {
|
||||
writeError(w, r, logger, http.StatusConflict, "no_plan", "this week has no plan yet; plan this week first")
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const ideasPath = "/api/v1/ideas/from-ingredients"
|
||||
|
||||
// newIdeasRouter returns a router whose ideas service runs
|
||||
// ideas_from_ingredients and quick_ideas through the real harness with the
|
||||
// scripted model and grounds with fake search, pages and images and the
|
||||
// scripted normalize, with its job runner and store, whose household
|
||||
// settings are seeded. The runner is started when start is set.
|
||||
func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, start bool) (http.Handler, *jobs.Runner, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(t.Context(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, normalize)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
svc, err := ideas.NewService(st, g, generate, quick, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if start {
|
||||
startRunner(t, runner)
|
||||
}
|
||||
return h, runner, st
|
||||
}
|
||||
|
||||
// startRunner starts runner until the test ends.
|
||||
func startRunner(t *testing.T, runner *jobs.Runner) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// scriptIdeas scripts ideas_from_ingredients to propose names, in order;
|
||||
// a recipe grounds those in grounds, the others ground none, and search
|
||||
// fails as fail says. It returns the router's fakes.
|
||||
func scriptIdeas(t *testing.T, names, grounds []string, fail map[string]error) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
|
||||
t.Helper()
|
||||
model, normalize := &llm.Fake{}, &llm.Fake{}
|
||||
search := &recipestest.Search{Results: map[string][]recipes.Candidate{}, Fail: fail}
|
||||
pages := &recipestest.Pages{Bodies: map[string][]byte{}}
|
||||
images := &recipestest.Images{JPEGs: map[string][]byte{}}
|
||||
for i, name := range grounds {
|
||||
page, photo := fmt.Sprintf("https://r.example/%d", i), fmt.Sprintf("https://r.example/%d.jpg", i)
|
||||
search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 25)
|
||||
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
}
|
||||
list := make([]string, len(names))
|
||||
for i, name := range names {
|
||||
list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":30,"method":"stovetop","difficulty":"easy","search_query":%q}`, name, name)
|
||||
}
|
||||
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
return model, normalize, search, pages, images
|
||||
}
|
||||
|
||||
// startIdeasJob posts a request for ideas from rice and returns the job id.
|
||||
func startIdeasJob(t *testing.T, h http.Handler) string {
|
||||
t.Helper()
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["rice"], "quick_only": false}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
return body["jobId"].(string)
|
||||
}
|
||||
|
||||
// assertFailedWithoutIdeas checks that GET /jobs/{id} reports job failed
|
||||
// with code and a result showing no ideas.
|
||||
func assertFailedWithoutIdeas(t *testing.T, job map[string]any, code string) {
|
||||
t.Helper()
|
||||
if job["status"] != "failed" || job["error"] != code {
|
||||
t.Fatalf("GET job = %v, want failed with %s", job, code)
|
||||
}
|
||||
res, _ := job["result"].(map[string]any)
|
||||
if res == nil {
|
||||
t.Fatalf("failed job result = %v, want one showing no ideas", job["result"])
|
||||
}
|
||||
if list, ok := res["ideas"].([]any); !ok || len(list) != 0 {
|
||||
t.Fatalf("failed job ideas = %v, want none", res["ideas"])
|
||||
}
|
||||
}
|
||||
|
||||
// storedIdeas is how many ideas job id's stored result holds.
|
||||
func storedIdeas(t *testing.T, st *store.Store, id string) int {
|
||||
t.Helper()
|
||||
row, err := db.New(st.Reader).GetJob(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var res ideas.Result
|
||||
if err := json.Unmarshal([]byte(row.Result.String), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return len(res.Ideas)
|
||||
}
|
||||
|
||||
func TestPostIdeasFromIngredientsRefusesBadRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
|
||||
for _, tc := range []struct{ body, message string }{
|
||||
{`{"quick_only": false}`, "ingredients: required"},
|
||||
{`{"ingredients": ["rice"]}`, "quick_only: required"},
|
||||
{`{"ingredients": [], "quick_only": false}`, "ingredients: list at least one ingredient"},
|
||||
{`{"ingredients": ["rice", " "], "quick_only": false}`, "ingredients: ingredient 2 is blank"},
|
||||
{`{"ingredients": ["Rice", "rice"], "quick_only": false}`, `ingredients: ingredient 2, "rice", repeats "Rice"`},
|
||||
{`{"ingredients": ["rice"], "quick_only": "yes"}`, "quick_only: want a JSON bool"},
|
||||
{`{"ingredients": ["rice"], "quick_only": false, "extra": 1}`, `invalid request body: json: unknown field "extra"`},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideasPath, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
|
||||
continue
|
||||
}
|
||||
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
|
||||
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostIdeasFromIngredientsRunsTheJob(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize := &llm.Fake{}, &llm.Fake{}
|
||||
search := &recipestest.Search{Results: map[string][]recipes.Candidate{"chicken fried rice": {{URL: "https://r.example/fried-rice"}}}}
|
||||
pages := &recipestest.Pages{Bodies: map[string][]byte{
|
||||
"https://r.example/fried-rice": recipestest.RecipePage("Chicken Fried Rice", "R Kitchen", "https://r.example/fried-rice.jpg", 25),
|
||||
}}
|
||||
images := &recipestest.Images{JPEGs: map[string][]byte{"https://r.example/fried-rice.jpg": recipestest.JPEG(t, 400, 320, 1)}}
|
||||
js := `{"ideas":[{"name":"Chicken Fried Rice","uses":["rice","chicken thighs","peas"],"missing":["2 eggs"],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":"chicken fried rice"}]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
h, runner, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["Chicken thighs", "rice", "scallions"], "quick_only": true}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := body["jobId"].(string)
|
||||
if id == "" || len(body) != 1 {
|
||||
t.Fatalf("POST answered %v, want only a jobId", body)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := runner.Get(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job still %s", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
rec, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
if rec.Code != http.StatusOK || job["status"] != "succeeded" || job["kind"] != "ideas_from_ingredients" {
|
||||
t.Fatalf("GET job = %d %v", rec.Code, job)
|
||||
}
|
||||
res := job["result"].(map[string]any)
|
||||
if res["stage"] != "done" || res["quick_only"] != true || res["dropped"] != 0.0 {
|
||||
t.Fatalf("result = %v", res)
|
||||
}
|
||||
list := res["ideas"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("ideas = %v, want 1", list)
|
||||
}
|
||||
got := list[0].(map[string]any)
|
||||
for key, want := range map[string]any{
|
||||
"name": "Chicken Fried Rice",
|
||||
"summary": "Uses 2 of 3 · missing: 2 eggs",
|
||||
"method": "stovetop",
|
||||
"minutes_total": 25.0,
|
||||
"quick": true,
|
||||
"site_name": "R Kitchen",
|
||||
"source_url": "https://r.example/fried-rice",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Errorf("idea %s = %v, want %v", key, got[key], want)
|
||||
}
|
||||
}
|
||||
if uses, _ := json.Marshal(got["uses"]); string(uses) != `["Chicken thighs","rice"]` {
|
||||
t.Errorf("idea uses = %s, want the household's two listed ingredients", uses)
|
||||
}
|
||||
if got["recipe_id"] == "" || got["photo_url"] == "" {
|
||||
t.Errorf("idea = %v, want a recipe and photo", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedIdeasJobShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// grounds are the ideas a recipe grounds; the others ground none.
|
||||
grounds []string
|
||||
ideas []string
|
||||
fail map[string]error
|
||||
code string
|
||||
}{
|
||||
{"no idea kept", nil, []string{"Mystery Stew"}, nil, "no_ideas_found"},
|
||||
{"a later round fails after ideas were shown",
|
||||
[]string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"},
|
||||
[]string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"},
|
||||
map[string]error{"Rice Pilaf": errors.New("boom")}, "search_failed"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := scriptIdeas(t, tc.ideas, tc.grounds, tc.fail)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
assertFailedWithoutIdeas(t, waitJob(t, h, startIdeasJob(t, h)), tc.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A job left running with ideas shown is failed at the next start as
|
||||
// interrupted, without its handler running again; it still shows no ideas.
|
||||
func TestIdeasJobInterruptedByRestartShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, runner, st := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
id := startIdeasJob(t, h)
|
||||
|
||||
// The previous process claimed the job and showed an idea, then stopped.
|
||||
q := db.New(st.Writer)
|
||||
now := store.Now().String()
|
||||
if _, err := q.ClaimNextJob(t.Context(), sql.NullString{String: now, Valid: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partial, err := ideas.Result{
|
||||
Stage: ideas.StageGrounding,
|
||||
Ingredients: []string{"rice"},
|
||||
Ideas: []ideas.Idea{{Name: "Fried Rice", Uses: []string{"rice"}, Missing: []string{}, Method: "stovetop", Difficulty: "easy"}},
|
||||
}.PublicJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := q.PublishJobResult(t.Context(), db.PublishJobResultParams{
|
||||
Result: sql.NullString{String: string(partial), Valid: true}, UpdatedAt: now, ID: id,
|
||||
}); err != nil || n != 1 {
|
||||
t.Fatalf("publish partial result = %d, %v", n, err)
|
||||
}
|
||||
|
||||
startRunner(t, runner)
|
||||
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
assertFailedWithoutIdeas(t, job, jobs.CodeInterrupted)
|
||||
if res := job["result"].(map[string]any); res["stage"] != "grounding" {
|
||||
t.Errorf("result = %v, want the rest of the partial result kept", res)
|
||||
}
|
||||
if n := storedIdeas(t, st, id); n != 1 {
|
||||
t.Fatalf("stored ideas = %d, want the interrupted job's 1 still stored", n)
|
||||
}
|
||||
}
|
||||
|
||||
// When the write clearing a failed job's ideas itself fails, the job fails
|
||||
// with the runner's internal_error and its stored result keeps the ideas;
|
||||
// it still shows none.
|
||||
func TestIdeasJobWhoseClearingWriteFailsShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := scriptIdeas(t,
|
||||
[]string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"},
|
||||
[]string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"},
|
||||
map[string]error{"Rice Pilaf": errors.New("boom")})
|
||||
h, runner, st := newIdeasRouter(t, model, normalize, search, pages, images, false)
|
||||
// Refuse any write that empties a running job's shown ideas.
|
||||
if _, err := st.Writer.ExecContext(t.Context(), `
|
||||
CREATE TRIGGER refuse_clearing_ideas BEFORE UPDATE OF result ON jobs
|
||||
WHEN NEW.status = 'running'
|
||||
AND json_array_length(OLD.result, '$.ideas') > 0
|
||||
AND json_array_length(NEW.result, '$.ideas') = 0
|
||||
BEGIN SELECT RAISE(ABORT, 'clearing refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startRunner(t, runner)
|
||||
|
||||
id := startIdeasJob(t, h)
|
||||
assertFailedWithoutIdeas(t, waitJob(t, h, id), jobs.CodeInternal)
|
||||
if n := storedIdeas(t, st, id); n != 4 {
|
||||
t.Fatalf("stored ideas = %d, want the 4 the refused write left", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsEndpointRefusesIdeasKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "ideas_from_ingredients", "payload": {"ingredients": [], "quick_only": false}}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
|
||||
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
)
|
||||
|
||||
// sourceCase is a recipe page an idea grounds in: its link and the site
|
||||
// name its page gives (empty for none, so the host is stored instead).
|
||||
type sourceCase struct {
|
||||
dish, link, site string
|
||||
// want is the idea's source_url and site_name; nil is null.
|
||||
wantURL, wantSite any
|
||||
}
|
||||
|
||||
const (
|
||||
cleanSourceLink = "https://kitchen.example/recipes/chicken-curry"
|
||||
queryToken = "query-token-abc123"
|
||||
queryHost = "query-host-q1.example"
|
||||
userinfoToken = "user-token-def456"
|
||||
userinfoHost = "userinfo-host-u2.example"
|
||||
)
|
||||
|
||||
// sourceCases are a clean link, credited by its page's name, and links
|
||||
// carrying a token in the query and the userinfo whose pages name no site,
|
||||
// so the stored name is the link's host.
|
||||
var sourceCases = []sourceCase{
|
||||
{"Chicken Curry", cleanSourceLink, "Curry House", cleanSourceLink, "Curry House"},
|
||||
{"Beef Stew", "https://" + queryHost + "/stew?access_token=" + queryToken, "", nil, nil},
|
||||
{"Bean Chili", "https://" + userinfoToken + "@" + userinfoHost + "/chili", "", nil, nil},
|
||||
}
|
||||
|
||||
// sourceSecrets are what no idea job response may contain.
|
||||
var sourceSecrets = []string{queryToken, "access_token", queryHost, userinfoToken, userinfoHost}
|
||||
|
||||
// sourceFakes makes the search for each case's dish find its page and
|
||||
// scripts the normalize_recipe reply grounding it takes.
|
||||
func sourceFakes(t *testing.T, minutes int) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
|
||||
t.Helper()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
for i, c := range sourceCases {
|
||||
photo := fmt.Sprintf("https://img.example/%d.jpg", i)
|
||||
search.Results[c.dish] = []recipes.Candidate{{URL: c.link}}
|
||||
pages.Bodies[c.link] = recipestest.RecipePage(c.dish, c.site, photo, minutes)
|
||||
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
|
||||
}
|
||||
return model, normalize, search, pages, images
|
||||
}
|
||||
|
||||
// checkIdeaSources checks the finished job id: each idea shows its case's
|
||||
// safe source, and the response holds nothing of a token-bearing link.
|
||||
func checkIdeaSources(t *testing.T, h http.Handler, id string) {
|
||||
t.Helper()
|
||||
job := waitJob(t, h, id)
|
||||
if job["status"] != "succeeded" {
|
||||
t.Fatalf("GET job = %v", job)
|
||||
}
|
||||
rec, _ := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
for _, secret := range sourceSecrets {
|
||||
if strings.Contains(rec.Body.String(), secret) {
|
||||
t.Errorf("job response contains %q: %s", secret, rec.Body)
|
||||
}
|
||||
}
|
||||
list := job["result"].(map[string]any)["ideas"].([]any)
|
||||
if len(list) != len(sourceCases) {
|
||||
t.Fatalf("ideas = %v, want %d", list, len(sourceCases))
|
||||
}
|
||||
for i, c := range sourceCases {
|
||||
idea := list[i].(map[string]any)
|
||||
if idea["name"] != c.dish {
|
||||
t.Fatalf("idea %d = %v, want %s", i, idea, c.dish)
|
||||
}
|
||||
for key, want := range map[string]any{"source_url": c.wantURL, "site_name": c.wantSite} {
|
||||
got, ok := idea[key]
|
||||
if !ok || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s %s = %v (present %t), want %v", c.dish, key, got, ok, want)
|
||||
}
|
||||
}
|
||||
if idea["recipe_id"] == "" || idea["photo_url"] == "" {
|
||||
t.Errorf("idea %s = %v, want a recipe and photo", c.dish, idea)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An ideas_from_ingredients job shows a clean link and its page's name,
|
||||
// and neither a token-bearing link nor a name taken from its host.
|
||||
func TestIdeasJobShowsOnlySafeSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := sourceFakes(t, 25)
|
||||
list := make([]string, len(sourceCases))
|
||||
for i, c := range sourceCases {
|
||||
list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, c.dish, c.dish)
|
||||
}
|
||||
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
checkIdeaSources(t, h, startIdeasJob(t, h))
|
||||
}
|
||||
|
||||
// A quick_ideas job shows sources as an ideas_from_ingredients job does.
|
||||
func TestQuickJobShowsOnlySafeSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := sourceFakes(t, 25)
|
||||
ideas := make([][3]any, len(sourceCases))
|
||||
for i, c := range sourceCases {
|
||||
ideas[i] = [3]any{c.dish, "stovetop", 20}
|
||||
}
|
||||
quickReply(model, ideas...)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
checkIdeaSources(t, h, body["jobId"].(string))
|
||||
}
|
||||
|
||||
// legacyIdea is an idea as the result builders stored it before sources
|
||||
// were made safe: the grounded recipe's raw link and stored site name,
|
||||
// which is the link's host when its page named no site.
|
||||
func legacyIdea(name, link, site string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name, "method": "stovetop", "minutes_total": 25, "difficulty": "easy",
|
||||
"recipe_id": "01JLEGACY" + strings.ToUpper(strings.ReplaceAll(name, " ", "")), "photo_url": "/api/v1/photos/legacy",
|
||||
"source_url": link, "site_name": site,
|
||||
}
|
||||
}
|
||||
|
||||
// A job of either idea kind whose result was stored before sources were
|
||||
// made safe is served with the sources a job stored now holds: neither a
|
||||
// token-bearing link nor a name taken from its host.
|
||||
func TestJobServesLegacyIdeaResultsWithOnlySafeSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, false)
|
||||
legacy := []any{
|
||||
legacyIdea("Chicken Curry", cleanSourceLink, "Curry House"),
|
||||
legacyIdea("Beef Stew", "https://"+queryHost+"/stew?access_token="+queryToken, queryHost),
|
||||
legacyIdea("Bean Chili", "https://"+userinfoToken+"@"+userinfoHost+"/chili", userinfoHost),
|
||||
}
|
||||
for kind, result := range map[string]map[string]any{
|
||||
ideas.KindFromIngredients: {"stage": "done", "ingredients": []string{"rice"}, "quick_only": false, "ideas": legacy, "dropped": 0},
|
||||
ideas.KindQuick: {"stage": "done", "kind": "under30", "exclude": []string{}, "ideas": legacy, "dropped": 0},
|
||||
} {
|
||||
js, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := "legacy-" + kind
|
||||
if _, err := st.Writer.ExecContext(t.Context(),
|
||||
`INSERT INTO jobs (id, kind, status, payload, result, created_at, updated_at) VALUES (?, ?, 'succeeded', '{}', ?, '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z')`,
|
||||
id, kind, string(js)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkIdeaSources(t, h, id)
|
||||
}
|
||||
}
|
||||
|
||||
// An idea job whose grounded recipe's stored link does not parse fails,
|
||||
// even when no site name is stored, rather than show the idea without a
|
||||
// source.
|
||||
func TestIdeaJobFailsOnUnparseableSourceWithoutSiteName(t *testing.T) {
|
||||
t.Parallel()
|
||||
const badLink = "https://kitchen.example/%zz"
|
||||
model, normalize, search, pages, images := sourceFakes(t, 25)
|
||||
idea := fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, sourceCases[0].dish, sourceCases[0].dish)
|
||||
js := `{"ideas":[` + idea + `]}`
|
||||
for range 2 {
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
if job := waitJob(t, h, startIdeasJob(t, h)); job["status"] != "succeeded" {
|
||||
t.Fatalf("grounding job = %v", job)
|
||||
}
|
||||
// The recipe is now stored under badLink with no site name, and the
|
||||
// search finds it there.
|
||||
res, err := st.Writer.ExecContext(t.Context(), `UPDATE recipes SET source_url = ?, site_name = NULL WHERE source_url = ?`, badLink, cleanSourceLink)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil || n != 1 {
|
||||
t.Fatalf("updated %d recipes (%v), want 1", n, err)
|
||||
}
|
||||
search.Results[sourceCases[0].dish] = []recipes.Candidate{{URL: badLink}}
|
||||
job := waitJob(t, h, startIdeasJob(t, h))
|
||||
assertFailedWithoutIdeas(t, job, jobs.CodeInternal)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, pub, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair)
|
||||
func newPushRouter(t *testing.T, svc *push.Service) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -103,7 +103,7 @@ func TestNewRouterRequiresPush(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil push service")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
)
|
||||
|
||||
const quickPath = "/api/v1/ideas/quick"
|
||||
|
||||
// quickRecipe makes the search for name find a page titled title with a
|
||||
// total time of minutes, and scripts the normalize_recipe reply grounding
|
||||
// it takes. "Slow Cooker" in title grounds the method as crockpot.
|
||||
func quickRecipe(t *testing.T, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, name, title string, minutes int) {
|
||||
t.Helper()
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
|
||||
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(images.JPEGs)+1))
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
|
||||
}
|
||||
|
||||
// quickReply scripts one quick_ideas reply holding the ideas named, each
|
||||
// searched by its name.
|
||||
func quickReply(model *llm.Fake, ideas ...[3]any) {
|
||||
list := make([]string, len(ideas))
|
||||
for i, idea := range ideas {
|
||||
list[i] = fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"medium","search_query":%q}`, idea[0], idea[2], idea[1], idea[0])
|
||||
}
|
||||
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
func newQuickFakes() (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
|
||||
return &llm.Fake{}, &llm.Fake{}, &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
&recipestest.Pages{Bodies: map[string][]byte{}}, &recipestest.Images{JPEGs: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func TestPostIdeasQuickRefusesBadRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
|
||||
for _, tc := range []struct{ body, message string }{
|
||||
{`{"exclude": []}`, "kind: required"},
|
||||
{`{"kind": "crockpot"}`, "exclude: required"},
|
||||
{`{"kind": "", "exclude": []}`, `kind: kind "" is not one of crockpot, under30`},
|
||||
{`{"kind": "quick", "exclude": []}`, `kind: kind "quick" is not one of crockpot, under30`},
|
||||
{`{"kind": "Crockpot", "exclude": []}`, `kind: kind "Crockpot" is not one of crockpot, under30`},
|
||||
{`{"kind": "under30", "exclude": ["Beef Stew", " "]}`, "exclude: excluded name 2 is blank"},
|
||||
{`{"kind": "under30", "exclude": ["Beef Stew", "beef-stew"]}`, `exclude: excluded name 2, "beef-stew", repeats "Beef Stew"`},
|
||||
{`{"kind": 30, "exclude": []}`, "kind: want a JSON string"},
|
||||
{`{"kind": "under30", "exclude": [], "extra": 1}`, `invalid request body: json: unknown field "extra"`},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
|
||||
continue
|
||||
}
|
||||
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
|
||||
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The job answers 202 at once and its result fills idea by idea: one
|
||||
// idea is shown while another still grounds. The kind is checked against
|
||||
// the grounded recipe and excluded names never come back.
|
||||
func TestPostIdeasQuickRunsTheJobWithProgressiveResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
quickRecipe(t, normalize, search, pages, images, "Beef Stroganoff", "Beef Stroganoff", 45)
|
||||
quickRecipe(t, normalize, search, pages, images, "Garlic Shrimp Pasta", "Garlic Shrimp Pasta", 30)
|
||||
gate := make(chan struct{})
|
||||
search.Gates = map[string]chan struct{}{"Garlic Shrimp Pasta": gate}
|
||||
quickReply(model,
|
||||
[3]any{"Easy Shrimp Tacos", "stovetop", 15},
|
||||
[3]any{"Chicken Fried Rice", "stovetop", 20},
|
||||
[3]any{"Beef Stroganoff", "stovetop", 25},
|
||||
[3]any{"Garlic Shrimp Pasta", "stovetop", 20},
|
||||
)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": ["Shrimp Tacos"]}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := body["jobId"].(string)
|
||||
if id == "" || len(body) != 1 {
|
||||
t.Fatalf("POST answered %v, want only a jobId", body)
|
||||
}
|
||||
|
||||
// While Garlic Shrimp Pasta still grounds, Chicken Fried Rice shows.
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
if res, _ := job["result"].(map[string]any); res != nil {
|
||||
if list, _ := res["ideas"].([]any); len(list) == 1 {
|
||||
if job["status"] != "running" || res["stage"] != "grounding" || list[0].(map[string]any)["name"] != "Chicken Fried Rice" {
|
||||
t.Fatalf("partial job = %v", job)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("no idea was shown while another still grounded")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(gate)
|
||||
|
||||
job := waitJob(t, h, id)
|
||||
if job["status"] != "succeeded" || job["kind"] != "quick_ideas" {
|
||||
t.Fatalf("GET job = %v", job)
|
||||
}
|
||||
res := job["result"].(map[string]any)
|
||||
if res["stage"] != "done" || res["kind"] != "under30" || res["dropped"] != 2.0 {
|
||||
t.Fatalf("result = %v, want done with the excluded and the 45-minute ideas dropped", res)
|
||||
}
|
||||
if ex, _ := json.Marshal(res["exclude"]); string(ex) != `["Shrimp Tacos"]` {
|
||||
t.Errorf("result exclude = %s", ex)
|
||||
}
|
||||
var names []string
|
||||
for _, i := range res["ideas"].([]any) {
|
||||
names = append(names, i.(map[string]any)["name"].(string))
|
||||
}
|
||||
if want := []string{"Chicken Fried Rice", "Garlic Shrimp Pasta"}; !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("ideas = %q, want %q", names, want)
|
||||
}
|
||||
got := res["ideas"].([]any)[1].(map[string]any)
|
||||
for key, want := range map[string]any{
|
||||
"method": "stovetop",
|
||||
"minutes_total": 30.0,
|
||||
"site_name": "R Kitchen",
|
||||
"source_url": "https://r.example/garlic-shrimp-pasta",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Errorf("idea %s = %v, want %v", key, got[key], want)
|
||||
}
|
||||
}
|
||||
if got["recipe_id"] == "" || got["photo_url"] == "" || got["difficulty"] == nil {
|
||||
t.Errorf("idea = %v, want a recipe, photo and difficulty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedQuickJobShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
quickRecipe(t, normalize, search, pages, images, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
|
||||
ideas := [][3]any{{"Chicken Chili", "crockpot", 240}}
|
||||
for i := range 5 {
|
||||
ideas = append(ideas, [3]any{fmt.Sprintf("Mystery Stew %d", i+1), "crockpot", 300})
|
||||
}
|
||||
quickReply(model, append(ideas, [3]any{"Lentil Soup", "crockpot", 300})...)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "crockpot", "exclude": []}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
assertFailedWithoutIdeas(t, waitJob(t, h, body["jobId"].(string)), "search_failed")
|
||||
}
|
||||
|
||||
func TestJobsEndpointRefusesQuickKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "quick_ideas", "payload": {"kind": "crockpot", "exclude": []}}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
|
||||
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
@@ -43,7 +45,7 @@ type RouteMounter interface {
|
||||
// /api/v1/settings/recipe-search and its /test; ai serves
|
||||
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
||||
// chatSvc serves GET and PUT /api/v1/settings/chat-webhook and its /test;
|
||||
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
|
||||
// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE
|
||||
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
||||
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||
// suggestSvc serves POST /api/v1/suggestions/batches, GET
|
||||
@@ -62,12 +64,16 @@ type RouteMounter interface {
|
||||
// /api/v1/integrations/google; pushSvc serves GET
|
||||
// /api/v1/push/vapid-public-key, POST and DELETE
|
||||
// /api/v1/push/subscriptions and POST
|
||||
// /api/v1/settings/notifications/test-push; runner serves
|
||||
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
|
||||
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
||||
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
||||
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home, GET
|
||||
// /api/v1/updates and POST /api/v1/updates/seen; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, homeSvc *home.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -98,6 +104,12 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if pushSvc == nil {
|
||||
return nil, errors.New("server: nil push service")
|
||||
}
|
||||
if ideasSvc == nil {
|
||||
return nil, errors.New("server: nil ideas service")
|
||||
}
|
||||
if homeSvc == nil {
|
||||
return nil, errors.New("server: nil home service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -130,6 +142,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
sub.NotFound(notFound)
|
||||
}
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/home", getHome(logger, homeSvc))
|
||||
apiV1.Get("/updates", getUpdates(logger, homeSvc))
|
||||
apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
|
||||
@@ -149,6 +164,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc))
|
||||
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
|
||||
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
|
||||
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
|
||||
@@ -181,6 +197,10 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc))
|
||||
apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc))
|
||||
apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc))
|
||||
apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
|
||||
apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc))
|
||||
apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc))
|
||||
apiV1.Post("/ideas/{jobId}/items/{i}/cook-tonight", postIdeaCookTonight(logger, ideasSvc, dinnerSvc, planSvc))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
+129
-13
@@ -22,9 +22,12 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
@@ -49,13 +52,19 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
}
|
||||
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
||||
t.Helper()
|
||||
return newTestRouterWithHome(t, version, settings, runner, newTestHome(t))
|
||||
}
|
||||
|
||||
// newTestRouterWithHome is newTestRouterWith serving Home from homeSvc.
|
||||
func newTestRouterWithHome(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner, homeSvc *home.Service) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
assets := fstest.MapFS{
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), homeSvc, runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -165,7 +174,7 @@ func newTestDinners(t *testing.T) *dinners.Service {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -194,6 +203,30 @@ func newTestSuggest(t *testing.T) *suggest.Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestIdeas returns an ideas service with its own unstarted job runner
|
||||
// and generators that always fail.
|
||||
func newTestIdeas(t *testing.T) *ideas.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
fail := func(context.Context, string, skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.Ideas{}, errors.New("no model in this test")
|
||||
}
|
||||
failQuick := func(context.Context, string, skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
return skills.QuickIdeaList{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := ideas.NewService(st, g, fail, failQuick, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestPlan returns a plan service over a migrated temp database, with
|
||||
// its own unstarted job runner and a drafter that always fails.
|
||||
func newTestPlan(t *testing.T) *plan.Service {
|
||||
@@ -207,7 +240,7 @@ func newTestPlan(t *testing.T) *plan.Service {
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -232,6 +265,71 @@ func newTestShopping(t *testing.T) *shopping.Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestHome returns a home service whose services all share one
|
||||
// migrated temp database with zone America/New_York, with an unstarted job
|
||||
// runner, no model, and Google and Google Chat endpoints that refuse every
|
||||
// connection. One database keeps the routers that never ask for Home cheap.
|
||||
func newTestHome(t *testing.T) *home.Service {
|
||||
t.Helper()
|
||||
return newTestHomeOn(t, newTestStore(t))
|
||||
}
|
||||
|
||||
// newTestHomeOn returns a home service over st, a migrated database, which
|
||||
// it seeds with zone America/New_York and the seed notification preferences.
|
||||
func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
|
||||
t.Helper()
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
|
||||
googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
PublicURL: pub,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"},
|
||||
RevokeURL: "http://127.0.0.1:1/revoke",
|
||||
CalendarAPI: "http://127.0.0.1:1/calendar/v3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chatSvc, err := chat.NewService(st, newTestBox(t), pub, refuseTransport{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shop, err := shopping.NewService(st, untitled, runner, notifier, chatSvc, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := home.NewService(household.NewSettingsService(st), planSvc, suggestSvc, shop, notifier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// untitled is a shopping.Titler with no model: every Supply run it is
|
||||
// asked to title stays untitled.
|
||||
func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
@@ -292,7 +390,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -301,7 +399,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -310,7 +408,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -319,7 +417,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -328,7 +426,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -337,7 +435,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
@@ -346,7 +444,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
@@ -355,16 +453,34 @@ func TestNewRouterRequiresShopping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil shopping service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil ideas service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresHome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil home service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresGoogle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||
}
|
||||
}
|
||||
@@ -373,7 +489,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
@@ -39,11 +40,17 @@ const (
|
||||
// only when no job is running it. A job that runs after the freeze finds
|
||||
// the list titled, or frozen untitled, and stores nothing.
|
||||
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
||||
return s.freeze(ctx, weekStart, s.now())
|
||||
}
|
||||
|
||||
// freeze is Freeze with the week ended by instant now rather than by the
|
||||
// service's clock.
|
||||
func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now time.Time) error {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return fmt.Errorf("shopping: freeze: %w", err)
|
||||
}
|
||||
q := db.New(s.st.Writer)
|
||||
thisWeek, err := s.thisWeek(ctx, q)
|
||||
thisWeek, err := weekAt(ctx, q, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -73,9 +80,9 @@ func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
||||
}
|
||||
}
|
||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||
now := store.Now().String()
|
||||
stamp := store.Now().String()
|
||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
|
||||
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
@@ -103,6 +110,12 @@ func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (T
|
||||
// thisWeek returns the Monday of the household's current week, in its
|
||||
// time zone, by the service's clock.
|
||||
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
|
||||
return weekAt(ctx, q, s.now())
|
||||
}
|
||||
|
||||
// weekAt returns the Monday of the household's week at instant t, in its
|
||||
// time zone.
|
||||
func weekAt(ctx context.Context, q *db.Queries, t time.Time) (store.LocalDate, error) {
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
|
||||
@@ -111,7 +124,7 @@ func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate,
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
|
||||
}
|
||||
return household.WeekStart(household.Today(s.now(), zone)), nil
|
||||
return household.WeekStart(household.Today(t, zone)), nil
|
||||
}
|
||||
|
||||
// Period is one heading of the Supply archive and its posts, newest first.
|
||||
|
||||
@@ -37,9 +37,9 @@ func TestGenerateEmitsListReadyOnce(t *testing.T) {
|
||||
}
|
||||
for _, r := range rows {
|
||||
p := r.Payload
|
||||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
|
||||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping?week=2026-10-12" || p.Week != "2026-10-12" ||
|
||||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
|
||||
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
|
||||
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping?week=2026-10-12", r.Channel, r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package shopping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// RolloverTime is when, every Monday in the household zone, week_rollover
|
||||
// freezes the week that has just ended. It is fixed, not a setting.
|
||||
const RolloverTime = "00:05"
|
||||
|
||||
// Reasons Autosend posts nothing.
|
||||
const (
|
||||
AutosendNotConfigured = "no Google Chat webhook is configured"
|
||||
AutosendChatOff = "list_ready is switched off for Google Chat"
|
||||
AutosendNoList = "the week has no Supply run"
|
||||
AutosendAlreadySent = "the Supply run was already sent to Google Chat"
|
||||
AutosendUnconfirmed = "a send of the Supply run to Google Chat is unconfirmed"
|
||||
)
|
||||
|
||||
// AutosendKey identifies the automatic send of the Supply run of the week
|
||||
// starting weekStart, list_ready:chat:YYYY-MM-DD: it is the
|
||||
// shopping_chat_autosend period key claimed in schedule_runs. The send
|
||||
// itself is recorded in the list's sent_to_chat_at.
|
||||
func AutosendKey(weekStart store.LocalDate) string {
|
||||
return string(notify.ListReady) + ":" + string(notify.ChannelChat) + ":" + weekStart.String()
|
||||
}
|
||||
|
||||
// ChatAutosendSchedule is the shopping_chat_autosend schedule: at the
|
||||
// household's chat_autosend_dow and chat_autosend_time it sends the Supply
|
||||
// run of the coming week, the week after the fire date's, to Google Chat
|
||||
// with Autosend. Its period is that week, scheduler.WeeklyForNextWeek's,
|
||||
// claimed under AutosendKey of the week's Monday. A send that fails is the
|
||||
// period's error and is not retried; the household can still send it from
|
||||
// the Supply run.
|
||||
func (s *Service) ChatAutosendSchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.ShoppingChatAutosend,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.WeeklyForNextWeek(time.Weekday(settings.ChatAutosendDow), settings.ChatAutosendTime)
|
||||
},
|
||||
PeriodKey: func(p scheduler.Period) string {
|
||||
return AutosendKey(p.Week)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
skipped, err := s.Autosend(ctx, p.Week)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log := s.logger.With("period", p.Key, "week_start", p.Week.String())
|
||||
if skipped != "" {
|
||||
log.InfoContext(ctx, "Supply run not sent to Google Chat: "+skipped)
|
||||
return nil
|
||||
}
|
||||
log.InfoContext(ctx, "Supply run sent to Google Chat")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Autosend sends the Supply run of the week starting weekStart to Google
|
||||
// Chat with SendToChat, as the household's send does, when a webhook is
|
||||
// stored, list_ready's Google Chat channel is switched on, and the week has
|
||||
// a Supply run not yet sent and with no send unconfirmed. Otherwise it
|
||||
// posts nothing and returns which of those reasons stopped it; it never
|
||||
// resends. A send that fails is SendToChat's error.
|
||||
func (s *Service) Autosend(ctx context.Context, weekStart store.LocalDate) (skipped string, err error) {
|
||||
settings, err := s.chat.Get(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: autosend: %w", err)
|
||||
}
|
||||
if settings.URLMasked == nil {
|
||||
return AutosendNotConfigured, nil
|
||||
}
|
||||
on, err := s.notifier.ChannelOn(ctx, notify.ListReady, notify.ChannelChat)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: autosend: %w", err)
|
||||
}
|
||||
if !on {
|
||||
return AutosendChatOff, nil
|
||||
}
|
||||
l, err := s.Get(ctx, weekStart)
|
||||
if errors.Is(err, ErrListNotFound) {
|
||||
return AutosendNoList, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case l.SentToChatAt != nil:
|
||||
return AutosendAlreadySent, nil
|
||||
case l.ChatSendUnconfirmedAt != nil:
|
||||
return AutosendUnconfirmed, nil
|
||||
}
|
||||
if _, err := s.SendToChat(ctx, weekStart, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// RolloverSchedule is the week_rollover schedule: every Monday at
|
||||
// RolloverTime it freezes the Supply run of the week before the fire
|
||||
// date's into the Supply archive with Freeze, the week ending by the fire
|
||||
// time. Its period scheduler.Weekly keys week_rollover:YYYY-Www for the
|
||||
// fire date's week. A week with no Supply run has nothing to freeze, and
|
||||
// the period says so; a freeze that fails is the period's error and is not
|
||||
// retried.
|
||||
func (s *Service) RolloverSchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.WeekRollover,
|
||||
Rule: func(household.Settings) scheduler.Rule {
|
||||
return scheduler.Weekly(time.Monday, RolloverTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
y, m, d := p.Week.Date()
|
||||
last := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
err := s.freeze(ctx, last, p.FireAt)
|
||||
if errors.Is(err, ErrListNotFound) {
|
||||
s.logger.InfoContext(ctx, "week rollover froze nothing: the week has no Supply run",
|
||||
"period", p.Key, "week_start", last.String())
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -516,7 +516,7 @@ func TestGenerateManualRecipe(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dsvc, err := dinners.NewService(e.st, grounder, runner, logger)
|
||||
dsvc, err := dinners.NewService(e.st, grounder, runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+97
-37
@@ -22,6 +22,9 @@ import (
|
||||
// BatchJob is the KindSuggestionBatch payload: the batch to run.
|
||||
type BatchJob struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
// Week is the YYYY-MM-DD Monday of the ISO week a scheduled batch is
|
||||
// for, which keys its new_suggestions; empty for any other trigger.
|
||||
Week string `json:"week,omitempty"`
|
||||
}
|
||||
|
||||
// Batch stages, as the job's result reports them.
|
||||
@@ -50,19 +53,22 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
// wanted at a time, until BatchSize are kept or none are left. A Side quest
|
||||
// no recipe grounds is dropped; any other grounding failure fails the
|
||||
// batch, as does suggest_side_quests failing, with no Side quest made up
|
||||
// instead. Each round's grounded Side quests are stored before the next
|
||||
// round starts, so the deck fills as the batch goes. The batch ends done
|
||||
// or failed; Side quests stored before a failure stay in the deck. When
|
||||
// recording the batch's end fails, so does the job; when it times out at
|
||||
// shutdown, the job is left running and the batch with it, for the next
|
||||
// start to fail.
|
||||
// instead. The batch ends done or failed. When recording the batch's end
|
||||
// fails, so does the job; when it times out at shutdown, the job is left
|
||||
// running and the batch with it, for the next start to fail.
|
||||
//
|
||||
// A scheduled or manual batch emits new_suggestions, keyed by the batch,
|
||||
// in the transaction that first adds Side quests to the deck, so the
|
||||
// household is told of every Side quest stored, whether or not the batch
|
||||
// then ends done: a later round, or recording the batch's end, may fail.
|
||||
// A replenish batch, which tops up the deck as the household swipes, never
|
||||
// notifies. See store.
|
||||
// When a batch stores its Side quests depends on whether it notifies (see
|
||||
// noticeOf). A scheduled or manual batch notifies: it holds every round's
|
||||
// grounded Side quests and, once the last round is done, stores them all
|
||||
// in one transaction with one new_suggestions counting and naming every
|
||||
// Side quest stored. So the household is told of exactly what the batch
|
||||
// added, once; and a batch that fails at any round, including a later
|
||||
// round's normalize_recipe, stores nothing and sends nothing, leaving no
|
||||
// Side quest in the deck that was never announced. A replenish batch,
|
||||
// which tops up the deck as the household swipes and never notifies,
|
||||
// stores each round's Side quests before the next round starts, so the
|
||||
// deck fills as it goes; those stored before a failure stay in the deck.
|
||||
// See store.
|
||||
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
|
||||
batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID)
|
||||
if err != nil {
|
||||
@@ -78,7 +84,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
||||
if n == 0 {
|
||||
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
|
||||
}
|
||||
res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
|
||||
res, err := s.fill(ctx, batch.Trigger, p, publish)
|
||||
if err == nil {
|
||||
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
|
||||
return batchResult{}, err
|
||||
@@ -98,10 +104,12 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
||||
return batchResult{}, errors.Join(err, finishErr)
|
||||
}
|
||||
|
||||
// fill does runBatch's work, returning a *jobs.Failure for a failure the
|
||||
// household may see. When notifies is true, store emits new_suggestions
|
||||
// with the Side quests it adds.
|
||||
func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) {
|
||||
// fill does runBatch's work for job of a batch with trigger, returning a
|
||||
// *jobs.Failure for a failure the household may see. A batch with a notice
|
||||
// is stored once, when its last round is done; any other batch round by
|
||||
// round.
|
||||
func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publish func(batchResult) error) (batchResult, error) {
|
||||
batchID := job.BatchID
|
||||
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
@@ -110,6 +118,10 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
note, err := noticeOf(trigger, job, in.Context.Today)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
out, err := s.suggest(ctx, batchID, in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
@@ -122,34 +134,81 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
var week *store.LocalDate
|
||||
if notifies {
|
||||
w := household.WeekStart(in.Context.Today)
|
||||
week = &w
|
||||
}
|
||||
left := out.Suggestions
|
||||
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
|
||||
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
|
||||
// held are a notifying batch's grounded Side quests, not yet stored.
|
||||
var held []groundedQuest
|
||||
for len(res.SuggestionIDs)+len(held) < BatchSize && len(left) > 0 {
|
||||
round := left[:min(BatchSize-len(res.SuggestionIDs)-len(held), len(left))]
|
||||
left = left[len(round):]
|
||||
grounded, err := s.ground(ctx, round)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
res.Dropped += len(round) - len(grounded)
|
||||
ids, err := s.store(ctx, batchID, week, grounded)
|
||||
if err != nil {
|
||||
if note != nil {
|
||||
held = append(held, grounded...)
|
||||
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
res.Dropped += len(grounded) - len(ids)
|
||||
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
}
|
||||
if note != nil {
|
||||
if err := s.storeInto(ctx, &res, note, held); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
}
|
||||
res.Stage = StageDone
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// storeInto stores quests for res's batch with note (see store) and adds
|
||||
// the suggestions stored, and the Side quests left out, to res.
|
||||
func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error {
|
||||
ids, err := s.store(ctx, res.BatchID, note, quests)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Dropped += len(quests) - len(ids)
|
||||
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// notice is how a batch's new_suggestions is keyed: by id, as
|
||||
// new_suggestions:{id}, for the week starting on week.
|
||||
type notice struct {
|
||||
id string
|
||||
week store.LocalDate
|
||||
}
|
||||
|
||||
// noticeOf returns the notice of job, run for a batch with trigger on
|
||||
// household-local date today, or nil for a replenish batch, which never
|
||||
// notifies. A scheduled batch is keyed by the ISO week it was scheduled
|
||||
// for ("2026-W41"), not the week it happens to run in, so each week's
|
||||
// schedule tells the household once even when its batch runs late; a
|
||||
// manual batch is keyed by its batch id, for the week of today. A job
|
||||
// whose week does not fit its trigger is an error.
|
||||
func noticeOf(trigger string, job BatchJob, today store.LocalDate) (*notice, error) {
|
||||
if trigger != TriggerScheduled {
|
||||
if job.Week != "" {
|
||||
return nil, fmt.Errorf("suggest: %s batch %s has a week", trigger, job.BatchID)
|
||||
}
|
||||
if trigger == TriggerReplenish {
|
||||
return nil, nil
|
||||
}
|
||||
return ¬ice{id: job.BatchID, week: household.WeekStart(today)}, nil
|
||||
}
|
||||
week, err := store.ParseLocalDate(job.Week)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: scheduled batch %s week: %w", job.BatchID, err)
|
||||
}
|
||||
if household.WeekStart(week) != week {
|
||||
return nil, fmt.Errorf("suggest: scheduled batch %s week %s is not a Monday", job.BatchID, week)
|
||||
}
|
||||
return ¬ice{id: household.PeriodKey(week), week: week}, nil
|
||||
}
|
||||
|
||||
// request reads what suggest_side_quests is given: the household context,
|
||||
// the deck's names and every passed name (ListPassedNames). A household with no Regulars
|
||||
// fails with ReasonNoRegulars.
|
||||
@@ -224,13 +283,14 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
||||
// or archived. Passed names are read in the same transaction as the store,
|
||||
// so a Pass or drop committed before it is never missed.
|
||||
//
|
||||
// When week is not nil and a Side quest is stored, new_suggestions:{batchID}
|
||||
// naming those stored is emitted in the same transaction, for the week
|
||||
// starting week, so the Side quests and their notification commit together
|
||||
// When note is not nil and a Side quest is stored, new_suggestions keyed by
|
||||
// note, counting and naming those stored, is emitted in the same
|
||||
// transaction, so the Side quests and their notification commit together
|
||||
// or not at all: a notification that cannot be stored fails the store, and
|
||||
// the batch with it. The event's key makes the first round to store Side
|
||||
// quests the one that notifies; a later round's emit stores nothing.
|
||||
func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) {
|
||||
// the batch with it. A notifying batch calls store once, with all its
|
||||
// Side quests, so the count is the batch's (see runBatch). This is the
|
||||
// only place new_suggestions is emitted.
|
||||
func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) {
|
||||
ids := []string{}
|
||||
var stored []string
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
@@ -298,15 +358,15 @@ func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDa
|
||||
ids = append(ids, id)
|
||||
stored = append(stored, d.Name)
|
||||
}
|
||||
if week == nil || len(stored) == 0 {
|
||||
if note == nil || len(stored) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored))
|
||||
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(note.id, note.week, stored))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if week != nil && len(stored) > 0 {
|
||||
if note != nil && len(stored) > 0 {
|
||||
s.notifier.Wake()
|
||||
}
|
||||
return ids, nil
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
)
|
||||
|
||||
// WeeklySchedule is the suggestions_weekly schedule: at the household's
|
||||
// suggestions_dow and suggestions_time it starts a scheduled batch for the
|
||||
// ISO week of the fire date, its period scheduler.Weekly keys
|
||||
// suggestions_weekly:YYYY-Www. The batch tells the household of the Side
|
||||
// quests it adds with new_suggestions:YYYY-Www for that week, once; a batch
|
||||
// that fails before adding any, as when the AI fails, sends nothing. When a
|
||||
// batch is already pending or running the period starts none, and says so.
|
||||
func (s *Service) WeeklySchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.SuggestionsWeekly,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.Weekly(time.Weekday(settings.SuggestionsDow), settings.SuggestionsTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
b, err := s.StartScheduledBatch(ctx, p.Week)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !b.Created {
|
||||
s.logger.WarnContext(ctx, "scheduled suggestion batch not started: a batch is already under way",
|
||||
"period", p.Key, "batch_id", b.ID, "trigger", b.Trigger)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"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/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
@@ -154,17 +155,35 @@ type Batch struct {
|
||||
Created bool `json:"-"`
|
||||
}
|
||||
|
||||
// StartBatch creates a batch with trigger and enqueues the
|
||||
// StartBatch creates a manual or replenish batch and enqueues the
|
||||
// KindSuggestionBatch job that runs it, unless a batch is already pending
|
||||
// or running, which it returns instead with Created false.
|
||||
// or running, which it returns instead with Created false. A scheduled
|
||||
// batch is started by StartScheduledBatch.
|
||||
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
|
||||
if trigger == TriggerScheduled {
|
||||
return Batch{}, errors.New("suggest: a scheduled batch is started by StartScheduledBatch")
|
||||
}
|
||||
if !slices.Contains(triggers, trigger) {
|
||||
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
|
||||
}
|
||||
return s.start(ctx, trigger, "")
|
||||
}
|
||||
|
||||
// StartScheduledBatch is StartBatch for a scheduled batch for the ISO week
|
||||
// starting on Monday week, which keys its new_suggestions (see noticeOf).
|
||||
func (s *Service) StartScheduledBatch(ctx context.Context, week store.LocalDate) (Batch, error) {
|
||||
if week.IsZero() || household.WeekStart(week) != week {
|
||||
return Batch{}, fmt.Errorf("suggest: scheduled batch week %s is not a Monday", week)
|
||||
}
|
||||
return s.start(ctx, TriggerScheduled, week.String())
|
||||
}
|
||||
|
||||
// start does StartBatch's work for a batch with trigger and the job week.
|
||||
func (s *Service) start(ctx context.Context, trigger, week string) (Batch, error) {
|
||||
var b Batch
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
b, err = s.startBatch(ctx, tx, trigger)
|
||||
b, err = s.startBatch(ctx, tx, trigger, week)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -228,10 +247,11 @@ func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
|
||||
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
|
||||
}
|
||||
|
||||
// startBatch does StartBatch's work in tx: the batch and the job that runs
|
||||
// it are committed together, so a batch is never seen without its job. The
|
||||
// caller wakes the job runner once tx has committed and Created is true.
|
||||
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
|
||||
// startBatch does StartBatch's work in tx, for a batch with trigger whose
|
||||
// job has week (BatchJob.Week): the batch and the job that runs it are
|
||||
// committed together, so a batch is never seen without its job. The caller
|
||||
// wakes the job runner once tx has committed and Created is true.
|
||||
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger, week string) (Batch, error) {
|
||||
q := db.New(tx)
|
||||
cur, err := s.activeBatch(ctx, q)
|
||||
if err != nil {
|
||||
@@ -250,7 +270,7 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
|
||||
}
|
||||
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
|
||||
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID, Week: week})
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
|
||||
}
|
||||
@@ -346,6 +366,17 @@ func (s *Service) Deck(ctx context.Context) (Deck, error) {
|
||||
return deck, nil
|
||||
}
|
||||
|
||||
// PendingCount is how many Side quests are pending on the deck. Unlike
|
||||
// Deck it only reads: it does not fail orphaned batches, so a reader such
|
||||
// as Home never writes.
|
||||
func (s *Service) PendingCount(ctx context.Context) (int, error) {
|
||||
rows, err := db.New(s.st.Reader).ListDeck(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("suggest: list deck: %w", err)
|
||||
}
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
// Decision is a suggestion once decided, with the id of the replenish
|
||||
// batch's job when the decision called for one: the batch it started, or
|
||||
// the one already pending or running that it joined.
|
||||
@@ -428,7 +459,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
|
||||
if left >= ReplenishBelow {
|
||||
return nil
|
||||
}
|
||||
b, err := s.startBatch(ctx, tx, TriggerReplenish)
|
||||
b, err := s.startBatch(ctx, tx, TriggerReplenish, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -300,6 +300,45 @@ func (e *env) runBatch(t *testing.T) batchResult {
|
||||
return res
|
||||
}
|
||||
|
||||
// scheduledWeek is the week the tests' scheduled batches are for: the
|
||||
// Monday of 2026-W41.
|
||||
var scheduledWeek = mustDate("2026-10-05")
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func mustZone(t *testing.T, name string) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := household.LoadZone(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// startBatch starts a batch with trigger, a scheduled one for
|
||||
// scheduledWeek, and returns it with the key its new_suggestions has.
|
||||
func (e *env) startBatch(t *testing.T, trigger string) (suggest.Batch, string) {
|
||||
t.Helper()
|
||||
if trigger == suggest.TriggerScheduled {
|
||||
b, err := e.svc.StartScheduledBatch(t.Context(), scheduledWeek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b, "new_suggestions:2026-W41"
|
||||
}
|
||||
b, err := e.svc.StartBatch(t.Context(), trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b, "new_suggestions:" + b.ID
|
||||
}
|
||||
|
||||
func (e *env) batchStatus(t *testing.T, id string) string {
|
||||
t.Helper()
|
||||
var status string
|
||||
@@ -404,8 +443,9 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
|
||||
// quests stores new_suggestions keyed by the batch, naming them; a
|
||||
// replenish batch, or one that adds none, stores nothing.
|
||||
// quests stores new_suggestions counting and naming them, keyed by the
|
||||
// scheduled ISO week or the manual batch, for that week or the current
|
||||
// one; a replenish batch, or one that adds none, stores nothing.
|
||||
func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
@@ -428,10 +468,7 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
|
||||
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
|
||||
)
|
||||
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, key := e.startBatch(t, tc.trigger)
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s", job.Status)
|
||||
}
|
||||
@@ -446,64 +483,70 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
|
||||
r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" {
|
||||
t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID)
|
||||
week := household.WeekStart(store.LocalDateOf(time.Now().In(mustZone(t, "America/New_York")))).String()
|
||||
if tc.trigger == suggest.TriggerScheduled {
|
||||
week = scheduledWeek.String()
|
||||
}
|
||||
if r.Key != key || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
|
||||
r.Payload.Title != "2 new Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week != week {
|
||||
t.Errorf("new_suggestions row = %+v, want key %s counting and naming the Side quests, week %s, /discover", r, key, week)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchNotifiesAsSideQuestsAreStored: new_suggestions is stored with
|
||||
// the first round to add Side quests, so a batch whose later round fails
|
||||
// still tells the household of those in the deck, once; a later round that
|
||||
// succeeds adds no second notification; a replenish batch notifies nothing.
|
||||
func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
|
||||
// twoRoundBatch scripts a suggest_side_quests reply of BatchSize+1 Side
|
||||
// quests, Fajita Dish A to F, whose first round grounds all but B, so a
|
||||
// second round grounds F. Unless lastGrounds, normalize_recipe fails for
|
||||
// F.
|
||||
func (e *env) twoRoundBatch(t *testing.T, lastGrounds bool) {
|
||||
t.Helper()
|
||||
var quests []string
|
||||
for i := range suggest.BatchSize + 1 {
|
||||
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
|
||||
e.search.Results[name] = nil
|
||||
switch {
|
||||
case i == suggest.BatchSize && !lastGrounds:
|
||||
// A page is found, but no normalize_recipe reply is scripted
|
||||
// for it, so the AI call grounding it fails.
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
e.search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
e.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
|
||||
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
|
||||
case i != 1:
|
||||
e.recipe(t, name)
|
||||
}
|
||||
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
|
||||
}
|
||||
e.reply(quests...)
|
||||
}
|
||||
|
||||
// TestBatchNotifiesOnceWithEverySideQuestStored: a batch that fills across
|
||||
// two grounding rounds stores new_suggestions once, when it completes,
|
||||
// counting and naming every Side quest it stored, not just the first
|
||||
// round's; a replenish batch notifies nothing.
|
||||
func TestBatchNotifiesOnceWithEverySideQuestStored(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
lastFails bool
|
||||
notify bool
|
||||
trigger string
|
||||
notify bool
|
||||
}{
|
||||
{suggest.TriggerScheduled, true, true},
|
||||
{suggest.TriggerManual, true, true},
|
||||
{suggest.TriggerManual, false, true},
|
||||
{suggest.TriggerReplenish, true, false},
|
||||
{suggest.TriggerScheduled, true},
|
||||
{suggest.TriggerManual, true},
|
||||
{suggest.TriggerReplenish, false},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s last round fails %v", tc.trigger, tc.lastFails), func(t *testing.T) {
|
||||
t.Run(tc.trigger, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
// The first round grounds BatchSize Side quests but B; the
|
||||
// second round grounds F.
|
||||
var quests []string
|
||||
for i := range suggest.BatchSize + 1 {
|
||||
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
|
||||
e.search.Results[name] = nil
|
||||
if i != 1 {
|
||||
e.recipe(t, name)
|
||||
}
|
||||
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
|
||||
}
|
||||
e.reply(quests...)
|
||||
if tc.lastFails {
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_f BEFORE INSERT ON dinners WHEN NEW.name = 'Fajita Dish F' BEGIN SELECT RAISE(ABORT, 'F refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
e.twoRoundBatch(t, true)
|
||||
|
||||
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
b, key := e.startBatch(t, tc.trigger)
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s, want succeeded", job.Status)
|
||||
}
|
||||
job := e.waitJob(t, b.JobID)
|
||||
want, deck := jobs.StatusSucceeded, suggest.BatchSize
|
||||
if tc.lastFails {
|
||||
want, deck = jobs.StatusFailed, suggest.BatchSize-1
|
||||
}
|
||||
if job.Status != want {
|
||||
t.Fatalf("batch job %s, want %s", job.Status, want)
|
||||
}
|
||||
if got := e.deckNames(t); len(got) != deck {
|
||||
t.Fatalf("deck = %v, want %d Side quests", got, deck)
|
||||
deck := e.deckNames(t)
|
||||
if want := []string{"Fajita Dish A", "Fajita Dish C", "Fajita Dish D", "Fajita Dish E", "Fajita Dish F"}; !slices.Equal(deck, want) {
|
||||
t.Fatalf("deck = %v, want %v", deck, want)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
if !tc.notify {
|
||||
@@ -512,9 +555,47 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID ||
|
||||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID)
|
||||
want := fmt.Sprintf("%d new Side quests are in", len(deck))
|
||||
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != want ||
|
||||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 2 more" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchFailingInALaterRoundStoresNothing: when normalize_recipe fails
|
||||
// in a batch's second grounding round, a scheduled or manual batch stores
|
||||
// none of the first round's Side quests and sends no new_suggestions, so
|
||||
// no Side quest is left in the deck unannounced; a replenish batch, which
|
||||
// never notifies, keeps the first round's in the deck.
|
||||
func TestBatchFailingInALaterRoundStoresNothing(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
deck int
|
||||
}{
|
||||
{suggest.TriggerScheduled, 0},
|
||||
{suggest.TriggerManual, 0},
|
||||
{suggest.TriggerReplenish, suggest.BatchSize - 1},
|
||||
} {
|
||||
t.Run(tc.trigger, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.twoRoundBatch(t, false)
|
||||
|
||||
b, _ := e.startBatch(t, tc.trigger)
|
||||
job := e.waitJob(t, b.JobID)
|
||||
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != suggest.ReasonAIFailed {
|
||||
t.Fatalf("batch job %s, error %+v, want failed %s", job.Status, job.Error, suggest.ReasonAIFailed)
|
||||
}
|
||||
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
|
||||
t.Errorf("batch status = %s, want failed", got)
|
||||
}
|
||||
if got := e.deckNames(t); len(got) != tc.deck {
|
||||
t.Errorf("deck = %v, want %d Side quests", got, tc.deck)
|
||||
}
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 {
|
||||
t.Errorf("new_suggestions rows = %+v, want none", rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1021,7 +1102,7 @@ func TestBatchNeverStoresDishPassedWhileRunning(t *testing.T) {
|
||||
func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) {
|
||||
e := newEnv(t) // runner not started: the replenish batch waits for the rename
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1069,14 +1150,14 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no draft in this test")
|
||||
}
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1299,7 +1380,7 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
|
||||
t.Errorf("batch status = %s with its job failed, want failed", got)
|
||||
}
|
||||
// Its Side quests stay in the deck, and the household was told of them.
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Body != "Chili Verde" {
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Title != "1 new Side quest is in" || rows[0].Payload.Body != "Chili Verde" {
|
||||
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
|
||||
}
|
||||
}
|
||||
@@ -1474,6 +1555,47 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduledBatchNeedsItsWeek: a scheduled batch is started only by
|
||||
// StartScheduledBatch, for a Monday.
|
||||
func TestScheduledBatchNeedsItsWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.StartBatch(t.Context(), suggest.TriggerScheduled); err == nil {
|
||||
t.Error("StartBatch started a scheduled batch with no week")
|
||||
}
|
||||
for _, week := range []store.LocalDate{{}, mustDate("2026-10-06")} {
|
||||
if _, err := e.svc.StartScheduledBatch(t.Context(), week); err == nil {
|
||||
t.Errorf("StartScheduledBatch(%q) accepted a week that is not a Monday", week)
|
||||
}
|
||||
}
|
||||
if n := e.activeBatches(t); n != 0 {
|
||||
t.Errorf("%d active batches, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduledBatchKeyedByItsWeekWhenRunLate: a scheduled batch's
|
||||
// new_suggestions is keyed by the week it was scheduled for, not the week
|
||||
// it runs in, so a batch caught up after its week ended still tells the
|
||||
// household of that week's Side quests and leaves the next week's key free.
|
||||
func TestScheduledBatchKeyedByItsWeekWhenRunLate(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.recipe(t, "Chili Verde")
|
||||
e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"))
|
||||
// A week long past: the batch runs today, weeks later.
|
||||
b, err := e.svc.StartScheduledBatch(t.Context(), mustDate("2026-01-05"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s", job.Status)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
if len(rows) != 1 || rows[0].Key != "new_suggestions:2026-W02" || rows[0].Payload.Week != "2026-01-05" ||
|
||||
rows[0].Payload.Title != "1 new Side quest is in" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed new_suggestions:2026-W02 for 2026-01-05", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// noCalendar is a plan.Calendar for tests that never push.
|
||||
type noCalendar struct{}
|
||||
|
||||
|
||||
@@ -66,3 +66,28 @@ SELECT * FROM notification_prefs;
|
||||
UPDATE notification_prefs
|
||||
SET push = ?, chat = ?, updated_at = ?
|
||||
WHERE event = ?;
|
||||
|
||||
-- name: HasNotificationSince :one
|
||||
-- Reports whether a notification was stored after since, or at all when
|
||||
-- since is NULL.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notification_outbox
|
||||
WHERE CAST(sqlc.narg(since) AS TEXT) IS NULL OR created_at > CAST(sqlc.narg(since) AS TEXT)
|
||||
) AS found;
|
||||
|
||||
-- name: ListRecentNotifications :many
|
||||
-- Returns the last lim distinct events stored, newest first: one row per
|
||||
-- event key, the last stored for it whatever its channel, so an event
|
||||
-- stored for push and Google Chat is one row. The last, because a channel
|
||||
-- switched on later can store the same event key again, and
|
||||
-- HasNotificationSince counts that row: an event is as new as its newest
|
||||
-- row, so marking it seen also covers that row.
|
||||
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
|
||||
WHERE o.id = (
|
||||
SELECT i.id FROM notification_outbox i
|
||||
WHERE i.event_key = o.event_key
|
||||
ORDER BY i.created_at DESC, i.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY o.created_at DESC, o.event_key DESC
|
||||
LIMIT sqlc.arg(lim);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- name: ClaimScheduleRun :execrows
|
||||
-- Claims one period of a schedule. The insert is the claim: 1 row means
|
||||
-- this caller owns the period, 0 rows means it was already claimed.
|
||||
INSERT INTO schedule_runs (id, schedule, period_key, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (schedule, period_key) DO NOTHING;
|
||||
@@ -154,3 +154,11 @@ RETURNING *;
|
||||
UPDATE chat_settings
|
||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||
WHERE id = 1 AND webhook_url_ct = ?;
|
||||
|
||||
-- name: MarkUpdatesSeen :execrows
|
||||
-- Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||
-- already at or after seen_at, so it never moves back. It is not a settings
|
||||
-- edit: updated_by and updated_at are left as they are.
|
||||
UPDATE household_settings
|
||||
SET updates_seen_at = CAST(sqlc.arg(seen_at) AS TEXT)
|
||||
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(sqlc.arg(seen_at) AS TEXT));
|
||||
|
||||
@@ -121,3 +121,14 @@ WHERE id = sqlc.arg(id) AND status = 'pending';
|
||||
UPDATE dinners
|
||||
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id) AND status = 'suggested';
|
||||
|
||||
-- name: GetDinnerSuggestionReasons :many
|
||||
-- The reason of each suggestion of dinner_id that can have created it,
|
||||
-- none for a dinner no suggestion created. A suggestion is only ever
|
||||
-- created with a new dinner, and declining one archives its dinner, so a
|
||||
-- live dinner's creating suggestion is pending (the dinner still
|
||||
-- suggested) or accepted; a declined one did not create it. More than one
|
||||
-- row means nothing records which did.
|
||||
SELECT reason FROM suggestions
|
||||
WHERE dinner_id = ? AND status IN ('pending', 'accepted')
|
||||
ORDER BY id;
|
||||
|
||||
+49
-26
@@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, HOME, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
import { dinnerBody } from './test/dinners.ts'
|
||||
import { defaultWeek } from './plan/week.ts'
|
||||
@@ -30,11 +30,15 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
|
||||
// The routes that render a built screen rather than a placeholder page.
|
||||
const SCREENS = {
|
||||
'/': true,
|
||||
'/onboarding': true,
|
||||
'/plan': true,
|
||||
'/plan/shopping': true,
|
||||
'/plan/shopping/archive': true,
|
||||
'/discover': true,
|
||||
'/quick': true,
|
||||
'/cook-from-pantry': true,
|
||||
'/dinner/:id': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
@@ -82,6 +86,16 @@ describe('App routes', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('/ renders the Home screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
|
||||
renderAt('/')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings renders the settings home inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
@@ -206,19 +220,19 @@ describe('App first-run redirect', () => {
|
||||
it('opens the routes once onboarding is finished', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt('/quick')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
renderAt('/settings/notifications')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -226,12 +240,12 @@ describe('App bootstrap', () => {
|
||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||
mockViewport(390)
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
||||
})
|
||||
@@ -259,12 +273,19 @@ describe('App bootstrap', () => {
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
const mes = [errorResponse(500, 'internal'), jsonResponse(200, ME)]
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/me': () => mes.shift()!,
|
||||
'/api/v1/home': () => jsonResponse(200, HOME),
|
||||
'/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }),
|
||||
})
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -279,9 +300,9 @@ describe('App offline bootstrap', () => {
|
||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||
// member, then the app is closed.
|
||||
async function visitOnline() {
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) })
|
||||
const { unmount } = renderAt('/')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
|
||||
@@ -292,15 +313,17 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
|
||||
// A test that emits an offline event leaves TanStack Query's online
|
||||
// manager, shared by every test, offline until an online event.
|
||||
// manager, shared by every test, offline until an online event. The
|
||||
// manager listens for it only while a query client is mounted, so the
|
||||
// event goes out before cleanup unmounts the app.
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
window.dispatchEvent(new Event('online'))
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/quick', 'Quick dinners'],
|
||||
['/dinner/01J9ZK', 'Dinner'],
|
||||
['/', "Tonight's quest"],
|
||||
['/settings/notifications', 'Notifications'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
@@ -323,7 +346,7 @@ describe('App offline bootstrap', () => {
|
||||
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
||||
).mockRejectedValue(new TypeError('Failed to fetch')),
|
||||
)
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
await vi.waitFor(() => expect(failMe).toBeDefined())
|
||||
onLine = false
|
||||
act(() => {
|
||||
@@ -331,7 +354,7 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
act(() => failMe())
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
@@ -340,15 +363,15 @@ describe('App offline bootstrap', () => {
|
||||
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
||||
await visitOnline()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
})
|
||||
|
||||
it('reports the failure offline when this device never verified a session', async () => {
|
||||
goOffline()
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
@@ -357,12 +380,12 @@ describe('App offline bootstrap', () => {
|
||||
it('reports the failure offline after the session was refused', async () => {
|
||||
await visitOnline()
|
||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const { location, unmount } = renderAt('/quick')
|
||||
const { location, unmount } = renderAt('/settings/notifications')
|
||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
|
||||
unmount()
|
||||
|
||||
goOffline()
|
||||
renderAt('/quick')
|
||||
renderAt('/settings/notifications')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -370,13 +393,13 @@ describe('App offline bootstrap', () => {
|
||||
it('loads /me again on reconnect and refuses writes until it has', async () => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation('/settings/notifications'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<InDataRouter path="/quick">
|
||||
<InDataRouter path="/settings/notifications">
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</InDataRouter>,
|
||||
)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
|
||||
const fetchMock = stubFetchByPath(SIGNED_IN)
|
||||
@@ -391,7 +414,7 @@ describe('App offline bootstrap', () => {
|
||||
]),
|
||||
)
|
||||
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings/calendar renders the Google Calendar screen inside the shell', async () => {
|
||||
|
||||
@@ -3,7 +3,11 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { HomePage } from './home/HomePage.tsx'
|
||||
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
||||
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
@@ -42,10 +46,14 @@ export const routes = [
|
||||
|
||||
// The screens built so far; every other route shows its placeholder page.
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/': <HomePage />,
|
||||
'/plan': <PlanPage />,
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/quick': <QuickDinnersPage />,
|
||||
'/cook-from-pantry': <CookFromPantryPage />,
|
||||
'/dinner/:id': <DinnerDetailPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/notifications': <NotificationsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* A week with no plan: why there's no day, over the link to draft it. */
|
||||
.noPlan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { usePlan, useWeekSwap, type PlanDinner, type Swap } from '../plan/api.ts'
|
||||
import { cooking } from '../plan/PlanPage.tsx'
|
||||
import swapStyles from '../plan/SwapSheet.module.css'
|
||||
import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||
import { useHouseholdSettings } from '../settings/api.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { ListRow } from '../ui/ListRow.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import type { DinnerDetail } from './detail.ts'
|
||||
import styles from './AddToWeekSheet.module.css'
|
||||
|
||||
// PLAN is the Draft board, where a week with no plan is drafted.
|
||||
export const PLAN = '/plan'
|
||||
|
||||
// Which is the week a dinner is added to: the household's current week or
|
||||
// the one after.
|
||||
export type Which = 'this' | 'next'
|
||||
|
||||
export const weekNames: Record<Which, string> = { this: 'this week', next: 'next week' }
|
||||
|
||||
// Added is a swap sent from the sheet: the week it went to and the swap.
|
||||
export type Added = { week: string; which: Which; swap: Swap }
|
||||
|
||||
// planDinner is the dinner as it is planned on a day.
|
||||
function planDinner(dinner: DinnerDetail): PlanDinner {
|
||||
return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method }
|
||||
}
|
||||
|
||||
type WeekDaysProps = {
|
||||
dinner: DinnerDetail
|
||||
week: string
|
||||
which: Which
|
||||
onAdd: (added: Added) => void
|
||||
}
|
||||
|
||||
// WeekDays is the seven days of the week starting on week, each with its
|
||||
// dinner; choosing one swaps the dinner in as the Draft board swaps a day
|
||||
// (useWeekSwap). A week with no plan offers to draft it on the Draft board:
|
||||
// nothing here makes a plan up. A dinner already on the week can't be added
|
||||
// again, nor can one while a swap of the week is still being saved or the
|
||||
// week is being pushed to Calendar.
|
||||
function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
|
||||
const plan = usePlan(week)
|
||||
const { swap, push, refused, swapIn } = useWeekSwap(week)
|
||||
|
||||
if (plan.isError) {
|
||||
return <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message}</ErrorBanner>
|
||||
}
|
||||
if (plan.isPending) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label="Plan loading" className={swapStyles.rows}>
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<Skeleton key={i} className={swapStyles.skeletonRow} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (plan.data === null) {
|
||||
return (
|
||||
<div className={styles.noPlan}>
|
||||
<p className={swapStyles.empty}>There's no plan for {weekNames[which]} yet, so there's no day to add it to.</p>
|
||||
<Link to={PLAN} className={[buttonStyles.button, buttonStyles.primary].join(' ')}>
|
||||
Draft {weekNames[which]} first
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const days = plan.data.days
|
||||
const planned = days.find((day) => day.dinner.id === dinner.id)
|
||||
const choosable = planned === undefined && !refused
|
||||
function add(date: string) {
|
||||
const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' }
|
||||
swapIn(chosen)
|
||||
onAdd({ week, which, swap: chosen })
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{planned !== undefined ? (
|
||||
<p className={swapStyles.empty}>
|
||||
{dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}.
|
||||
</p>
|
||||
) : swap.pending ? (
|
||||
<p className={swapStyles.empty}>A change to {weekNames[which]} is still being saved.</p>
|
||||
) : (
|
||||
push.pending && <p className={swapStyles.empty}>The plan for {weekNames[which]} is being pushed to Calendar.</p>
|
||||
)}
|
||||
<ul aria-label={`Days of ${weekNames[which]}`} className={swapStyles.rows}>
|
||||
{days.map((day) => (
|
||||
<li key={day.date}>
|
||||
<ListRow
|
||||
className={choosable ? undefined : swapStyles.already}
|
||||
leading={
|
||||
// The row's dinner names what the photo shows.
|
||||
<span aria-hidden="true">
|
||||
<Photo id={day.dinner.photo_id} name={day.dinner.name} radius="var(--radius-md)" sizes="48px" className={swapStyles.photo} />
|
||||
</span>
|
||||
}
|
||||
title={`${weekdayName(day.date)} ${dayOfMonth(day.date)}`}
|
||||
meta={[day.dinner.name, cooking(day.dinner)].filter(Boolean).join(' · ')}
|
||||
onClick={choosable ? () => add(day.date) : undefined}
|
||||
trailing={day === planned && <Tag>This dinner</Tag>}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type AddToWeekSheetProps = {
|
||||
dinner: DinnerDetail
|
||||
onAdd: (added: Added) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// AddToWeekSheet is the sheet Add to this week opens: the seven days of the
|
||||
// household's current week, Monday to Sunday in its timezone, each with its
|
||||
// dinner, and a toggle to next week. Choosing a day replaces its dinner.
|
||||
export function AddToWeekSheet({ dinner, onAdd, onClose }: AddToWeekSheetProps) {
|
||||
const titleId = useId()
|
||||
const settings = useHouseholdSettings()
|
||||
const [which, setWhich] = useState<Which>('this')
|
||||
|
||||
let body
|
||||
if (settings.isError) {
|
||||
body = (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
Couldn't load the household settings. {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
} else if (settings.isPending) {
|
||||
body = <Skeleton className={swapStyles.skeletonRow} />
|
||||
} else {
|
||||
const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date)
|
||||
const week = which === 'this' ? thisWeek : addDays(thisWeek, 7)
|
||||
body = (
|
||||
<>
|
||||
<p className={swapStyles.replaces}>
|
||||
{weekLabel(week).dates} · replaces the day's dinner
|
||||
</p>
|
||||
<Segmented
|
||||
label="Week"
|
||||
options={[
|
||||
{ value: 'this', label: 'This week' },
|
||||
{ value: 'next', label: 'Next week' },
|
||||
]}
|
||||
value={which}
|
||||
onValueChange={setWhich}
|
||||
/>
|
||||
<WeekDays key={week} dinner={dinner} week={week} which={which} onAdd={onAdd} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||
<div className={swapStyles.sheet}>
|
||||
<h2 id={titleId} className={swapStyles.title}>
|
||||
Add {dinner.name}
|
||||
</h2>
|
||||
{body}
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { PlanPage } from '../plan/PlanPage.tsx'
|
||||
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
||||
|
||||
const DINNER = '/api/v1/dinners/d1'
|
||||
const FAVORITE = `${DINNER}/favorite`
|
||||
const THIS_WEEK = '/api/v1/plans/2026-10-05'
|
||||
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
|
||||
const THURSDAY = `${THIS_WEEK}/days/2026-10-08`
|
||||
|
||||
// detail is the Korean beef bowls, a Quest-log Side quest, as GET
|
||||
// /api/v1/dinners/{id} sends it.
|
||||
function detail(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
status: 'quest_log',
|
||||
origin: 'suggested',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: null,
|
||||
ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }],
|
||||
steps: ['Brown the beef with the sauce.'],
|
||||
source: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
why_picked: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// listed is the bowls as the dinners list, and so the favorite toggle,
|
||||
// answers: no recipe.
|
||||
function listed(is_favorite: boolean) {
|
||||
return {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
is_favorite,
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
}
|
||||
}
|
||||
|
||||
const THIS_NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Lemon orzo', 'Chicken stir-fry', 'Pizza night', 'Fish tacos', 'Sheet-pan salmon']
|
||||
const NEXT_NAMES = ['Chili', 'Pad thai', 'Burgers', 'Ramen', 'Pizza night', 'Pot roast', 'Fried rice']
|
||||
|
||||
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it, with
|
||||
// dinner ids p0 to p6 unless ids says otherwise.
|
||||
function planBody(weekStart: string, names: string[], ids = names.map((_, i) => `p${i}`)) {
|
||||
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
|
||||
return {
|
||||
id: `plan-${weekStart}`,
|
||||
week_start: weekStart,
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null as string | null,
|
||||
days: names.map((name, i) => ({
|
||||
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
||||
dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' },
|
||||
is_new: false,
|
||||
start_by: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// THURSDAY_SWAPPED is this week's plan as the swap of Thursday answers it.
|
||||
function thursdaySwapped() {
|
||||
const ids = THIS_NAMES.map((_, i) => (i === 3 ? 'd1' : `p${i}`))
|
||||
const names = THIS_NAMES.map((name, i) => (i === 3 ? 'Korean beef bowls' : name))
|
||||
const plan = planBody('2026-10-05', names, ids)
|
||||
plan.days[3]!.is_new = true
|
||||
return plan
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me, the
|
||||
// household settings and the dinner; any other request fails the test.
|
||||
function stubServer(responders: Record<string, Responder> = {}) {
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
[`GET ${DINNER}`]: () => jsonResponse(200, detail()),
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// sent lists the requests sent with method, as "path body".
|
||||
function sent(fetchMock: Mock<typeof fetch>, method: string) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => init?.method === method)
|
||||
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
|
||||
}
|
||||
|
||||
function reads(fetchMock: Mock<typeof fetch>, path: string) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length
|
||||
}
|
||||
|
||||
// deferred is a response the test sends when it chooses.
|
||||
function deferred() {
|
||||
let send!: (response: Response) => void
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
send = resolve
|
||||
})
|
||||
return { response, send }
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the bowls' detail screen, with plan for the Plan: a probe unless told
|
||||
// otherwise. It answers with the router.
|
||||
async function renderPage(plan = <h1>Plan probe</h1>) {
|
||||
const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/dinner/:id', element: <DinnerDetailPage /> },
|
||||
{ path: '/plan', element: plan },
|
||||
],
|
||||
{ initialEntries: ['/dinner/d1'] },
|
||||
)
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
return router
|
||||
}
|
||||
|
||||
// openSheet opens Add to this week and waits for the week's days.
|
||||
async function openSheet(listName = 'Days of this week') {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
|
||||
const days = await within(sheet).findByRole('list', { name: listName })
|
||||
return { sheet, days }
|
||||
}
|
||||
|
||||
// dayRows is each day's row text, as "Monday 5 · Beef tacos · Stovetop · 30 min",
|
||||
// with plain spaces.
|
||||
function dayRows(days: HTMLElement) {
|
||||
return within(days)
|
||||
.getAllByRole('listitem')
|
||||
.map((row) => [...row.querySelectorAll('span')].filter((span) => span.children.length === 0 && !span.closest('[aria-hidden="true"]')).map((span) => span.textContent).join(' · ').replace(/\s/g, ' '))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(390)
|
||||
// Wednesday Oct 7, noon in New York.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Add to this week', () => {
|
||||
it('lists the seven days of this week with their dinners, and next week by the toggle', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
|
||||
[`GET ${NEXT_WEEK}`]: () => jsonResponse(200, planBody('2026-10-12', NEXT_NAMES)),
|
||||
})
|
||||
await renderPage()
|
||||
// Nothing about the week is read until the sheet opens.
|
||||
expect(reads(fetchMock, THIS_WEEK)).toBe(0)
|
||||
|
||||
const { sheet, days } = await openSheet()
|
||||
expect(within(sheet).getByText("Oct 5 – 11 · replaces the day's dinner")).toBeInTheDocument()
|
||||
expect(dayRows(days)).toEqual([
|
||||
'Monday 5 · Beef tacos · Stovetop · 30 min',
|
||||
'Tuesday 6 · Spaghetti bolognese · Stovetop · 30 min',
|
||||
'Wednesday 7 · Lemon orzo · Stovetop · 30 min',
|
||||
'Thursday 8 · Chicken stir-fry · Stovetop · 30 min',
|
||||
'Friday 9 · Pizza night · Stovetop · 30 min',
|
||||
'Saturday 10 · Fish tacos · Stovetop · 30 min',
|
||||
'Sunday 11 · Sheet-pan salmon · Stovetop · 30 min',
|
||||
])
|
||||
expect(within(days).getAllByRole('button')).toHaveLength(7)
|
||||
|
||||
fireEvent.click(within(sheet).getByRole('radio', { name: 'Next week' }))
|
||||
const next = await within(sheet).findByRole('list', { name: 'Days of next week' })
|
||||
expect(within(sheet).getByText("Oct 12 – 18 · replaces the day's dinner")).toBeInTheDocument()
|
||||
expect(dayRows(next)[0]).toBe('Monday 12 · Chili · Stovetop · 30 min')
|
||||
expect(dayRows(next)[6]).toBe('Sunday 18 · Fried rice · Stovetop · 30 min')
|
||||
|
||||
fireEvent.click(within(sheet).getByRole('radio', { name: 'This week' }))
|
||||
expect(await within(sheet).findByRole('list', { name: 'Days of this week' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("takes this week in the household's timezone, not the device's UTC date", async () => {
|
||||
// Sunday Oct 11, 10 pm in New York, already Monday Oct 12 in UTC.
|
||||
vi.setSystemTime(new Date('2026-10-12T02:00:00Z'))
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)) })
|
||||
await renderPage()
|
||||
|
||||
const { days } = await openSheet()
|
||||
expect(dayRows(days)[6]).toBe('Sunday 11 · Sheet-pan salmon · Stovetop · 30 min')
|
||||
expect(reads(fetchMock, THIS_WEEK)).toBe(1)
|
||||
expect(reads(fetchMock, NEXT_WEEK)).toBe(0)
|
||||
})
|
||||
|
||||
it('swaps the day chosen through the plan API, confirms it and reads the plan again', async () => {
|
||||
const swap = deferred()
|
||||
let plan = planBody('2026-10-05', THIS_NAMES)
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
|
||||
[`PUT ${THURSDAY}`]: () => swap.response,
|
||||
})
|
||||
await renderPage()
|
||||
const { days } = await openSheet()
|
||||
|
||||
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(sent(fetchMock, 'PUT')).toHaveLength(1))
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Adding to Thursday…')
|
||||
|
||||
plan = thursdaySwapped()
|
||||
await act(async () => swap.send(jsonResponse(200, plan)))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
|
||||
// The plan query is invalidated: the week is read again from the server.
|
||||
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(2))
|
||||
|
||||
// The sheet opened again shows the plan as swapped.
|
||||
const again = await openSheet()
|
||||
expect(dayRows(again.days)[3]).toBe('Thursday 8 · Korean beef bowls · Stovetop · 30 min · This dinner')
|
||||
expect(within(again.sheet).getByText('Korean beef bowls is already on Thursday this week.')).toBeInTheDocument()
|
||||
expect(within(again.days).queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('offers to draft a week with no plan on the Plan, and swaps nothing', async () => {
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found') })
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to this week' }))
|
||||
const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' })
|
||||
const draft = await within(sheet).findByRole('link', { name: 'Draft this week first' })
|
||||
expect(draft).toHaveAttribute('href', '/plan')
|
||||
expect(within(sheet).getByText("There's no plan for this week yet, so there's no day to add it to.")).toBeInTheDocument()
|
||||
expect(within(sheet).queryByRole('list')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(draft)
|
||||
expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument()
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([])
|
||||
})
|
||||
|
||||
it('shows a refused swap with the error, puts the day back and retries it', async () => {
|
||||
let refuse = true
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
|
||||
[`PUT ${THURSDAY}`]: () => (refuse ? errorResponse(500, 'internal') : jsonResponse(200, thursdaySwapped())),
|
||||
})
|
||||
await renderPage()
|
||||
const { days } = await openSheet()
|
||||
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't add it to Thursday this week. internal message")
|
||||
const reopened = await openSheet()
|
||||
expect(dayRows(reopened.days)[3]).toBe('Thursday 8 · Chicken stir-fry · Stovetop · 30 min')
|
||||
fireEvent.keyDown(reopened.sheet, { key: 'Escape' })
|
||||
|
||||
refuse = false
|
||||
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`])
|
||||
})
|
||||
|
||||
it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => {
|
||||
let plan = planBody('2026-10-05', THIS_NAMES)
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
|
||||
[`POST ${THIS_WEEK}/push-calendar`]: () => {
|
||||
plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||
return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } })
|
||||
},
|
||||
[`PUT ${THURSDAY}`]: () => {
|
||||
plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||
return jsonResponse(200, plan)
|
||||
},
|
||||
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const router = await renderPage(<PlanPage />)
|
||||
|
||||
// The week is pushed on the Plan, which says its Supply run was made.
|
||||
await act(() => router.navigate('/plan'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
// Coming back from the dinner with nothing swapped, it still says so.
|
||||
await act(() => router.navigate('/dinner/d1'))
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
await act(() => router.navigate('/plan'))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
|
||||
// Thursday is swapped from the dinner's Add to this week.
|
||||
await act(() => router.navigate('/dinner/d1'))
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
const { days } = await openSheet()
|
||||
fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
|
||||
|
||||
// The Plan shows the swapped plan, not changed since its push, and no
|
||||
// longer says the earlier plan's Supply run was made.
|
||||
await act(() => router.navigate('/plan'))
|
||||
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Korean beef bowls')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument()
|
||||
expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`])
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
|
||||
})
|
||||
|
||||
it('refuses a swap while the week is being pushed to Calendar', async () => {
|
||||
const push = deferred()
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
|
||||
[`POST ${THIS_WEEK}/push-calendar`]: () => push.response,
|
||||
'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const router = await renderPage(<PlanPage />)
|
||||
await act(() => router.navigate('/plan'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
|
||||
|
||||
await act(() => router.navigate('/dinner/d1'))
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
const { sheet, days } = await openSheet()
|
||||
expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument()
|
||||
expect(within(days).queryAllByRole('button')).toHaveLength(0)
|
||||
expect(sent(fetchMock, 'PUT')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the favorite heart', () => {
|
||||
it('fills at once, keeps the recipe and takes the server answer', async () => {
|
||||
const favorite = deferred()
|
||||
const fetchMock = stubServer({ [`POST ${FAVORITE}`]: () => favorite.response })
|
||||
await renderPage()
|
||||
const heart = screen.getByRole('button', { name: 'Favorite' })
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
fireEvent.click(heart)
|
||||
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'true'))
|
||||
expect(sent(fetchMock, 'POST')).toEqual([`${FAVORITE} undefined`])
|
||||
|
||||
await act(async () => favorite.send(jsonResponse(200, listed(true))))
|
||||
await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
// The list-shaped answer did not replace the detail: the recipe stays.
|
||||
expect(screen.getByText('Ground beef')).toBeInTheDocument()
|
||||
expect(screen.getByText('Brown the beef with the sauce.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('empties again with the error when the server refuses, and retries', async () => {
|
||||
const refused = deferred()
|
||||
let refuse = true
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${DINNER}`]: () => jsonResponse(200, detail({ is_favorite: true })),
|
||||
[`POST ${FAVORITE}`]: () => (refuse ? refused.response : jsonResponse(200, listed(false))),
|
||||
})
|
||||
await renderPage()
|
||||
const heart = screen.getByRole('button', { name: 'Favorite' })
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(heart)
|
||||
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
|
||||
await act(async () => refused.send(errorResponse(500, 'internal')))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't unfavorite Korean beef bowls. internal message")
|
||||
expect(heart).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
refuse = false
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false'))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
expect(sent(fetchMock, 'POST')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user