Files
member-console/internal/server/list_scale_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

204 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bytes"
"errors"
"html/template"
"io/fs"
"net/http/httptest"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
func TestParseListParamsClamping(t *testing.T) {
cases := []struct {
url string
wantQ string
wantF string
wantPage int
}{
{"/operator/grants", "", "", 1},
{"/operator/grants?q=acme&state=live&page=3", "acme", "live", 3},
{"/operator/grants?page=abc", "", "", 1},
{"/operator/grants?page=-4", "", "", 1},
{"/operator/grants?page=0", "", "", 1},
}
for _, tc := range cases {
r := httptest.NewRequest("GET", tc.url, nil)
p := ParseListParams(r, "state")
if p.Q != tc.wantQ || p.Facet != tc.wantF || p.Page != tc.wantPage {
t.Errorf("%s: got %+v, want q=%q facet=%q page=%d", tc.url, p, tc.wantQ, tc.wantF, tc.wantPage)
}
}
// A list with no facet parameter never reads one.
r := httptest.NewRequest("GET", "/operator/persons?state=live", nil)
if p := ParseListParams(r, ""); p.Facet != "" {
t.Errorf("facet must stay empty when the list declares no facet param, got %q", p.Facet)
}
}
func TestFetchPageClampsPastTheEnd(t *testing.T) {
calls := []int32{}
load := func(limit, offset int32) ([]string, int64, error) {
calls = append(calls, offset)
if offset >= 60 {
return nil, 0, nil // past the end: no rows, and no total knowledge
}
return []string{"row"}, 60, nil
}
p := &ListParams{Page: 9}
rows, total, err := FetchPage(p, load)
if err != nil || len(rows) != 1 || total != 60 {
t.Fatalf("clamped fetch: rows=%d total=%d err=%v", len(rows), total, err)
}
if p.Page != 1 {
t.Errorf("page must clamp to 1 after an out-of-range fetch, got %d", p.Page)
}
if len(calls) != 2 || calls[1] != 0 {
t.Errorf("expected a re-run at offset 0, calls=%v", calls)
}
wantErr := errors.New("boom")
_, _, err = FetchPage(&ListParams{Page: 1}, func(_, _ int32) ([]string, int64, error) { return nil, 0, wantErr })
if !errors.Is(err, wantErr) {
t.Errorf("load errors must surface, got %v", err)
}
}
func TestListNavMathAndURLs(t *testing.T) {
n := ListNav{BasePath: "/operator/grants", FacetParam: "state", Q: "a b", Facet: "live", Page: 2, Total: 120}
if n.Pages() != 3 || n.From() != 51 || n.To() != 100 {
t.Errorf("math: pages=%d from=%d to=%d", n.Pages(), n.From(), n.To())
}
if got := n.Showing(); got != "Showing 51100 of 120" {
t.Errorf("Showing() = %q", got)
}
if !n.HasPrev() || !n.HasNext() {
t.Error("page 2 of 3 must have both prev and next")
}
// URLs preserve the rest of the state and escape the term.
if got := n.NextURL(); got != "/operator/grants?page=3&q=a+b&state=live" {
t.Errorf("NextURL = %q", got)
}
if got := n.FacetURL("inactive"); got != "/operator/grants?q=a+b&state=inactive" {
t.Errorf("FacetURL = %q", got)
}
if got := n.FacetURL(""); got != "/operator/grants?q=a+b" {
t.Errorf("FacetURL clear = %q", got)
}
if got := n.ClearSearchURL(); got != "/operator/grants?state=live" {
t.Errorf("ClearSearchURL = %q", got)
}
empty := ListNav{BasePath: "/operator/persons", Total: 0, Page: 1}
if empty.Pages() != 1 || empty.From() != 0 || empty.To() != 0 || empty.Showing() != "" {
t.Errorf("empty list math: pages=%d from=%d to=%d showing=%q", empty.Pages(), empty.From(), empty.To(), empty.Showing())
}
if empty.Filtered() {
t.Error("empty unfiltered list must not report Filtered")
}
exact := ListNav{BasePath: "/x", Page: 2, Total: 100}
if exact.Pages() != 2 || exact.To() != 100 || exact.HasNext() {
t.Errorf("exact-multiple math: pages=%d to=%d hasNext=%v", exact.Pages(), exact.To(), exact.HasNext())
}
}
func TestValidFacet(t *testing.T) {
opts := []FacetOption{{Value: "live", Label: "Live"}, {Value: "inactive", Label: "Inactive"}}
if got := ValidFacet("live", opts); got != "live" {
t.Errorf("valid value rejected: %q", got)
}
if got := ValidFacet("bogus", opts); got != "" {
t.Errorf("unknown facet value must be ignored, got %q", got)
}
}
// listControlsTemplates builds the operator partial set the same way the
// smoke test does, so the shared list-controls defines render exactly as
// they will in handlers.
func listControlsTemplates(t *testing.T) *template.Template {
t.Helper()
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
t.Fatalf("fs.Sub partials: %v", err)
}
tmpl := template.New("t").Funcs(template.FuncMap{
"renderBody": func(string, any) (template.HTML, error) { return "", nil },
"routeURL": web.RouteURL,
"fieldErr": func(_, _, _, _ string, _, _ any) string { return "" },
"stripeEntityURL": func(string, string) string { return "" },
"deploymentName": config.DeploymentName,
})
tmpl, err = web.ParseUIPartials(template.Must(tmpl.ParseFS(partialsSub, "operator_*.html")))
if err != nil {
t.Fatalf("ParseFS partials: %v", err)
}
return tmpl
}
func renderListDefine(t *testing.T, name string, nav ListNav) string {
t.Helper()
var buf bytes.Buffer
if err := listControlsTemplates(t).ExecuteTemplate(&buf, name, nav); err != nil {
t.Fatalf("ExecuteTemplate %s: %v", name, err)
}
return buf.String()
}
func TestListControlsRender(t *testing.T) {
nav := ListNav{
BasePath: "/operator/grants",
SearchPlaceholder: "Search organization or product",
FacetParam: "state",
FacetOptions: []FacetOption{{Value: "live", Label: "Live"}, {Value: "inactive", Label: "Inactive"}},
Q: "acme",
Facet: "live",
Page: 2,
Total: 120,
}
out := renderListDefine(t, "listControls", nav)
for _, want := range []string{
`action="/operator/grants"`,
`name="q" value="acme"`,
`name="state" value="live"`, // hidden input keeps the facet through a search
`>Clear</a>`,
`>All</a>`,
} {
if !strings.Contains(out, want) {
t.Errorf("listControls missing %q in:\n%s", want, out)
}
}
if !strings.Contains(out, `active" href="/operator/grants?q=acme">All`) &&
!strings.Contains(out, `small active" href="/operator/grants?q=acme&amp;state=live">Live</a>`) {
t.Errorf("active facet pill not marked in:\n%s", out)
}
pager := renderListDefine(t, "listPager", nav)
for _, want := range []string{
"Showing 51100 of 120",
"Page 2 of 3",
">Previous</a>",
">Next</a>",
} {
if !strings.Contains(pager, want) {
t.Errorf("listPager missing %q in:\n%s", want, pager)
}
}
if pagerEmpty := renderListDefine(t, "listPager", ListNav{BasePath: "/x", Page: 1, Total: 0}); strings.Contains(pagerEmpty, "Showing") {
t.Errorf("empty list must render no Showing line, got:\n%s", pagerEmpty)
}
noMatch := renderListDefine(t, "listNoMatch", nav)
if !strings.Contains(noMatch, "No rows match") || !strings.Contains(noMatch, "acme") {
t.Errorf("listNoMatch must name the term, got:\n%s", noMatch)
}
if !strings.Contains(noMatch, `href="/operator/grants"`) {
t.Errorf("listNoMatch must link the bare list, got:\n%s", noMatch)
}
}