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

242 lines
9.6 KiB
Go

package server
// Render tests for the ux-first-run empty/blocked-state work: the shared
// "emptyState" define (operator_empty_state.html, task 3.1) plus every
// screen that consumes it or its equivalent inline markup (3.2-3.5, 4.1-4.3).
// No DB needed -- these execute the real templates against literal view
// models, mirroring the existing renderGrantsIndex / renderOrgTypesPageBody
// / renderPlanLaddersPageBody pattern (each in this package's own
// *_render_test.go file).
import (
"bytes"
"html/template"
"io/fs"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// renderOperatorPartial executes one named template from the real operator
// partials set (which auto-loads operator_empty_state.html alongside every
// other operator_*.html file) against data.
func renderOperatorPartial(t *testing.T, name string, data any) string {
t.Helper()
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
t.Fatalf("fs.Sub partials: %v", err)
}
tmpl := template.New("operator").Funcs(template.FuncMap{
"renderBody": func(string, any) (template.HTML, error) { return "", nil },
"routeURL": web.RouteURL,
"stripeEntityURL": func(string, string) string { return "" },
"fieldErr": func(activeForm, expectedForm, activeScope, expectedScope string, errs web.FieldErrors, field string) string {
return ""
},
})
tmpl, err = web.ParseUIPartials(template.Must(tmpl.ParseFS(partialsSub, "operator_*.html")))
if err != nil {
t.Fatalf("ParseFS: %v", err)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
t.Fatalf("ExecuteTemplate %s: %v", name, err)
}
return buf.String()
}
// --- 3.1: the shared define itself ---
func TestEmptyStateDefine_Blocked(t *testing.T) {
out := renderOperatorPartial(t, "emptyState", EmptyStateParams{
Blocked: true,
BlockerCopy: "A widget must exist first.",
PrerequisiteURL: "/operator/widgets",
PrerequisiteLabel: "Go to Widgets",
})
if !strings.Contains(out, "alert-warning") {
t.Errorf("expected the blocked branch to use the warning alert styling, got:\n%s", out)
}
if !strings.Contains(out, "A widget must exist first.") {
t.Errorf("expected the blocker copy, got:\n%s", out)
}
if !strings.Contains(out, `href="/operator/widgets"`) || !strings.Contains(out, "Go to Widgets") {
t.Errorf("expected the prerequisite link, got:\n%s", out)
}
}
func TestEmptyStateDefine_NotBlocked(t *testing.T) {
out := renderOperatorPartial(t, "emptyState", EmptyStateParams{
Headline: "No widgets yet.",
Note: "Widgets are optional.",
ActionURL: "#createWidgetForm",
ActionLabel: "Create Widget",
})
if strings.Contains(out, "alert-warning") {
t.Errorf("must not use the blocked styling when not blocked, got:\n%s", out)
}
if !strings.Contains(out, "No widgets yet.") || !strings.Contains(out, "Widgets are optional.") {
t.Errorf("expected the headline and note, got:\n%s", out)
}
if !strings.Contains(out, `href="#createWidgetForm"`) || !strings.Contains(out, "Create Widget") {
t.Errorf("expected the primary action, got:\n%s", out)
}
}
// --- 3.2 / 4.1: Products ---
func TestProductsPartial_Blocked(t *testing.T) {
out := renderOperatorPartial(t, "operator_products.html", OperatorProductsData{
EntitlementSetGate: EmptyStateParams{
Blocked: true,
BlockerCopy: "Products need an entitlement set to define what they deliver. Create one before creating a product.",
PrerequisiteURL: "/operator/entitlement-sets",
PrerequisiteLabel: "Go to Entitlement Sets",
},
})
if strings.Contains(out, `id="createProductForm"`) {
t.Errorf("must not render the enabled form with no entitlement set to pick, got:\n%s", out)
}
if strings.Count(out, "Products need an entitlement set") == 0 {
t.Errorf("expected the blocked explanation, got:\n%s", out)
}
if !strings.Contains(out, `href="/operator/entitlement-sets"`) {
t.Errorf("expected a link to Entitlement Sets, got:\n%s", out)
}
}
func TestProductsPartial_EmptyButUnblocked(t *testing.T) {
out := renderOperatorPartial(t, "operator_products.html", OperatorProductsData{
EntitlementSets: []EntitlementSetOption{{SetID: "s1", Name: "Standard"}},
EntitlementSetGate: EmptyStateParams{
Headline: "No products yet.",
ActionURL: "#createProductForm",
ActionLabel: "Create Product",
},
})
if !strings.Contains(out, `id="createProductForm"`) {
t.Errorf("expected the enabled create-product form, got:\n%s", out)
}
if !strings.Contains(out, "No products yet.") {
t.Errorf("expected the actionable empty-list state, got:\n%s", out)
}
if strings.Contains(out, "Products need an entitlement set") {
t.Errorf("must not render blocked copy once a set exists, got:\n%s", out)
}
}
func TestProductsPartial_NonEmptyListSkipsEmptyState(t *testing.T) {
out := renderOperatorPartial(t, "operator_products.html", OperatorProductsData{
EntitlementSets: []EntitlementSetOption{{SetID: "s1", Name: "Standard"}},
Products: []OperatorProductViewModel{{ProductID: "p1", Name: "Widget Plan", EntitlementSetName: "Standard"}},
})
if strings.Contains(out, "No products yet.") {
t.Errorf("must not render the empty state when products exist, got:\n%s", out)
}
if !strings.Contains(out, "Widget Plan") {
t.Errorf("expected the product row, got:\n%s", out)
}
}
// --- 3.4 / 4.3: Entitlement Sets ---
func TestEntitlementSetsPartial_HeaderExplanationSurvivesPastEmptyState(t *testing.T) {
out := renderOperatorPartial(t, "operator_entitlement_sets.html", EntitlementSetsData{
EntitlementSets: []EntitlementSetViewModel{{SetID: "s1", Name: "Standard"}},
})
if !strings.Contains(out, "every product must reference exactly one set") {
t.Errorf("expected the always-on header explanation of what a set unlocks, got:\n%s", out)
}
}
func TestEntitlementSetsPartial_EmptyStateTwoEndedCopy(t *testing.T) {
out := renderOperatorPartial(t, "operator_entitlement_sets.html", EntitlementSetsData{
Empty: EmptyStateParams{
Headline: "No entitlement sets yet.",
Note: "Rules feed into a set, and products consume it. Creating a set is the first step toward a sellable product.",
ActionURL: "#createEntitlementSetForm",
ActionLabel: "Create Entitlement Set",
},
})
if !strings.Contains(out, "Rules feed into a set") || !strings.Contains(out, "products consume it") {
t.Errorf("expected the two-ended dependency copy, got:\n%s", out)
}
if !strings.Contains(out, `href="#createEntitlementSetForm"`) {
t.Errorf("expected the create action, got:\n%s", out)
}
}
// --- 3.3: Grants ---
func TestGrantsPartial_BlockedOnZeroPublishedProducts(t *testing.T) {
out := renderOperatorPartial(t, "operator_grants.html", GrantsData{})
if !strings.Contains(out, "No grants found.") {
t.Errorf("expected the base empty-state line to still render (pre-existing contract), got:\n%s", out)
}
if !strings.Contains(out, "no product has been published yet") {
t.Errorf("expected the blocked explanation, got:\n%s", out)
}
if !strings.Contains(out, `href="/operator/products"`) {
t.Errorf("expected a link to Products, got:\n%s", out)
}
}
func TestGrantsPartial_EmptyPointsAtIssueFlow(t *testing.T) {
out := renderOperatorPartial(t, "operator_grants.html", GrantsData{
Products: []ProductViewModel{{ProductID: "p1", Name: "Standard"}},
})
if !strings.Contains(out, "issued from each organization's detail page") {
t.Errorf("expected the empty state to point at the issue flow, got:\n%s", out)
}
if strings.Contains(out, "no product has been published yet") {
t.Errorf("must not render the blocked copy once a product exists, got:\n%s", out)
}
}
// --- 3.5: Plan Ladders ---
func TestPlanLaddersPartial_EmptyStateActionable(t *testing.T) {
out := renderOperatorPartial(t, "operator_plan_ladders.html", PlanLaddersData{})
if !strings.Contains(out, "No plan ladders yet.") {
t.Errorf("expected the empty headline, got:\n%s", out)
}
if !strings.Contains(out, "its tiers get attached to a product") {
t.Errorf("expected the tiers-need-a-product dependency note, got:\n%s", out)
}
if !strings.Contains(out, `href="#createPlanLadderForm"`) {
t.Errorf("expected an actionable Create Plan Ladder link, got:\n%s", out)
}
}
// --- 4.2: Org Types card ---
func TestOrgTypeCardPartial_ZeroLaddersShowsPrerequisite(t *testing.T) {
out := renderOperatorPartial(t, "operator_org_type_card.html", OrgTypeCardData{
OrgTypeViewModel: OrgTypeViewModel{OrgType: "personal", DisplayName: "Personal", IsActive: true},
})
if strings.Contains(out, `name="default_plan_ladder_id"`) {
t.Errorf("must not render a None-only dropdown when no plan ladders exist, got:\n%s", out)
}
if !strings.Contains(out, "must exist before this type can have a default plan") {
t.Errorf("expected the prerequisite note, got:\n%s", out)
}
if !strings.Contains(out, `href="/operator/plan-ladders"`) {
t.Errorf("expected a link to Plan Ladders, got:\n%s", out)
}
}
func TestOrgTypeCardPartial_WithLaddersShowsDropdown(t *testing.T) {
out := renderOperatorPartial(t, "operator_org_type_card.html", OrgTypeCardData{
OrgTypeViewModel: OrgTypeViewModel{OrgType: "personal", DisplayName: "Personal", IsActive: true},
PlanLadders: []PlanLadderOption{{PlanLadderID: "l1", LadderName: "Standard", RankZeroProductName: "Free", HasRankZero: true}},
})
if !strings.Contains(out, `name="default_plan_ladder_id"`) {
t.Errorf("expected the dropdown once plan ladders exist, got:\n%s", out)
}
if strings.Contains(out, "must exist before this type can have a default plan") {
t.Errorf("must not render the prerequisite note once plan ladders exist, got:\n%s", out)
}
}