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.
418 lines
14 KiB
Go
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")
|
|
}
|