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.
216 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|