Files
member-console/internal/forms/invariants_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

418 lines
14 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package forms
import (
"strings"
"testing"
)
// The registry's invariants (design D7; spec form-library "The registry
// holds every form and is checked"). CheckInvariants is exported so the
// package that owns the forms can run it over the whole registry from its
// own test, where every declaration has registered; the tests below run it
// over hand-built declarations, one per rule, so each rule has a case that
// fails without it.
func base() FormSpec {
return FormSpec{
Name: "test.invariants",
Kind: KindCreate,
Family: Stacked,
Method: "POST",
Path: "/partials/test",
Commit: "Save",
Fields: []Field{{Name: "name", Label: "Name", Control: Text}},
}
}
// denseBase is base() as a sub-record form, the one kind laid out dense.
// The kind/family pairing is fixed and the invariants refuse any other
// (design D10 as corrected in round 4), so a dense rule's test declares
// the kind that goes with the family.
func denseBase() FormSpec {
spec := base()
spec.Kind = KindSubRecord
spec.Family = Dense
return spec
}
// wants asserts that checking spec produces one problem mentioning want.
func wants(t *testing.T, spec FormSpec, want string) {
t.Helper()
problems := CheckInvariants(spec)
for _, p := range problems {
if strings.Contains(p, want) {
return
}
}
t.Errorf("expected a problem mentioning %q, got %v", want, problems)
}
// A Confirm form's fields are supplied per trigger by
// confirm-action-modal.js, never declared: the "no fields" rule that holds
// every other kind to declaring at least one does not apply to it.
func TestInvariantsConfirmFormNeedsNoFields(t *testing.T) {
spec := base()
spec.Kind = KindConfirm
spec.Fields = nil
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("a Confirm form with no declared fields must pass, got %v", problems)
}
spec = base()
spec.Fields = nil
wants(t, spec, "declares no fields")
}
func TestInvariantsAcceptAWellFormedDeclaration(t *testing.T) {
if problems := CheckInvariants(base()); len(problems) != 0 {
t.Errorf("a well-formed declaration must produce no problems, got %v", problems)
}
}
func TestInvariantsUniqueFieldNames(t *testing.T) {
spec := base()
spec.Fields = append(spec.Fields, Field{Name: "name", Label: "Name again", Control: Text})
wants(t, spec, "declares the field \"name\" twice")
}
func TestInvariantsLabelOnEveryField(t *testing.T) {
spec := base()
spec.Fields[0].Label = ""
wants(t, spec, "has no label")
}
// A one-sided field declares its side and nothing else: the justification
// string is gone, because nothing rendered it and nothing read it
// (maintainer, 2026-09-04). Why a field is one-sided is a Go comment.
func TestInvariantsOneSidedFieldNeedsNothingButItsSide(t *testing.T) {
spec := base()
spec.Fields[0].Only = EditOnly
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("a one-sided field carries no further burden, got %v", problems)
}
spec.Fields[0].Only = "someday"
wants(t, spec, "declares a side the library does not know")
}
func TestInvariantsKindFamilyMethodPath(t *testing.T) {
for _, tc := range []struct {
mutate func(*FormSpec)
want string
}{
{func(s *FormSpec) { s.Kind = "" }, "has no kind"},
{func(s *FormSpec) { s.Kind = "wizard" }, "has no kind"},
{func(s *FormSpec) { s.Family = "" }, "has no layout family"},
{func(s *FormSpec) { s.Method = "" }, "has no method"},
{func(s *FormSpec) { s.Method = "TRACE" }, "has no method"},
{func(s *FormSpec) { s.Path = "" }, "has no path"},
{func(s *FormSpec) { s.Path = "partials/test" }, "has no path"},
{func(s *FormSpec) { s.Commit = "" }, "has no commit label"},
{func(s *FormSpec) { s.Name = "" }, "has no name"},
} {
spec := base()
tc.mutate(&spec)
wants(t, spec, tc.want)
}
}
func TestInvariantsNoRequiredCheckbox(t *testing.T) {
spec := base()
spec.Fields = append(spec.Fields, Field{Name: "agree", Label: "Agree", Control: Checkbox})
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("a checkbox is optional by construction, got %v", problems)
}
spec.Fields[1].Optional = false
spec.Fields[1].MaxLen = 3
wants(t, spec, "carries a rule a checkbox cannot have")
}
func TestInvariantsSelectAndRadioNeedOptions(t *testing.T) {
spec := base()
spec.Fields = append(spec.Fields, Field{Name: "kind", Label: "Kind", Control: Select})
wants(t, spec, "offers no options")
spec.Fields[1].RuntimeOptions = true
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("a declared runtime option set satisfies the rule, got %v", problems)
}
}
func TestInvariantsEscapeHatchKeys(t *testing.T) {
for _, key := range []string{"class", "required", "id", "name", "aria-describedby"} {
spec := base()
spec.Fields[0].Attrs = map[string]string{key: "x"}
wants(t, spec, "escape hatch")
}
spec := base()
spec.Fields[0].Attrs = map[string]string{"data-thing": "x", "hx-get": "/x"}
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("data-* and hx-* keys are the escape hatch, got %v", problems)
}
}
func TestInvariantsPlaceholderIsNotASentence(t *testing.T) {
spec := base()
spec.Fields[0].Placeholder = "Enter the product's name."
wants(t, spec, "placeholder reads as a sentence")
spec.Fields[0].Placeholder = "e.g. addon"
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("a format example is a placeholder, got %v", problems)
}
}
func TestInvariantsNoEmDashInAnyString(t *testing.T) {
spec := base()
spec.Fields[0].Hint = "The product's name; shown everywhere — lists included."
wants(t, spec, "em dash")
spec = base()
spec.Commit = "Save — now"
wants(t, spec, "em dash")
}
func TestInvariantsUnknownControl(t *testing.T) {
spec := base()
spec.Fields[0].Control = "slider"
wants(t, spec, "control")
}
// The registry itself must satisfy every rule. The forms declared by other
// packages are checked by those packages' own tests, because a form only
// registers once its package is linked in; this one covers whatever has
// registered here.
func TestRegisteredFormsSatisfyTheInvariants(t *testing.T) {
for _, spec := range All() {
for _, problem := range CheckInvariants(spec) {
t.Errorf("%s: %s", spec.Name, problem)
}
}
}
// A dense row aligns its controls on one baseline, so a line under one
// control lifts that field's label above its neighbours' and the row reads
// as broken (maintainer, 2026-09-03, on the Extend tier row). The
// explanation belongs behind the help icon.
func TestInvariantsDenseRowsCarryNoLinesUnderControls(t *testing.T) {
spec := denseBase()
spec.Fields[0].Hint = "Why this grant is issued."
wants(t, spec, "carries a hint in a dense row")
spec = denseBase()
spec.Fields[0].Notice = "Set now; this cannot be changed later."
wants(t, spec, "carries a notice in a dense row")
spec = denseBase()
spec.Fields[0].Help = Help("Description", "Why this grant is issued.")
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("a dense field explains itself through Help, got %v", problems)
}
}
// One visible line under a control is the whole budget.
func TestInvariantsHintAndNoticeAreExclusive(t *testing.T) {
spec := base()
spec.Fields[0].Hint = "As it appears on invoices."
spec.Fields[0].Notice = "Set now; this cannot be changed later."
wants(t, spec, "declares a hint and a notice")
}
// ShowIf's condition is read from Values as Parse builds them field by
// field, so the field it names must already have been parsed.
func TestInvariantsShowIfMustReferenceAnEarlierField(t *testing.T) {
spec := base()
spec.Fields = append(spec.Fields, Field{
Name: "amount", Label: "Amount", Control: Number,
ShowIf: ShowIf{Field: "kind", Equals: []string{"numeric"}},
})
wants(t, spec, `"amount" shows only when "kind" holds a value`)
}
func TestInvariantsShowIfReferencingAnEarlierFieldPasses(t *testing.T) {
spec := base()
spec.Fields = append(spec.Fields,
Field{Name: "kind", Label: "Kind", Control: Hidden, Optional: true},
Field{Name: "amount", Label: "Amount", Control: Number,
ShowIf: ShowIf{Field: "kind", Equals: []string{"numeric"}}},
)
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("a ShowIf referencing an earlier field must pass, got %v", problems)
}
}
// A hint over the cap is not one short line any more (ui-vocabulary "Copy
// earns its place"; the sources' own qualitative cap, made mechanisable).
func TestInvariantsHintLengthCap(t *testing.T) {
spec := base()
spec.Fields[0].Hint = strings.Repeat("x", 101)
wants(t, spec, "over the 100-character cap")
spec = base()
spec.Fields[0].Hint = strings.Repeat("x", 100)
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("a hint at exactly the cap must pass, got %v", problems)
}
}
// A hint whose every content word already appears in the label restates it
// rather than adding a fact (the audit's research, 4.2 "Hint repeats the
// label"; GOV.UK: "there's usually no need to say 'This is the total
// cost'.").
func TestInvariantsHintRestatingLabelFails(t *testing.T) {
spec := base()
spec.Fields[0].Label = "Email address"
spec.Fields[0].Hint = "Enter your email address"
wants(t, spec, "restates its label")
spec = base()
spec.Fields[0].Label = "Description"
spec.Fields[0].Hint = "Kept with the grant's history."
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("a hint that carries a fact the label does not must pass, got %v", problems)
}
}
// The shared grant values are written for the dense rows that include
// them, so they must satisfy the dense rules by construction.
func TestSharedGrantFieldsFitADenseRow(t *testing.T) {
spec := denseBase()
spec.Fields = []Field{GrantNote(), GrantValidUntil()}
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("the shared grant fields must fit a dense row, got %v", problems)
}
}
// An unknown width string is still refused, the way an unknown control is
// (design D2 keeps this rule from the earlier three-value Width).
func TestInvariantsUnknownWidth(t *testing.T) {
spec := base()
spec.Fields[0].Width = "sideways"
wants(t, spec, "width the library does not know")
}
// A step on a field whose control stands on no rung, hidden, checkbox,
// radio, static and textarea alike, is refused naming the field (design
// D2).
func TestInvariantsWidthOnARunglessControlRefused(t *testing.T) {
for _, c := range []Control{Hidden, Checkbox, Radio, Static, Textarea} {
for _, w := range []Width{WidthNarrower, WidthWider} {
spec := base()
spec.Fields[0].Control = c
spec.Fields[0].Width = w
wants(t, spec, "stands on no rung to step from")
}
}
}
// A narrower step below a sixth, the ladder's bottom rung, is refused
// naming the field.
func TestInvariantsWidthNarrowerBelowSixthRefused(t *testing.T) {
for _, c := range []Control{Number, Date} {
spec := base()
spec.Fields[0].Control = c
spec.Fields[0].Width = WidthNarrower
wants(t, spec, "steps narrower than a sixth")
}
}
// A valid step in each direction is accepted: a select or a text control
// narrower lands on a quarter, wider lands on a half.
func TestInvariantsValidStepsAccepted(t *testing.T) {
for _, w := range []Width{WidthNarrower, WidthWider} {
spec := base()
spec.Fields[0].Control = Select
spec.Fields[0].Options = []Option{{Value: "a", Label: "A"}}
spec.Fields[0].Width = w
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("a select stepped %s must pass, got %v", w, problems)
}
}
}
// batchBase is a well-formed batch form, the one kind laid out in rows
// (design D1's "Rows family contract").
func batchBase() FormSpec {
spec := base()
spec.Kind = KindBatch
spec.Family = Rows
spec.CommitAction = "/partials/test/apply"
return spec
}
func TestInvariantsAcceptAWellFormedBatchForm(t *testing.T) {
spec := batchBase()
spec.Fields = append(spec.Fields,
Field{Name: "policy", Label: "Reduction policy", Control: Select, Placement: PlaceDelta,
Options: []Option{{Value: "clamp", Label: "Clamp"}}},
Field{Name: "note", Label: "Note", Control: Textarea, Optional: true, Placement: PlaceTray})
if problems := CheckInvariants(spec); len(problems) != 0 {
t.Errorf("a well-formed batch form was refused: %v", problems)
}
}
// A batch form's body is the caller's table and its commit region is the
// tray, so the three declarations that shape another family's container,
// and the row that states a fact instead of offering a control, mean
// nothing here and are refused (spec form-library "A batch form renders the
// caller's rows and one tray").
func TestInvariantsBatchFormRefusesAnotherFamilysDeclarations(t *testing.T) {
columns := batchBase()
columns.Columns = []string{"Effective value"}
wants(t, columns, "Columns outside the table family")
wide := batchBase()
wide.Wide = true
wants(t, wide, "Wide outside the bar family")
after := batchBase()
after.MessageAfter = "name"
wants(t, after, "MessageAfter outside a preview")
static := batchBase()
static.Fields = append(static.Fields, Field{Name: "key", Label: "Key", Control: Static, Value: "Set in the environment"})
wants(t, static, "static control outside the table family")
}
// Placement names one of the three regions a batch form has; no other
// family has them (design D1's "Rows family contract").
func TestInvariantsPlacementOutsideABatchForm(t *testing.T) {
spec := base()
spec.Fields[0].Placement = PlaceTray
wants(t, spec, "placement outside the rows family")
unknown := batchBase()
unknown.Fields[0].Placement = Placement("footer")
wants(t, unknown, "placement the library does not know")
}
// Only a batch form's commit posts somewhere other than the form's own
// path, and a batch form's commit must post somewhere (design D2).
func TestInvariantsCommitActionBelongsToABatchForm(t *testing.T) {
elsewhere := base()
elsewhere.CommitAction = "/partials/test/apply"
wants(t, elsewhere, "CommitAction outside the rows family")
missing := batchBase()
missing.CommitAction = ""
wants(t, missing, "declares no CommitAction")
notARoute := batchBase()
notARoute.CommitAction = "apply"
wants(t, notARoute, "CommitAction is not a route")
}
// The kind and the family are paired here as everywhere else: a batch form
// is laid out in rows and nothing else is.
func TestInvariantsBatchKindIsPairedWithRows(t *testing.T) {
spec := batchBase()
spec.Family = Stacked
wants(t, spec, "laid out rows")
other := base()
other.Family = Rows
wants(t, other, "create form in the rows family")
}