Files
member-console/internal/server/anatomy_test.go
T
cgalo5758 408fa6f5a6 Add page anatomy parts and UI quality gate
- Add shared ui_*.html parts (pageHeader, sectionHeader, statusBadge,
  emptyState) parsed into every template set
- Add anatomy lint rules with a shrinking allowlist and screen-coverage
  check
- Add make screens capture harness with contact sheets and baseline diff
- Compose member and FedWiki regions server-side so pages arrive
  complete
- Rebuild Domains and Integrations on the parts as pilots
2026-08-30 04:05:31 -05:00

120 lines
5.4 KiB
Go

package server
import (
"bytes"
"html/template"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// partsSet is a bare template set holding only the shared parts, the way
// every production set holds them after web.ParseUIPartials.
func partsSet(t *testing.T) *template.Template {
t.Helper()
tmpl, err := web.ParseUIPartials(template.New("parts").Funcs(template.FuncMap{"routeURL": web.RouteURL}))
if err != nil {
t.Fatalf("ParseUIPartials: %v", err)
}
return tmpl
}
func renderPart(t *testing.T, name string, data any) (string, error) {
t.Helper()
var buf bytes.Buffer
err := partsSet(t).ExecuteTemplate(&buf, name, data)
return buf.String(), err
}
// TestStatusBadgeMapIsComplete pins page-anatomy "Status badges come from
// one map": every state the handlers emit has a label and a tone, labels
// are title case, and an unknown state is visible rather than styled.
func TestStatusBadgeMapIsComplete(t *testing.T) {
for _, state := range knownStates {
b, ok := badgeMap[state]
if !ok {
t.Errorf("state %q is emitted but missing from badgeMap", state)
continue
}
if b.Label == "" || b.Tone == "" {
t.Errorf("state %q has an incomplete badge %+v", state, b)
}
if first := b.Label[:1]; first != strings.ToUpper(first) {
t.Errorf("state %q label %q is not title case", state, b.Label)
}
}
tones := map[string]bool{"success": true, "secondary": true, "warning": true, "danger": true, "info": true, "light": true}
for state, b := range badgeMap {
if !tones[b.Tone] {
t.Errorf("state %q uses unknown tone %q", state, b.Tone)
}
}
if got := StatusBadge("something_new"); got.Label != "something_new" || got.Tone != "secondary" {
t.Errorf("unknown state must render raw and secondary, got %+v", got)
}
}
// TestPageHeaderRejectsCountAndAction pins "The right-hand slot holds one
// thing": rendering fails, naming the page, rather than rendering both.
func TestPageHeaderRejectsCountAndAction(t *testing.T) {
_, err := renderPart(t, "pageHeader", PageHeader{Title: "Grants", Count: "3 grants", Action: &Link{Label: "New", URL: "/x"}})
if err == nil || !strings.Contains(err.Error(), `page header "Grants"`) {
t.Fatalf("expected the one-slot error naming the page, got %v", err)
}
}
// TestPartsRender pins each part's markup: the one title size, the header
// slots, the section header and its summary variant, the badge tones and
// tooltip, and the two empty-state branches.
func TestPartsRender(t *testing.T) {
out, err := renderPart(t, "pageHeader", PageHeader{Title: "Domains", Lead: "Every live claim.", Count: "1 live claim", Back: &Link{Label: "People", URL: "/operator/persons"}})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`<h1 class="h2 mb-0">Domains</h1>`, `<p class="text-body-secondary mb-0">Every live claim.</p>`, `>1 live claim</span>`, `href="/operator/persons">← Back to People</a>`} {
if !strings.Contains(out, want) {
t.Errorf("pageHeader missing %q in:\n%s", want, out)
}
}
out, _ = renderPart(t, "pageHeader", PageHeader{Title: "Products", Action: &Link{Label: "Organization types", URL: "/operator/org-types"}})
if !strings.Contains(out, `class="btn btn-sm btn-outline-secondary flex-shrink-0">Organization types</a>`) {
t.Errorf("pageHeader action must render as the outline button, got:\n%s", out)
}
out, _ = renderPart(t, "pageHeader", PageHeader{Title: "Products", Action: &Link{Label: "New product", URL: "/x", Primary: true}})
if !strings.Contains(out, `class="btn btn-sm btn-primary flex-shrink-0">New product</a>`) {
t.Errorf("primary action must render filled, got:\n%s", out)
}
out, _ = renderPart(t, "sectionHeader", SectionHeader{Title: "Placements", Count: "0 served names"})
if !strings.Contains(out, `<h2 class="h5 mb-0">Placements</h2>`) || !strings.Contains(out, `>0 served names</span>`) {
t.Errorf("sectionHeader markup wrong:\n%s", out)
}
out, _ = renderPart(t, "sectionSummary", SectionHeader{Title: "Claim history", Count: "4"})
if !strings.Contains(out, `<summary class="app-section-header mb-2"><h2 class="h5 d-inline mb-0">Claim history (4)</h2></summary>`) {
t.Errorf("sectionSummary markup wrong:\n%s", out)
}
out, _ = renderPart(t, "statusBadge", StatusBadge("active"))
if out != `<span class="badge text-bg-success">Active</span>` {
t.Errorf("statusBadge active rendered %q", out)
}
out, _ = renderPart(t, "statusBadge", StatusBadge("operator_root"))
if out != `<span class="badge text-bg-light border">Operator root</span>` {
t.Errorf("light badge must carry a border, rendered %q", out)
}
out, _ = renderPart(t, "statusBadge", StatusBadge("not_configured").WithTitle("Missing: a, b"))
if out != `<span class="badge text-bg-warning" title="Missing: a, b">Not configured</span>` {
t.Errorf("badge tooltip rendered %q", out)
}
out, _ = renderPart(t, "emptyState", EmptyStateParams{Headline: "No placements."})
if !strings.Contains(out, `<div class="text-center py-4">`) || !strings.Contains(out, `<p class="text-muted mb-2">No placements.</p>`) || strings.Contains(out, "border") {
t.Errorf("emptyState not-blocked branch wrong:\n%s", out)
}
out, _ = renderPart(t, "emptyState", EmptyStateParams{Blocked: true, BlockerCopy: "Create a product first.", PrerequisiteURL: "/operator/products", PrerequisiteLabel: "Products"})
if !strings.Contains(out, `alert alert-warning`) || !strings.Contains(out, `href="/operator/products"`) {
t.Errorf("emptyState blocked branch wrong:\n%s", out)
}
}