Skip to content

Developer Tests Forms

Ed Mozley edited this page Sep 21, 2026 · 1 revision

πŸ§ͺ Developer Tests β€” Forms

Part of Developer Tests. Seven suites covering the Forms module: how a question keeps its identity when a form is edited, how conditions are evaluated in two languages at once, how a form is laid out, and the three things a lookup field made newly possible.

Test Needs
forms-logic/run.php Database, optionally headless Chrome
form-layout.php Nothing
field-widths-agree.php Nothing
form-class-collisions.php Nothing
form-drafts.php Database
form-audiences.php Database
forms-lookup/run.php Database (read-only)

tests/forms-logic/run.php

What it tests

Two quite different things.

Field identity. Editing a form used to sync its fields by position. Drag a question and the labels were rewritten while the stored answers stayed where they were β€” so every historic submission silently began reading against the wrong question, and removing a field hard-deleted the last one's answers. Fields are now identified by id.

The condition evaluator. Conditional visibility exists twice: includes/form_logic.php decides on submit, assets/js/form-logic.js shows and hides as someone types. Two copies that disagree is the entire risk.

How it works

For identity, it does the thing you cannot do by reading code: records a real submission, edits the form the way the builder does, then reads the answers back and checks they are still attached to the questions they were given to.

For the evaluator, it runs the same table of cases through both copies and compares the answers. The JS half is driven through headless Chrome.

It does not use the single-transaction trick other suites use β€” FormsService opens its own transactions and MySQL has no nested ones. Instead it creates throwaway forms prefixed ZZ_TEST_ and removes them in a finally block.

Run it

php tests/forms-logic/run.php

Reading the result

Ends with a pass/fail count. Watch for SKIPPED: without headless Chrome the entire JavaScript half is skipped, and it says so rather than quietly passing. A green run with that section skipped has proved only half of what you wanted β€” it has not compared the two evaluators at all.

If it fails

  • An identity assertion β€” something in FormsService::syncFields() has gone back to matching on position or order. This is the serious one: it corrupts historic submissions silently, so treat a red here as blocking.
  • An evaluator mismatch β€” the two copies have drifted. The failure names the case. Fix whichever side is wrong, and remember the change has to land in both includes/form_logic.php and assets/js/form-logic.js.

tests/form-layout.php

What it tests

A form's layout as an object separate from its questions, so a form can be re-laid-out without touching a question. Two things have to hold and neither would fail loudly:

  1. A form with no stored layout must derive exactly what it already draws. Every form predating the feature stores NULL, so a wrong derivation would silently re-lay-out every existing form on every install.
  2. A stored layout must never drop a question. The case that matters is a field added or retired after the layout was saved β€” a field the layout does not mention would simply never render.

How it works

Pure logic, no database. It builds synthetic field lists with a small fld() helper, derives layouts from them and reconciles stored layouts against changed field lists, asserting on the result.

Run it

php tests/form-layout.php

43 assertions.

Reading the result

All ok, ending 43 passed, 0 failed.

If it fails

A derivation failure means an existing form would be re-laid-out on upgrade β€” compare what the derivation produced against what the pool order says it should. A reconciliation failure means a question can vanish from a form; look at how FormsService merges stored cells with the live field pool, particularly for fields that are new or retired.


tests/field-widths-agree.php

What it tests

Field widths are expressed in twelfths, and the mechanism has two halves that must both be right.

The list of permitted widths is written down in more than one place β€” FormsService::FIELD_WIDTHS (what may be saved) and WIDTHS in assets/js/form-logic.js (what the fillers render) β€” and they must agree. The builder must defer to FormLogic rather than declare a third list.

The grid container, which is what makes a width mean anything. A field can carry data-width="6" all it likes; unless its parent is the 12-column grid, it spans the row. So for each of the three surfaces that draw a form β€” the analyst filler, the portal and the builder preview β€” the test checks the container class is really emitted, a stylesheet really defines it as display: grid, the page really loads that stylesheet, and it has a rule for every width the service will accept.

How it works

Source reading and pattern matching. It also asserts structural rules about the list itself: the default must be on it and must be the full row (so absent means unchanged), and every width must have a partner that completes the row β€” 9 with 3, 8 with 4, 6 with 6. Note that is not the same as "12 is divisible by the width": 9 and 8 are on the list precisely because asymmetric pairs are what a real document wants.

Run it

php tests/field-widths-agree.php

44 assertions.

Reading the result

All ok, ending 44 passed, 0 failed. The two CONTROL lines at the end prove the comparison itself works.

If it fails

  • "form-logic agrees with the service" β€” you added or removed a width in one place. The detail line prints both lists side by side. Neither half errors on its own: a width in the builder but not the service is offered and then refused on save; a width in the service but not form-logic saves and renders as full.
  • "…has a rule for width N" β€” you added a width to the list but not to one of the three grid stylesheets. It will render full width on that one surface only.
  • "emits the container class" β€” a container class was renamed in the markup but not the stylesheet, or vice versa. This is the check that exists because the portal shipped with class="cat-form-table", a class defined in no stylesheet at all, while its real grid sat unreferenced in self-service.css.

tests/form-class-collisions.php

What it tests

That the Forms module's CSS class names do not collide with rules in the stylesheets every page already loads.

