Files
member-console/internal/server/ui_form_part_render_test.go
T
cgalo5758 f8a3478f2a Rebuild the entitlement set Rules surface as a staged batch
The Rules section is one record table grouped by kind, Limit then
Boolean, on fixed columns, edited in place: Edit opens a row's controls
in their columns, Add rule opens a dense row above the table, and every
change is staged into a tray that lists the deltas with Undo and applies
them as one rule-change act. The reduction policy is a column of the
rule beside its limit. History shows counts only. Group rows are a quiet
heading rather than a divider, the maintainer's pick from four rounds of
outside-model ideation.

Dense rows align to the top and render each error under its control in
every form family (design D16), replacing the below-row error block; the
forms library gains the batch form (rows plus one tray) and the RowField
dense and label-hidden options. Migration 00019 records the governing
reduction policy on effect rows.

Archive staged-rule-changes with its spec updates (entitlement-set-
management, entitlement-set-history, entitlements, form-library,
form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
2026-09-19 19:46:09 -05:00

443 lines
20 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
// Render tests for the two form parts themselves (spec form-library "The
// form part owns the container"; spec form-conventions). They execute
// ui_form.html and ui_form_field.html against forms.Render's view, so the
// markup every migrated form will produce is pinned once, here, rather
// than re-asserted per form. No DB needed.
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/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// renderFormPart executes the form part against one rendered declaration.
func renderFormPart(t *testing.T, view forms.FormView) string {
t.Helper()
tmpl := template.New("parts").Funcs(template.FuncMap{"helpIcon": helpIcon})
tmpl, err := web.ParseUIPartials(tmpl)
if err != nil {
t.Fatalf("ParseUIPartials: %v", err)
}
if _, err := fs.Sub(embeds.Templates, "templates"); err != nil {
t.Fatalf("fs.Sub: %v", err)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "form", view); err != nil {
t.Fatalf("execute the form part: %v", err)
}
return buf.String()
}
// A checkbox renders as Bootstrap's form-check with the label after the
// box, because the label names the state the box turns on, and the help
// icon after the label as its sibling, never inside it (findings FA-37,
// FA-50).
func TestFormPartCheckboxLabelSitsAfterTheBox(t *testing.T) {
spec := forms.FormSpec{
Name: "test.checkbox", Kind: forms.KindEdit, Family: forms.Stacked,
Method: "PUT", Path: "/partials/test", Commit: "Save",
Fields: []forms.Field{{
Name: "visibility", Label: "Listed", Control: forms.Checkbox, Value: "public",
Help: forms.Help("Listed", "Shown in the member catalog."),
}},
}
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound}))
box := strings.Index(out, `type="checkbox"`)
label := strings.Index(out, `class="form-check-label"`)
help := strings.Index(out, `aria-label="Help: Listed"`)
if box < 0 || label < 0 || help < 0 {
t.Fatalf("checkbox, label or help icon missing:\n%s", out)
}
if !(box < label && label < help) {
t.Errorf("expected the order box, label, help icon; got %d, %d, %d:\n%s", box, label, help, out)
}
if !strings.Contains(out, `<div class="form-check">`) {
t.Errorf("expected Bootstrap's form-check wrapper:\n%s", out)
}
// The help icon is the label's sibling: no button inside the label.
if strings.Contains(out, `<label class="form-check-label" for="form-test.checkbox-visibility-control">Listed <button`) {
t.Errorf("the help icon must not sit inside the <label>:\n%s", out)
}
}
// A dense row is one row of small controls with the commit at its end, at
// its natural width, never stretched to its column (finding FA-18).
func TestFormPartDenseRowCommitIsNaturalWidth(t *testing.T) {
spec := forms.FormSpec{
Name: "test.dense", Kind: forms.KindSubRecord, Family: forms.Dense,
Method: "POST", Path: "/partials/test", Commit: "Add price",
WayOut: forms.ClosePanel("Cancel", "addPricePanel"),
Fields: []forms.Field{
{Name: "amount", Label: "Amount", Control: forms.Number,
Help: forms.Help("Amount", "In the deployment's currency.")},
{Name: "interval", Label: "Interval", Control: forms.Text, Width: forms.WidthNarrower},
{Name: "prorate", Label: "Prorate", Control: forms.Checkbox, Value: "true"},
},
}
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound}))
if strings.Contains(out, "w-100") {
t.Errorf("a dense commit is never stretched to its column:\n%s", out)
}
for _, want := range []string{
`class="app-form app-form-dense"`,
`<div class="row g-2 align-items-start app-form-dense-row">`,
`<div class="col-auto app-form-commit app-form-unlabeled">`,
`class="btn btn-primary btn-sm"`,
`class="btn btn-outline-secondary btn-sm" data-bs-toggle="collapse" data-bs-target="#addPricePanel"`,
`class="form-control form-control-sm"`,
`class="form-label form-label-sm"`,
// A checkbox is its own content, not a fraction of the row: below
// the extra-large breakpoint it still takes its own line (design
// D3, D5). It has no label line of its own, so it carries the
// label line's height and sits on the control line (D16).
`class="col-12 col-xl-auto app-form-unlabeled app-form-field"`,
} {
if !strings.Contains(out, want) {
t.Errorf("dense row missing %q:\n%s", want, out)
}
}
// No line under any control in a dense row: the invariants refuse a
// hint or a notice there, so the only form-text a dense row can carry
// would be a bug in the part.
if strings.Contains(out, `class="form-text"`) {
t.Errorf("a dense row carries no line under its controls:\n%s", out)
}
}
// A one-sided field's declared reason is documentation, not copy: the part
// renders it nowhere. Only a declared Notice reaches the page, and a field
// never shows both a hint and a notice (maintainer, 2026-09-03).
func TestFormPartRendersNoticeAndNeverTheReason(t *testing.T) {
spec := forms.FormSpec{
Name: "test.sides", Kind: forms.KindCreate, Family: forms.Stacked,
Method: "POST", Path: "/partials/test", Commit: "Create",
Fields: []forms.Field{
{Name: "name", Label: "Name", Control: forms.Text, Hint: "As it appears on invoices."},
{Name: "seed", Label: "Seed", Control: forms.Text, Optional: true,
// One-sided, and the notice is the only thing a
// person is told about it.
Only: forms.CreateOnly, Notice: "Set now; this cannot be changed later."},
},
}
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound, Side: forms.CreateOnly}))
if strings.Contains(out, "It cannot be changed after creation.") {
t.Errorf("the declared reason must never render:\n%s", out)
}
if !strings.Contains(out, `<div id="form-test.sides-seed-notice" class="form-text">Set now; this cannot be changed later.</div>`) {
t.Errorf("a declared notice must render as one line under the control:\n%s", out)
}
if n := strings.Count(out, `class="form-text"`); n != 2 {
t.Errorf("expected one line under each of the two fields, found %d:\n%s", n, out)
}
}
// Every form whose refusal is a 422 carries novalidate, so the refusal a
// person sees is the server's, under the control (maintainer, 2026-09-03).
func TestFormPartEmitsNoValidateOnMutationForms(t *testing.T) {
spec := forms.FormSpec{
Name: "test.server-refusal", Kind: forms.KindCreate, Family: forms.Stacked,
Method: "POST", Path: "/partials/test", Commit: "Create",
Fields: []forms.Field{{Name: "name", Label: "Name", Control: forms.Text}},
}
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound}))
if !strings.Contains(out, ` novalidate `) && !strings.Contains(out, ` novalidate>`) {
t.Errorf("expected novalidate on the form tag:\n%s", out)
}
// The constraint stays on the control, for assistive technology and
// for :invalid styling.
if !strings.Contains(out, `required`) {
t.Errorf("the constraint attributes must survive novalidate:\n%s", out)
}
spec.Kind = forms.KindSearch
spec.Method = "GET"
out = renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound}))
if strings.Contains(out, "novalidate") {
t.Errorf("a search form navigates; it keeps the browser's validation:\n%s", out)
}
}
// The part owns the section box, and a stacked box hugs its fields: it
// carries the cap, so it ends where the fields end rather than running the
// content column's width (maintainer, 2026-09-04: "Hugging definitely").
// A dense row gets no box, because its panel is one already; a preview
// gets the muted alert the preview idiom uses (design D10).
func TestFormPartOwnsTheSectionBox(t *testing.T) {
spec := forms.FormSpec{
Name: "test.box", Kind: forms.KindCreate, Family: forms.Stacked,
Method: "POST", Path: "/partials/test", Commit: "Create",
Fields: []forms.Field{{Name: "name", Label: "Name", Control: forms.Text}},
}
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound}))
if !strings.Contains(out, `<div class="card app-form-box mb-3"><div class="card-body">`) {
t.Errorf("a stacked create form renders its own capped card:\n%s", out)
}
spec.Kind = forms.KindEdit
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeRecord})); !strings.Contains(out, "app-form-box") {
t.Errorf("an edit form renders the same box as its create page:\n%s", out)
}
spec.Kind = forms.KindPreview
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound})); !strings.Contains(out, `<div class="alert alert-secondary app-form-box mb-3" role="status">`) {
t.Errorf("a preview renders the muted alert box:\n%s", out)
}
spec.Kind = forms.KindSubRecord
spec.Family = forms.Dense
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound})); strings.Contains(out, "app-form-box") {
t.Errorf("a dense row's panel is its box; the part adds none:\n%s", out)
}
spec.Kind = forms.KindSearch
spec.Family = forms.Bar
spec.Method = "GET"
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound})); strings.Contains(out, "app-form-box") {
t.Errorf("a search form sits in a list header; the part adds no box:\n%s", out)
}
spec.Kind = forms.KindSettings
spec.Family = forms.Table
spec.Method = "POST"
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeRecord})); strings.Contains(out, "app-form-box") {
t.Errorf("a settings table is its own shape; the part adds no box:\n%s", out)
}
}
// A search renders as one input group with a leading magnifier, an outline
// commit and, when a query is active, its Clear in the same group: the
// shape a search actually has (design D20). Round 3 rendered it stacked
// and every list page showed a narrow input with the button wrapped onto
// the next line.
func TestFormPartBarIsOneInputGroup(t *testing.T) {
spec := forms.FormSpec{
Name: "test.bar", Kind: forms.KindSearch, Family: forms.Bar,
Method: "GET", Path: "/operator/products", Commit: "Search",
WayOut: forms.LinkOut("Clear", "/operator/products"),
Fields: []forms.Field{
{Name: "q", Label: "Search this list", HideLabel: true, Control: forms.Text, Optional: true, Autocomplete: "off"},
{Name: "per", Label: "Page size", Control: forms.Hidden, Optional: true},
},
}
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeRecord, Boosted: true}))
for _, want := range []string{
`class="app-form app-form-bar"`,
`<div class="input-group">`,
`<span class="input-group-text"><svg`,
`aria-hidden="true"`,
`fill="currentColor"`,
`class="form-label visually-hidden"`,
`<input type="hidden" name="per"`,
`class="btn btn-outline-secondary"><span class="spinner-border`,
`<a href="/operator/products" class="btn btn-outline-secondary">Clear</a>`,
`role="search"`,
} {
if !strings.Contains(out, want) {
t.Errorf("bar missing %q:\n%s", want, out)
}
}
for _, banned := range []string{"btn-primary", "(optional)", "app-form-box", "app-form-fields"} {
if strings.Contains(out, banned) {
t.Errorf("bar rendered %q, which no search carries:\n%s", banned, out)
}
}
// The magnifier precedes the control, which precedes the commit.
svg, control, commit := strings.Index(out, "<svg"), strings.Index(out, `name="q"`), strings.Index(out, `type="submit"`)
if !(svg >= 0 && svg < control && control < commit) {
t.Errorf("expected the order magnifier, control, commit; got %d, %d, %d:\n%s", svg, control, commit, out)
}
spec.Wide = true
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeRecord, Boosted: true})); !strings.Contains(out, `class="app-form app-form-bar-wide"`) {
t.Errorf("a wide bar drops the width cap:\n%s", out)
}
}
// A settings form renders as one table: the label and its hint in the
// first column, the page's own cells next, the control last, and one
// commit below the table (design D21).
func TestFormPartTableIsOneTable(t *testing.T) {
spec := forms.FormSpec{
Name: "test.table", Kind: forms.KindSettings, Family: forms.Table,
Method: "POST", Path: "/partials/test", Commit: "Save",
LabelColumn: "Key", ControlColumn: "Override",
Columns: []string{"Effective value", "Source"},
Fields: []forms.Field{
{Name: "widget-url", Label: "widget-url", Control: forms.Text, Optional: true, Hint: "Widget service URL"},
{Name: "widget-token", Label: "widget-token", Control: forms.Static,
Value: "Set widget-token (or widget-token-file) in the environment."},
},
}
values := forms.NewValues()
values.Set("widget-url", "https://widgets.example.test")
out := renderFormPart(t, forms.Render(spec, forms.Binding{
Mode: forms.ModeRecord,
Values: values,
Cells: map[string][]template.HTML{
"widget-url": {`<code>https://widgets.example.test</code>`, `<span class="badge">Environment</span>`},
"widget-token": {`<span class="badge">Not set</span>`},
},
}))
for _, want := range []string{
`class="app-form app-form-table"`,
`<div class="table-responsive">`,
`<table class="table align-middle">`,
`<th scope="col">Key</th>`,
`<th scope="col">Effective value</th>`,
`<th scope="col">Source</th>`,
`<th scope="col">Override</th>`,
`<td><code>https://widgets.example.test</code></td>`,
`<div id="form-test.table-widget-url-hint" class="form-text">Widget service URL</div>`,
`value="https://widgets.example.test"`,
`<span class="text-muted small">Set widget-token (or widget-token-file) in the environment.</span>`,
`<div class="d-flex flex-wrap gap-2 app-form-commit">`,
} {
if !strings.Contains(out, want) {
t.Errorf("settings table missing %q:\n%s", want, out)
}
}
// A static row carries no name at all: nothing is submitted for it.
if strings.Contains(out, `name="widget-token"`) {
t.Errorf("a static row must not submit anything:\n%s", out)
}
// Never "(optional)": every setting is optional by nature.
if strings.Contains(out, "(optional)") {
t.Errorf("a settings table never renders the optional marker:\n%s", out)
}
// A row short of cells is padded, so its columns still line up.
if n := strings.Count(out, "<td>"); n != 6 {
t.Errorf("expected six cells (two rows, two supplied columns plus the control), found %d:\n%s", n, out)
}
}
// A dense form's field errors render under their controls, inside the
// field's own column, and the row aligns its columns to the top under one
// label line: a refused column grows alone, and the commit column carries
// the label line's height so it sits on the control line (design D16 of
// staged-rule-changes, which retired D22's error block below the row).
func TestFormPartDenseErrorsRenderUnderTheirControls(t *testing.T) {
spec := forms.FormSpec{
Name: "test.dense-errors", Kind: forms.KindSubRecord, Family: forms.Dense,
Method: "POST", Path: "/partials/test", Commit: "Add rule",
WayOut: forms.ClosePanel("Cancel", "addRulePanel"),
Fields: []forms.Field{
{Name: "resource_key", Label: "Resource key", Control: forms.Select, HideLabel: true,
Options: []forms.Option{forms.ChooseOption("a resource key"), {Value: "seats", Label: "Seats"}}},
{Name: "limit", Label: "Limit", Control: forms.Number},
{Name: "per_unit", Label: "Per unit", Control: forms.Checkbox},
},
}
errs := forms.NewErrors()
errs.Field("resource_key", "Choose a resource key.")
errs.Field("limit", "Enter a limit.")
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeSubmission, Errors: errs}))
for _, want := range []string{
`<div class="row g-2 align-items-start app-form-dense-row">`,
`<div class="col-auto app-form-commit app-form-unlabeled">`,
`<div id="form-test.dense-errors-resource_key-error" class="invalid-feedback d-block">Choose a resource key.</div>`,
`<div id="form-test.dense-errors-limit-error" class="invalid-feedback d-block">Enter a limit.</div>`,
`aria-describedby="form-test.dense-errors-resource_key-error"`,
`aria-invalid="true"`,
`is-invalid`,
// A checkbox has no label line of its own, so its column sits on
// the control line through the same padding as the commit.
`col-12 col-xl-auto app-form-unlabeled app-form-field`,
} {
if !strings.Contains(out, want) {
t.Errorf("dense refusal missing %q:\n%s", want, out)
}
}
for _, reject := range []string{"app-form-errors", "align-items-end", "Limit: Enter a limit."} {
if strings.Contains(out, reject) {
t.Errorf("dense refusal must not carry %q:\n%s", reject, out)
}
}
// Each error sits inside its own field's wrapper, after its control.
limit := out[strings.Index(out, `id="form-test.dense-errors-limit"`):]
limit = limit[:strings.Index(limit, `app-form-commit`)]
if strings.Index(limit, `name="limit"`) > strings.Index(limit, "Enter a limit.") {
t.Errorf("the Limit error must follow its control inside its column:\n%s", limit)
}
if n := strings.Count(out, "invalid-feedback"); n != 2 {
t.Errorf("expected one error per errored field, found %d:\n%s", n, out)
}
}
// A preview at rest has nothing to apply and nothing to discard, so it
// renders no commit row at all; a message brings both back, after the
// field the declaration names (design D23).
func TestFormPartPreviewAtRestHasNoCommitRow(t *testing.T) {
spec := forms.FormSpec{
Name: "test.preview", Kind: forms.KindPreview, Family: forms.Stacked,
Method: "POST", Path: "/partials/test", Commit: "Apply change",
WayOut: forms.Discard("Discard", "/partials/test"),
MessageAfter: "candidate",
Fields: []forms.Field{
{Name: "candidate", Label: "Change default to", Control: forms.Select,
Options: []forms.Option{forms.ChooseOption("a new default"), {Value: "l1", Label: "Hosting"}}},
},
}
rest := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeRecord}))
for _, banned := range []string{"Apply change", "Discard", "app-form-commit"} {
if strings.Contains(rest, banned) {
t.Errorf("a preview at rest renders %q with nothing to apply:\n%s", banned, rest)
}
}
if !strings.Contains(rest, `name="candidate"`) {
t.Errorf("a preview at rest still renders its fields:\n%s", rest)
}
chosen := renderFormPart(t, forms.Render(spec, forms.Binding{
Mode: forms.ModeRecord, Message: template.HTML(`<p>Two organizations move.</p>`),
}))
for _, want := range []string{"Apply change", "Discard", "Two organizations move."} {
if !strings.Contains(chosen, want) {
t.Errorf("a preview with a message missing %q:\n%s", want, chosen)
}
}
// The message is the consequence of the select, so it follows it.
if strings.Index(chosen, `name="candidate"`) > strings.Index(chosen, "Two organizations move.") {
t.Errorf("MessageAfter must put the message below the field it names:\n%s", chosen)
}
}
// A disabled commit carries the classes the enabled commit would have, so
// it and the way out beside it are the same height (design D23).
func TestFormPartDisabledCommitTakesTheEnabledCommitsClasses(t *testing.T) {
spec := forms.FormSpec{
Name: "test.disabled-commit", Kind: forms.KindPreview, Family: forms.Stacked,
Method: "POST", Path: "/partials/test", Commit: "Apply change",
WayOut: forms.Discard("Discard", "/partials/test"),
Fields: []forms.Field{{Name: "candidate", Label: "Candidate", Control: forms.Text}},
}
view := forms.Render(spec, forms.Binding{Mode: forms.ModeRecord, Message: template.HTML("<p>Something changes.</p>")})
view.CommitState = &forms.DisabledControlView{
ID: "why-not", Label: "Apply change", Reason: "Choose a disposition first.",
Classes: "btn btn-link btn-sm", // a caller's value; the render overrides it
}
out := renderFormPart(t, view)
if !strings.Contains(out, `<button type="button" class="btn btn-primary" disabled aria-describedby="why-not">`) {
t.Errorf("the disabled commit must carry the enabled commit's classes:\n%s", out)
}
if strings.Contains(out, "btn-link") || strings.Contains(out, "btn-sm") {
t.Errorf("a caller's classes must not reach the rendered commit:\n%s", out)
}
}