Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb63496de | ||
|
|
fd05d51ebf | ||
|
|
89574333e8 | ||
|
|
428e87674e | ||
|
|
1cbd215286 | ||
|
|
c4ea7a855e |
@@ -11,61 +11,14 @@ single Go service with the React PWA embedded in the binary.
|
||||
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
|
||||
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## Quick start
|
||||
## Running it locally
|
||||
|
||||
Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that
|
||||
is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0
|
||||
or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the
|
||||
`engines` range of jsdom in the committed lockfile, the strictest of all the
|
||||
web dependencies.
|
||||
|
||||
```sh
|
||||
(cd web && npm ci)
|
||||
mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret
|
||||
chmod 700 ~/.mealquest-dev
|
||||
head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key
|
||||
chmod 600 ~/.mealquest-dev/secret_key
|
||||
export MEALQUEST_HTTP_ADDR=:8080
|
||||
export MEALQUEST_PUBLIC_URL=http://localhost:5173
|
||||
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
|
||||
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
|
||||
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
|
||||
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
|
||||
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
|
||||
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
|
||||
export MEALQUEST_OIDC_CLIENT_ID=mealquest
|
||||
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
|
||||
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
|
||||
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
|
||||
# export MEALQUEST_SESSION_IDLE=720h
|
||||
# export MEALQUEST_SESSION_MAX=4320h
|
||||
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
|
||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||
# trusts no peer, which is right for local development.
|
||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
# Optional: a SearXNG instance the Settings screen suggests as the recipe
|
||||
# search URL until one is saved.
|
||||
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
|
||||
export MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
make dev
|
||||
```
|
||||
|
||||
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
|
||||
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||
every variable above is required except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE),
|
||||
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
|
||||
(an absolute `http://` or `https://` URL without credentials, a query or a
|
||||
fragment) and `MEALQUEST_DEFAULT_TZ`, and
|
||||
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
|
||||
with on first boot: until those settings exist, boot fails unless it is a
|
||||
valid IANA zone. Later boots ignore it, set or not (change the zone in
|
||||
household settings instead).
|
||||
The developer setup (prerequisites, Authentik and Google clients, the
|
||||
`MEALQUEST_*` environment, `make dev` and the optional SearXNG sidecar) is
|
||||
runbook information and lives in CTXD: project `mealquest`, file
|
||||
`RUNBOOKS.MD`, section "Local development". Configuration is read only from
|
||||
the environment, and the service refuses to start, naming every missing or
|
||||
invalid variable.
|
||||
|
||||
## Common commands
|
||||
|
||||
@@ -78,7 +31,8 @@ household settings instead).
|
||||
|
||||
## Where to read next
|
||||
|
||||
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions.
|
||||
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, the quality
|
||||
floor every change must pass, repository layout, conventions.
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record.
|
||||
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements.
|
||||
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff.
|
||||
|
||||
+14
-2
@@ -133,7 +133,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
shopSvc, err := shopping.NewService(db)
|
||||
shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -280,7 +280,7 @@ 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).
|
||||
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun).
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
@@ -325,6 +325,18 @@ func weekDrafter(ai *aisettings.Service, guard *harness.Guard) plan.Drafter {
|
||||
}
|
||||
}
|
||||
|
||||
// archiveTitler runs archive_title with the AI settings current at each
|
||||
// run, sharing guard.
|
||||
func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler {
|
||||
return func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.ArchiveTitle{}, err
|
||||
}
|
||||
return skills.TitleArchive(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) {
|
||||
|
||||
+18
-23
@@ -1,20 +1,15 @@
|
||||
# MealQuest production environment. Copy to deploy/.env (never committed)
|
||||
# and replace the placeholders. The service fails fast: a missing or invalid
|
||||
# required variable stops it at boot with the variable named.
|
||||
# MealQuest production environment: the template for deploy/.env (never
|
||||
# committed). The service fails fast: a missing or invalid required variable
|
||||
# stops it at boot with the variable named. Creating deploy/.env: CTXD
|
||||
# project mealquest, RUNBOOKS.MD "Stack and first deploy" > "3. Environment
|
||||
# file".
|
||||
#
|
||||
# Secrets are never set here. They live in deploy/secrets/ (never committed),
|
||||
# one value per file, owned by the container user and readable only by it:
|
||||
#
|
||||
# mkdir -p deploy/secrets && chmod 700 deploy/secrets
|
||||
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
|
||||
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
|
||||
# printf '%s\n' '<Google OAuth client secret>' > deploy/secrets/google_client_secret
|
||||
# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/*
|
||||
#
|
||||
# SearXNG's secret key is the exception: compose reads it itself, so it
|
||||
# stays owned by you (mode 0600). Create it after the chown above:
|
||||
#
|
||||
# (umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env)
|
||||
# one value per file, owned by the container user 10001:10001 with mode
|
||||
# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's
|
||||
# searxng.env is the exception: compose reads it itself, so it is owned by
|
||||
# the host account that runs compose, mode 0600. Creating them: CTXD project
|
||||
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
|
||||
#
|
||||
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the
|
||||
# *_FILE variables at them.
|
||||
@@ -42,8 +37,7 @@ MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
|
||||
# IANA zone the household settings are created with on first boot. Required
|
||||
# until those settings exist; ignored afterwards (change the zone in
|
||||
# household settings instead). Confirm the household's zone before the first
|
||||
# deploy.
|
||||
# household settings instead).
|
||||
MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
|
||||
# --- Authentik OIDC ----------------------------------------------------------
|
||||
@@ -57,10 +51,10 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
|
||||
# --- Google Calendar (Push to Calendar) ------------------------------------
|
||||
|
||||
# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google
|
||||
# Calendar OAuth client"). Required: the service will not start without it
|
||||
# and the client secret file. Its authorized redirect URI is
|
||||
# <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||
# Client ID of the Google OAuth web client (CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Google Calendar OAuth client"). Required: the service will
|
||||
# not start without it and the client secret file. Its authorized redirect
|
||||
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
|
||||
|
||||
# Optional session lifetimes. A session ends after IDLE unused and always MAX
|
||||
@@ -82,13 +76,14 @@ MEALQUEST_SESSION_MAX=4320h
|
||||
# The SearXNG recipe-search sidecar on the compose network. The Settings
|
||||
# screen pre-fills it as the SearXNG URL until one is saved; the saved
|
||||
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
|
||||
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
|
||||
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
|
||||
|
||||
# --- Planned; not read by the service yet -------------------------------------
|
||||
#
|
||||
# Added to deploy/compose.yaml and this file by the features that use them.
|
||||
#
|
||||
# Web Push (Feature 19). Generate the key pair with cmd/vapidgen.
|
||||
# Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD
|
||||
# "VAPID keys (Web Push)".
|
||||
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
|
||||
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
|
||||
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||
|
||||
-1417
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
# Development override: publishes SearXNG on the dev machine's loopback so
|
||||
# it can be queried directly, e.g.
|
||||
#
|
||||
# docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up -d searxng
|
||||
# curl 'http://127.0.0.1:8888/search?q=chicken+tikka+masala+recipe&format=json'
|
||||
# Development override layered on deploy/compose.yaml: publishes SearXNG
|
||||
# on the dev machine's loopback (127.0.0.1:8888) so it can be queried
|
||||
# directly. Using it: CTXD project mealquest, RUNBOOKS.MD "Local
|
||||
# development".
|
||||
#
|
||||
# Loopback only, never a LAN address. Never used in production: the
|
||||
# systemd unit starts deploy/compose.yaml alone.
|
||||
|
||||
+10
-12
@@ -1,18 +1,16 @@
|
||||
# Production stack for MealQuest on 10.60.1.14 (D-018). Build on the host,
|
||||
# then start through systemd:
|
||||
#
|
||||
# docker compose -f deploy/compose.yaml build
|
||||
# sudo systemctl restart mealquest
|
||||
# Production stack for MealQuest on 10.60.1.14 (D-018). Building, deploying
|
||||
# and operating it: CTXD project mealquest, RUNBOOKS.MD "Stack unit" and
|
||||
# "Routine deploy and rollback".
|
||||
#
|
||||
# The services use restart: "no" on purpose: Docker must not start them
|
||||
# before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service
|
||||
# owns start and stop and requires mealquest-firewall.service first.
|
||||
#
|
||||
# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets
|
||||
# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets
|
||||
# with their host owner and mode, so each file in deploy/secrets/ must be
|
||||
# owned by the container user 10001:10001 with mode 0600 (except
|
||||
# searxng.env, read by compose itself; see the searxng service).
|
||||
# Configuration comes from deploy/.env (template: deploy/.env.example) and
|
||||
# secrets from deploy/secrets/; neither is committed. Compose bind-mounts
|
||||
# file secrets with their host owner and mode, so each file in
|
||||
# deploy/secrets/ must be owned by the container user 10001:10001 with mode
|
||||
# 0600 (except searxng.env, read by compose itself; see the searxng service).
|
||||
name: mealquest
|
||||
|
||||
services:
|
||||
@@ -65,8 +63,8 @@ services:
|
||||
# the app's own user (owner of the volume) with a read-only root
|
||||
# filesystem, no privilege escalation and no Linux capabilities.
|
||||
#
|
||||
# Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the
|
||||
# stack, move the database with its -wal and -shm aside, restore, check).
|
||||
# Restoring the database: CTXD project mealquest, RUNBOOKS.MD "Restore
|
||||
# the database".
|
||||
litestream:
|
||||
image: litestream/litestream:0.5.17
|
||||
user: "10001:10001"
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
# are one-way: a failure here fails this unit visibly and never fails
|
||||
# docker.service.
|
||||
#
|
||||
# Start MealQuest only after this unit has succeeded:
|
||||
# systemctl start mealquest-firewall && docker compose up -d
|
||||
# mealquest.service requires this unit and starts the stack only after it
|
||||
# has succeeded. Installing and operating both: CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Host firewall" and "Stack unit".
|
||||
[Unit]
|
||||
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
|
||||
After=docker.service
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup).
|
||||
# Triggered by mealquest-media-backup.timer; run by hand with
|
||||
# systemctl start mealquest-media-backup
|
||||
# and read the result with
|
||||
# journalctl -u mealquest-media-backup
|
||||
# Triggered by mealquest-media-backup.timer; output goes to the journal.
|
||||
# Installing it and running it by hand: CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Media — nightly rsync".
|
||||
#
|
||||
# The script runs as the app's user (10001, host user "mealquest") with no
|
||||
# capabilities. systemd, as root, only bind-mounts the data volume's root
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
# swapping the path afterwards cannot redirect it. Symlinks inside the
|
||||
# directory are copied as links (-a), never followed.
|
||||
#
|
||||
# The media directory is created at deploy time (deploy/RUNBOOK.md). If it
|
||||
# is missing the job fails; it is never treated as "nothing to back up".
|
||||
# The media directory is created at deploy time (CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Media — nightly rsync"). If it is missing the job fails; it
|
||||
# is never treated as "nothing to back up".
|
||||
#
|
||||
# rsync mirrors into .../mealquest-backups/media/ only: the target ends in
|
||||
# a slash, so --delete removes files from that directory and nothing
|
||||
|
||||
@@ -470,6 +470,45 @@ 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.
|
||||
- **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
|
||||
household temperature with no household context. A title must be 1–48
|
||||
characters on one line, with no emoji and no surrounding quotation
|
||||
marks; a reply breaking a rule gets the one repair turn, never a
|
||||
truncation. `shopping.Generate` (and `GeneratePushed`), on storing a
|
||||
list that is still untitled — a new list, or one a failed title left
|
||||
untitled — enqueues a `title_supply_run` job in the generation's
|
||||
transaction and returns without waiting for it, so neither Push to
|
||||
Calendar nor `POST /shopping/{weekStart}/generate` waits for a model
|
||||
call; the list reads untitled until the job stores its title. The job's
|
||||
scope is `<weekStart>@<plan revision>/<sha256 of the dinner names in
|
||||
order>`, so runs share a title only for the same names (a renamed dinner
|
||||
changes the names, not the revision). It writes `shopping_lists.title`
|
||||
only while the list is untitled and not frozen. A title is written once;
|
||||
a remake keeps it and enqueues no job. A failed title never fails the
|
||||
generation: the list stays untitled (the UI shows the week label), the
|
||||
failure is logged as a warning and recorded in `ai_runs`, the job fails
|
||||
`title_failed`, and the week's next generation tries again.
|
||||
`shopping.Freeze` titles a list still untitled once more before
|
||||
freezing it (see Supply archive), through the same skill, scope and
|
||||
conditional write.
|
||||
|
||||
## Supply archive (Feature 17)
|
||||
|
||||
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
|
||||
archive: `status` becomes `archived` and `frozen_at` is set, after which
|
||||
generating the list or changing an item is 409 `archived`. It freezes only
|
||||
a week that has ended in the household's time zone (its Monday is before
|
||||
the Monday of the household's current week), so a week not yet ended is
|
||||
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
|
||||
frozen list does nothing. A list still untitled is titled first with
|
||||
`archive_title` from the week's plan, as `Generate` titles it; a title
|
||||
that cannot be written is logged and the list is frozen untitled, when its
|
||||
week label stands in for good. A `title_supply_run` job still in flight
|
||||
with the same scope shares that run (`harness.Run`); whichever stores the
|
||||
title first wins, and a job storing after the freeze stores nothing. The
|
||||
`week_rollover` schedule is to call `Freeze` for last week.
|
||||
|
||||
## Recipe grounding
|
||||
|
||||
@@ -770,6 +809,20 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
needed, needed_count, total_count}` with the list's counts after the
|
||||
change. 404 for an unknown item, 409 `archived` when the list is in the
|
||||
Supply archive, 400 `invalid_request` for a bad body.
|
||||
- `GET /api/v1/shopping/archive?by=week|month|year` returns the Supply
|
||||
archive `{by, periods: [{key, heading, posts: [{weekStart, label,
|
||||
title, dinnerCount, itemCount, onHandCount, dinners, current}]}]}`: the
|
||||
Supply run of every week up to the household's current week, newest
|
||||
first, grouped by the Monday's ISO week (`key` "2026-W41", `heading` the
|
||||
week label), month ("2026-10", "October 2026") or year ("2026"). The
|
||||
Monday is a household-local date, so the week of Monday 28 December
|
||||
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
|
||||
UI sets it in capitals), `title` null while untitled, `dinners` the
|
||||
list's dinners in day order and `dinnerCount` their number,
|
||||
`itemCount` all items, `onHandCount` the items marked "have it", and
|
||||
`current` true for the household's current week. 400 `invalid_request`
|
||||
for any other `by`. A frozen list is read through `GET
|
||||
/api/v1/shopping/{weekStart}` with `status` `archived`.
|
||||
|
||||
## Frontend
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// TitleLimit is the most characters a Supply run's title may have.
|
||||
const TitleLimit = 48
|
||||
|
||||
// ArchiveTitleRequest is what archive_title reads.
|
||||
type ArchiveTitleRequest struct {
|
||||
// Dinners are the names of the week's planned dinners in day order,
|
||||
// Monday first. It must hold at least one, none blank.
|
||||
Dinners []string
|
||||
}
|
||||
|
||||
// ArchiveTitle is archive_title's output.
|
||||
type ArchiveTitle struct {
|
||||
// Title is the Supply run's post title in the Supply archive: 1 to
|
||||
// TitleLimit characters on one line, with no emoji and no surrounding
|
||||
// quotation marks.
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// TitleArchive runs archive_title v1 on in and returns the title of the
|
||||
// week's Supply run, written from its dinners. A request with no dinners,
|
||||
// or a blank one, fails before any LLM call. scope names the week and its
|
||||
// dinners (see harness.Run).
|
||||
func TitleArchive(ctx context.Context, cfg harness.Config, scope string, in ArchiveTitleRequest) (ArchiveTitle, error) {
|
||||
return harness.Run(ctx, cfg, archiveTitle{}, scope, in)
|
||||
}
|
||||
|
||||
// archiveTitle is the skill.
|
||||
type archiveTitle struct{}
|
||||
|
||||
var _ harness.Skill[ArchiveTitleRequest, ArchiveTitle] = archiveTitle{}
|
||||
|
||||
func (archiveTitle) Name() string { return "archive_title" }
|
||||
|
||||
func (archiveTitle) Version() int { return 1 }
|
||||
|
||||
func (archiveTitle) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// archiveTitlePrompt is the template data of archive_title.v1.tmpl.
|
||||
type archiveTitlePrompt struct {
|
||||
// Dinners are the week's dinner names in day order, each on one line.
|
||||
Dinners []string
|
||||
}
|
||||
|
||||
func (archiveTitle) PromptInput(in ArchiveTitleRequest) (harness.PromptInput, error) {
|
||||
if len(in.Dinners) == 0 {
|
||||
return harness.PromptInput{}, errors.New("the week has no dinners to title it from")
|
||||
}
|
||||
var p archiveTitlePrompt
|
||||
for i, name := range in.Dinners {
|
||||
name = oneLine(name)
|
||||
if name == "" {
|
||||
return harness.PromptInput{}, fmt.Errorf("dinner %d has a blank name", i+1)
|
||||
}
|
||||
p.Dinners = append(p.Dinners, name)
|
||||
}
|
||||
return harness.PromptInput{Data: p}, nil
|
||||
}
|
||||
|
||||
func (archiveTitle) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "archive_title", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"}
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring its field as the schema
|
||||
// does.
|
||||
func (t *ArchiveTitle) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "title"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain ArchiveTitle
|
||||
return json.Unmarshal(data, (*plain)(t))
|
||||
}
|
||||
|
||||
// quotes are the quotation marks a title may not start or end with.
|
||||
const quotes = `"'` + "`" + "“”‘’„‚«»‹›「」『』"
|
||||
|
||||
// Validate finds every broken rule of the title as PostProcess will store
|
||||
// it, on one line: empty, over TitleLimit characters, holding an emoji, or
|
||||
// starting or ending with a quotation mark.
|
||||
func (archiveTitle) Validate(out ArchiveTitle) []string {
|
||||
var problems []string
|
||||
title := oneLine(out.Title)
|
||||
switch n := utf8.RuneCountInString(title); {
|
||||
case n == 0:
|
||||
problems = append(problems, fmt.Sprintf("title is empty; give a title of 1 to %d characters", TitleLimit))
|
||||
case n > TitleLimit:
|
||||
problems = append(problems, fmt.Sprintf("title %q has %d characters; shorten it to at most %d", title, n, TitleLimit))
|
||||
}
|
||||
for _, r := range title {
|
||||
if isEmoji(r) {
|
||||
problems = append(problems, fmt.Sprintf("title %q has an emoji (%U); use words only", title, r))
|
||||
break
|
||||
}
|
||||
}
|
||||
if first, _ := utf8.DecodeRuneInString(title); title != "" && strings.ContainsRune(quotes, first) {
|
||||
problems = append(problems, fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", title))
|
||||
} else if last, _ := utf8.DecodeLastRuneInString(title); title != "" && strings.ContainsRune(quotes, last) {
|
||||
problems = append(problems, fmt.Sprintf("title %q ends with a quotation mark; give the title without quotation marks around it", title))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// isEmoji reports whether r is an emoji or a part of one: a pictograph,
|
||||
// a symbol from the emoji blocks, a regional indicator, a keycap, a skin
|
||||
// tone, an emoji variation selector or the zero-width joiner.
|
||||
func isEmoji(r rune) bool {
|
||||
switch {
|
||||
case r >= 0x1F000 && r <= 0x1FAFF: // tiles, cards, enclosed, pictographs, emoticons, transport, supplemental symbols
|
||||
return true
|
||||
case r >= 0x2600 && r <= 0x27BF: // miscellaneous symbols and dingbats
|
||||
return true
|
||||
case r >= 0x2B00 && r <= 0x2BFF: // arrows and stars such as ⭐ and ⬛
|
||||
return true
|
||||
case r >= 0x2300 && r <= 0x23FF: // technical symbols such as ⌚ and ⏰
|
||||
return true
|
||||
case r >= 0xE0020 && r <= 0xE007F: // tag sequences of flags
|
||||
return true
|
||||
case r == 0x200D, r == 0x20E3, r == 0xFE0F: // joiner, keycap, emoji presentation
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PostProcess puts the title on one line, collapsing its white space.
|
||||
func (archiveTitle) PostProcess(out ArchiveTitle) (ArchiveTitle, error) {
|
||||
return ArchiveTitle{Title: oneLine(out.Title)}, nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// titleWeek is a week of seven dinners, Monday first.
|
||||
var titleWeek = ArchiveTitleRequest{Dinners: []string{
|
||||
"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Fish Tacos",
|
||||
}}
|
||||
|
||||
func titleReply(title string) llm.GenerateResult {
|
||||
return reply(fmt.Sprintf(`{"title":%q}`, title))
|
||||
}
|
||||
|
||||
// title runs TitleArchive on in with f and checks f was called exactly
|
||||
// calls times, each at the household temperature with the archive_title
|
||||
// schema.
|
||||
func title(t *testing.T, f *llm.Fake, in ArchiveTitleRequest, calls int) (ArchiveTitle, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := TitleArchive(context.Background(), testConfig(t, f), "2026-10-12", 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 != "archive_title" {
|
||||
t.Fatalf("request %d schema = %v, want archive_title", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func TestArchiveTitleValid(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(titleReply(" Slow Simmers\nand Taco Night "))
|
||||
out, reqs, err := title(t, &f, titleWeek, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Title != "Slow Simmers and Taco Night" {
|
||||
t.Fatalf("title = %q", out.Title)
|
||||
}
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, "archive_title_valid.system.golden", system)
|
||||
golden(t, "archive_title_valid.user.golden", user)
|
||||
want := "Title the week whose dinners are, in day order from Monday:\n" +
|
||||
"- Beef Chili\n- Pot Roast\n- Chicken Fajitas\n- Spaghetti Bolognese\n- Sheet Pan Salmon\n- Pad Thai\n- Fish Tacos"
|
||||
if user != want {
|
||||
t.Errorf("user message = %q, want %q", user, want)
|
||||
}
|
||||
if !strings.Contains(system, "## Output contract") || !strings.Contains(system, `"title"`) {
|
||||
t.Error("system prompt lacks the output contract")
|
||||
}
|
||||
}
|
||||
|
||||
// titleRepaired checks a first reply is repaired once, quoting it and the
|
||||
// problem wanted.
|
||||
func titleRepaired(t *testing.T, first, want string) {
|
||||
t.Helper()
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(titleReply(first), titleReply("Chili, Roast and Taco Night"))
|
||||
out, reqs, err := title(t, &f, titleWeek, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Title != "Chili, Roast and Taco Night" {
|
||||
t.Fatalf("title = %q", out.Title)
|
||||
}
|
||||
repair := reqs[1].Messages
|
||||
if len(repair) != 3 || repair[1].Content != fmt.Sprintf(`{"title":%q}`, first) {
|
||||
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
|
||||
}
|
||||
if !strings.Contains(repair[2].Content, want) {
|
||||
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveTitleTooLongRepaired(t *testing.T) {
|
||||
long := "A Week of Slow Simmers, Sizzling Fajitas and Taco Night" // 55 characters
|
||||
titleRepaired(t, long, fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", long))
|
||||
}
|
||||
|
||||
func TestArchiveTitleEmojiRepaired(t *testing.T) {
|
||||
titleRepaired(t, "Taco Night 🌮", `title "Taco Night 🌮" has an emoji (U+1F32E); use words only`)
|
||||
}
|
||||
|
||||
func TestArchiveTitleQuotedRepaired(t *testing.T) {
|
||||
titleRepaired(t, `"Taco Night"`, `title "\"Taco Night\"" starts with a quotation mark; give the title without quotation marks around it`)
|
||||
}
|
||||
|
||||
func TestArchiveTitleEmojiTwiceFails(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(titleReply("Chili Week 🔥"), titleReply("Chili Week ❤️"))
|
||||
out, _, err := title(t, &f, titleWeek, 2)
|
||||
var bad *harness.BadOutputError
|
||||
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v, want a BadOutputError", err)
|
||||
}
|
||||
if !slices.Equal(bad.Repair, []string{`title "Chili Week ❤️" has an emoji (U+2764); use words only`}) {
|
||||
t.Fatalf("repair problems = %q", bad.Repair)
|
||||
}
|
||||
if out.Title != "" {
|
||||
t.Fatalf("title = %q, want none", out.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveTitleRules(t *testing.T) {
|
||||
s := archiveTitle{}
|
||||
for _, tc := range []struct {
|
||||
title string
|
||||
want []string
|
||||
}{
|
||||
{"Taco Night", nil},
|
||||
{strings.Repeat("a", TitleLimit), nil},
|
||||
{"Crème Brûlée & Co.", nil},
|
||||
{"Fajitas — Then Tacos", nil},
|
||||
{"Mom's Pot Roast Week", nil},
|
||||
{" Spaced out ", nil},
|
||||
{"", []string{"title is empty; give a title of 1 to 48 characters"}},
|
||||
{" \n ", []string{"title is empty; give a title of 1 to 48 characters"}},
|
||||
{strings.Repeat("é", TitleLimit+1), []string{fmt.Sprintf("title %q has 49 characters; shorten it to at most 48", strings.Repeat("é", TitleLimit+1))}},
|
||||
{"Hot ☕ Week", []string{`title "Hot ☕ Week" has an emoji (U+2615); use words only`}},
|
||||
{"Star ⭐ Week", []string{`title "Star ⭐ Week" has an emoji (U+2B50); use words only`}},
|
||||
{"Go 🇮🇹", []string{`title "Go 🇮🇹" has an emoji (U+1F1EE); use words only`}},
|
||||
{"‘Taco Night’", []string{`title "‘Taco Night’" starts with a quotation mark; give the title without quotation marks around it`}},
|
||||
{"Taco Night”", []string{`title "Taco Night”" ends with a quotation mark; give the title without quotation marks around it`}},
|
||||
{"«Pasta» 🍝 " + strings.Repeat("x", 45), []string{
|
||||
fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
|
||||
fmt.Sprintf("title %q has an emoji (U+1F35D); use words only", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
|
||||
fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
|
||||
}},
|
||||
} {
|
||||
if got := s.Validate(ArchiveTitle{Title: tc.title}); !slices.Equal(got, tc.want) {
|
||||
t.Errorf("Validate(%q) =\n%q\nwant\n%q", tc.title, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveTitleBadRequestFailsBeforeCall(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
in ArchiveTitleRequest
|
||||
want string
|
||||
}{
|
||||
"no dinners": {ArchiveTitleRequest{}, "the week has no dinners to title it from"},
|
||||
"blank dinner": {ArchiveTitleRequest{Dinners: []string{"Pad Thai", " \n"}}, "dinner 2 has a blank name"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var f llm.Fake
|
||||
_, _, err := title(t, &f, tc.in, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want one containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{/* archive_title titles a week's Supply run for the Supply archive, from the week's dinners. */}}
|
||||
{{define "instructions" -}}
|
||||
You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners.
|
||||
|
||||
Rules:
|
||||
- At most 48 characters, on one line.
|
||||
- Words only: no emoji.
|
||||
- No quotation marks around the title.
|
||||
- Do not name a date or week number; the archive shows those.
|
||||
|
||||
Give:
|
||||
- title: the title.
|
||||
|
||||
One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household):
|
||||
{"title": "Slow Simmers and Taco Night"}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
Title the week whose dinners are, in day order from Monday:
|
||||
{{- range .Dinners}}
|
||||
- {{.}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
@@ -5,3 +5,4 @@ echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
|
||||
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
|
||||
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
|
||||
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
|
||||
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
## Instructions
|
||||
You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners.
|
||||
|
||||
Rules:
|
||||
- At most 48 characters, on one line.
|
||||
- Words only: no emoji.
|
||||
- No quotation marks around the title.
|
||||
- Do not name a date or week number; the archive shows those.
|
||||
|
||||
Give:
|
||||
- title: the title.
|
||||
|
||||
One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household):
|
||||
{"title": "Slow Simmers and Taco Night"}
|
||||
|
||||
## Household context
|
||||
No household context.
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "archive_title" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
Title the week whose dinners are, in day order from Monday:
|
||||
- Beef Chili
|
||||
- Pot Roast
|
||||
- Chicken Fajitas
|
||||
- Spaghetti Bolognese
|
||||
- Sheet Pan Salmon
|
||||
- Pad Thai
|
||||
- Fish Tacos
|
||||
@@ -87,6 +87,9 @@ type Querier interface {
|
||||
// Records a pending or running batch as done or failed; a batch already
|
||||
// finished is left as it is (0 rows).
|
||||
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
// frozen already.
|
||||
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||
// The job of kind queued or running for the week starting week_start,
|
||||
@@ -184,6 +187,9 @@ type Querier interface {
|
||||
// The items of list list_id with their group's dinner, in list order: by
|
||||
// group, then by item.
|
||||
ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error)
|
||||
// The Supply runs of the weeks starting on or before week_start, newest
|
||||
// week first.
|
||||
ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error)
|
||||
// The ids of the dinners with status quest_log planned on date, on a plan
|
||||
// of any status, that have no Rotation check for date yet.
|
||||
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
|
||||
@@ -243,6 +249,9 @@ type Querier interface {
|
||||
SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error)
|
||||
// Marks item id needed (1) or "have it" (0).
|
||||
SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error
|
||||
// Titles list id, unless it has a title already or is frozen into the
|
||||
// Supply archive.
|
||||
SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, error)
|
||||
// Marks a pending batch running; a batch in any other status is left as
|
||||
// it is (0 rows).
|
||||
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
|
||||
|
||||
@@ -112,6 +112,28 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
|
||||
UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
WHERE id = ? AND frozen_at IS NULL
|
||||
`
|
||||
|
||||
type FreezeShoppingListParams struct {
|
||||
FrozenAt sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
// frozen already.
|
||||
func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, freezeShoppingList, arg.FrozenAt, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getShoppingItemList = `-- name: GetShoppingItemList :one
|
||||
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at
|
||||
FROM shopping_items
|
||||
@@ -299,6 +321,47 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
|
||||
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
|
||||
WHERE week_start <= ?
|
||||
ORDER BY week_start DESC
|
||||
`
|
||||
|
||||
// The Supply runs of the weeks starting on or before week_start, newest
|
||||
// week first.
|
||||
func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listShoppingListsThrough, weekStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ShoppingList
|
||||
for rows.Next() {
|
||||
var i ShoppingList
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WeekStart,
|
||||
&i.PlanID,
|
||||
&i.Title,
|
||||
&i.Status,
|
||||
&i.SentToChatAt,
|
||||
&i.FrozenAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); 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 moveShoppingGroup = `-- name: MoveShoppingGroup :exec
|
||||
UPDATE shopping_groups
|
||||
SET position = ?, updated_at = ?
|
||||
@@ -378,3 +441,25 @@ func (q *Queries) SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItem
|
||||
_, err := q.db.ExecContext(ctx, setShoppingItemNeeded, arg.Needed, arg.UpdatedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setShoppingListTitle = `-- name: SetShoppingListTitle :execrows
|
||||
UPDATE shopping_lists
|
||||
SET title = ?, updated_at = ?
|
||||
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL
|
||||
`
|
||||
|
||||
type SetShoppingListTitleParams struct {
|
||||
Title sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Titles list id, unless it has a title already or is frozen into the
|
||||
// Supply archive.
|
||||
func (q *Queries) SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, setShoppingListTitle, arg.Title, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ type plansEnv struct {
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
svc *plan.Service
|
||||
shop *shopping.Service
|
||||
runner *jobs.Runner
|
||||
questLog string
|
||||
// google is the fake Google the plan service pushes to; it starts
|
||||
// unconnected.
|
||||
@@ -47,6 +49,13 @@ type plansEnv struct {
|
||||
}
|
||||
|
||||
func newPlansRouter(t *testing.T) plansEnv {
|
||||
t.Helper()
|
||||
return newPlansRouterTitled(t, untitled)
|
||||
}
|
||||
|
||||
// newPlansRouterTitled is newPlansRouter whose Supply runs are titled by
|
||||
// title.
|
||||
func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
ctx := t.Context()
|
||||
@@ -80,7 +89,7 @@ func newPlansRouter(t *testing.T) plansEnv {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shop, err := shopping.NewService(st)
|
||||
shop, err := shopping.NewService(st, title, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -119,7 +128,7 @@ func newPlansRouter(t *testing.T) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
|
||||
return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
|
||||
google: google, gcal: googleSvc, logs: logs}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ type RouteMounter interface {
|
||||
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
|
||||
// POST /api/v1/dinners/{id}/rotation-review, and POST
|
||||
// /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the
|
||||
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/{weekStart},
|
||||
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/archive,
|
||||
// GET /api/v1/shopping/{weekStart},
|
||||
// POST /api/v1/shopping/{weekStart}/generate and PATCH
|
||||
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
||||
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
||||
@@ -142,6 +143,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
|
||||
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
|
||||
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc))
|
||||
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
|
||||
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
|
||||
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
|
||||
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
|
||||
|
||||
@@ -211,16 +211,28 @@ func newTestPlan(t *testing.T) *plan.Service {
|
||||
}
|
||||
|
||||
// newTestShopping returns a shopping service over a migrated temp
|
||||
// database.
|
||||
// database, whose Supply runs stay untitled.
|
||||
func newTestShopping(t *testing.T) *shopping.Service {
|
||||
t.Helper()
|
||||
svc, err := shopping.NewService(newTestStore(t))
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := shopping.NewService(st, untitled, runner, logger)
|
||||
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) {
|
||||
return skills.ArchiveTitle{}, errors.New("no model in this test")
|
||||
}
|
||||
|
||||
// newTestBox returns a secret box under a random key.
|
||||
func newTestBox(t *testing.T) *secretbox.Box {
|
||||
t.Helper()
|
||||
|
||||
@@ -143,6 +143,70 @@ func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerF
|
||||
}
|
||||
}
|
||||
|
||||
// supplyArchive is the GET /shopping/archive answer: the Supply archive's
|
||||
// periods, newest first, as grouped by by.
|
||||
type supplyArchive struct {
|
||||
By string `json:"by"`
|
||||
Periods []archivePeriod `json:"periods"`
|
||||
}
|
||||
|
||||
// archivePeriod is one heading of the Supply archive and its posts,
|
||||
// newest first.
|
||||
type archivePeriod struct {
|
||||
Key string `json:"key"`
|
||||
Heading string `json:"heading"`
|
||||
Posts []archivePost `json:"posts"`
|
||||
}
|
||||
|
||||
// archivePost is one week's Supply run in the Supply archive. label is
|
||||
// the week label, which stands in for title while it is null, and
|
||||
// onHandCount counts the items marked "have it".
|
||||
type archivePost struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
Title *string `json:"title"`
|
||||
DinnerCount int `json:"dinnerCount"`
|
||||
ItemCount int `json:"itemCount"`
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
|
||||
// the Supply archive grouped by that period.
|
||||
func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
by := r.URL.Query().Get("by")
|
||||
periods, err := svc.Archive(r.Context(), by)
|
||||
if errors.Is(err, shopping.ErrInvalidPeriod) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", shopping.ErrInvalidPeriod.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeShoppingError(w, r, logger, "read Supply archive", err)
|
||||
return
|
||||
}
|
||||
out := supplyArchive{By: by, Periods: make([]archivePeriod, len(periods))}
|
||||
for i, p := range periods {
|
||||
posts := make([]archivePost, len(p.Posts))
|
||||
for j, post := range p.Posts {
|
||||
posts[j] = archivePost{
|
||||
WeekStart: post.WeekStart,
|
||||
Label: post.Label,
|
||||
Title: post.Title,
|
||||
DinnerCount: len(post.Dinners),
|
||||
ItemCount: post.Items,
|
||||
OnHandCount: post.OnHand,
|
||||
Dinners: post.Dinners,
|
||||
Current: post.Current,
|
||||
}
|
||||
}
|
||||
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// supplyRunOf is l, the Supply run of the week starting week, as the API
|
||||
// shows it.
|
||||
func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"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/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -378,6 +386,128 @@ func TestPushPlanMakesSupplyRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// heldTitler is a shopping.Titler that reports each call on entered and
|
||||
// answers title only once release is closed.
|
||||
type heldTitler struct {
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func newHeldTitler() heldTitler {
|
||||
return heldTitler{entered: make(chan struct{}, 4), release: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (h heldTitler) title(ctx context.Context, _ string, _ skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
h.entered <- struct{}{}
|
||||
select {
|
||||
case <-h.release:
|
||||
return skills.ArchiveTitle{Title: "Chili and Friends"}, nil
|
||||
case <-ctx.Done():
|
||||
return skills.ArchiveTitle{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// startRunner starts e's job runner, stopping it when the test ends. e's
|
||||
// logs must not be read while it runs.
|
||||
func (e plansEnv) startRunner(t *testing.T) {
|
||||
t.Helper()
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
// waitEntered waits for the title call to begin.
|
||||
func (h heldTitler) waitEntered(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-h.entered:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("archive_title was never called")
|
||||
}
|
||||
}
|
||||
|
||||
// waitTitled waits for the week's Supply run to have a title through GET
|
||||
// and returns it.
|
||||
func (e plansEnv) waitTitled(t *testing.T) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
if l.Title != nil {
|
||||
return *l.Title
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the Supply run was never titled")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushPlanDoesNotWaitForTitle: Push to Calendar answers while the
|
||||
// Supply run's title is still being written; the list reads untitled until
|
||||
// the title job stores it.
|
||||
func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
held := newHeldTitler()
|
||||
e := newPlansRouterTitled(t, held.title)
|
||||
e.groundWeek(t)
|
||||
e.connectCalendar(t, true)
|
||||
e.startRunner(t)
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/push-calendar", "")
|
||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
held.waitEntered(t)
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
if l.Title != nil {
|
||||
t.Errorf("title = %q while archive_title is still running, want null", *l.Title)
|
||||
}
|
||||
|
||||
close(held.release)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunGenerateDoesNotWaitForTitle: POST generate answers with the
|
||||
// list untitled while its title is still being written.
|
||||
func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
held := newHeldTitler()
|
||||
e := newPlansRouterTitled(t, held.title)
|
||||
e.groundWeek(t)
|
||||
e.startRunner(t)
|
||||
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, body)
|
||||
}
|
||||
if l.Title != nil {
|
||||
t.Errorf("title = %q while archive_title is still running, want null", *l.Title)
|
||||
}
|
||||
if !reflect.DeepEqual(l.render(), weekLines) {
|
||||
t.Errorf("generated = %v, want %v", l.render(), weekLines)
|
||||
}
|
||||
held.waitEntered(t)
|
||||
|
||||
close(held.release)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
|
||||
// run.
|
||||
func TestFailedPushMakesNoSupplyRun(t *testing.T) {
|
||||
@@ -509,3 +639,151 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
|
||||
t.Errorf("archived Supply run after push = %v, want unchanged", l.render())
|
||||
}
|
||||
}
|
||||
|
||||
// archiveJSON is a GET /shopping/archive answer decoded with every field
|
||||
// the API promises.
|
||||
type archiveJSON struct {
|
||||
By string `json:"by"`
|
||||
Periods []struct {
|
||||
Key string `json:"key"`
|
||||
Heading string `json:"heading"`
|
||||
Posts []struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
Title *string `json:"title"`
|
||||
DinnerCount int `json:"dinnerCount"`
|
||||
ItemCount int `json:"itemCount"`
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
} `json:"posts"`
|
||||
} `json:"periods"`
|
||||
}
|
||||
|
||||
// weekSupplyRun plans a new dinner named name, grounded in a recipe
|
||||
// listing ings, on the Monday of the week starting monday, and generates
|
||||
// the week's Supply run.
|
||||
func (e plansEnv) weekSupplyRun(t *testing.T, monday store.LocalDate, name string, ings ...shopIng) supplyRunJSON {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
d, err := q.CreateDinner(t.Context(), 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)
|
||||
}
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: monday.String(),
|
||||
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.groundDay(t, monday.String(), ings...)
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+monday.String()+"/generate", "", &l); status != http.StatusOK {
|
||||
t.Fatalf("generate %s = %d %v", monday, status, body)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// TestSupplyArchive freezes last week's Supply run and reads the Supply
|
||||
// archive: posts newest first with exactly the promised fields, this
|
||||
// week's marked current. The frozen list is served read-only.
|
||||
func TestSupplyArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
// The fixture's plan is for a fixed week; this test plans weeks
|
||||
// around today instead.
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := household.LoadZone("America/New_York") // the env's household zone
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
|
||||
y, m, d := thisWeek.Date()
|
||||
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""}, shopIng{"onion", "1", ""})
|
||||
onion := last.itemID(t, "onion")
|
||||
if status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil); status != http.StatusOK {
|
||||
t.Fatalf("PATCH = %d %v", status, body)
|
||||
}
|
||||
e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"chicken thighs", "6", ""}, shopIng{"lemon", "1", ""})
|
||||
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.shop.Freeze(t.Context(), thisWeek); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Fatalf("Freeze this week = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
|
||||
var got archiveJSON
|
||||
status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("GET archive = %d %v", status, raw)
|
||||
}
|
||||
if k := keys(raw); !reflect.DeepEqual(k, []string{"by", "periods"}) {
|
||||
t.Errorf("archive fields = %v", k)
|
||||
}
|
||||
period := raw["periods"].([]any)[0].(map[string]any)
|
||||
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
|
||||
t.Errorf("period fields = %v", k)
|
||||
}
|
||||
wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
|
||||
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
|
||||
t.Errorf("post fields = %v, want %v", k, wantPost)
|
||||
}
|
||||
var posts []string
|
||||
for _, p := range got.Periods {
|
||||
if len(p.Posts) != 1 || p.Key != household.PeriodKey(mustLocalDate(t, p.Posts[0].WeekStart)) || p.Heading != p.Posts[0].Label {
|
||||
t.Errorf("period %+v, want one post under its week", p)
|
||||
}
|
||||
for _, post := range p.Posts {
|
||||
label, err := household.WeekLabel(mustLocalDate(t, post.WeekStart))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if post.Label != label || post.Title != nil {
|
||||
t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label)
|
||||
}
|
||||
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
|
||||
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
|
||||
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
|
||||
}
|
||||
if got.By != "week" || !reflect.DeepEqual(posts, want) {
|
||||
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
|
||||
}
|
||||
|
||||
for _, q := range []string{"", "?by=", "?by=day", "?by=MONTH"} {
|
||||
status, body := callShopping(t, e, http.MethodGet, shoppingPath+"archive"+q, "", nil)
|
||||
if status != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("GET archive%s = %d %v, want 400 invalid_request", q, status, body)
|
||||
}
|
||||
}
|
||||
|
||||
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), `{"needed":false}`, nil)
|
||||
if status != http.StatusConflict || errorCode(body) != "archived" {
|
||||
t.Errorf("PATCH after Freeze = %d %v, want 409 archived", status, body)
|
||||
}
|
||||
var frozen supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+lastWeek.String(), "", &frozen); status != http.StatusOK {
|
||||
t.Fatalf("GET frozen list = %d %v", status, body)
|
||||
}
|
||||
wantLines := []string{
|
||||
"Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed",
|
||||
"Monday " + lastWeek.String() + " Beef Stew: carrots [4] needed",
|
||||
"Monday " + lastWeek.String() + " Beef Stew: onion [1] have it",
|
||||
}
|
||||
if frozen.Status != "archived" || frozen.NeededCount != 2 || frozen.TotalCount != 3 || !reflect.DeepEqual(frozen.render(), wantLines) {
|
||||
t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package shopping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Archive groupings: the period Archive groups its posts by.
|
||||
const (
|
||||
ByWeek = "week"
|
||||
ByMonth = "month"
|
||||
ByYear = "year"
|
||||
)
|
||||
|
||||
// Freeze moves the Supply run of the week starting weekStart into the
|
||||
// Supply archive: its status becomes archived and frozen_at is set, after
|
||||
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
|
||||
// frozen list does nothing and is no error.
|
||||
//
|
||||
// The week must have ended in the household's time zone: a week not yet
|
||||
// ended is ErrWeekNotEnded, and a week with no Supply run ErrListNotFound.
|
||||
//
|
||||
// A list still untitled is titled first, with archive_title and the same
|
||||
// conditional write as a KindTitleSupplyRun job, from the week's plan as
|
||||
// Generate titles it, so its post in the Supply archive gets a title when
|
||||
// the AI can write one. Titling never fails the freeze: a title that
|
||||
// cannot be written is logged and the list is frozen untitled, when its
|
||||
// week label stands in for the title for good. Freeze first waits for the
|
||||
// title jobs in flight to store their titles or fail, and holds off new
|
||||
// ones until the list is frozen, so a title a job's archive_title run
|
||||
// wrote is stored before the freeze, and the freeze runs archive_title
|
||||
// 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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if weekStart.String() >= thisWeek.String() {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, ErrWeekNotEnded)
|
||||
}
|
||||
s.titling.Lock()
|
||||
defer s.titling.Unlock()
|
||||
list, err := q.GetShoppingList(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrListNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: freeze: read list: %w", err)
|
||||
}
|
||||
if frozen(list) {
|
||||
return nil
|
||||
}
|
||||
if !list.Title.Valid {
|
||||
p, err := freezeTitleJob(ctx, q, list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.storeTitle(ctx, p); err != nil {
|
||||
s.logger.WarnContext(ctx, "Supply run frozen untitled; the Supply archive shows its week label",
|
||||
"week", list.WeekStart, "list", list.ID, "error", err)
|
||||
}
|
||||
}
|
||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||
now := store.Now().String()
|
||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// freezeTitleJob is the title job of list as Generate would enqueue it:
|
||||
// the week's plan revision and its dinners in day order.
|
||||
func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (TitleJob, error) {
|
||||
plan, err := q.GetWeekPlan(ctx, list.WeekStart)
|
||||
if err != nil {
|
||||
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan: %w", err)
|
||||
}
|
||||
days, err := q.ListPlanDays(ctx, plan.ID)
|
||||
if err != nil {
|
||||
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan days: %w", err)
|
||||
}
|
||||
names := make([]string, len(days))
|
||||
for i, d := range days {
|
||||
names[i] = d.Dinner.Name
|
||||
}
|
||||
return TitleJob{ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: names}, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
|
||||
}
|
||||
return household.WeekStart(household.Today(s.now(), zone)), nil
|
||||
}
|
||||
|
||||
// Period is one heading of the Supply archive and its posts, newest first.
|
||||
type Period struct {
|
||||
// Key identifies the period: the ISO week ("2026-W41"), the month
|
||||
// ("2026-10") or the year ("2026").
|
||||
Key string
|
||||
// Heading is the period as shown: the week label, the month and year
|
||||
// ("October 2026") or the year ("2026").
|
||||
Heading string
|
||||
Posts []Post
|
||||
}
|
||||
|
||||
// Post is one Supply run in the Supply archive.
|
||||
type Post struct {
|
||||
WeekStart string
|
||||
// Label is the week label, such as "Week 41 · Oct 5 – 11".
|
||||
Label string
|
||||
// Title is nil while the list is untitled, when Label stands in.
|
||||
Title *string
|
||||
// Dinners are the names of the list's dinners, in day order.
|
||||
Dinners []string
|
||||
// Items is how many items the list has, and OnHand how many of them
|
||||
// the household marked "have it".
|
||||
Items, OnHand int
|
||||
// Current is true for the household's current week.
|
||||
Current bool
|
||||
}
|
||||
|
||||
// Archive returns the Supply archive: the Supply run of every week up to
|
||||
// and including the household's current week, newest first, grouped by
|
||||
// by, which is ByWeek, ByMonth or ByYear (else ErrInvalidPeriod). A
|
||||
// week's month and year are those of its Monday, a household-local date,
|
||||
// so a week belongs to one period whatever the server's zone; the week
|
||||
// starting Monday 2026-12-28 is in December 2026.
|
||||
func (s *Service) Archive(ctx context.Context, by string) (out []Period, err error) {
|
||||
// period returns the key and heading of the period holding p, whose
|
||||
// week starts on Monday monday.
|
||||
var period func(monday store.LocalDate, p Post) (string, string)
|
||||
switch by {
|
||||
case ByWeek:
|
||||
period = func(monday store.LocalDate, p Post) (string, string) {
|
||||
return household.PeriodKey(monday), p.Label
|
||||
}
|
||||
case ByMonth:
|
||||
period = func(monday store.LocalDate, _ Post) (string, string) {
|
||||
y, m, _ := monday.Date()
|
||||
return fmt.Sprintf("%04d-%02d", y, int(m)), fmt.Sprintf("%s %d", m, y)
|
||||
}
|
||||
case ByYear:
|
||||
period = func(monday store.LocalDate, _ Post) (string, string) {
|
||||
y, _, _ := monday.Date()
|
||||
return strconv.Itoa(y), strconv.Itoa(y)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("shopping: archive by %q: %w", by, ErrInvalidPeriod)
|
||||
}
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
thisWeek, err := s.thisWeek(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lists, err := q.ListShoppingListsThrough(ctx, thisWeek.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: read Supply runs: %w", err)
|
||||
}
|
||||
out = []Period{}
|
||||
for _, row := range lists {
|
||||
monday, err := store.ParseLocalDate(row.WeekStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: Supply run %s week: %w", row.ID, err)
|
||||
}
|
||||
l, err := readList(ctx, q, row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := postOf(monday, l, monday == thisWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, heading := period(monday, p)
|
||||
if n := len(out); n == 0 || out[n-1].Key != k {
|
||||
out = append(out, Period{Key: k, Heading: heading})
|
||||
}
|
||||
out[len(out)-1].Posts = append(out[len(out)-1].Posts, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// postOf is l, the Supply run of the week starting monday, as a post in
|
||||
// the Supply archive.
|
||||
func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
|
||||
label, err := household.WeekLabel(monday)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
|
||||
}
|
||||
dinners := make([]string, len(l.Groups))
|
||||
for i, g := range l.Groups {
|
||||
dinners[i] = g.DinnerName
|
||||
}
|
||||
c := l.Counts()
|
||||
return Post{
|
||||
WeekStart: l.WeekStart,
|
||||
Label: label,
|
||||
Title: l.Title,
|
||||
Dinners: dinners,
|
||||
Items: c.Total,
|
||||
OnHand: c.Total - c.Needed,
|
||||
Current: current,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"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/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// zoned makes zone the household's time zone and at the instant the
|
||||
// service's clock reads.
|
||||
func (e *env) zoned(t *testing.T, zone, at string) {
|
||||
t.Helper()
|
||||
if err := household.SeedSettings(t.Context(), e.st, zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.at(t, at)
|
||||
}
|
||||
|
||||
// at sets the service's clock to the RFC 3339 instant at.
|
||||
func (e *env) at(t *testing.T, at string) {
|
||||
t.Helper()
|
||||
now, err := time.Parse(time.RFC3339, at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopping.SetClock(e.svc, func() time.Time { return now })
|
||||
}
|
||||
|
||||
// frozenState returns the week's list's status and frozen_at; frozen_at is
|
||||
// "" while NULL.
|
||||
func (e *env) frozenState(t *testing.T, monday store.LocalDate) (string, string) {
|
||||
t.Helper()
|
||||
var status string
|
||||
var frozenAt sql.NullString
|
||||
if err := e.st.Reader.QueryRow(`SELECT status, frozen_at FROM shopping_lists WHERE week_start = ?`, monday.String()).Scan(&status, &frozenAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return status, frozenAt.String
|
||||
}
|
||||
|
||||
// weekList plans the dinners named names on the days of the week starting
|
||||
// monday, in order, and generates its Supply run.
|
||||
func (e *env) weekList(t *testing.T, monday string, names ...string) shopping.List {
|
||||
t.Helper()
|
||||
start := mustDate(monday)
|
||||
days, err := household.WeekDays(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, name := range names {
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{
|
||||
ID: store.NewID(), PlanID: planID, Date: days[i].String(),
|
||||
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
g, err := e.svc.Generate(t.Context(), start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g.List
|
||||
}
|
||||
|
||||
// endedWeek is the env with the tests' week generated, in New York, on
|
||||
// the Monday after it, so the week has ended.
|
||||
func endedWeek(t *testing.T) (*env, shopping.List) {
|
||||
t.Helper()
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
|
||||
e.plan(t, "Beef Chili")
|
||||
return e, e.generate(t).List
|
||||
}
|
||||
|
||||
// TestFreeze freezes an ended week's list: it is archived with frozen_at,
|
||||
// read back read-only, and freezing it again changes nothing.
|
||||
func TestFreeze(t *testing.T) {
|
||||
e, l := endedWeek(t)
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, frozenAt := e.frozenState(t, week)
|
||||
if status != shopping.StatusArchived || frozenAt == "" {
|
||||
t.Fatalf("after Freeze: status %q, frozen_at %q; want archived and set", status, frozenAt)
|
||||
}
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != shopping.StatusArchived {
|
||||
t.Errorf("read back status = %q, want archived", got.Status)
|
||||
}
|
||||
wantLines(t, "read back", lines(got), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
|
||||
})
|
||||
|
||||
time.Sleep(2 * time.Millisecond) // a second freeze would stamp a later frozen_at
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatalf("second Freeze = %v, want no error", err)
|
||||
}
|
||||
if s, again := e.frozenState(t, week); s != shopping.StatusArchived || again != frozenAt {
|
||||
t.Errorf("after second Freeze: status %q, frozen_at %q; want archived, %q kept", s, again, frozenAt)
|
||||
}
|
||||
|
||||
if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[1].ID, false); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("SetNeeded after Freeze = %v, want ErrFrozen", err)
|
||||
}
|
||||
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("Generate after Freeze = %v, want ErrFrozen", err)
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
|
||||
})
|
||||
}
|
||||
|
||||
// TestFreezeWeekNotEnded: a week has not ended until Monday comes in the
|
||||
// household's zone. At 23:30 on the Sunday in Los Angeles it is already
|
||||
// Monday in UTC, and the list is still not frozen; at 00:05 on the Monday
|
||||
// it is.
|
||||
func TestFreezeWeekNotEnded(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/Los_Angeles", "2026-10-19T06:30:00Z") // Sun 18 Oct 23:30 PDT
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Fatalf("Freeze on the Sunday = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusCurrent || frozenAt != "" {
|
||||
t.Fatalf("after refused Freeze: status %q, frozen_at %q; want current, unset", status, frozenAt)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Errorf("Freeze of a later week = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
|
||||
e.at(t, "2026-10-19T07:05:00Z") // Mon 19 Oct 00:05 PDT
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatalf("Freeze on the Monday = %v", err)
|
||||
}
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreezeWeekWithoutList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) {
|
||||
t.Fatalf("Freeze = %v, want ErrListNotFound", err)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-13")); err == nil {
|
||||
t.Fatal("Freeze of a Tuesday succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeTitlesUntitledList: a list still untitled when its week is
|
||||
// frozen is titled by archive_title first, from the plan's dinners, and
|
||||
// frozen with that title. Its title job, still queued, runs after the
|
||||
// freeze, finds the title and succeeds without asking the model again.
|
||||
func TestFreezeTitlesUntitledList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.dinner(t, "Fish Tacos")
|
||||
e.plan(t, "Fish Tacos", "Beef Chili")
|
||||
g := e.generate(t) // the runner is not started: its title job waits
|
||||
e.model.QueueGenerate(titled("Tacos then Chili"))
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Tacos then Chili")
|
||||
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusArchived || frozenAt == "" {
|
||||
t.Errorf("status %q, frozen_at %q; want archived and set", status, frozenAt)
|
||||
}
|
||||
reqs := e.model.Requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("%d model calls, want 1", len(reqs))
|
||||
}
|
||||
if want := "Title the week whose dinners are, in day order from Monday:\n- Fish Tacos\n- Beef Chili"; reqs[0].Messages[0].Content != want {
|
||||
t.Errorf("user message = %q, want %q", reqs[0].Messages[0].Content, want)
|
||||
}
|
||||
|
||||
e.start(t)
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the job finds the list titled", n)
|
||||
}
|
||||
wantTitle(t, "stored title after the job", e.storedTitle(t), "Tacos then Chili")
|
||||
}
|
||||
|
||||
// pauseTitle makes the env's first archive_title run pause once it has
|
||||
// returned, before its title is stored, until the returned release is
|
||||
// called or the test ends; paused is closed as it pauses.
|
||||
func (e *env) pauseTitle(t *testing.T) (paused <-chan struct{}, release func()) {
|
||||
t.Helper()
|
||||
p, r := make(chan struct{}), make(chan struct{})
|
||||
var first atomic.Bool
|
||||
e.afterTitle = func() {
|
||||
if first.CompareAndSwap(false, true) {
|
||||
close(p)
|
||||
select {
|
||||
case <-r:
|
||||
case <-t.Context().Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
var released sync.Once
|
||||
return p, func() { released.Do(func() { close(r) }) }
|
||||
}
|
||||
|
||||
// waitClosed fails t unless c is closed within 10 seconds.
|
||||
func waitClosed(t *testing.T, what string, c <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-c:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeWaitsForTitleJob: a freeze that starts while a title job's
|
||||
// archive_title run has returned but its title is not yet stored waits for
|
||||
// the job to store it, freezes the list with that title, and asks the
|
||||
// model nothing itself.
|
||||
func TestFreezeWaitsForTitleJob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.model.QueueGenerate(titled("Chili Night"))
|
||||
paused, release := e.pauseTitle(t)
|
||||
e.start(t)
|
||||
g := e.generate(t)
|
||||
waitClosed(t, "the title job's archive_title run", paused)
|
||||
|
||||
froze := make(chan error, 1)
|
||||
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
|
||||
var err error
|
||||
select {
|
||||
case err = <-froze:
|
||||
t.Errorf("Freeze = %v before the title job stored its title, want it to wait", err)
|
||||
release()
|
||||
case <-time.After(time.Second):
|
||||
release()
|
||||
err = <-froze
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the freeze waits for the job's title", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTitleJobWaitsForFreeze: a title job that starts while a freeze's
|
||||
// archive_title run has returned but its title is not yet stored waits for
|
||||
// the freeze, then finds the list titled and asks the model nothing.
|
||||
func TestTitleJobWaitsForFreeze(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.model.QueueGenerate(titled("Chili Night"))
|
||||
g := e.generate(t) // the runner is not started: its title job waits
|
||||
paused, release := e.pauseTitle(t)
|
||||
|
||||
froze := make(chan error, 1)
|
||||
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
|
||||
waitClosed(t, "the freeze's archive_title run", paused)
|
||||
e.start(t)
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusRunning {
|
||||
break
|
||||
}
|
||||
if job.Status != jobs.StatusQueued || time.Now().After(deadline) {
|
||||
t.Fatalf("title job %s, want it running while the freeze is paused", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
release()
|
||||
if err := <-froze; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the job waits for the freeze's title", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeUntitledWhenTitleFails: when archive_title cannot title the
|
||||
// list, it is frozen untitled all the same, the failure is logged, and the
|
||||
// Supply archive shows its week label for good.
|
||||
func TestFreezeUntitledWhenTitleFails(t *testing.T) {
|
||||
e, _ := endedWeek(t) // nothing queued on the model: archive_title fails
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantUntitled(t, "stored title", e.storedTitle(t))
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if !strings.Contains(e.log.String(), "Supply run frozen untitled") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
periods, err := e.svc.Archive(t.Context(), shopping.ByWeek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(periods) != 1 || len(periods[0].Posts) != 1 || periods[0].Posts[0].Title != nil ||
|
||||
periods[0].Posts[0].Label != "Week 42 · Oct 12 – 18" {
|
||||
t.Errorf("archive = %+v, want the untitled week under its label", periods)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeKeepsTitle: a titled list is frozen with its title and the
|
||||
// model is not asked again.
|
||||
func TestFreezeKeepsTitle(t *testing.T) {
|
||||
e, _ := endedWeek(t)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili Night'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if n := len(e.model.Requests()); n != 0 {
|
||||
t.Errorf("%d model calls, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// postLine renders p as "weekStart label | title | dinners | items/on hand
|
||||
// | current".
|
||||
func postLine(p shopping.Post) string {
|
||||
title := "-"
|
||||
if p.Title != nil {
|
||||
title = *p.Title
|
||||
}
|
||||
cur := ""
|
||||
if p.Current {
|
||||
cur = " | current"
|
||||
}
|
||||
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
|
||||
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
|
||||
}
|
||||
|
||||
// periodLines renders periods one line per heading and post.
|
||||
func periodLines(periods []shopping.Period) []string {
|
||||
var out []string
|
||||
for _, p := range periods {
|
||||
out = append(out, "# "+p.Key+" "+p.Heading)
|
||||
for _, post := range p.Posts {
|
||||
out = append(out, postLine(post))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestArchiveGroups reads the Supply archive across a month and a year
|
||||
// boundary in Auckland, where it is already Monday 4 January 2027 while
|
||||
// UTC still reads Sunday 3 January: that week is current, the next one is
|
||||
// not yet in the archive, and each week falls in the month and year of
|
||||
// its Monday.
|
||||
func TestArchiveGroups(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "Pacific/Auckland", "2027-01-03T12:00:00Z") // Mon 4 Jan 2027 01:00 NZDT
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""})
|
||||
e.dinner(t, "Fish Tacos") // no recipe: no group, not counted
|
||||
e.dinner(t, "Pot Roast", ing{"carrots", "4", ""})
|
||||
|
||||
nov := e.weekList(t, "2026-11-30", "Pot Roast") // Nov 30 – Dec 6
|
||||
e.weekList(t, "2026-12-07", "Pad Thai", "Fish Tacos")
|
||||
dec := e.weekList(t, "2026-12-28", "Beef Chili", "Fish Tacos", "Pad Thai") // ISO 2026-W53, into January
|
||||
jan := e.weekList(t, "2027-01-04", "Pad Thai", "Beef Chili")
|
||||
e.weekList(t, "2027-01-11", "Pot Roast") // next week: not in the archive yet
|
||||
e.haveIt(t, dec, "Beef Chili", "onion")
|
||||
e.haveIt(t, dec, "Pad Thai", "lime")
|
||||
e.haveIt(t, jan, "Pad Thai", "lime")
|
||||
e.haveIt(t, nov, "Pot Roast", "carrots")
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
posts := map[string]string{
|
||||
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
|
||||
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
|
||||
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
|
||||
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
by string
|
||||
want []string
|
||||
}{
|
||||
{shopping.ByWeek, []string{
|
||||
"# 2027-W01 Week 1 · Jan 4 – 10", posts["jan"],
|
||||
"# 2026-W53 Week 53 · Dec 28 – Jan 3", posts["dec"],
|
||||
"# 2026-W50 Week 50 · Dec 7 – 13", posts["d07"],
|
||||
"# 2026-W49 Week 49 · Nov 30 – Dec 6", posts["nov"],
|
||||
}},
|
||||
{shopping.ByMonth, []string{
|
||||
"# 2027-01 January 2027", posts["jan"],
|
||||
"# 2026-12 December 2026", posts["dec"], posts["d07"],
|
||||
"# 2026-11 November 2026", posts["nov"],
|
||||
}},
|
||||
{shopping.ByYear, []string{
|
||||
"# 2027 2027", posts["jan"],
|
||||
"# 2026 2026", posts["dec"], posts["d07"], posts["nov"],
|
||||
}},
|
||||
} {
|
||||
got, err := e.svc.Archive(t.Context(), tc.by)
|
||||
if err != nil {
|
||||
t.Fatalf("Archive(%s): %v", tc.by, err)
|
||||
}
|
||||
if lines := periodLines(got); !reflect.DeepEqual(lines, tc.want) {
|
||||
t.Errorf("Archive(%s) =\n %s\nwant\n %s", tc.by, strings.Join(lines, "\n "), strings.Join(tc.want, "\n "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveEmptyAndInvalidPeriod(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
got, err := e.svc.Archive(t.Context(), shopping.ByMonth)
|
||||
if err != nil || got == nil || len(got) != 0 {
|
||||
t.Errorf("Archive of no Supply runs = %#v, %v; want empty", got, err)
|
||||
}
|
||||
for _, by := range []string{"", "day", "Week", "months"} {
|
||||
if _, err := e.svc.Archive(t.Context(), by); !errors.Is(err, shopping.ErrInvalidPeriod) {
|
||||
t.Errorf("Archive(%q) = %v, want ErrInvalidPeriod", by, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package shopping
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock makes now the clock s ends and dates weeks on.
|
||||
func SetClock(s *Service, now func() time.Time) { s.now = now }
|
||||
+214
-16
@@ -5,19 +5,32 @@
|
||||
// Generate makes a week's Supply run from its plan, and remakes it after
|
||||
// the plan changes. It is deterministic and runs no model: an item is one
|
||||
// ingredient of the dinner's recipe exactly as recipe grounding stored it
|
||||
// (see Generate).
|
||||
// (see Generate). The one model call is the Supply run's title, written by
|
||||
// archive_title in a KindTitleSupplyRun job when the list is generated
|
||||
// untitled (see Generate).
|
||||
//
|
||||
// Freeze moves a week's Supply run into the Supply archive once its week
|
||||
// has ended, and Archive reads the archive back as posts grouped by week,
|
||||
// month or year.
|
||||
package shopping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/store"
|
||||
)
|
||||
|
||||
@@ -36,6 +49,15 @@ const (
|
||||
SkipNoIngredients = "no_ingredients"
|
||||
)
|
||||
|
||||
// KindTitleSupplyRun is the job kind that titles a Supply run with
|
||||
// archive_title. Only Generate and GeneratePushed enqueue it.
|
||||
const KindTitleSupplyRun = "title_supply_run"
|
||||
|
||||
// ReasonTitleFailed is the failure code of a KindTitleSupplyRun job that
|
||||
// stored no title: archive_title failed, or the Supply run was frozen or
|
||||
// replaced first. The Supply run stays untitled.
|
||||
const ReasonTitleFailed = "title_failed"
|
||||
|
||||
// ErrPlanNotFound is a generation for a week with no plan.
|
||||
var ErrPlanNotFound = errors.New("the week has no plan")
|
||||
|
||||
@@ -54,17 +76,60 @@ var ErrListNotFound = errors.New("the week has no Supply run")
|
||||
// ErrItemNotFound is a change to an item on no Supply run.
|
||||
var ErrItemNotFound = errors.New("no Supply run has that item")
|
||||
|
||||
// ErrWeekNotEnded is a freeze of a Supply run whose week has not ended in
|
||||
// the household's time zone.
|
||||
var ErrWeekNotEnded = errors.New("the week has not ended")
|
||||
|
||||
// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and
|
||||
// ByYear.
|
||||
var ErrInvalidPeriod = errors.New("by must be week, month or year")
|
||||
|
||||
// Titler runs archive_title on in; scope names the week, its plan revision
|
||||
// and its dinners.
|
||||
// In production it is skills.TitleArchive with the household's
|
||||
// harness.Config.
|
||||
type Titler func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error)
|
||||
|
||||
// Service generates Supply runs.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
st *store.Store
|
||||
title Titler
|
||||
titleJob *jobs.Kind[TitleJob]
|
||||
logger *slog.Logger
|
||||
// now is the clock weeks are ended and current on; tests replace it.
|
||||
now func() time.Time
|
||||
// titling orders the title writes against Freeze. A title job holds it
|
||||
// shared from reading the list to storing its title, and Freeze holds
|
||||
// it exclusively from reading the list to freezing it, so a freeze
|
||||
// waits for the title jobs in flight to store their titles or fail,
|
||||
// and no title job runs archive_title while a freeze is under way.
|
||||
titling sync.RWMutex
|
||||
}
|
||||
|
||||
// NewService returns a service over st.
|
||||
func NewService(st *store.Store) (*Service, error) {
|
||||
if st == nil {
|
||||
// NewService returns a service over st that titles Supply runs with title
|
||||
// in KindTitleSupplyRun jobs, which it registers on runner, and logs to
|
||||
// logger. runner must not have started yet.
|
||||
func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("shopping: nil store")
|
||||
case title == nil:
|
||||
return nil, errors.New("shopping: nil titler")
|
||||
case runner == nil:
|
||||
return nil, errors.New("shopping: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("shopping: nil logger")
|
||||
}
|
||||
return &Service{st: st}, nil
|
||||
s := &Service{st: st, title: title, logger: logger, now: time.Now}
|
||||
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.ServiceOnly(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.titleJob = kind
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// List is a Supply run.
|
||||
@@ -142,6 +207,9 @@ type Generated struct {
|
||||
// Skipped are the plan days whose dinner has no ingredients to shop
|
||||
// for, by date.
|
||||
Skipped []SkippedDay `json:"skipped"`
|
||||
// TitleJobID is the KindTitleSupplyRun job titling the list; nil when
|
||||
// the list already has its title.
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
}
|
||||
|
||||
// Generate makes the Supply run of the week starting weekStart from the
|
||||
@@ -166,6 +234,16 @@ type Generated struct {
|
||||
// new and needed; an item no longer on the plan is removed, and with it a
|
||||
// group no longer on the plan.
|
||||
//
|
||||
// A list still untitled once it is stored, as a new list always is, gets a
|
||||
// KindTitleSupplyRun job, enqueued in the same transaction, that titles it
|
||||
// with archive_title from the names of the plan's dinners in day order.
|
||||
// Generate returns without waiting for the job: the list is returned
|
||||
// untitled, with the job's id. A title is written once: a remake keeps it
|
||||
// and enqueues no job. Titling never fails the generation: a title that
|
||||
// cannot be written leaves the list untitled, the failure is logged and
|
||||
// the job failed with ReasonTitleFailed, and the next generation of the
|
||||
// week, Generate or GeneratePushed, tries again.
|
||||
//
|
||||
// A week with no plan is ErrPlanNotFound; a list frozen into the Supply
|
||||
// archive is ErrFrozen. A fetched recipe's ingredient without a name is an
|
||||
// error: grounding always names one.
|
||||
@@ -206,7 +284,7 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
|
||||
if err := check(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
want, skipped, err := wanted(ctx, q, plan.ID)
|
||||
want, skipped, dinnerNames, err := wanted(ctx, q, plan.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -235,14 +313,126 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
|
||||
return err
|
||||
}
|
||||
out = Generated{List: listOf(list, groups), Skipped: skipped}
|
||||
if list.Title.Valid {
|
||||
return nil
|
||||
}
|
||||
id, err := s.titleJob.EnqueueTx(ctx, tx, TitleJob{
|
||||
ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: dinnerNames,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: enqueue title: %w", err)
|
||||
}
|
||||
out.TitleJobID = &id
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Generated{}, err
|
||||
}
|
||||
if out.TitleJobID != nil {
|
||||
s.titleJob.Wake()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TitleJob is a KindTitleSupplyRun job's payload: the untitled Supply run
|
||||
// ListID of the week WeekStart, made from the plan at Revision, whose
|
||||
// dinners in day order are Dinners.
|
||||
type TitleJob struct {
|
||||
ListID string `json:"list_id"`
|
||||
WeekStart string `json:"week_start"`
|
||||
Revision int64 `json:"revision"`
|
||||
Dinners []string `json:"dinners"`
|
||||
}
|
||||
|
||||
// titleResult is a KindTitleSupplyRun job's result: the Supply run and the
|
||||
// title it has.
|
||||
type titleResult struct {
|
||||
ListID string `json:"list_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// PublicJSON returns the Supply run's id and title.
|
||||
func (r titleResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// runTitleJob titles the Supply run of p with archive_title and stores the
|
||||
// title. A title that cannot be stored leaves the list untitled: the
|
||||
// failure is logged and the job fails with ReasonTitleFailed.
|
||||
func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResult) error) (titleResult, error) {
|
||||
title, err := s.writeTitle(ctx, p)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "Supply run left untitled; its next generation titles it again",
|
||||
"week", p.WeekStart, "list", p.ListID, "error", err)
|
||||
return titleResult{}, &jobs.Failure{Code: ReasonTitleFailed}
|
||||
}
|
||||
return titleResult{ListID: p.ListID, Title: title}, nil
|
||||
}
|
||||
|
||||
// writeTitle runs archive_title for p's Supply run and stores its title,
|
||||
// as storeTitle does, while no freeze is under way: it waits for one to
|
||||
// end first, and a freeze waits for it to end.
|
||||
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
s.titling.RLock()
|
||||
defer s.titling.RUnlock()
|
||||
return s.storeTitle(ctx, p)
|
||||
}
|
||||
|
||||
// storeTitle runs archive_title for p's Supply run and stores its title.
|
||||
// When the list was titled by another job, before this one ran or
|
||||
// meanwhile, that title stands and is returned. The caller holds titling.
|
||||
func (s *Service) storeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
q := db.New(s.st.Writer)
|
||||
title, err := s.currentTitle(ctx, q, p)
|
||||
if err != nil || title != "" {
|
||||
return title, err
|
||||
}
|
||||
out, err := s.title(ctx, titleScope(p), skills.ArchiveTitleRequest{Dinners: p.Dinners})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: title: %w", err)
|
||||
}
|
||||
write, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
n, err := q.SetShoppingListTitle(write, db.SetShoppingListTitleParams{
|
||||
Title: sql.NullString{String: out.Title, Valid: true}, UpdatedAt: store.Now().String(), ID: p.ListID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: store title: %w", err)
|
||||
}
|
||||
if n == 1 {
|
||||
return out.Title, nil
|
||||
}
|
||||
title, err = s.currentTitle(write, q, p)
|
||||
if err == nil && title == "" {
|
||||
err = errors.New("shopping: store title: the Supply run is still untitled")
|
||||
}
|
||||
return title, err
|
||||
}
|
||||
|
||||
// currentTitle returns the title of p's Supply run, or "" while it is
|
||||
// untitled. A list frozen or replaced untitled is an error: it can no
|
||||
// longer be titled.
|
||||
func (s *Service) currentTitle(ctx context.Context, q *db.Queries, p TitleJob) (string, error) {
|
||||
list, err := q.GetShoppingList(ctx, p.WeekStart)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("shopping: title: read list: %w", err)
|
||||
}
|
||||
switch {
|
||||
case err == nil && list.ID == p.ListID && list.Title.Valid:
|
||||
return list.Title.String, nil
|
||||
case err != nil || list.ID != p.ListID || frozen(list):
|
||||
return "", errors.New("shopping: store title: the Supply run was frozen or replaced before its title was stored")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// titleScope is the archive_title scope of p: its week and plan revision,
|
||||
// and a fingerprint of its dinners in day order, so runs share a title only
|
||||
// when they title the same dinner names. A renamed dinner changes the
|
||||
// names but not the plan revision.
|
||||
func titleScope(p TitleJob) string {
|
||||
names, _ := json.Marshal(p.Dinners) // a []string always encodes
|
||||
return fmt.Sprintf("%s@%d/%x", p.WeekStart, p.Revision, sha256.Sum256(names))
|
||||
}
|
||||
|
||||
// Get returns the Supply run of the week starting weekStart, as stored: a
|
||||
// week with none is ErrListNotFound.
|
||||
func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, err error) {
|
||||
@@ -264,13 +454,18 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read list: %w", err)
|
||||
}
|
||||
return readList(ctx, q, list)
|
||||
}
|
||||
|
||||
// readList reads the groups and items of list through q.
|
||||
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
|
||||
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read groups: %w", err)
|
||||
return List{}, fmt.Errorf("shopping: read groups of %s: %w", list.WeekStart, err)
|
||||
}
|
||||
items, err := q.ListShoppingItems(ctx, list.ID)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read items: %w", err)
|
||||
return List{}, fmt.Errorf("shopping: read items of %s: %w", list.WeekStart, err)
|
||||
}
|
||||
byGroup := make(map[string][]Item, len(rows))
|
||||
for _, r := range items {
|
||||
@@ -365,18 +560,21 @@ type wantItem struct {
|
||||
}
|
||||
|
||||
// wanted reads plan planID's days and their dinners' ingredients: the
|
||||
// groups the list must have, by date, and the days that get none.
|
||||
func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []SkippedDay, error) {
|
||||
// groups the list must have, by date, the days that get none, and the
|
||||
// names of the plan's dinners in day order.
|
||||
func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []SkippedDay, []string, error) {
|
||||
days, err := q.ListPlanDays(ctx, planID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("shopping: read plan days: %w", err)
|
||||
return nil, nil, nil, fmt.Errorf("shopping: read plan days: %w", err)
|
||||
}
|
||||
var (
|
||||
groups []wantGroup
|
||||
skipped []SkippedDay
|
||||
names []string
|
||||
)
|
||||
for _, day := range days {
|
||||
d := day.Dinner
|
||||
names = append(names, d.Name)
|
||||
skip := SkippedDay{Date: day.Date, DinnerID: d.ID, DinnerName: d.Name}
|
||||
if !d.RecipeID.Valid {
|
||||
skip.Reason = SkipNoRecipe
|
||||
@@ -385,11 +583,11 @@ func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []S
|
||||
}
|
||||
recipe, err := q.GetRecipe(ctx, d.RecipeID.String)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("shopping: read recipe %s of dinner %s: %w", d.RecipeID.String, d.ID, err)
|
||||
return nil, nil, nil, fmt.Errorf("shopping: read recipe %s of dinner %s: %w", d.RecipeID.String, d.ID, err)
|
||||
}
|
||||
ings, err := q.ListRecipeIngredients(ctx, recipe.ID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("shopping: read ingredients of recipe %s: %w", recipe.ID, err)
|
||||
return nil, nil, nil, fmt.Errorf("shopping: read ingredients of recipe %s: %w", recipe.ID, err)
|
||||
}
|
||||
if len(ings) == 0 {
|
||||
skip.Reason = SkipNoIngredients
|
||||
@@ -401,13 +599,13 @@ func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []S
|
||||
for _, ing := range ings {
|
||||
it, err := item(ing, manual)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("shopping: recipe %s: %w", recipe.ID, err)
|
||||
return nil, nil, nil, fmt.Errorf("shopping: recipe %s: %w", recipe.ID, err)
|
||||
}
|
||||
g.items = append(g.items, it)
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, skipped, nil
|
||||
return groups, skipped, names, nil
|
||||
}
|
||||
|
||||
// item is the item for ingredient ing of a recipe entered by hand
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -8,10 +10,13 @@ import (
|
||||
"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/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
@@ -42,6 +47,38 @@ type env struct {
|
||||
planID string
|
||||
// ids are the dinners' ids by name.
|
||||
ids map[string]string
|
||||
// runner runs the title jobs, two at a time, once start starts it.
|
||||
runner *jobs.Runner
|
||||
// model answers archive_title, which titles the Supply runs; with
|
||||
// nothing queued it fails, and the lists stay untitled.
|
||||
model *llm.Fake
|
||||
// beforeTitle, when set, runs as archive_title is asked for a title.
|
||||
beforeTitle func()
|
||||
// afterTitle, when set, runs once archive_title has returned, before
|
||||
// its title is stored.
|
||||
afterTitle func()
|
||||
// beforeModel, when set, runs as the model is sent a request.
|
||||
beforeModel func(llm.Request)
|
||||
// log holds what the service logged.
|
||||
log *syncBuffer
|
||||
}
|
||||
|
||||
// syncBuffer is a bytes.Buffer safe for concurrent use.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
@@ -59,11 +96,89 @@ func newEnv(t *testing.T) *env {
|
||||
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := shopping.NewService(st)
|
||||
e := &env{st: st, dataDir: dataDir, ids: map[string]string{}, model: &llm.Fake{}, log: &syncBuffer{}}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &env{svc: svc, st: st, dataDir: dataDir, ids: map[string]string{}}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return hookedModel{e}, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
title := func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
if e.beforeTitle != nil {
|
||||
e.beforeTitle()
|
||||
}
|
||||
out, err := skills.TitleArchive(ctx, cfg, scope, in)
|
||||
if e.afterTitle != nil {
|
||||
e.afterTitle()
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(e.log, nil))
|
||||
e.runner, err = jobs.NewRunner(st, logger, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = shopping.NewService(st, title, e.runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// hookedModel is the env's model, running beforeModel first.
|
||||
type hookedModel struct{ e *env }
|
||||
|
||||
func (m hookedModel) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
if m.e.beforeModel != nil {
|
||||
m.e.beforeModel(req)
|
||||
}
|
||||
return m.e.model.Generate(ctx, req)
|
||||
}
|
||||
|
||||
func (m hookedModel) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||
return m.e.model.ListModels(ctx)
|
||||
}
|
||||
|
||||
func (m hookedModel) Verify(ctx context.Context) error { return m.e.model.Verify(ctx) }
|
||||
|
||||
// start starts the env's job runner, stopping it when the test ends.
|
||||
func (e *env) start(t *testing.T) {
|
||||
t.Helper()
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
// waitTitle waits for g's title job to finish and returns it.
|
||||
func (e *env) waitTitle(t *testing.T, g shopping.Generated) jobs.Job {
|
||||
t.Helper()
|
||||
if g.TitleJobID == nil {
|
||||
t.Fatal("generation enqueued no title job")
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
return job
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("title job %s still %s", job.ID, job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// ing is a recipe ingredient as recipe grounding stores it: an empty
|
||||
@@ -349,8 +464,13 @@ func TestGenerateSevenDayPlan(t *testing.T) {
|
||||
}
|
||||
|
||||
// Generating again from the same plan changes nothing: same rows, same
|
||||
// ids, no extra rows.
|
||||
// ids, no extra rows. The list is still untitled, so it gets another
|
||||
// title job.
|
||||
again := e.generate(t)
|
||||
if again.TitleJobID == nil || g.TitleJobID == nil || *again.TitleJobID == *g.TitleJobID {
|
||||
t.Errorf("title jobs = %v then %v, want one per generation of the untitled list", deref(g.TitleJobID), deref(again.TitleJobID))
|
||||
}
|
||||
again.TitleJobID = g.TitleJobID
|
||||
if !reflect.DeepEqual(again, g) {
|
||||
t.Errorf("second generation =\n %+v\nwant the first\n %+v", again, g)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"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/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
)
|
||||
|
||||
// titled is archive_title's reply giving title.
|
||||
func titled(title string) llm.GenerateResult {
|
||||
js := fmt.Sprintf(`{"title":%q}`, title)
|
||||
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
|
||||
}
|
||||
|
||||
// titleWeek stores three dinners, one with no recipe, and plans them on
|
||||
// the week's first three days; its job runner is started.
|
||||
func titleWeek(t *testing.T) *env {
|
||||
t.Helper()
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.dinner(t, "Fish Tacos")
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||
e.plan(t, "Pad Thai", "Fish Tacos", "Beef Chili")
|
||||
return e
|
||||
}
|
||||
|
||||
// storedTitle returns the week's list's stored title; nil when NULL.
|
||||
func (e *env) storedTitle(t *testing.T) *string {
|
||||
t.Helper()
|
||||
var title sql.NullString
|
||||
if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&title); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !title.Valid {
|
||||
return nil
|
||||
}
|
||||
return &title.String
|
||||
}
|
||||
|
||||
// runStatuses returns the status of every archive_title run recorded in
|
||||
// ai_runs, oldest first.
|
||||
func (e *env) runStatuses(t *testing.T) []string {
|
||||
t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT status FROM ai_runs WHERE skill = 'archive_title' ORDER BY created_at, rowid`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func wantTitle(t *testing.T, what string, got *string, want string) {
|
||||
t.Helper()
|
||||
if got == nil || *got != want {
|
||||
t.Errorf("%s = %v, want %q", what, deref(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func wantUntitled(t *testing.T, what string, got *string) {
|
||||
t.Helper()
|
||||
if got != nil {
|
||||
t.Errorf("%s = %q, want none", what, *got)
|
||||
}
|
||||
}
|
||||
|
||||
func deref(s *string) any {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// wantJob fails t unless job ended with status and, when failed, code.
|
||||
func wantJob(t *testing.T, job jobs.Job, status jobs.Status, code string) {
|
||||
t.Helper()
|
||||
if job.Status != status || (code != "" && (job.Error == nil || *job.Error != code)) {
|
||||
t.Errorf("title job = %s %v, want %s %s", job.Status, deref(job.Error), status, code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitlesNewList: a new list is returned untitled with a title
|
||||
// job, which titles it by archive_title from the plan's dinners in day
|
||||
// order, a dinner with no group included, and stores the title.
|
||||
func TestGenerateTitlesNewList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
|
||||
g := e.generate(t)
|
||||
wantUntitled(t, "returned title", g.List.Title)
|
||||
job := e.waitTitle(t, g)
|
||||
|
||||
wantJob(t, job, jobs.StatusSucceeded, "")
|
||||
if want := `{"list_id":"` + g.List.ID + `","title":"Noodles, Tacos and Chili"}`; string(job.Result) != want {
|
||||
t.Errorf("job result = %s, want %s", job.Result, want)
|
||||
}
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTitle(t, "title read back", got.Title, "Noodles, Tacos and Chili")
|
||||
reqs := e.model.Requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("%d model calls, want 1", len(reqs))
|
||||
}
|
||||
want := "Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili"
|
||||
if user := reqs[0].Messages[0].Content; user != want {
|
||||
t.Errorf("user message = %q, want %q", user, want)
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusOK) {
|
||||
t.Errorf("archive_title runs = %q, want one ok", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleTooLongRepaired: a title over 48 characters is repaired
|
||||
// through the repair turn, and the repaired title is stored, not a
|
||||
// truncation of the first.
|
||||
func TestGenerateTitleTooLongRepaired(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
long := "Pad Thai Monday, Fish Taco Tuesday and Chili Wednesday"
|
||||
e.model.QueueGenerate(titled(long), titled("Noodles, Tacos and Chili"))
|
||||
|
||||
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusSucceeded, "")
|
||||
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||||
reqs := e.model.Requests()
|
||||
if len(reqs) != 2 || !strings.Contains(reqs[1].Messages[2].Content, "has 54 characters; shorten it to at most 48") {
|
||||
t.Fatalf("model calls = %+v, want a repair turn asking for a shorter title", reqs)
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusRepaired) {
|
||||
t.Errorf("archive_title runs = %q, want one repaired", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleFailureKeepsList: archive_title giving an emoji twice
|
||||
// fails, and the list is still made, whole and untitled, with the failure
|
||||
// logged, recorded in ai_runs and failing the title job.
|
||||
func TestGenerateTitleFailureKeepsList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Taco Week 🌮"), titled("Taco Week 🌮"))
|
||||
|
||||
g, err := e.svc.Generate(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate = %v, want the list made though its title failed", err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"0 2026-10-12 Pad Thai: 0 rice noodles [8 oz] needed",
|
||||
"1 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
}
|
||||
wantLines(t, "list", lines(g.List), want)
|
||||
wantLines(t, "stored list", e.stored(t), want)
|
||||
if len(g.Skipped) != 1 || g.Skipped[0].DinnerName != "Fish Tacos" {
|
||||
t.Errorf("skipped = %+v, want Fish Tacos", g.Skipped)
|
||||
}
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
wantUntitled(t, "returned title", g.List.Title)
|
||||
wantUntitled(t, "stored title", e.storedTitle(t))
|
||||
if g.List.Status != shopping.StatusCurrent {
|
||||
t.Errorf("status = %q, want current", g.List.Status)
|
||||
}
|
||||
log := e.log.String()
|
||||
for _, part := range []string{"level=WARN", "Supply run left untitled", "week=2026-10-12", "has an emoji (U+1F32E)"} {
|
||||
if !strings.Contains(log, part) {
|
||||
t.Errorf("log lacks %q:\n%s", part, log)
|
||||
}
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusBadOutput) {
|
||||
t.Errorf("archive_title runs = %q, want one bad_output", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateRetriesTitle: a list left untitled is titled by the week's
|
||||
// next generation, Generate or GeneratePushed; once titled, a remake keeps
|
||||
// its title and asks for none.
|
||||
func TestGenerateRetriesTitle(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(llm.GenerateResult{Err: fmt.Errorf("%w: model down", llm.ErrProvider)})
|
||||
first := e.generate(t)
|
||||
wantJob(t, e.waitTitle(t, first), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
wantUntitled(t, "first title", e.storedTitle(t))
|
||||
if !strings.Contains(e.log.String(), "model down") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
plan, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := e.svc.GeneratePushed(t.Context(), week, plan.Revision)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.List.ID != first.List.ID {
|
||||
t.Fatalf("second generation made list %s, want list %s again", second.List.ID, first.List.ID)
|
||||
}
|
||||
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
|
||||
wantTitle(t, "retried title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||||
|
||||
e.swap(t, "2026-10-12", "Beef Chili")
|
||||
third := e.generate(t)
|
||||
wantTitle(t, "title after a remake", third.List.Title, "Noodles, Tacos and Chili")
|
||||
if third.TitleJobID != nil {
|
||||
t.Errorf("a remake of a titled list enqueued title job %s", *third.TitleJobID)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 2 {
|
||||
t.Errorf("%d model calls, want 2: a titled list is not titled again", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleNotStoredOnFrozenList: a list frozen into the Supply
|
||||
// archive while its title is written keeps no title, and the failure is
|
||||
// logged.
|
||||
func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
e.beforeTitle = func() {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
|
||||
wantUntitled(t, "stored title", e.storedTitle(t))
|
||||
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleOverlapAcrossRename: a dinner renamed between two
|
||||
// generations of an untitled list changes the names to title but not the
|
||||
// plan revision. The two title jobs, running at once, each send the model
|
||||
// their own names; neither is given the other's title.
|
||||
func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"), titled("Drunken Noodles Week"))
|
||||
// Each model call waits for the other, so the runs overlap.
|
||||
var arrived sync.WaitGroup
|
||||
arrived.Add(2)
|
||||
e.beforeModel = func(llm.Request) {
|
||||
arrived.Done()
|
||||
done := make(chan struct{})
|
||||
go func() { arrived.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("the title runs did not overlap: one model call never came")
|
||||
}
|
||||
}
|
||||
revision := func() int64 {
|
||||
p, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p.Revision
|
||||
}
|
||||
|
||||
before := revision()
|
||||
first := e.generate(t)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE dinners SET name = 'Drunken Noodles', normalized_name = 'drunken noodles' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := e.generate(t)
|
||||
if revision() != before {
|
||||
t.Fatalf("plan revision moved from %d to %d: the rename must leave it", before, revision())
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, first), jobs.StatusSucceeded, "")
|
||||
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
|
||||
var users []string
|
||||
for _, r := range e.model.Requests() {
|
||||
users = append(users, r.Messages[0].Content)
|
||||
}
|
||||
slices.Sort(users)
|
||||
want := []string{
|
||||
"Title the week whose dinners are, in day order from Monday:\n- Drunken Noodles\n- Fish Tacos\n- Beef Chili",
|
||||
"Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili",
|
||||
}
|
||||
if !slices.Equal(users, want) {
|
||||
t.Errorf("model was sent %q, want each generation's own names %q", users, want)
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 2 {
|
||||
t.Errorf("archive_title runs = %q, want two", s)
|
||||
}
|
||||
if got := e.storedTitle(t); got == nil || (*got != "Noodles, Tacos and Chili" && *got != "Drunken Noodles Week") {
|
||||
t.Errorf("stored title = %v, want one of the two titles", deref(got))
|
||||
}
|
||||
}
|
||||
@@ -81,3 +81,24 @@ WHERE shopping_items.id = ?;
|
||||
UPDATE shopping_items
|
||||
SET needed = ?, updated_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: SetShoppingListTitle :execrows
|
||||
-- Titles list id, unless it has a title already or is frozen into the
|
||||
-- Supply archive.
|
||||
UPDATE shopping_lists
|
||||
SET title = ?, updated_at = ?
|
||||
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;
|
||||
|
||||
-- name: FreezeShoppingList :execrows
|
||||
-- Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
-- frozen already.
|
||||
UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
WHERE id = ? AND frozen_at IS NULL;
|
||||
|
||||
-- name: ListShoppingListsThrough :many
|
||||
-- The Supply runs of the weeks starting on or before week_start, newest
|
||||
-- week first.
|
||||
SELECT * FROM shopping_lists
|
||||
WHERE week_start <= ?
|
||||
ORDER BY week_start DESC;
|
||||
|
||||
Reference in New Issue
Block a user