Every one of these was silent at the code level and loud in a real
browser; each fix carries its own tripwire (render pin, unit pin, or
CSP-safe rewrite) so the class cannot quietly return.
1. Theme picker dead since the CSP migration (eb57b720): the Appearance
panel's theme buttons emitted `@click="selectTheme(opt.Name)"` as
LITERAL text (templ gotcha #1 — quoted attribute values are never
interpolated; `opt` is a Go loop var), so Alpine threw "Undefined
variable: opt" on every click and no PUT /api/auth/theme ever fired.
The wood-paneling sibling always used the correct expression form,
which is why the panel half-worked. Fixed to the interpolated form;
TestThemePickerHandlersAreInterpolated pins that real theme names
reach the HTML and the raw identifier never does.
2. Filter-builder op dropdown "does nothing": the field ingest
hardcoded `ops: []` into every field's meta while only mirroring the
ops into the global label registry — opsFor() returned [] for EVERY
field, so the op menu rendered zero items: an invisible menu, no
console error (user tried author/publisher/title/subtitle, all
equally dead). FieldMeta.ops is now the wire's op objects, opsFor()
is a standalone exported helper, and opsForField unit tests pin the
round-trip. Verified live: the field menu opens/closes while the op
menu stayed shut, both with identical wiring — the difference was
the data, not the wiring.
3. AND/OR connective chip dead: the chip carried an `if` STATEMENT in
the Alpine attribute (`@click="if (row.chipClickable) ..."`) which
the CSP build's evaluator rejects as a statement — the whole
expression silently never ran. Moved the clickability check into a
toggleConnective(row) component method; the attribute is now a plain
method call.
4. Pencil vs card navigation race: navigateToCollection guarded with
`event.target instanceof HTMLElement`, which is FALSE for the
pencil glyph's SVGElement — a click landing on the glyph sailed past
the guard and navigated while the htmx edit-modal request still
fired (the reported "modal flashes, then the builder page"). The
guard now uses target.closest("button") (works on SVGElement) and
takes $event explicitly instead of the deprecated window.event.
.icon-btn is already a 36px square, so no hit-area change needed.
5. Add Items modal under the sidebar + taller than the window: the
picker overlay sat at z-50 UNDER the app-sidebar's z-60 (its left
edge rendered beneath the nav), and the card had no height bound —
header + tabs + filters + grid + footer overflow short windows with
the footer unreachable (fixed overlay, no page scroll). The picker
and create-modal overlays move to z-[65], both confirm dialogs to
z-[70], and the picker card is now a flex column capped at
max-h-[85vh] whose grids flex-fill (they grow on tall windows,
shrink on short ones). The ladder is documented at .app-sidebar in
input.css: sidebar 60 / overlays 65 / confirm 70 / toasts 9999.
Found by clicking every control the way a user does (panel clicks +
computed-style reads + screenshots) after the previous sweep trusted
DOM-text snapshots, which show hidden menus' text and cannot see
occlusion, stacking, or geometry at all.
168 lines
6.3 KiB
Go
168 lines
6.3 KiB
Go
package templates
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"bookhoard/internal/handlers"
|
|
)
|
|
|
|
// The rendering-ownership partials must render standalone (they are
|
|
// fetched by htmx.ajax at runtime) and must never leak templ literal
|
|
// expressions into their markup.
|
|
func TestRenderingPartialsRender(t *testing.T) {
|
|
var b strings.Builder
|
|
col := CollectionData{ID: "c1", Name: "Test Shelf", IsSmart: false}
|
|
books := []handlers.BookInfo{{MediaItemID: "m1", Title: "A Book", Author: "An Author"}}
|
|
if err := CollectionBooksGrid(col, books).Render(context.Background(), &b); err != nil {
|
|
t.Fatalf("CollectionBooksGrid: %v", err)
|
|
}
|
|
out := b.String()
|
|
for _, want := range []string{"A Book", `data-media-id="m1"`, "Remove from Collection"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("books grid missing %q", want)
|
|
}
|
|
}
|
|
|
|
// Empty collection renders the empty state, not a literal loop.
|
|
b.Reset()
|
|
if err := CollectionBooksGrid(col, nil).Render(context.Background(), &b); err != nil {
|
|
t.Fatalf("CollectionBooksGrid empty: %v", err)
|
|
}
|
|
if !strings.Contains(b.String(), "Nothing in this collection yet.") {
|
|
t.Error("books grid empty state missing")
|
|
}
|
|
|
|
b.Reset()
|
|
user := User{ID: "u1", Timezone: "UTC"}
|
|
conflicts := []handlers.ConflictDetailResponse{{ID: "cf1", MediaItemTitle: "War and Peace", ConflictType: "progress"}}
|
|
if err := ConflictsRefreshFragment(user, conflicts, 3, 1).Render(context.Background(), &b); err != nil {
|
|
t.Fatalf("ConflictsRefreshFragment: %v", err)
|
|
}
|
|
out = b.String()
|
|
for _, want := range []string{`id="conflicts-list"`, `id="stats-bar"`, "hx-swap-oob", "Resolve"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("conflicts fragment missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The unlinked-books queue banner renders only when the queue is
|
|
// non-empty (hidden-unless-needed).
|
|
func TestDashboardUnlinkedBanner(t *testing.T) {
|
|
render := func(count int) string {
|
|
var b strings.Builder
|
|
err := Dashboard(User{ID: "u1"}, nil, nil, nil, "", nil, 0, nil, "", "", count).Render(context.Background(), &b)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
return b.String()
|
|
}
|
|
if out := render(0); strings.Contains(out, "/unlinked-books") {
|
|
t.Error("banner rendered with empty queue")
|
|
}
|
|
out := render(2)
|
|
if !strings.Contains(out, "/unlinked-books") || !strings.Contains(out, "could not be identified") {
|
|
t.Error("banner missing with non-empty queue")
|
|
}
|
|
}
|
|
|
|
// The unlinked-books page renders queue rows standalone, both buckets.
|
|
func TestUnlinkedBooksPageRender(t *testing.T) {
|
|
var b strings.Builder
|
|
user := User{ID: "u1", Timezone: "UTC"}
|
|
books := []UnlinkedBookData{{
|
|
ProgressID: "ub1",
|
|
DeviceName: "Kobo Clara",
|
|
DeviceType: "kobo",
|
|
FilePath: "/mnt/onboard/book.epub",
|
|
SHA256: "abc123",
|
|
TitleFromDevice: "Some Sideloaded Book",
|
|
LastSyncTime: "2026-10-18 12:00",
|
|
}}
|
|
ignored := []UnlinkedBookData{{
|
|
ProgressID: "ub2",
|
|
DeviceName: "KOReader Phone",
|
|
DeviceType: "koreader",
|
|
FilePath: "/sdcard/ignored.epub",
|
|
SHA256: "def456",
|
|
TitleFromDevice: "Some Dismissed Book",
|
|
LastSyncTime: "2026-10-18 12:00",
|
|
}}
|
|
if err := UnlinkedBooks(user, books, ignored).Render(context.Background(), &b); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
out := b.String()
|
|
for _, want := range []string{
|
|
"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches",
|
|
// Dismiss/delete actions and the ignored recovery bucket.
|
|
"Some Dismissed Book", `hx-post="/unlinked-books/ub1/ignore"`,
|
|
`hx-delete="/unlinked-books/ub1"`, `hx-post="/unlinked-books/ub2/unignore"`,
|
|
"Ignored (1)",
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("unlinked page missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The library switcher's D13 podcast exclusion must hold at the
|
|
// presentation layer — and the templ `continue` statement leak (codegen
|
|
// emits it as literal TEXT, browser sweep 2026-10-30) must never return.
|
|
func TestLibrarySwitcherExcludesPodcasts(t *testing.T) {
|
|
var b strings.Builder
|
|
libs := []LibraryData{
|
|
{ID: "l1", Name: "Ebooks", TypeName: "books", MediaCount: 5},
|
|
{ID: "l2", Name: "Podcasts", TypeName: "podcasts", MediaCount: 235},
|
|
}
|
|
if err := LibrarySwitcher(libs, "l1", "", nil).Render(context.Background(), &b); err != nil {
|
|
t.Fatalf("LibrarySwitcher: %v", err)
|
|
}
|
|
out := b.String()
|
|
if strings.Contains(out, "Podcasts (235)") {
|
|
t.Error("switcher leaked the podcasts library into the book switcher")
|
|
}
|
|
if strings.Contains(out, "continue") {
|
|
t.Error("switcher rendered the literal text `continue` (templ has no continue statement)")
|
|
}
|
|
if !strings.Contains(out, "Ebooks (5)") {
|
|
t.Error("switcher missing the books library")
|
|
}
|
|
}
|
|
|
|
// The Appearance panel's theme buttons interpolate their click handlers
|
|
// from Go loop vars. The CSP-build migration (eb57b720) shipped
|
|
// `@click="selectTheme(opt.Name)"` as LITERAL text (templ gotcha #1:
|
|
// quoted attribute values are never interpolated), so Alpine threw
|
|
// "Undefined variable: opt" on every theme click and themes were
|
|
// unchangeable until the user report, 2026-10-31. The wood-paneling
|
|
// sibling always had the correct expression form, which is why the
|
|
// panel half-worked. Pin both halves: handlers must carry real theme
|
|
// names, and the raw Go identifier must never reach the HTML.
|
|
func TestThemePickerHandlersAreInterpolated(t *testing.T) {
|
|
var b strings.Builder
|
|
user := User{ID: "u1", Username: "testuser", Role: "admin", Theme: "tokyo-night", ShowPodcastsNav: true}
|
|
if err := Header(user, "/collections").Render(context.Background(), &b); err != nil {
|
|
t.Fatalf("Header: %v", err)
|
|
}
|
|
out := b.String()
|
|
// templ escapes `'` as ' inside attribute values; the HTML parser
|
|
// decodes entities before Alpine evaluates, so normalize for matching.
|
|
decoded := strings.ReplaceAll(out, "'", "'")
|
|
if strings.Contains(out, "selectTheme(opt.Name)") {
|
|
t.Error("theme buttons leaked the raw Go loop var into the click handler (themes unchangeable)")
|
|
}
|
|
if strings.Contains(out, "changeWoodPaneling(w.Name)") {
|
|
t.Error("wood buttons leaked the raw Go loop var into the click handler")
|
|
}
|
|
// At least the SSR'd current theme and one wood option must appear
|
|
// as real string-literal arguments.
|
|
if !strings.Contains(decoded, "selectTheme('tokyo-night')") {
|
|
t.Error("theme buttons missing the interpolated current-theme handler")
|
|
}
|
|
if !strings.Contains(decoded, "changeWoodPaneling('none')") {
|
|
t.Error("wood buttons missing the interpolated handler")
|
|
}
|
|
}
|