Files
bookhoard/internal/docs/ui_labels_test.go
T
John O'Keefe e07301f74a docs(standard): screenshot-free UI documentation standard + UI-label gate
The planned Playwright screenshot docs (SCREENSHOT_AUTOMATION.md) are
replaced by a text standard: screenshots rot silently and cannot be
gate-checked, while this UI's labels are server-rendered templ literals
that can. Layers: pattern-first prose (concepts survive redesigns) >
capability inventory > label->outcome flows > quoted labels > generated
references where a registry exists (filters/fields.md precedent). The
filter docs stay hands-off exemplars; user-doc tone is plain language by
contract, dev/admin tone explicitly deferred.

writing-ui-docs.md is the standard (page template, findability notes,
tone-by-audience, process rule: a UI change is not done until the docs
audit runs). ui-controls.md is the canonical control-label glossary the
docs quote from; TestUIControlLabels greps every entry against
templates/*.templ (source: ts override; also:-fragments for
Alpine-composed labels like the Match/Group header) so renames fail
make test in the same commit as the rename. Gate proven both ways: a
planted bogus label failed; restored set passes.
2026-11-02 08:18:19 -05:00

216 lines
5.4 KiB
Go

package docs
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestUIControlLabels holds the UI documentation to the source of the UI.
// The glossary (docs/developer/ui-controls.md) is the canonical spelling of
// every control the docs cite; this test greps that spelling against the
// server-rendered templates, so a renamed button fails make test in the
// same commit as the rename — the class of rot screenshots could never be
// gated. Dynamic (Alpine-composed) labels are OR-checked with their `also`
// fragments instead of requiring the composed string to exist.
//
// Scope is substring-level (not word-boundary): the point is catching
// renames and deletions, not stopping coincidental substrings. New controls
// cited in prose get a glossary entry in the same commit (writing-ui-docs.md
// is the standard; the filter docs are the hands-off exemplar).
const (
labelGlossaryRel = "../../docs/developer/ui-controls.md"
labelTemplRootRel = "../../templates"
labelTSRootRel = "../../web/src"
)
type docGlossaryEntry struct {
label string
where string
does string
source string
reason string
also []string
skip bool
}
// docCorpus concatenates every file under root ending in suffix.
func docCorpus(t *testing.T, root string, suffix string) string {
t.Helper()
abs, err := filepath.Abs(root)
if err != nil {
t.Fatalf("abs %s: %v", root, err)
}
var b strings.Builder
err = filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, suffix) {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
b.Write(raw)
b.WriteString("\n")
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
return b.String()
}
func corpusName(source string) string {
if source == "ts" {
return "web/src"
}
return "templ"
}
func parseUIControlsBlock(t *testing.T, raw string) []docGlossaryEntry {
t.Helper()
var block strings.Builder
inBlock, foundAny := false, false
for _, line := range strings.Split(raw, "\n") {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "```controls"):
inBlock, foundAny = true, true
continue
case trimmed == "```" && inBlock:
inBlock = false
continue
}
if inBlock {
block.WriteString(line)
block.WriteString("\n")
}
}
if !foundAny {
t.Fatalf("no ```controls block found in %s", labelGlossaryRel)
}
var entries []docGlossaryEntry
haveCur := false
cur := docGlossaryEntry{}
flush := func() {
if haveCur {
entries = append(entries, cur)
haveCur = false
}
}
for _, line := range strings.Split(block.String(), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, ":")
if !ok {
t.Fatalf("malformed controls line %q (want `key: value`)", line)
}
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
switch key {
case "label":
flush()
haveCur = true
cur = docGlossaryEntry{label: val}
case "where":
cur.where = val
case "does":
cur.does = val
case "source":
cur.source = val
case "also":
for _, frag := range strings.Split(val, "|") {
if frag = strings.TrimSpace(frag); frag != "" {
cur.also = append(cur.also, frag)
}
}
case "skip":
cur.skip = val == "true"
case "reason":
cur.reason = val
case "note":
// free text for humans
default:
t.Fatalf("unknown field in controls block: %q (allowed: label|where|does|source|also|skip|reason|note)", key)
}
}
flush()
return entries
}
func TestUIControlLabels(t *testing.T) {
raw, err := os.ReadFile(labelGlossaryRel)
if err != nil {
t.Fatalf("read glossary: %v", err)
}
entries := parseUIControlsBlock(t, string(raw))
if len(entries) == 0 {
t.Fatalf("glossary has no entries — add at least one before citing controls")
}
tmplRequired, tsRequired := false, false
for _, e := range entries {
if e.source == "ts" {
tsRequired = true
} else {
tmplRequired = true
}
}
corpora := map[string]string{}
if tmplRequired {
corpora[corpusName("")] = docCorpus(t, labelTemplRootRel, ".templ")
}
if tsRequired {
corpora[corpusName("ts")] = docCorpus(t, labelTSRootRel, ".ts")
}
seen := map[string]int{}
for _, e := range entries {
if e.label == "" {
t.Errorf("entry with empty label (where: %q, does: %q)", e.where, e.does)
continue
}
seen[e.label]++
if e.where == "" || e.does == "" {
t.Errorf("control %q: `where` and `does` are required", e.label)
}
if e.skip && e.reason == "" {
t.Errorf("control %q: skip=true requires a reason", e.label)
}
switch e.source {
case "", "templ", "ts":
default:
t.Errorf("control %q: unknown source %q (templ|ts)", e.label, e.source)
}
if e.skip {
continue
}
fragments := append([]string{e.label}, e.also...)
found := false
for _, frag := range fragments {
if strings.Contains(corpora[corpusName(e.source)], frag) {
found = true
break
}
}
if !found {
t.Errorf("control %q (%s): none of the fragments [%s] found in %s sources — the label was renamed or the glossary is stale. Update ui-controls.md in the same commit as the rename.",
e.label, e.where, strings.Join(fragments, " | "), corpusName(e.source))
}
}
for label, n := range seen {
if n > 1 {
t.Errorf("control %q listed %d times — one entry per control", label, n)
}
}
}