Structure - Kirjaa and Historia are one page. They were two views of the same thing — every history row already linked into the logger, and the logger had a day switcher. Two tabs instead of three. Also closed a gap: on an already-logged day there was no way to swap to a different dish, only to re-pick its sides. - Ruoat → Ruuat, label and route. - The catalog has a structure. It had no top-level headings at all — the mains simply began with "Liha". Both halves now carry a heading and a count, categories are visibly subordinate, and the add/edit forms collapse instead of filling the screen before any content. Finding things - Dishes grouped by category on both screens, Sekalaiset for multi-category ones. Derived from the stored set, not a fifth category, so one Tortillat still covers all four for the §8.1 suggester later. - Live search on both lists, 250 ms after typing stops. Both remain plain GET forms, so they still filter with JavaScript off. - History is paged 30 days at a time — it previously rendered every day back to the first entry, forever. Correctness - Future meals refused. The picker offered them and ?pvm= accepted them. - today() wasn't midnight, so it never equalled a date parsed from ?pvm= — after saving, the card read "la 5.9. kirjattu" instead of "Tänään kirjattu". - Deletes ask first, for dishes and logged meals. The meal is the more destructive: a dish is only soft-deleted. - DB open failures name the path and uid, instead of unable to open database file (14). Visual - Category icons replace colour dots — steak, drumstick, fish, leaf, quartered circle. - Row actions are a pencil and a bin; the header has a surface. Housekeeping - Datastar SDK dropped — one JSON decode was pulling in four modules including an HTTP compression stack. Five lines replace it. - Release policy documented: main protected, releases arrive as PRs. Co-authored-by: Esa Kataja <[email protected]> Reviewed-on: #1
319 lines
8.4 KiB
Go
319 lines
8.4 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)
|
|
}
|
|
|
|
page, err := history(h.db, loc, now, 60)
|
|
if err != nil {
|
|
t.Fatalf("history: %v", err)
|
|
}
|
|
rows := page.Rows
|
|
// 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)
|
|
}
|
|
if page.More {
|
|
t.Error("More is set although the window reached the oldest entry")
|
|
}
|
|
}
|
|
|
|
func TestHistoryPagesInWindows(t *testing.T) {
|
|
h := seeded(t)
|
|
loc := time.UTC
|
|
now := today(loc)
|
|
|
|
// Entries today and 9 days back, with a 5-day window over 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, -9), h.mainNamed(t, "Lihapullat"), nil); err != nil {
|
|
t.Fatalf("save -9: %v", err)
|
|
}
|
|
|
|
first, err := history(h.db, loc, now, 5)
|
|
if err != nil {
|
|
t.Fatalf("first window: %v", err)
|
|
}
|
|
if len(first.Rows) != 5 {
|
|
t.Errorf("%d rows in the first window, want 5", len(first.Rows))
|
|
}
|
|
if !first.More {
|
|
t.Error("More should be set: older entries exist")
|
|
}
|
|
if want := now.AddDate(0, 0, -5); !first.Next.Equal(want) {
|
|
t.Errorf("Next = %s, want %s", first.Next.Format(dateLayout), want.Format(dateLayout))
|
|
}
|
|
|
|
// The windows must meet exactly: no day repeated, none skipped.
|
|
second, err := history(h.db, loc, first.Next, 5)
|
|
if err != nil {
|
|
t.Fatalf("second window: %v", err)
|
|
}
|
|
if len(second.Rows) != 5 {
|
|
t.Errorf("%d rows in the second window, want 5", len(second.Rows))
|
|
}
|
|
if second.More {
|
|
t.Error("the second window reaches the oldest entry, so More should be clear")
|
|
}
|
|
last := second.Rows[len(second.Rows)-1]
|
|
if last.Entry == nil || last.Entry.Main.Name != "Lihapullat" {
|
|
t.Errorf("last row should be the oldest entry, got %+v", last.Entry)
|
|
}
|
|
}
|
|
|
|
func TestHistoryEmptyWithoutEntries(t *testing.T) {
|
|
h := seeded(t)
|
|
|
|
page, err := history(h.db, time.UTC, today(time.UTC), 60)
|
|
if err != nil {
|
|
t.Fatalf("history: %v", err)
|
|
}
|
|
if len(page.Rows) != 0 {
|
|
t.Errorf("%d rows for an empty log, want 0", len(page.Rows))
|
|
}
|
|
if page.More {
|
|
t.Error("More is set although there is no history at all")
|
|
}
|
|
}
|