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.
246 lines
12 KiB
Go
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">—</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)
|
|
}
|
|
}
|