Files
bookhoard/internal/docs/navigation_test.go
T
John O'Keefe 89fc479bdb docs(admin): scanning page — the shallow vs. force taxonomy from source
First page of the admin tree, written to the new admin tier (what the
click actually does). Documents every scan surface with its verified
semantics: Force Rescan All Libraries (dashboard, force, all libraries,
one job each with skip-on-active), the per-library Scan button (ALSO a
force scan — hx-vals force:true since the button's birth commit 706be09d,
which corrects the owner's stated 'other scan buttons are shallow'
belief; flagged to the owner), Scan this folder (shallow, single-folder
scope, archive pass skipped for partial scans, podcast roots refused),
Auto-Scan + Scan Interval (the watcher + poll + startup triggers, all
shallow, default 60s range 1-3600), Processing Issues retry (per-item
sequential, deliberately not a library scan), and book-level Rescan /
Reset to Scanned. Plus the standing rules: one scan per library
(coalescing), background jobs with the dashboard progress panel, and
the two-consecutive-scans archive rule.

Glossary gains Scan and Scan this folder (same-commit rule for controls
cited in prose). The real-tree nav test's Administration assertion now
pins shape (Admin Guide, Libraries prefix + usabilityOrder subsequence)
instead of an exact slice, so pages landing one at a time don't churn it.
2026-11-02 13:47:40 -05:00

321 lines
11 KiB
Go

