package handlers import ( "bytes" "html/template" "path/filepath" "strings" "testing" "task-dashboard/internal/models" ) // renderShoppingTab renders the real shopping-tab template with the real func // map, so these assertions cover what actually ships rather than a stand-in. func renderShoppingTab(t *testing.T, data any) string { t.Helper() dir := filepath.Join("..", "..", "web", "templates") tmpl, err := template.New("").Funcs(templateFuncMap()).ParseGlob(filepath.Join(dir, "*.html")) if err != nil { t.Fatalf("parsing top-level templates: %v", err) } if _, err := tmpl.ParseGlob(filepath.Join(dir, "partials", "*.html")); err != nil { t.Fatalf("parsing partials: %v", err) } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, "shopping-tab", data); err != nil { t.Fatalf("executing shopping-tab: %v", err) } return buf.String() } func twoStoreTabData() any { return struct { Stores []models.ShoppingStore Grouped bool }{ Stores: []models.ShoppingStore{ {Name: "Costco", Categories: []models.ShoppingCategory{ {Name: "", Items: []models.UnifiedShoppingItem{ {ID: "user-1", Name: "Milk", Store: "Costco", Source: "user"}, }}, }}, {Name: "Hardware Store", Categories: []models.ShoppingCategory{ {Name: "", Items: []models.UnifiedShoppingItem{ {ID: "user-2", Name: "Screws", Store: "Hardware Store", Source: "user"}, }}, }}, }, Grouped: true, } } // The inline per-store add form used to submit with a bare // hx-swap="innerHTML". Because that form lives *inside* the swap target // (#shopping-items-list), submitting it destroys the form mid-request and the // page ends up scrolled to the bottom instead of staying where the user was. // The quick-add form at the top already pinned this with an explicit // scroll: modifier; the per-store forms did not. func TestShoppingTab_InlineAddScrollsToItsOwnStore(t *testing.T) { html := renderShoppingTab(t, twoStoreTabData()) for _, want := range []string{ `id="store-costco"`, `id="store-hardware-store"`, `scroll:#store-costco:top`, `scroll:#store-hardware-store:top`, } { if !strings.Contains(html, want) { t.Errorf("rendered shopping tab is missing %q", want) } } } // Every inline add form must carry a scroll target. A form without one is the // bug, and it is easy to reintroduce by adding a new store section by copy. func TestShoppingTab_EveryInlineAddFormHasAScrollTarget(t *testing.T) { html := renderShoppingTab(t, twoStoreTabData()) // Count the class attribute specifically — a bare "inline-add-form" also // matches the string literal in the page's own JS handler. forms := strings.Count(html, `class="inline-add-form`) scrolls := strings.Count(html, "scroll:#store-") if forms == 0 { t.Fatal("expected at least one inline add form in the grouped view") } if forms != scrolls { t.Errorf("found %d inline add forms but %d scroll targets; every form needs one", forms, scrolls) } } func TestSlug(t *testing.T) { slug, ok := templateFuncMap()["slug"].(func(string) string) if !ok { t.Fatal("templateFuncMap has no slug function of type func(string) string") } cases := []struct{ in, want string }{ {"Costco", "costco"}, {"Hardware Store", "hardware-store"}, {"Trader Joe's", "trader-joe-s"}, {" Spaced Out ", "spaced-out"}, {"Multiple---Dashes", "multiple-dashes"}, // Non-ASCII is treated as a separator rather than transliterated. That // keeps the id valid without pulling in a transliteration table; the // result only has to be stable and unique, not pretty. {"Ünïcode Störe", "n-code-st-re"}, {"", "unnamed"}, {"!!!", "unnamed"}, } for _, c := range cases { if got := slug(c.in); got != c.want { t.Errorf("slug(%q) = %q, want %q", c.in, got, c.want) } } } // Two stores whose names differ only by punctuation must not collide into the // same element id, or the scroll target becomes ambiguous. func TestSlug_DistinguishesSimilarNames(t *testing.T) { slug := templateFuncMap()["slug"].(func(string) string) if slug("Store A") == slug("Store B") { t.Error("distinct store names produced the same slug") } }