Two changes that keep the lists usable after years of entries, and the first
real use of the Datastar client that has been shipping unused.
Paging: history() now walks back from any given day and returns the rows plus
whether older ones exist. The page shows a 30-day window and "Näytä lisää"
grows it by another 30 through ?paivat=, capped at five years so a
hand-edited URL cannot ask for a decade of rows at once. Two tests check that
consecutive windows meet exactly, repeating no day and skipping none, which
is the mistake this shape invites.
Live search: both search boxes bind to a Datastar signal and re-render their
list 250 ms after typing stops. The board search and the catalog search each
return only their own fragment, and the catalog search covers sides as well
as mains.
Three things worth knowing about the Datastar side. Its attribute syntax is
colon-separated in v1.0.3 — data-on:input, not data-on-input; the dashed form
parses as a plugin named "on-input", matches nothing, and fails silently. A
plain text/html response is accepted and matched to the element by id, so
there is no SSE stream to manage and the SDK is used only for ReadSignals.
And both boxes are still ordinary GET forms, so ?haku= filters server-side
with JavaScript off: the live version is an enhancement, not a requirement.
Smoke checks send the real ?datastar={"haku":"..."} wire format and assert
the response is a fragment rather than a whole page.
151 lines
3.7 KiB
Go
151 lines
3.7 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func TestCreateMain(t *testing.T) {
|
|
h := seeded(t)
|
|
|
|
id, err := createMain(h.db, "Uunikala", []string{"fish"}, true)
|
|
if err != nil {
|
|
t.Fatalf("createMain: %v", err)
|
|
}
|
|
|
|
dish, err := dishByID(h.db, id)
|
|
if err != nil {
|
|
t.Fatalf("dishByID: %v", err)
|
|
}
|
|
if dish.Name != "Uunikala" || !dish.HasSides {
|
|
t.Errorf("got %+v", dish)
|
|
}
|
|
if len(dish.Categories) != 1 || dish.Categories[0] != "fish" {
|
|
t.Errorf("categories = %v, want [fish]", dish.Categories)
|
|
}
|
|
}
|
|
|
|
func TestCreateMainRejectsDuplicateName(t *testing.T) {
|
|
h := seeded(t)
|
|
|
|
// Seeded bundle already has Lohikeitto; casing must not matter.
|
|
_, err := createMain(h.db, "lohikeitto", []string{"fish"}, true)
|
|
if !errors.Is(err, errNameTaken) {
|
|
t.Errorf("err = %v, want errNameTaken", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateMainReplacesCategories(t *testing.T) {
|
|
h := seeded(t)
|
|
id := h.mainNamed(t, "Tortillat")
|
|
|
|
if err := updateMain(h.db, id, "Tortillat", []string{"chicken"}, false); err != nil {
|
|
t.Fatalf("updateMain: %v", err)
|
|
}
|
|
|
|
dish, err := dishByID(h.db, id)
|
|
if err != nil {
|
|
t.Fatalf("dishByID: %v", err)
|
|
}
|
|
if len(dish.Categories) != 1 || dish.Categories[0] != "chicken" {
|
|
t.Errorf("categories = %v, want [chicken]", dish.Categories)
|
|
}
|
|
if dish.HasSides {
|
|
t.Error("has_sides should have been cleared")
|
|
}
|
|
if got := dish.CategoryKey(); got != "kana" {
|
|
t.Errorf("CategoryKey = %q, want kana", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateMainRejectsAnotherDishesName(t *testing.T) {
|
|
h := seeded(t)
|
|
|
|
err := updateMain(h.db, h.mainNamed(t, "Tortillat"), "Lohikeitto", []string{"fish"}, true)
|
|
if !errors.Is(err, errNameTaken) {
|
|
t.Errorf("err = %v, want errNameTaken", err)
|
|
}
|
|
}
|
|
|
|
func TestSideCRUD(t *testing.T) {
|
|
h := seeded(t)
|
|
|
|
if err := createSide(h.db, "Lohkoperunat"); err != nil {
|
|
t.Fatalf("createSide: %v", err)
|
|
}
|
|
if err := createSide(h.db, "lohkoperunat"); !errors.Is(err, errNameTaken) {
|
|
t.Errorf("duplicate side err = %v, want errNameTaken", err)
|
|
}
|
|
|
|
id := h.sideNamed(t, "Lohkoperunat")
|
|
if err := updateSide(h.db, id, "Lohkoperunat uunista"); err != nil {
|
|
t.Fatalf("updateSide: %v", err)
|
|
}
|
|
side, err := sideByID(h.db, id)
|
|
if err != nil {
|
|
t.Fatalf("sideByID: %v", err)
|
|
}
|
|
if side.Name != "Lohkoperunat uunista" {
|
|
t.Errorf("name = %q", side.Name)
|
|
}
|
|
}
|
|
|
|
func TestSoftDeleteHidesDishButKeepsHistory(t *testing.T) {
|
|
h := seeded(t)
|
|
id := h.mainNamed(t, "Lohikeitto")
|
|
date := day(t, "2026-09-05")
|
|
|
|
if err := saveEntry(h.db, date, id, nil); err != nil {
|
|
t.Fatalf("saveEntry: %v", err)
|
|
}
|
|
if err := softDeleteMain(h.db, id); err != nil {
|
|
t.Fatalf("softDeleteMain: %v", err)
|
|
}
|
|
|
|
// Gone from the pickers...
|
|
dishes, err := listDishes(h.db, "")
|
|
if err != nil {
|
|
t.Fatalf("listDishes: %v", err)
|
|
}
|
|
for _, d := range dishes {
|
|
if d.ID == id {
|
|
t.Fatal("soft-deleted dish still appears in the catalog")
|
|
}
|
|
}
|
|
if _, err := dishByID(h.db, id); err == nil {
|
|
t.Error("dishByID returned a soft-deleted dish")
|
|
}
|
|
|
|
// ...but the log entry still resolves its name (PRD §6).
|
|
entry, err := entryFor(h.db, date)
|
|
if err != nil || entry == nil {
|
|
t.Fatalf("entryFor: %v, %v", entry, err)
|
|
}
|
|
if entry.Main.Name != "Lohikeitto" {
|
|
t.Errorf("historical name = %q, want Lohikeitto", entry.Main.Name)
|
|
}
|
|
|
|
// And the freed name can be reused.
|
|
if _, err := createMain(h.db, "Lohikeitto", []string{"fish"}, true); err != nil {
|
|
t.Errorf("name still blocked after soft delete: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSoftDeleteSideHidesItFromPickers(t *testing.T) {
|
|
h := seeded(t)
|
|
id := h.sideNamed(t, "Riisi")
|
|
|
|
if err := softDeleteSide(h.db, id); err != nil {
|
|
t.Fatalf("softDeleteSide: %v", err)
|
|
}
|
|
sides, err := listSides(h.db, "")
|
|
if err != nil {
|
|
t.Fatalf("listSides: %v", err)
|
|
}
|
|
for _, s := range sides {
|
|
if s.ID == id {
|
|
t.Fatal("soft-deleted side still appears")
|
|
}
|
|
}
|
|
}
|