package docs
import (
"os"
"path/filepath"
"reflect"
"testing"
"bookhoard/templates"
)
func TestPlaceDoc(t *testing.T) {
tests := []struct {
path string
section string
group string
pinned bool
}{
{"index.md", "Getting Started", "", true},
{"user/user-guide.md", "User Guide", "", true},
{"user/dashboard.md", "User Guide", "", false},
{"admin/admin-guide.md", "Administration", "", true},
{"admin/libraries.md", "Administration", "", false},
{"user/devices/kobo-setup.md", "Devices", "", false},
{"developer/api/api-reference.md", "API Reference", "", true},
{"developer/api/dashboard.md", "API Reference", "", false},
{"developer/api/authentication/login.md", "API Reference", "Authentication", false},
{"developer/api/saved-filters/index.md", "API Reference", "Saved Filters", true},
{"developer/testing.md", "Developer", "", false},
{"developer/development.md", "Developer", "", true},
{"developer/scanner/audio-metadata.md", "Developer", "Scanner", false},
{"contract_pins.md", "Developer", "Reference", false},
{"FRONTEND_INTEGRATION.md", "Developer", "Reference", false},
{"development/calibre-opf-implementation.md", "Developer", "Reference", false},
{"operations/operations.md", "Operations", "", true},
{"operations/troubleshooting.md", "Operations", "", false},
{"contributing/contributing.md", "Contributing", "", true},
{"contributing/development.md", "Contributing", "", false},
}
for _, tt := range tests {
got := placeDoc(tt.path)
if got.section != tt.section || got.group != tt.group {
t.Errorf("placeDoc(%q) = (%q, %q), want (%q, %q)",
tt.path, got.section, got.group, tt.section, tt.group)
}
if (got.pin < 0) != tt.pinned {
t.Errorf("placeDoc(%q) pin = %d, pinned want %v", tt.path, got.pin, tt.pinned)
}
}
}
func TestDocTitle(t *testing.T) {
tests := map[string]string{
"index.md": "Overview",
"user/user-guide.md": "User Guide",
"admin/admin-guide.md": "Admin Guide",
"user/searching-and-filtering.md": "Searching and Filtering",
"user/devices/koreader-setup.md": "KOReader Setup",
"developer/development.md": "Developer Portal",
"developer/api/api-reference.md": "Overview",
"developer/api/users/update_profile.md": "Update Profile",
"developer/api/book-matching/link_book.md": "Link Book",
"operations/troubleshooting.md": "Troubleshooting",
"contract_pins.md": "Contract Pins",
}
for path, want := range tests {
if got := docTitle(path); got != want {
t.Errorf("docTitle(%q) = %q, want %q", path, got, want)
}
}
}
func TestDocURL(t *testing.T) {
if got := docURL("index.md"); got != "/docs" {
t.Errorf("docURL(index.md) = %q, want /docs", got)
}
if got := docURL("user/sync-guide.md"); got != "/docs/user/sync-guide" {
t.Errorf("docURL = %q, want /docs/user/sync-guide", got)
}
}
// buildTestDocs creates a representative docs tree in a temp dir and returns
// a handler over it.
func buildTestDocs(t *testing.T) *DocsHandler {
t.Helper()
dir := t.TempDir()
files := []string{
"index.md",
"user/user-guide.md",
"user/dashboard.md",
"admin/admin-guide.md",
"user/devices/kobo-setup.md",
"user/devices/koreader-setup.md",
"developer/api/api-reference.md",
"developer/api/authentication/login.md",
"developer/api/authentication/overview.md",
"developer/api/saved-filters/index.md",
"developer/testing.md",
"operations/troubleshooting.md",
"contributing/development.md",
"contract_pins.md",
}
for _, f := range files {
path := filepath.Join(dir, f)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("# Test\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return &DocsHandler{docsFS: os.DirFS(dir), docsPath: dir}
}
func TestBuildNavigation(t *testing.T) {
h := buildTestDocs(t)
// Current page: a device setup guide → User Guide section and its
// Devices group must render expanded; everything else collapsed.
nav := h.BuildNavigation("/docs/user/devices/kobo-setup")
if len(nav.Sections) == 0 {
t.Fatal("no sections built")
}
wantOrder := []string{"Getting Started", "User Guide", "Administration", "Devices", "API Reference", "Developer", "Operations", "Contributing"}
for i, want := range wantOrder {
if nav.Sections[i].Title != want {
t.Fatalf("section %d = %q, want %q", i, nav.Sections[i].Title, want)
}
}
getting := nav.Sections[0]
if !getting.Collapsed {
t.Error("Getting Started should be collapsed: only sections containing the current page open")
}
if len(getting.Items) != 1 || getting.Items[0].Title != "Overview" || getting.Items[0].URL != "/docs" {
t.Errorf("Getting Started items = %+v, want single Overview → /docs", getting.Items)
}
// User Guide no longer contains the current page (device guides are
// their own section now): collapsed, no groups.
userGuide := nav.Sections[1]
if !userGuide.Collapsed {
t.Error("User Guide should be collapsed: it no longer contains the current page")
}
if len(userGuide.Groups) != 0 {
t.Errorf("User Guide groups = %+v, want none (Devices + Administration carved out)", userGuide.Groups)
}
if len(userGuide.Items) != 2 || userGuide.Items[0].Title != "User Guide" {
t.Errorf("User Guide direct items = %+v, want User Guide pinned first", userGuide.Items)
}
// Devices: own top-level section, expanded, no groups. KOReader sorts
// ahead of Kobo because usabilityOrder ranks the device guides by how
// the docs front door presents them (Android → KOReader → Kobo), not
// alphabetically. (Administration sits above it in the section order;
// covered by the collapsed sweep below.)
devices := nav.Sections[3]
if devices.Collapsed {
t.Error("Devices section should be expanded: it contains the current page")
}
if len(devices.Groups) != 0 {
t.Errorf("Devices groups = %+v, want none", devices.Groups)
}
if len(devices.Items) != 2 || devices.Items[0].Title != "KOReader Setup" {
t.Errorf("Devices items = %+v, want KOReader Setup before Kobo Setup (usability order)", devices.Items)
}
// Everything else starts collapsed (Administration included).
for _, s := range nav.Sections[4:] {
if !s.Collapsed {
t.Errorf("section %q should be collapsed (current page is elsewhere)", s.Title)
}
}
if !nav.Sections[2].Collapsed || nav.Sections[2].Title != "Administration" {
t.Errorf("Administration section not collapsed at its position: %+v", nav.Sections[2])
}
// API Reference: subdirectory groups with the index page as Overview.
api := nav.Sections[4]
if len(api.Groups) != 2 {
t.Fatalf("API Reference groups = %d, want 2 (Authentication, Saved Filters)", len(api.Groups))
}
if api.Groups[0].Title != "Authentication" || len(api.Groups[0].Items) != 2 {
t.Errorf("Authentication group = %+v", api.Groups[0])
}
saved := api.Groups[1]
if saved.Title != "Saved Filters" || len(saved.Items) != 1 || saved.Items[0].Title != "Overview" {
t.Errorf("Saved Filters group = %+v, want single Overview item", saved)
}
if !saved.Collapsed {
t.Error("Saved Filters group should be collapsed on an unrelated page")
}
// Stray root files land in Developer → Reference.
dev := nav.Sections[5]
if len(dev.Groups) != 1 || dev.Groups[0].Title != "Reference" {
t.Errorf("Developer groups = %+v, want Reference", dev.Groups)
}
}
// TestBuildNavigationUsabilityOrder (2026-11-02): the REAL docs tree must
// present its direct nav items in usability order (usabilityOrder), not
// alphabetically — User Guide mirrors the "Where to go" table on
// user-guide.md, Devices orders Android → KOReader → Kobo, Administration
// mirrors the app's own admin sidebar. Display-order only: expansion and
// highlighting stay URL-driven (order-blind) by construction.
func TestBuildNavigationUsabilityOrder(t *testing.T) {
handler := NewDocsHandler("../../docs")
nav := handler.BuildNavigation("/docs")
find := func(title string) *templates.NavSection {
for i := range nav.Sections {
if nav.Sections[i].Title == title {
return &nav.Sections[i]
}
}
t.Fatalf("section %q missing", title)
return nil
}
titles := func(s *templates.NavSection) []string {
out := make([]string, 0, len(s.Items))
for _, item := range s.Items {
out = append(out, item.Title)
}
return out
}
wantUserGuide := []string{
"User Guide", "Dashboard", "Library Browsing", "Collections",
"Searching and Filtering", "Podcasts", "Reader Guide", "Themes",
"Profile Guide", "Sync Guide", "Calibre Integration",
}
if got := titles(find("User Guide")); !reflect.DeepEqual(got, wantUserGuide) {
t.Errorf("User Guide order = %v, want %v", got, wantUserGuide)
}
wantDevices := []string{"Android App", "KOReader Setup", "Kobo Setup"}
if got := titles(find("Devices")); !reflect.DeepEqual(got, wantDevices) {
t.Errorf("Devices order = %v, want %v", got, wantDevices)
}
// Administration grows as its pages are written; pin its shape, not
// its size: the guide leads, Libraries follows, and every other item
// must appear in usabilityOrder's declared relative order.
admin := find("Administration")
if len(admin.Items) < 2 || admin.Items[0].Title != "Admin Guide" || admin.Items[1].Title != "Libraries" {
t.Fatalf("Administration must lead with Admin Guide then Libraries, got %+v", admin.Items)
}
pos := map[string]int{}
for i, p := range usabilityOrder["Administration"] {
pos[docURL(p)] = i
}
last := -1
for _, item := range admin.Items[2:] {
r, ok := pos[item.URL]
if !ok {
t.Errorf("Administration item %q (%s) is not in usabilityOrder", item.Title, item.URL)
continue
}
if r <= last {
t.Errorf("Administration item %q breaks usabilityOrder sequence", item.Title)
}
last = r
}
}
func TestBuildNavigationNothingCurrent(t *testing.T) {
h := buildTestDocs(t)
nav := h.BuildNavigation("/docs/nonexistent")
open := 0
for _, s := range nav.Sections {
if !s.Collapsed {
open++
}
}
if open != 0 {
t.Errorf("expected zero sections open when nothing matches (docs home / 404 renders fully closed), got %d", open)
}
}
// TestBuildNavigationFolderIndex (2026-11-01): folder-index pages serve
// at their directory URL (/docs/user/filters) while their nav entries
// carry /index (/docs/user/filters/index) — the sidebar must EXPAND the
// containing section/group and HIGHLIGHT the index item for the folder
// URL, exactly like a file page. The filter manual's folder is the
// first page of this shape.
func TestBuildNavigationFolderIndex(t *testing.T) {
// The REAL docs tree: the Filters group only exists because the
// filter manual (guide/cookbook/fields) lives in user/filters/.
handler := NewDocsHandler("../../docs")
nav := handler.BuildNavigation("/docs/user/filters")
var userGuide *templates.NavSection
for i := range nav.Sections {
if nav.Sections[i].Title == "User Guide" {
userGuide = &nav.Sections[i]
}
}
if userGuide == nil {
t.Fatal("User Guide section missing")
}
if userGuide.Collapsed {
t.Error("User Guide section must expand for /docs/user/filters")
}
var filters *templates.NavGroup
for i := range userGuide.Groups {
if userGuide.Groups[i].Title == "Filters" {
filters = &userGuide.Groups[i]
}
}
if filters == nil {
t.Fatal("Filters group missing from User Guide")
}
if filters.Collapsed {
t.Error("Filters group must expand for its folder URL")
}
}