It exists because a table question was given class="form-grid", and inbox.css had long defined .form-grid { display: grid; grid-template-columns: 1fr 1fr; }. Every Forms page loads inbox.css, so the <table> became a two-column grid and its <thead> and <tbody> were laid out side by side.

⚠️ Every test passed at the time. The markup was correct, the column ids were correct β€” because the rendering harness did not load inbox.css. A harness missing a stylesheet the real page loads is not a rendering environment, and it will report a broken screen as perfect.

How it works

It parses the class names each stylesheet defines, and the class names the Forms pages emit, and looks for overlap. form-grid is additionally checked by name so it cannot come back quietly, with a control asserting the parser really does see .form-grid in inbox.css β€” if that control fails, every "no collision" result above it is worthless.

Class attributes are split on whitespace and compared as whole tokens. An earlier version matched with \b, which treats a hyphen as a word boundary, so the innocent class="cat-form-grid" was reported as emitting form-grid.

Run it

php tests/form-class-collisions.php

11 assertions.

If it fails

A genuine collision means a rule from another module is styling Forms markup that never asked for it. Rename the Forms class β€” it is the newcomer. Do not rename the other one; something else depends on it.


tests/form-drafts.php

What it tests

Drafts β€” a form somebody started and did not finish. Four things, none of which fails loudly on its own:

  1. A draft is never validated. Not being finished is the point: a required field may be empty and a number box may hold "tbc".
  2. A draft cannot park arbitrary data. Keys must be field ids belonging to that form; anything else is dropped rather than stored.
  3. A draft is pinned to the form version it was typed into, and one whose version has been superseded is reported as stale. createVersion() renumbers every field, so loading old answers into a newer version would attach them to whatever now holds those ids β€” silently, and wrongly.
  4. Drafts stay out of form_submissions. They live in their own table, so nothing that reads submissions β€” the list, collections, counts, exports, the approval inbox, workflow triggers, REST β€” had to be taught anything.

How it works

Runs against the real database, creating a form and drafts against it, then versioning the form to prove the stale path. Everything is deleted in a finally block and the cleanup is asserted, not assumed.

Run it

php tests/form-drafts.php

Reading the result

If it prints a line about draftsAvailable and stops, the form_drafts table does not exist β€” run System β†’ Database Verification and try again. That is a skip, not a pass.

If it fails

  • Validation crept in β€” something now rejects an incomplete draft. That breaks the only thing the feature is for.
  • The stale check β€” a draft is being loaded into a version it was not typed into. Answers will land in the wrong boxes.
  • Cleanup assertions β€” rows were left behind; look at the finally.

tests/form-audiences.php

What it tests

Restricting a form to a group of people. A restriction is only worth the name if every way in enforces it β€” the catalogue hiding a card is not a check, since someone who was in the group last week, or who has a colleague's link, arrives at the other endpoints with a perfectly valid form id. So it checks all five:

  • the catalogue list β€” the form is not in it
  • opening it by id β€” 404, the same as a form that does not exist
  • saving a draft of it β€” refused
  • fetching its images β€” 404
  • submitting it β€” refused in the service, not just the adapter

πŸ”‘ No rows means everyone, which is every form that predates the feature. The first thing the test proves is that an unrestricted form is unaffected β€” a restriction feature that quietly narrows existing forms is the worst possible outcome.

Run it

php tests/form-audiences.php

Needs the database. Everything it creates is removed in a finally block and the cleanup is asserted.

Reading the result

A message about form_audiences not existing means the table is missing β€” run System β†’ Database Verification. That is a skip.

If it fails

The label names the door that let someone through. Fix it at the service layer, not in the adapter that happened to be tested β€” the point of the test is that there are five doors and they must all be locked by the same lock.


tests/forms-lookup/run.php

What it tests

Lookup fields, which answer "which one?" by searching records the app already holds. That makes them the first field type whose possible answers are built at answer time out of live, company-scoped data, so three things can go wrong that no earlier field type could:

  1. Scoping. The search must never return a record the person asking may not see. A form is a place where a customer types, so "the widget only shows their own kit" has to be true on the server, not in the UI.
  2. Tampering. The posted answer is an id, and nothing stops a crafted request naming someone else's asset. If it were accepted, the submission would render that asset's name to an analyst who would reasonably believe it. This is the generalisation of the older rule that a dropdown must be answered with one of its own choices.
  3. Drift. A field type has to be added in several places at once β€” the service whitelist, the AI generator's whitelist and its prompt, the shared JS evaluator, the builder. The prompt is prose, so nothing fails when it falls behind; it just quietly stops offering the type, or offers one that no longer exists. The suite reads all five and compares them.

How it works

Read-only β€” it searches existing records and never writes. Every "it refused" assertion is paired with a positive control that the same call accepts something legitimate, because a function that refuses everything (a wrong column name, say) would otherwise look like a clean pass.

Run it

php tests/forms-lookup/run.php

If it fails

  • A scoping failure is a live data leak. Treat it as blocking.
  • A control failure means the opposite: lookups are refusing everything and the feature is broken, even though the "it refused" assertions look green.
  • A drift failure names the two sources that disagree. The AI prompt is the one that falls behind silently β€” check it first.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally