Second page of the admin tree. Documents the four-card stats grid with what each number actually counts (instance-wide totals; Books = every media item including podcast episodes), the File Watcher card's data source (/api/scanner/watch/status, armed by Auto-Scan) and its zero-libraries meaning, Force Rescan All Libraries with its real mechanics (one job per library, skip-on-active, backfill tool vs. routine shallow refresh), and the Scanning panel's live progress + Cancel all scans. Links only to pages that exist (scanning.md); the Settings reference stays prose until that page lands. The real-tree nav test's Administration assertion loosens further: guide leads, everything else strictly follows usabilityOrder — durable across the remaining page landings. Also fixes the amend this commit replaces: its first version briefly carried a forward link to the unwritten settings.md (caught by the link gate, now removed) — the gate worked; the && chain around go test let the bad commit through, which is exactly why this amend exists.
321 lines
11 KiB
Go
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 (pinned landing), and every other item
|
|
// must appear in usabilityOrder's declared relative order.
|
|
admin := find("Administration")
|
|
if len(admin.Items) < 1 || admin.Items[0].Title != "Admin Guide" {
|
|
t.Fatalf("Administration must lead with the Admin Guide, 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[1:] {
|
|
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")
|
|
}
|
|
}
|