1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
|
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()
}
// renderShoppingItemsContent renders just the swapped-in portion of the tab.
func renderShoppingItemsContent(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-items-content", data); err != nil {
t.Fatalf("executing shopping-items-content: %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 form that lives *inside* the swap target must carry a scroll modifier.
// Those forms are destroyed by their own swap, and without an explicit target
// the page lands at the bottom. Forms outside the target (the store-create
// form) do not have this problem and are excluded by rendering only the items
// content here.
func TestShoppingItemsContent_EveryFormInsideTheSwapTargetScrolls(t *testing.T) {
html := renderShoppingItemsContent(t, twoStoreTabData())
forms := strings.Count(html, `hx-target="#shopping-items-list"`)
scrolls := strings.Count(html, "scroll:#store-")
if forms == 0 {
t.Fatal("expected forms inside the items content")
}
if forms != scrolls {
t.Errorf("%d forms target the items list but only %d carry a scroll target", 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")
}
}
|