Stage 1's point is collecting eating history, so the logging path is the one that has to be frictionless: pick a dish, tick sides, done. Kirjaa: - dishes ordered and sized by how often they have been eaten, so the likely answer is the biggest target on the screen - picking a dish opens the sides step; a dish with has_sides false says so instead of offering an empty list - saving redirects, so a refresh cannot double-post - a day already logged shows the entry with edit and delete. Editing reopens the sides step with the existing sides ticked, which makes editing and creating the same screen - day switcher and a server-side search over the catalog Historia walks back day by day to the oldest entry, so a day nobody wrote down appears as an explicit gap rather than quietly missing. No JavaScript is involved: every interaction is a link or a form, and the checked-chip styling is :has(input:checked). Datastar stays loaded but unused until an interaction genuinely needs to avoid a page load. Also fix a Makefile ordering bug: lint did not depend on generate, so `go vet` could run against templ output that was being rewritten. check now runs its phases as sub-makes so `make -j` cannot interleave them. Records two features that are specified but not built: dish CRUD in the UI (§7.3, only import exists today) and a per-device light/dark switch (§7.4). The switch must show the state that is active, not the one clicking produces.
268 lines
6.8 KiB
Go
268 lines
6.8 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// dbHandle wraps a scratch database with lookups the tests need, so a test
|
|
// reads as "log Lohikeitto" rather than juggling row ids.
|
|
type dbHandle struct{ db *sql.DB }
|
|
|
|
func (h *dbHandle) mainNamed(t *testing.T, name string) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
if err := h.db.QueryRow(
|
|
`SELECT id FROM main_dishes WHERE name = ?`, name).Scan(&id); err != nil {
|
|
t.Fatalf("main dish %q: %v", name, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func (h *dbHandle) sideNamed(t *testing.T, name string) int64 {
|
|
t.Helper()
|
|
var id int64
|
|
if err := h.db.QueryRow(
|
|
`SELECT id FROM side_dishes WHERE name = ?`, name).Scan(&id); err != nil {
|
|
t.Fatalf("side dish %q: %v", name, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func containsFold(s, sub string) bool {
|
|
return strings.Contains(strings.ToLower(s), strings.ToLower(sub))
|
|
}
|
|
|
|
// seeded opens a scratch database populated from the committed bundle.
|
|
func seeded(t *testing.T) *dbHandle {
|
|
t.Helper()
|
|
|
|
db, err := openDB(t.TempDir() + "/test.db")
|
|
if err != nil {
|
|
t.Fatalf("openDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
f, err := os.Open("../../seeds/testi.json")
|
|
if err != nil {
|
|
t.Fatalf("open seed: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
if _, err := importBundle(db, f); err != nil {
|
|
t.Fatalf("import seed: %v", err)
|
|
}
|
|
return &dbHandle{db}
|
|
}
|
|
|
|
func day(t *testing.T, s string) time.Time {
|
|
t.Helper()
|
|
d, err := time.ParseInLocation(dateLayout, s, time.UTC)
|
|
if err != nil {
|
|
t.Fatalf("bad date %q: %v", s, err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
func TestSaveAndReadEntry(t *testing.T) {
|
|
h := seeded(t)
|
|
date := day(t, "2026-09-05")
|
|
|
|
if e, err := entryFor(h.db, date); err != nil || e != nil {
|
|
t.Fatalf("empty day: entry = %v, err = %v; want nil, nil", e, err)
|
|
}
|
|
|
|
main := h.mainNamed(t, "Lohikeitto")
|
|
side := h.sideNamed(t, "Ruisleipä")
|
|
|
|
if err := saveEntry(h.db, date, main, []int64{side}); err != nil {
|
|
t.Fatalf("saveEntry: %v", err)
|
|
}
|
|
|
|
e, err := entryFor(h.db, date)
|
|
if err != nil || e == nil {
|
|
t.Fatalf("entryFor: %v, %v", e, err)
|
|
}
|
|
if e.Main.Name != "Lohikeitto" {
|
|
t.Errorf("main = %q, want Lohikeitto", e.Main.Name)
|
|
}
|
|
if e.SidesLabel() != "Ruisleipä" {
|
|
t.Errorf("sides = %q, want Ruisleipä", e.SidesLabel())
|
|
}
|
|
}
|
|
|
|
func TestSaveEntryReplacesTheDay(t *testing.T) {
|
|
h := seeded(t)
|
|
date := day(t, "2026-09-05")
|
|
|
|
if err := saveEntry(h.db, date, h.mainNamed(t, "Lohikeitto"),
|
|
[]int64{h.sideNamed(t, "Ruisleipä")}); err != nil {
|
|
t.Fatalf("first save: %v", err)
|
|
}
|
|
// PRD §6 allows one entry per date, so a second save is an edit.
|
|
if err := saveEntry(h.db, date, h.mainNamed(t, "Lihapullat"),
|
|
[]int64{h.sideNamed(t, "Perunamuusi"), h.sideNamed(t, "Vihersalaatti")}); err != nil {
|
|
t.Fatalf("second save: %v", err)
|
|
}
|
|
|
|
var rows int
|
|
if err := h.db.QueryRow(`SELECT count(*) FROM meal_log WHERE date = ?`,
|
|
date.Format(dateLayout)).Scan(&rows); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if rows != 1 {
|
|
t.Errorf("%d rows for one date, want 1", rows)
|
|
}
|
|
|
|
e, _ := entryFor(h.db, date)
|
|
if e.Main.Name != "Lihapullat" {
|
|
t.Errorf("main = %q, want Lihapullat", e.Main.Name)
|
|
}
|
|
if len(e.Sides) != 2 {
|
|
t.Errorf("%d sides, want 2 (%s)", len(e.Sides), e.SidesLabel())
|
|
}
|
|
|
|
// The replaced entry's sides must go with it, not linger orphaned.
|
|
var orphans int
|
|
if err := h.db.QueryRow(`SELECT count(*) FROM meal_log_sides ls
|
|
WHERE NOT EXISTS (SELECT 1 FROM meal_log l WHERE l.id = ls.meal_log_id)`,
|
|
).Scan(&orphans); err != nil {
|
|
t.Fatalf("orphan check: %v", err)
|
|
}
|
|
if orphans != 0 {
|
|
t.Errorf("%d orphaned sides after replace", orphans)
|
|
}
|
|
}
|
|
|
|
func TestDeleteEntry(t *testing.T) {
|
|
h := seeded(t)
|
|
date := day(t, "2026-09-05")
|
|
|
|
if err := saveEntry(h.db, date, h.mainNamed(t, "Lohikeitto"), nil); err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
if err := deleteEntry(h.db, date); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
if e, _ := entryFor(h.db, date); e != nil {
|
|
t.Errorf("entry survived delete: %v", e)
|
|
}
|
|
}
|
|
|
|
func TestListDishesOrdersByTimesEaten(t *testing.T) {
|
|
h := seeded(t)
|
|
lihapullat := h.mainNamed(t, "Lihapullat")
|
|
|
|
for i := 1; i <= 3; i++ {
|
|
d := day(t, fmt.Sprintf("2026-09-%02d", i))
|
|
if err := saveEntry(h.db, d, lihapullat, nil); err != nil {
|
|
t.Fatalf("save %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
dishes, err := listDishes(h.db, "")
|
|
if err != nil {
|
|
t.Fatalf("listDishes: %v", err)
|
|
}
|
|
if len(dishes) == 0 {
|
|
t.Fatal("no dishes")
|
|
}
|
|
if dishes[0].Name != "Lihapullat" {
|
|
t.Errorf("first dish = %q, want the most-eaten Lihapullat", dishes[0].Name)
|
|
}
|
|
if dishes[0].TimesEaten != 3 {
|
|
t.Errorf("timesEaten = %d, want 3", dishes[0].TimesEaten)
|
|
}
|
|
if got := dishes[0].Size(); got != "md" {
|
|
t.Errorf("size for 3 = %q, want md", got)
|
|
}
|
|
}
|
|
|
|
func TestListDishesSearchIsCaseInsensitive(t *testing.T) {
|
|
h := seeded(t)
|
|
|
|
dishes, err := listDishes(h.db, "KEITTO")
|
|
if err != nil {
|
|
t.Fatalf("listDishes: %v", err)
|
|
}
|
|
if len(dishes) == 0 {
|
|
t.Fatal("no matches for KEITTO, want the soups")
|
|
}
|
|
for _, d := range dishes {
|
|
if !containsFold(d.Name, "keitto") {
|
|
t.Errorf("%q does not match the search", d.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMultiCategoryDishUsesMixedMarker(t *testing.T) {
|
|
h := seeded(t)
|
|
|
|
tortillat, err := dishByID(h.db, h.mainNamed(t, "Tortillat"))
|
|
if err != nil {
|
|
t.Fatalf("dishByID: %v", err)
|
|
}
|
|
if len(tortillat.Categories) != 4 {
|
|
t.Fatalf("%d categories, want 4", len(tortillat.Categories))
|
|
}
|
|
if got := tortillat.CategoryKey(); got != "sek" {
|
|
t.Errorf("CategoryKey = %q, want sek", got)
|
|
}
|
|
|
|
lohi, err := dishByID(h.db, h.mainNamed(t, "Lohikeitto"))
|
|
if err != nil {
|
|
t.Fatalf("dishByID: %v", err)
|
|
}
|
|
if got := lohi.CategoryKey(); got != "kala" {
|
|
t.Errorf("CategoryKey = %q, want kala", got)
|
|
}
|
|
}
|
|
|
|
func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
|
|
h := seeded(t)
|
|
loc := time.UTC
|
|
now := today(loc)
|
|
|
|
// Log today and three days ago, leaving two gaps between them.
|
|
if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), nil); err != nil {
|
|
t.Fatalf("save today: %v", err)
|
|
}
|
|
if err := saveEntry(h.db, now.AddDate(0, 0, -3), h.mainNamed(t, "Lihapullat"), nil); err != nil {
|
|
t.Fatalf("save -3: %v", err)
|
|
}
|
|
|
|
rows, err := history(h.db, loc, 60)
|
|
if err != nil {
|
|
t.Fatalf("history: %v", err)
|
|
}
|
|
// Walks back to the oldest entry only: today, -1, -2, -3.
|
|
if len(rows) != 4 {
|
|
t.Fatalf("%d rows, want 4", len(rows))
|
|
}
|
|
if rows[0].Entry == nil || rows[0].Entry.Main.Name != "Lohikeitto" {
|
|
t.Errorf("first row should be today's Lohikeitto, got %+v", rows[0].Entry)
|
|
}
|
|
if rows[1].Entry != nil || rows[2].Entry != nil {
|
|
t.Error("the two unlogged days should be gaps")
|
|
}
|
|
if rows[3].Entry == nil || rows[3].Entry.Main.Name != "Lihapullat" {
|
|
t.Errorf("last row should be Lihapullat, got %+v", rows[3].Entry)
|
|
}
|
|
}
|
|
|
|
func TestHistoryEmptyWithoutEntries(t *testing.T) {
|
|
h := seeded(t)
|
|
|
|
rows, err := history(h.db, time.UTC, 60)
|
|
if err != nil {
|
|
t.Fatalf("history: %v", err)
|
|
}
|
|
if len(rows) != 0 {
|
|
t.Errorf("%d rows for an empty log, want 0", len(rows))
|
|
}
|
|
}
|