Files
member-console/internal/server/operator_sidebar_render_test.go
T
cgalo5758 929c176ce1 Unify application shell across both surfaces
Extract the top bar and account menu into shell_topbar.html and the
member rail into shell_rail_member.html, backed by a single
server.Shell value. Move session controls into the account menu, add
the mirrored Operator panel/Member dashboard surface switch, and turn
the rail into an offcanvas drawer below lg with shell.js closing it on
navigation. Update docs, specs, and tests.
Unify application shell across both surfaces

Extract the top bar and member rail into shared partials and introduce
server.Shell as the single data value for page chrome. Move session
controls into an account menu, make the rail an offcanvas drawer below
lg, and add the mirrored surface switch.
2026-08-30 01:07:48 -05:00

193 lines
8.1 KiB
Go

package server
import (
"regexp"
"strings"
"testing"
)
// sidebarRegion slices the rendered operator.html document down to the
// sidebar <aside>, so link-text assertions can't accidentally match the
// top bar's account menu or the main content.
func sidebarRegion(t *testing.T, out string) string {
t.Helper()
const open = `<aside id="app-rail" class="app-sidebar operator-ia-sidebar`
start := strings.Index(out, open)
if start < 0 {
t.Fatalf("rendered output has no operator sidebar")
}
rest := out[start:]
end := strings.Index(rest, "</aside>")
if end < 0 {
t.Fatalf("rendered output sidebar has no closing </aside>")
}
return rest[:end]
}
// sidebarLinkActive reports whether the sidebar <a> whose text is exactly
// linkText carries the "active" class. Fails the test if no such link is
// found in the sidebar region.
func sidebarLinkActive(t *testing.T, sidebar, linkText string) bool {
t.Helper()
re := regexp.MustCompile(`<a class="nav-link([^"]*)"[^>]*>` + regexp.QuoteMeta(linkText) + `</a>`)
m := re.FindStringSubmatch(sidebar)
if m == nil {
t.Fatalf("sidebar link %q not found in:\n%s", linkText, sidebar)
}
return strings.Contains(m[1], "active")
}
// TestOperatorSidebarFlatTopLevelOrder covers operator-panel-navigation
// ("No rendered sidebar groups", "The sidebar is a flat list of section
// entries"): exactly the eight top-level entries Overview, People,
// Organizations, Grants, Billing, Products, Domains, Integrations in that
// order, with no children, no indentation, and no hairline group-separator
// markup (maintainer 2026-08-23: second-level surfaces are reached
// in-page, not from the sidebar; People joined via ux-operator-scale).
func TestOperatorSidebarFlatTopLevelOrder(t *testing.T) {
out := sidebarRegion(t, renderOperator(t, OperatorPageData{CSRFToken: "csrf"}))
topLevel := []string{">Overview<", ">People<", ">Organizations<", ">Grants<", ">Billing<", ">Products<", ">Domains<", ">Integrations<"}
positions := make([]int, len(topLevel))
for i, want := range topLevel {
idx := strings.Index(out, want)
if idx < 0 {
t.Fatalf("sidebar missing top-level entry %q in:\n%s", want, out)
}
positions[i] = idx
}
for i := 1; i < len(positions); i++ {
if positions[i] <= positions[i-1] {
t.Errorf("sidebar top-level entries out of order: %q (at %d) did not follow %q (at %d)",
topLevel[i], positions[i], topLevel[i-1], positions[i-1])
}
}
// The eight sections plus the surface switch, the rail's one separated
// non-section entry (unify-shell), and nothing else.
if got := strings.Count(out, "nav-link"); got != len(topLevel)+1 {
t.Errorf("sidebar must hold exactly the %d section entries plus the surface switch, found %d nav-link occurrences in:\n%s", len(topLevel), got, out)
}
switchEntry := `<li class="nav-item app-rail-switch"><a class="nav-link" href="/">Member dashboard</a></li>`
if strings.Count(out, switchEntry) != 1 || strings.Index(out, switchEntry) < positions[len(positions)-1] {
t.Errorf("the surface switch must render exactly once, after Integrations, in:\n%s", out)
}
for _, banned := range []string{
">Org Types<",
">Accounts<", ">Subscriptions<", ">Invoices<", ">Payments<",
">Entitlement Sets<", ">Plan Ladders<", ">Plan Topology<",
"ps-4",
"operator-ia-group-start",
"mt-2 pt-2 border-top",
"/logout",
} {
if strings.Contains(out, banned) {
t.Errorf("sidebar must not contain %q (children and group markup are retired), got:\n%s", banned, out)
}
}
}
// TestOperatorSidebarSecondLevelMarksSection covers operator-panel-navigation
// ("A second-level page marks its section's sidebar entry active"): a page
// reached from within a section (Org Types, the billing views, Entitlement
// Sets, Plan Ladders) lights up that section's single sidebar entry, and no
// other.
func TestOperatorSidebarSecondLevelMarksSection(t *testing.T) {
cases := []struct {
name string
activeCapability string
sectionText string
}{
{"person-detail", "persons", "People"},
{"org-types", "org-types", "Organizations"},
{"entitlement-sets", "entitlement-sets", "Products"},
{"plan-ladders", "plan-ladders", "Products"},
{"billing-accounts", "billing-accounts", "Billing"},
{"billing-invoices", "billing-invoices", "Billing"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := sidebarRegion(t, renderOperator(t, OperatorPageData{
CSRFToken: "csrf",
ActiveCapability: tc.activeCapability,
}))
if !sidebarLinkActive(t, out, tc.sectionText) {
t.Errorf("section entry %q must be marked active for capability %q, sidebar:\n%s", tc.sectionText, tc.activeCapability, out)
}
// A sibling section must not light up for another section's page.
if tc.sectionText != "Organizations" && sidebarLinkActive(t, out, "Organizations") {
t.Errorf("unrelated section Organizations must not be marked active for %q, sidebar:\n%s", tc.activeCapability, out)
}
})
}
}
// TestOperatorSidebarOverviewOnlyActiveOnLanding guards against the
// section active-marking logic accidentally lighting up every top-level
// entry (an `or` chain with a missing `eq` degenerating to always-true).
func TestOperatorSidebarOverviewOnlyActiveOnLanding(t *testing.T) {
out := sidebarRegion(t, renderOperator(t, OperatorPageData{CSRFToken: "csrf"}))
for _, text := range []string{"People", "Organizations", "Grants", "Billing", "Products", "Domains", "Integrations"} {
if sidebarLinkActive(t, out, text) {
t.Errorf("%q must not be active on the landing surface, sidebar:\n%s", text, out)
}
}
if !sidebarLinkActive(t, out, "Overview") {
t.Errorf("Overview must be active on the landing surface, sidebar:\n%s", out)
}
}
// TestOperatorShellTopBarAndDrawer covers chrome-conventions "One shell for
// both surfaces" and "The surface switch is a visible, mirrored rail entry"
// on the operator surface (unify-shell): the top bar renders the brand, the
// "Operator" label, and the account menu, and no section links; the rail is
// the responsive offcanvas element whose toggler lives in the top bar; the
// "Member dashboard" switch is never marked active, whatever the page.
func TestOperatorShellTopBarAndDrawer(t *testing.T) {
for _, active := range []string{"", "organizations", "billing-invoices"} {
out := renderOperator(t, OperatorPageData{
CSRFToken: "csrf",
Name: "Alice Admin",
Username: "alice",
Email: "alice@example.test",
KeycloakAccountURL: "https://idp.example.test/account",
ActiveCapability: active,
})
topBar := out[:strings.Index(out, `<aside id="app-rail"`)]
for _, want := range []string{
`<span class="navbar-text text-secondary app-surface-label">Operator</span>`,
`data-bs-toggle="dropdown" aria-expanded="false"><span class="app-account-label">Alice Admin</span></button>`,
`<a class="dropdown-item" href="/logout" hx-boost="false">Sign out</a>`,
`Identity and <span class="text-nowrap">Access<svg class="external-link-icon"`,
`data-bs-toggle="offcanvas" data-bs-target="#app-rail" aria-controls="app-rail" aria-label="Open navigation"`,
`<a class="navbar-brand" href="/">`,
} {
if !strings.Contains(topBar, want) {
t.Errorf("top bar missing %q (active=%q)", want, active)
}
}
for _, banned := range []string{">Dashboard</a>", ">Products</a>", ">Operator</a>", ">Billing</a>", "Logout", "text-danger", "navbar-collapse"} {
if strings.Contains(topBar, banned) {
t.Errorf("top bar must not contain %q (active=%q)", banned, active)
}
}
sidebar := sidebarRegion(t, out)
if sidebarLinkActive(t, sidebar, "Member dashboard") {
t.Errorf("the surface switch must never be active (active=%q)", active)
}
for _, want := range []string{`<div class="offcanvas-header">`, `<div class="offcanvas-body p-0">`, `data-bs-dismiss="offcanvas" data-bs-target="#app-rail" aria-label="Close navigation"`} {
if !strings.Contains(sidebar, want) {
t.Errorf("rail missing drawer markup %q", want)
}
}
if strings.Contains(sidebar, "d-none d-lg-flex") {
t.Error("the rail must not be hidden below lg any more")
}
if !strings.Contains(out, `<script defer src="/static/shell.js"></script>`) {
t.Error("operator.html must load shell.js")
}
}
}