Files
member-console/internal/server/anatomy_test.go
T
cgalo5758 8c85983758 Align operator overview with page anatomy parts
Align the landing surface with the shared parts: sectionHeader for the
regions, the new readout part for every headline count, flush lists for
the System sheet, chevron-marked linked cards, and a boxed paginated
activity feed. Add the raw-section-title lint rule and classify the
remaining hand-written titles; archive the overview-consistency change.
2026-09-06 19:50:36 -05:00

246 lines
12 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
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. surfaceRoot is
// overridden to the operator surface's root (design D18) so pageHeader
// tests exercise the real trail, the way an operator page's set does.
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.Funcs(template.FuncMap{"surfaceRoot": OperatorSurfaceRoot})
}
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", Crumbs: []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>`,
// design D18: the root crumb (this set's surfaceRoot is the
// operator surface) comes first, then .Crumbs, then the current
// page unlinked.
`<li class="breadcrumb-item"><a href="/operator">Operator</a></li>`,
`<li class="breadcrumb-item"><a href="/operator/persons">People</a></li>`, `<li class="breadcrumb-item active" aria-current="page">Domains</li>`} {
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)
}
// design D18: a rail-entry page with no Crumbs of its own still gets
// the root crumb, and no page header ever renders a filled action
// (design D19): a page header's action navigates, so it renders
// outline-secondary even when the Link is marked Filled.
out, _ = renderPart(t, "pageHeader", PageHeader{Title: "Products", Action: &Link{Label: "New product", URL: "/x", Filled: true}})
if !strings.Contains(out, `<li class="breadcrumb-item"><a href="/operator">Operator</a></li>`) {
t.Errorf("a rail-entry page (no Crumbs) must still render the surface root crumb, got:\n%s", out)
}
if !strings.Contains(out, `<li class="breadcrumb-item active" aria-current="page">Products</li>`) {
t.Errorf("pageHeader must append the current page as the unlinked crumb, got:\n%s", out)
}
if !strings.Contains(out, `class="btn btn-sm btn-outline-secondary flex-shrink-0">New product</a>`) {
t.Errorf("a page header action must render outline-secondary, got:\n%s", out)
}
if strings.Contains(out, `"btn btn-sm btn-primary`) {
t.Errorf("page header action must never render the filled btn-primary, got:\n%s", out)
}
// design D18: the two surface roots carry no trail at all.
out, _ = renderPart(t, "pageHeader", PageHeader{Title: "Operator overview", NoTrail: true})
if strings.Contains(out, "breadcrumb") {
t.Errorf("a NoTrail page header must render no breadcrumb nav, 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)
}
// design D19 (round 4): a section header's link is filled only when it
// is the section's one call to action (a list's "New ..." link to the
// create page); every other link navigates and is outline-secondary.
out, _ = renderPart(t, "sectionHeader", SectionHeader{Title: "Products", Action: &Link{Label: "New product", URL: "/operator/products/new", Filled: true}})
if !strings.Contains(out, `<a href="/operator/products/new" class="btn btn-sm btn-primary">New product</a>`) {
t.Errorf("a Filled section-header link must render btn-primary, got:\n%s", out)
}
out, _ = renderPart(t, "sectionHeader", SectionHeader{Title: "Prices", Action: &Link{Label: "Settings", URL: "/x"}})
if !strings.Contains(out, `class="btn btn-sm btn-outline-secondary">Settings</a>`) {
t.Errorf("a navigating section-header link must render outline-secondary, got:\n%s", out)
}
// design D19 (round 4): a panel opener is tertiary and carries the plus
// glyph that rotates into a cross while the panel is open; the retired
// pressed rule means no filled or active weight rides on it.
out, _ = renderPart(t, "sectionHeader", SectionHeader{Title: "Prices", Action: &Link{Label: "Add price", Toggle: "createPricePanel"}})
for _, want := range []string{
`class="btn btn-sm btn-outline-secondary"`,
`data-bs-toggle="collapse"`,
`data-bs-target="#createPricePanel"`,
`aria-expanded="false"`,
`<svg class="app-opener-icon"`,
`aria-hidden="true"`,
`focusable="false"`,
} {
if !strings.Contains(out, want) {
t.Errorf("a panel opener missing %q, got:\n%s", want, out)
}
}
if strings.Contains(out, "btn-outline-primary") || strings.Contains(out, `"btn btn-sm btn-primary`) {
t.Errorf("a panel opener is tertiary, never primary, got:\n%s", out)
}
out, _ = renderPart(t, "sectionHeader", SectionHeader{Title: "Prices", Action: &Link{Label: "Add price", Toggle: "createPricePanel", Expanded: true}})
if !strings.Contains(out, `aria-expanded="true"`) {
t.Errorf("an opener re-rendered over a failing form must report expanded, got:\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)
}
}
// TestReadoutPart pins page-anatomy "A readout is a part"
// (overview-consistency D3, round 2): one label-over-value shape at one
// size, the label a small semibold uppercase eyebrow and never a link, the
// em dash marker instead of a zero when the count could not be loaded, and
// the danger colour when the value needs an operator.
func TestReadoutPart(t *testing.T) {
out, err := renderPart(t, "readout", Readout{Label: "People", Value: "1,204", Caption: "212 joined in the last 30 days", Available: true})
if err != nil {
t.Fatalf("readout: %v", err)
}
for _, want := range []string{
`<dl class="mb-0">`,
`<dt class="small fw-semibold text-uppercase text-body-secondary">People</dt>`,
`<dd class="fs-3 fw-semibold mb-0">1,204</dd>`,
`<small class="text-body-secondary">212 joined in the last 30 days</small>`,
} {
if !strings.Contains(out, want) {
t.Errorf("a readout is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "<a ") {
t.Errorf("the readout part must render no anchor of its own:\n%s", out)
}
if strings.Contains(out, "link-primary") {
t.Errorf("the readout label must never be a link:\n%s", out)
}
for _, banned := range []string{"display-6", "fs-4"} {
if strings.Contains(out, banned) {
t.Errorf("a readout renders %q; the console has one readout size:\n%s", banned, out)
}
}
// No caption, no caption line.
out, _ = renderPart(t, "readout", Readout{Label: "Pending", Value: "2", Available: true})
if strings.Contains(out, "<small") {
t.Errorf("a readout with no caption must render no caption line:\n%s", out)
}
// A count that could not be loaded states that, never a zero.
out, _ = renderPart(t, "readout", Readout{Label: "Organizations", Caption: "Active organizations"})
if !strings.Contains(out, `<span class="text-body-secondary" title="This count could not be loaded">&mdash;</span>`) {
t.Errorf("an unavailable readout must render the em dash marker:\n%s", out)
}
if !strings.Contains(out, `<small class="text-body-secondary">Count unavailable</small>`) {
t.Errorf("an unavailable readout must explain itself:\n%s", out)
}
if strings.Contains(out, "Active organizations") {
t.Errorf("an unavailable readout must not state a caption about a count it does not have:\n%s", out)
}
// Only a value that needs an operator takes the danger colour.
out, _ = renderPart(t, "readout", Readout{Label: "Dead-letter", Value: "3", Available: true, Attention: true})
if !strings.Contains(out, `<dd class="fs-3 fw-semibold mb-0 text-danger">3</dd>`) {
t.Errorf("an attention readout must render the danger tone:\n%s", out)
}
out, _ = renderPart(t, "readout", Readout{Label: "Dead-letter", Value: "0", Available: true})
if strings.Contains(out, "text-danger") {
t.Errorf("a readout with no attention must not raise an alarm:\n%s", out)
}
}