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

614 lines
22 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package forms
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// One test per rule Parse applies (design D6; spec form-library "The
// handler parses through the declaration"). The declaration below carries
// one field of every shape the rules touch; each test submits one body and
// asserts one refusal or one accepted value.
func testSpec() FormSpec {
return FormSpec{
Name: "test.form",
Kind: KindCreate,
Family: Stacked,
Method: "POST",
Path: "/partials/test",
Commit: "Save",
Fields: []Field{
{Name: "name", Label: "Name", Control: Text, MaxLen: 10},
{Name: "note", Label: "Note", Control: Text, Optional: true},
{Name: "quantity", Label: "Quantity", Control: Number, Min: "1", Max: "100"},
{Name: "code", Label: "Code", Control: Text, Optional: true, Pattern: `^[a-z]+$`, PatternHint: "Code is lowercase letters only."},
{Name: "kind", Label: "Kind", Control: Select, Options: []Option{
ChooseOption("a kind"), {Value: "a", Label: "A"}, {Value: "b", Label: "B"},
}},
{Name: "public", Label: "Public", Control: Checkbox, Value: "public"},
{Name: "starts", Label: "Starts", Control: Date, Optional: true},
},
}
}
func post(t *testing.T, target string, body url.Values) *http.Request {
t.Helper()
r := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
// The query string cannot shadow a field: Parse reads r.PostForm, not
// r.FormValue, which merges the two and hides where a value came from
// (lesson L§5).
func TestParseIgnoresTheQueryString(t *testing.T) {
r := post(t, "/partials/test?name=fromquery", url.Values{
"quantity": {"1"}, "kind": {"a"},
})
values, errs := testSpec().Parse(r)
if values.Present("name") {
t.Errorf("name reported present from the query string: %q", values.Raw("name"))
}
if errs.Get("name") == "" {
t.Error("an absent required field must be refused")
}
}
// A search form reads the query string instead, because that is what a
// native GET submits.
func TestParseSearchReadsTheQuery(t *testing.T) {
spec := testSpec()
spec.Kind = KindSearch
spec.Method = "GET"
spec.Fields = []Field{{Name: "q", Label: "Search", Control: Text, Optional: true}}
r := httptest.NewRequest(http.MethodGet, "/operator/lookup?q=jamie", nil)
values, errs := spec.Parse(r)
if errs.Any() {
t.Fatalf("unexpected refusal: %+v", errs)
}
if got := values.String("q"); got != "jamie" {
t.Errorf("q = %q, want %q", got, "jamie")
}
}
func TestParseIgnoresUndeclaredKeys(t *testing.T) {
r := post(t, "/partials/test", url.Values{
"name": {"Widget"}, "quantity": {"2"}, "kind": {"a"},
"csrf_token": {"x"}, "lifecycle_status": {"retired"},
})
values, errs := testSpec().Parse(r)
if errs.Any() {
t.Fatalf("unexpected refusal: %+v", errs)
}
if values.Present("lifecycle_status") {
t.Error("an undeclared key was read")
}
}
func TestParseTrimsAndRequires(t *testing.T) {
r := post(t, "/partials/test", url.Values{
"name": {" "}, "quantity": {"2"}, "kind": {"a"},
})
values, errs := testSpec().Parse(r)
if got := errs.Get("name"); got != "Enter a name." {
t.Errorf("name refusal = %q, want %q", got, "Enter a name.")
}
if values.Raw("name") != "" {
t.Errorf("a whitespace-only value must trim to empty, got %q", values.Raw("name"))
}
}
func TestParseOptionalEmptyIsAccepted(t *testing.T) {
r := post(t, "/partials/test", url.Values{
"name": {"Widget"}, "quantity": {"2"}, "kind": {"a"}, "note": {""},
})
_, errs := testSpec().Parse(r)
if errs.Has("note") {
t.Errorf("an optional empty field must not be refused: %q", errs.Get("note"))
}
}
func TestParseMaxLen(t *testing.T) {
r := post(t, "/partials/test", url.Values{
"name": {strings.Repeat("x", 11)}, "quantity": {"2"}, "kind": {"a"},
})
values, errs := testSpec().Parse(r)
if got := errs.Get("name"); got != "Name must be 10 characters or fewer." {
t.Errorf("name refusal = %q", got)
}
// The raw text is carried back for the re-render even though the field
// was refused (lesson L§11, finding FA-23).
if len(values.Raw("name")) != 11 {
t.Errorf("the refused value must still be carried back, got %q", values.Raw("name"))
}
}
func TestParseNumberRange(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"0", "Quantity must be 1 or more."},
{"101", "Quantity must be 100 or fewer."},
{"nope", "Quantity must be a whole number."},
} {
r := post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {tc.in}, "kind": {"a"}})
values, errs := testSpec().Parse(r)
if got := errs.Get("quantity"); got != tc.want {
t.Errorf("quantity %q refusal = %q, want %q", tc.in, got, tc.want)
}
if _, ok := values.Int("quantity"); ok {
t.Errorf("quantity %q must have no typed value when refused", tc.in)
}
}
r := post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"7"}, "kind": {"a"}})
values, errs := testSpec().Parse(r)
if errs.Any() {
t.Fatalf("unexpected refusal: %+v", errs)
}
if n, ok := values.Int("quantity"); !ok || n != 7 {
t.Errorf("quantity = %d, %v; want 7, true", n, ok)
}
}
func TestParsePatternUsesItsHint(t *testing.T) {
r := post(t, "/partials/test", url.Values{
"name": {"W"}, "quantity": {"1"}, "kind": {"a"}, "code": {"AB1"},
})
_, errs := testSpec().Parse(r)
if got := errs.Get("code"); got != "Code is lowercase letters only." {
t.Errorf("code refusal = %q", got)
}
}
// A value outside the option set is an error, not a silent default
// (finding FA-8).
func TestParseSelectOutsideTheOptions(t *testing.T) {
r := post(t, "/partials/test", url.Values{
"name": {"W"}, "quantity": {"1"}, "kind": {"c"},
})
values, errs := testSpec().Parse(r)
if got := errs.Get("kind"); got != "Choose a kind from the list." {
t.Errorf("kind refusal = %q", got)
}
if values.Raw("kind") != "c" {
t.Errorf("the refused value must be carried back, got %q", values.Raw("kind"))
}
}
// The placeholder option's empty value is refused on a select that needs a
// choice, and accepted on one whose option list offers an empty value.
func TestParseSelectEmptyFollowsTheOptionSet(t *testing.T) {
r := post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {""}})
_, errs := testSpec().Parse(r)
if got := errs.Get("kind"); got != "Choose a kind." {
t.Errorf("kind refusal = %q, want %q", got, "Choose a kind.")
}
optional := testSpec()
optional.Fields[4].Options = []Option{NoneOption(), {Value: "a", Label: "A"}}
r = post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {""}})
values, errs := optional.Parse(r)
if errs.Has("kind") {
t.Errorf("an offered empty option must be accepted: %q", errs.Get("kind"))
}
if values.Raw("kind") != "" {
t.Errorf("kind = %q, want the empty deliberate choice", values.Raw("kind"))
}
}
// radioSpec declares one radio group, required by default, for the
// requiredness tests below.
func radioSpec(optional bool) FormSpec {
return FormSpec{
Name: "test.radio", Kind: KindCreate, Family: Stacked,
Method: "POST", Path: "/partials/test", Commit: "Save",
Fields: []Field{
{Name: "disposition", Label: "Disposition", Control: Radio, Optional: optional,
Options: []Option{{Value: "a", Label: "A"}, {Value: "b", Label: "B"}}},
},
}
}
// An optional radio group left untouched is a legitimate empty submission:
// unlike a Select, it has no enabled empty-valued option to submit instead
// (parse.go's Select/Radio membership branch).
func TestParseOptionalRadioAcceptsNoSelection(t *testing.T) {
r := post(t, "/partials/test", url.Values{})
values, errs := radioSpec(true).Parse(r)
if errs.Has("disposition") {
t.Errorf("an optional radio group left untouched must be accepted, got %q", errs.Get("disposition"))
}
if values.Raw("disposition") != "" {
t.Errorf("disposition = %q, want empty", values.Raw("disposition"))
}
}
// A required radio group left untouched is refused, the same as any other
// required control.
func TestParseRequiredRadioNeedsAChoice(t *testing.T) {
r := post(t, "/partials/test", url.Values{})
_, errs := radioSpec(false).Parse(r)
if !errs.Has("disposition") {
t.Error("a required radio group left untouched must be refused")
}
}
// Options loaded per request reach Parse through the same map the render
// used, so what the control offered and what the handler accepts are one
// list.
func TestParseWithRuntimeOptions(t *testing.T) {
spec := testSpec()
spec.Fields[4] = Field{Name: "kind", Label: "Kind", Control: Select, RuntimeOptions: true}
runtime := map[string][]Option{"kind": {{Value: "live", Label: "Live"}}}
r := post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {"live"}})
if _, errs := spec.ParseWith(r, runtime); errs.Any() {
t.Fatalf("unexpected refusal: %+v", errs)
}
r = post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {"stale"}})
if _, errs := spec.ParseWith(r, runtime); !errs.Has("kind") {
t.Error("a value outside the runtime options must be refused")
}
}
// A checkbox's absence is false and never an error (lesson L§21).
func TestParseCheckboxAbsenceIsFalse(t *testing.T) {
r := post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {"a"}})
values, errs := testSpec().Parse(r)
if errs.Has("public") {
t.Errorf("an absent checkbox must not be refused: %q", errs.Get("public"))
}
if values.Bool("public") {
t.Error("an absent checkbox must read false")
}
r = post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {"a"}, "public": {"public"}})
values, _ = testSpec().Parse(r)
if !values.Bool("public") {
t.Error("a ticked checkbox must read true")
}
}
// A checkbox bound to a value set (SetBool, the record-bind and re-render
// path) answers Bool the same way a parsed one does: the typed half is
// recorded beside the raw value and presence, not left to Parse alone
// (design D1, the defect where a bound checkbox read false while its raw
// value read true).
func TestSetBoolAnswersBool(t *testing.T) {
f := testSpec().Fields[5] // "public", the Checkbox field
if f.Name != "public" {
t.Fatalf("test fixture drifted: field 5 is %q, want %q", f.Name, "public")
}
values := NewValues()
values.SetBool(f, true)
if !values.Bool("public") {
t.Error("SetBool(f, true) must answer Bool true")
}
values = NewValues()
values.SetBool(f, false)
if values.Bool("public") {
t.Error("SetBool(f, false) must answer Bool false")
}
}
// Repeated names on a scalar field take the first value, as Go's own form
// parsing does; a multi-valued control does not exist in this library.
func TestParseRepeatedNameTakesTheFirst(t *testing.T) {
r := post(t, "/partials/test", url.Values{
"name": {"first", "second"}, "quantity": {"1"}, "kind": {"a"},
})
values, _ := testSpec().Parse(r)
if got := values.String("name"); got != "first" {
t.Errorf("name = %q, want %q", got, "first")
}
}
func TestParseDate(t *testing.T) {
r := post(t, "/partials/test", url.Values{
"name": {"W"}, "quantity": {"1"}, "kind": {"a"}, "starts": {"2026-09-03"},
})
values, errs := testSpec().Parse(r)
if errs.Any() {
t.Fatalf("unexpected refusal: %+v", errs)
}
got, ok := values.Time("starts")
if !ok || got.Format("2006-01-02") != "2026-09-03" {
t.Errorf("starts = %v, %v", got, ok)
}
r = post(t, "/partials/test", url.Values{
"name": {"W"}, "quantity": {"1"}, "kind": {"a"}, "starts": {"the third"},
})
if _, errs := testSpec().Parse(r); errs.Get("starts") != "Starts is not a valid date." {
t.Errorf("starts refusal = %q", errs.Get("starts"))
}
}
// A field's side decides whether it is read at all, so a create submission
// cannot set an edit-only column.
func TestParseSideFiltersOneSidedFields(t *testing.T) {
spec := testSpec()
spec.Fields = append(spec.Fields, Field{
Name: "is_active", Label: "Active", Control: Checkbox, Value: "true",
Only: EditOnly,
})
r := post(t, "/partials/test", url.Values{
"name": {"W"}, "quantity": {"1"}, "kind": {"a"}, "is_active": {"true"},
})
values, _ := spec.ParseSide(r, CreateOnly, nil)
if values.Present("is_active") {
t.Error("an edit-only field must not be read from a create submission")
}
values, _ = spec.ParseSide(r, EditOnly, nil)
if !values.Bool("is_active") {
t.Error("an edit-only field must be read from an edit submission")
}
}
// ShowIf gates parsing the way it gates rendering: a field whose condition
// does not match is neither validated nor carried into Values, since the
// operator never saw a control for it (design D2).
func showIfParseSpec() FormSpec {
return FormSpec{
Name: "test.showif.parse", Kind: KindSubRecord, Family: Dense,
Method: "POST", Path: "/partials/test", Commit: "Add",
Fields: []Field{
{Name: "kind", Label: "Kind", Control: Hidden, Optional: true},
{Name: "amount", Label: "Amount", Control: Number,
ShowIf: ShowIf{Field: "kind", Equals: []string{"numeric"}}},
},
}
}
func TestParseShowIfSkipsValidationWhenHidden(t *testing.T) {
r := post(t, "/partials/test", url.Values{"kind": {"boolean"}})
values, errs := showIfParseSpec().Parse(r)
if errs.Any() {
t.Errorf("a field hidden by ShowIf must not be required, got %v", errs)
}
if values.Present("amount") {
t.Error("a field hidden by ShowIf must not be carried into Values")
}
}
func TestParseShowIfValidatesWhenShown(t *testing.T) {
r := post(t, "/partials/test", url.Values{"kind": {"numeric"}})
_, errs := showIfParseSpec().Parse(r)
if !errs.Has("amount") {
t.Error("a required field shown by ShowIf must still be required when absent")
}
}
func TestParseShowIfCarriesBackWhatWasSubmitted(t *testing.T) {
r := post(t, "/partials/test", url.Values{"kind": {"numeric"}, "amount": {"not-a-number"}})
values, errs := showIfParseSpec().Parse(r)
if !errs.Has("amount") {
t.Error("an invalid shown field must still be refused")
}
if values.Raw("amount") != "not-a-number" {
t.Errorf("the refused value must be carried back for the re-render, got %q", values.Raw("amount"))
}
}
// A refusal that belongs to no field reaches the form-level slot through
// the same call with an empty field name (lesson L§11).
func TestErrorsFormLevel(t *testing.T) {
errs := NewErrors()
if errs.Any() {
t.Error("a fresh error set must be empty")
}
errs.Field("", "Something is wrong with the whole form.")
if errs.FormError() == "" {
t.Error("an empty field name must reach the form level")
}
if !errs.Any() {
t.Error("a form-level error must count as a refusal")
}
}
// ParseBatch (design D1's "Rows family contract"): one request carries what
// was pressed, every open editor's values, the whole staged batch and the
// tray's fields, and every one of them is read through the declaration's
// own rules.
func TestParseBatchReadsThePressTheRowsTheBatchAndTheTray(t *testing.T) {
r := post(t, "/partials/test/rules", url.Values{
"act": {"stage"},
"key": {"rule-12"},
"open": {"rule-12", "rule-77"},
"rows.rule-12.resource_key": {"seats"},
"rows.rule-12.kind": {"numeric"},
"rows.rule-12.limit": {"5"},
"rows.rule-77.resource_key": {"sites"},
"rows.rule-77.kind": {"numeric"},
"rows.rule-77.limit": {"9"},
"staged.0.verb": {"edit"},
"staged.0.key": {"rule-9"},
"staged.0.resource_key": {"sites"},
"staged.0.kind": {"numeric"},
"staged.0.limit": {"3"},
"staged.0.policy": {"force_reduce"},
"note": {"Raising the site limit."},
"csrf_token": {"ignored"},
})
batch := batchSpec().ParseBatch(r)
if batch.Errors.Any() {
t.Fatalf("a well-formed batch was refused: %+v", batch.Errors)
}
if batch.Act != "stage" || batch.Key != "rule-12" {
t.Errorf("act %q, key %q", batch.Act, batch.Key)
}
if strings.Join(batch.Open, ",") != "rule-12,rule-77" {
t.Errorf("open = %v", batch.Open)
}
if n, ok := batch.Rows["rule-12"].Int("limit"); !ok || n != 5 {
t.Errorf("rule-12 limit = %d, %v", n, ok)
}
if got := batch.Rows["rule-77"].Raw("resource_key"); got != "sites" {
t.Errorf("rule-77 resource_key = %q", got)
}
if len(batch.Staged) != 1 {
t.Fatalf("staged = %+v", batch.Staged)
}
d := batch.Staged[0]
if d.Verb != "edit" || d.Key != "rule-9" {
t.Errorf("delta = %+v", d)
}
if got := d.Values.Raw("policy"); got != "force_reduce" {
t.Errorf("delta policy = %q", got)
}
if n, ok := d.Values.Int("limit"); !ok || n != 3 {
t.Errorf("delta limit = %d, %v", n, ok)
}
if d.Line != "" || d.Error != "" {
t.Errorf("the request supplied a line or an error: %+v", d)
}
if got := batch.Tray.Raw("note"); got != "Raising the site limit." {
t.Errorf("tray note = %q", got)
}
// The declaration is the allowlist here as it is in Parse: a name it
// does not declare is read by nothing.
if batch.Rows["rule-12"].Present("csrf_token") {
t.Error("an undeclared name reached a row's values")
}
}
// Requiredness applies to an open editor and to a staged delta, because
// both carry what someone typed; a row that is not open submits nothing and
// is not read at all.
func TestParseBatchAppliesTheRulesToOpenRowsAndDeltas(t *testing.T) {
r := post(t, "/partials/test/rules", url.Values{
"act": {"stage"},
"open": {"rule-12"},
"rows.rule-12.resource_key": {"seats"},
"rows.rule-12.kind": {"numeric"},
"rows.rule-12.limit": {""},
"rows.rule-99.limit": {"4"},
"staged.0.verb": {"edit"},
"staged.0.key": {"rule-9"},
"staged.0.resource_key": {"sites"},
"staged.0.kind": {"numeric"},
"staged.0.limit": {"nope"},
"staged.0.policy": {"force_reduce"},
})
batch := batchSpec().ParseBatch(r)
if got := batch.Errors.Get("rows.rule-12.limit"); got != "Enter a limit." {
t.Errorf("the open row's refusal = %q", got)
}
if got := batch.Errors.Get("staged.0.limit"); got != "Limit must be a whole number." {
t.Errorf("the delta's refusal = %q", got)
}
if _, ok := batch.Rows["rule-99"]; ok {
t.Error("a row that was not open was read")
}
// The refusals come back keyed the way the render wants them.
if got := batch.Row("rule-12").Errors.Get("limit"); got != "Enter a limit." {
t.Errorf("Row's refusal = %q", got)
}
}
// A field the row never showed is neither validated nor carried, per group:
// the delta below is a boolean rule, whose Limit was not on the page.
func TestParseBatchShowIfIsReadPerGroup(t *testing.T) {
r := post(t, "/partials/test/rules", url.Values{
"act": {"undo"},
"staged.0.verb": {"remove"},
"staged.0.key": {"rule-4"},
"staged.0.resource_key": {"seats"},
"staged.0.kind": {"boolean"},
"staged.0.policy": {"clamp"},
})
batch := batchSpec().ParseBatch(r)
if batch.Errors.Any() {
t.Errorf("a boolean delta was refused: %+v", batch.Errors)
}
if batch.Staged[0].Values.Present("limit") {
t.Error("a field the row never showed was carried")
}
}
// Option membership holds in every group, and a value outside the set is a
// refusal rather than a silent default (finding FA-8).
func TestParseBatchOptionMembership(t *testing.T) {
r := post(t, "/partials/test/rules", url.Values{
"act": {"stage"},
"open": {"rule-12"},
"rows.rule-12.resource_key": {"invented"},
"rows.rule-12.kind": {"boolean"},
"staged.0.verb": {"edit"},
"staged.0.key": {"rule-9"},
"staged.0.resource_key": {"sites"},
"staged.0.kind": {"boolean"},
"staged.0.policy": {"block"},
})
batch := batchSpec().ParseBatch(r)
if got := batch.Errors.Get("rows.rule-12.resource_key"); got == "" {
t.Error("a resource outside the list was accepted")
}
if got := batch.Errors.Get("staged.0.policy"); got == "" {
t.Error("a policy outside the list was accepted")
}
// A per-record option the render offered is one the parse accepts, so
// the control and the server read one list (ParseBatchWith).
with := batchSpec().ParseBatchWith(post(t, "/partials/test/rules", url.Values{
"staged.0.verb": {"edit"},
"staged.0.key": {"rule-9"},
"staged.0.resource_key": {"sites"},
"staged.0.kind": {"boolean"},
"staged.0.policy": {"block"},
}), map[string][]Option{"policy": {
{Value: "clamp", Label: "Clamp"},
{Value: "force_reduce", Label: "Force reduce"},
{Value: "block", Label: "Block"},
}})
if with.Errors.Any() {
t.Errorf("a policy the render offered was refused: %+v", with.Errors)
}
}
// The batch is read from index 0 up and stops where the groups do, so a
// body that numbers a group it never rendered adds nothing.
func TestParseBatchReadsContiguousGroupsOnly(t *testing.T) {
r := post(t, "/partials/test/rules", url.Values{
"staged.0.verb": {"remove"},
"staged.0.key": {"rule-1"},
"staged.0.resource_key": {"seats"},
"staged.0.kind": {"boolean"},
"staged.0.policy": {"clamp"},
"staged.2.verb": {"remove"},
"staged.2.key": {"rule-3"},
})
batch := batchSpec().ParseBatch(r)
if len(batch.Staged) != 1 || batch.Staged[0].Key != "rule-1" {
t.Errorf("staged = %+v", batch.Staged)
}
}
// The batch arrives as numbered names, which a forged body can number as
// high as it likes; the cap is out of reach in use and refused past it.
func TestParseBatchCapsTheBatch(t *testing.T) {
body := url.Values{}
for i := 0; i <= 250; i++ {
body.Set(fmt.Sprintf("staged.%d.verb", i), "remove")
body.Set(fmt.Sprintf("staged.%d.key", i), fmt.Sprintf("rule-%d", i))
body.Set(fmt.Sprintf("staged.%d.resource_key", i), "seats")
body.Set(fmt.Sprintf("staged.%d.kind", i), "boolean")
body.Set(fmt.Sprintf("staged.%d.policy", i), "clamp")
}
batch := batchSpec().ParseBatch(post(t, "/partials/test/rules", body))
if len(batch.Staged) != maxStagedDeltas {
t.Errorf("read %d deltas, want the cap of %d", len(batch.Staged), maxStagedDeltas)
}
if got := batch.Errors.FormError(); got != "A batch holds at most 200 changes." {
t.Errorf("form-level refusal = %q", got)
}
}