Findings from the systematic gotcha-class review before the browser
pass:
- The collection smart editor's Apply derived the collection NAME from
document.title — which parses to "Smart Collection", RENAMING the
collection on every apply. The server now renders the real name
(data-collection-name) and the write path refuses to proceed without
it.
- TestRenderingPartialsRender pinned the pre-episode-collections
empty-state copy ("No books in this collection yet.") — updated to
the shipped label ("Nothing in this collection yet."; collections
hold episodes now).
- The deleted handler-local rule comparators (evaluateRule/
compareValues — removed in item 3.4's single-evaluator refactor)
left behind two unit-test files testing the duplicates; deleted.
Their semantics remain covered at the service level
(TestEvaluateRules_* over FieldMatches/EvaluateRules) and
TestTextToString (which actually pins utils.PgTextString) stays.
Everything else reviewed clean against the gotcha classes: templ
whole-expression attrs, ?={ boolean attrs, JSONArg quoting in
x-init/data attrs, Alpine CSP store paths, htmx form-encoding, the
" assert escaping, and the aria-label selector contract on the
player's reload listener.
109 lines
3.6 KiB
Go
109 lines
3.6 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)
|
|
}
|
|
}
|
|
}
|