Skip to content

§11.2 Mirroring Test Design (S0-4 → W1-10, B2-3, D2-3)

Normative design for the conformance suite enforcing TECH_SPEC §11.2:

Every declarative reactive rule that affects requiredness/visibility MUST be enforced in the compiled validator, so the client-side evaluation is purely cosmetic.

Everything here is implementable directly. The fixture corpus exists (docs/reactivity/fixtures/*.fixture.json, 27 files, 47 cases); a reference implementation of the algorithm exists (spikes/jsonlogic-reactivity/src/mirror.ts, 0-failure run).

1. The invariant, restated as assertions

For every reactive fixture and every case, in both create and edit modes (where the fixture declares no mode restriction):

  • (a) Validator exclusion. A field hidden by the case state is absent from the compiled validator’s key set: submitting an invalid value for it produces no error entry for that key, and the key is absent from validated output.
  • (b) Fill exclusion. A field hidden (or disabledWhen-true) by the case state is absent from the fill set: submitting a value for it — bypassing the client UI entirely — leaves the model attribute untouched (create: attribute keeps its non-submitted default; edit: attribute keeps the record’s prior value).
  • (c) Requiredness enforcement. requiredWhen evaluating true for the case state makes the key required server-side: omitting it fails validation with an error keyed to the field, even though a bypassing client never rendered the field. When the rule evaluates false, omitting it passes.

Plus (d) Parity. The client evaluator and the server evaluator produce identical visibility/requiredness maps for every case (the fixtures are the shared oracle; D2-3 consumes the same files).

2. Fixture format (v1)

Files: docs/reactivity/fixtures/<nn>-<pattern>.fixture.json — the canonical home, and it stayed there when B2-3 landed. Both conformance suites read these files directly by relative path (packages/core/tests/mirroring.spec.ts, packages/ui/tests/reactivity_fixtures.ts) rather than importing a copy: the corpus is the shared oracle for client/server parity, and a copy is a corpus that can drift from the thing both sides are measured against. The W1-10 copies under packages/core/tests/fixtures/reactivity/ are gone.

{
  "version": 1,                      // fixture format version (required)
  "name": "10-required-when-equals", // unique, kebab-case
  "pattern": "required-when-equals", // catalogue row id (docs/reactivity/catalogue.md)
  "classification": "jsonlogic",     // jsonlogic | capability-url | registry-transform | server-closure
  "note": "…",                       // optional prose
  "fields": [                        // flat field list (layout already flattened)
    {
      "key": "companyName",          // state key; unique within the fixture
      "type": "text",                // §7.3 type key (drives resourceFromFixture mapping)
      "default": "x",                // optional; merged UNDER case state (R1)
      "required": true,              // optional static requiredness
      "virtual": false,              // optional; virtual fields never fill
      "hiddenOn": ["edit"],          // optional static projection (§7.4), tested by R2
      "reactive": {                  // optional ReactiveSpec, wire shape (§9.3 + the reactivity contract)
        "live": { "debounceMs": 300 },
        "visibleWhen":  { "===": [{ "var": "status" }, "published"] },
        "requiredWhen": { /* JsonLogicRule */ },
        "disabledWhen": { /* JsonLogicRule */ },
        "sets": [{ "target": "slug", "transform": "slugify", "source": "title", "if": { "var": "on" } }],
        "refetch": { "url": "urls.options", "withState": ["country"] },
        "refetchSchema": true
      },
      "children": [ /* item sub-form of a state boundary; same field shape, recursive */ ]
    }
  ],
  "cases": [
    {
      "state": { "status": "draft" },  // full submitted state (pre-validation)
      "mode": "create",                // optional, default "create"
      "expect": {
        "visibility": { "publishedAt": false },        // keys to check (subset allowed)
        "validator":  { "keys": ["status"],            // EXACT validator key set
                        "requires": [] },              // EXACT required key set
        "fill":       { "keys": ["status"],            // EXACT fill key set
                        "with": { "slug": "hello-world" } }, // optional exact filled values
        "derived":    { "slug": "hello-world" },       // optional EXACT sets re-derivation map
        "refetch":    { "url": "urls.options", "withState": ["country"] }, // optional shape assertion
        "nested":     {                                // optional per-ROW expectations (R-7)
          "items": [                                   // one entry per submitted row, in order
            { "keys": ["kind", "note"],                // EXACT validator key set for the row
              "requires": ["note"],                    // EXACT required key set for the row
              "fill": ["kind", "note"],                // EXACT fill key set for the row
              "with": { "slug": "hello-world" } }      // optional exact validated row values
          ]
        }
      }
    }
  ]
}

Exact-set expectations (keys, requires) are deliberate: subset assertions would let a leaking field pass. visibility is a subset map because fixtures may pin only the reactive fields.

3. Algorithm (what the server does at submit — and what the test drives)

Rules R1–R6 (implemented in spikes/jsonlogic-reactivity/src/mirror.ts, normative for B2-3):

  • R1 — Evaluation state. Rules evaluate against the full submitted state with field defaults merged under it (submitted wins). Hidden fields’ submitted values ARE visible to rules. No fixpoint: one pass; rule outcomes never feed back into the state. This is deterministic and identical to the client, which evaluates against live FormState including hidden fields.
  • R2 — Static projection first. A field absent from the mode (§7.4: hiddenOn, visibleOn, canSee compile-time removal) is excluded from validator and fill set before any rule runs.
  • R3 — Hidden ⇒ excluded. visibleWhen false ⇒ key absent from validator keys AND fill keys, even if present in the payload.
  • R4 — Requiredness mirrored. required static or requiredWhen true (same evaluation state) ⇒ required. disabledWhen true suppresses requiredness.
  • R5 — Disabled ⇒ not filled. disabledWhen true ⇒ excluded from fill set; a tampered submission is shape-validated but its value dropped.
  • R6 — Sets re-derived. sets entries are recomputed server-side from submitted source values (guard if evaluated on the same state), and derived values overlay the fill for fillable targets. fn:* resolves from the server transform registry; unregistered ⇒ skip + dev warning (fail closed, never eval).

Pseudo-code for the conformance driver (runMirroringFixture):

for fixture in corpus:
  resource = resourceFromFixture(fixture)          // §4
  for case in fixture.cases:
    for mode in (case.mode ? [case.mode] : ['create','edit']):
      state = defaults(fixture.fields) <⊂ case.state            // R1
      // (a) validator exclusion
      validator = compileValidator(resource, mode)              // §11.1 compiler
      probe = { ...case.state, <each hidden key>: INVALID_VALUE }  // e.g. 12345 for a string field
      outcome = validateWith(resource, mode, probe)
      assert outcome.errors has no entry for any hidden key
      assert outcome.output has no hidden key
      assert validatorKeySet(validator, state) == case.expect.validator.keys   // exact
      // (b) fill exclusion
      record = save(resource, mode, payloadWithHiddenValues, existingRecord?)  // transaction, rolled back or in-memory model
      for key in hiddenKeys(fixture, case, mode):
        assert record[key] == priorValue(key)                  // default (create) / untouched (edit)
      assert fillKeys(resource, mode, state) == case.expect.fill.keys          // exact
      for (k, v) in case.expect.fill.with: assert record[k] == v               // R6 overlay
      // (c) requiredness enforcement
      assert requiredKeys(resource, mode, state) == case.expect.validator.requires
      for key in case.expect.validator.requires:
        outcome = validateWith(resource, mode, { ...case.state, [key]: MISSING })
        assert outcome.errors has key
      // (d) parity — client side, same fixture file (D2-3)
      visibilityMap = clientEngine.evaluate(fixture.descriptorForm, case.state)
      assert visibilityMap == serverVisibilityMap(resource, mode, state)

Mode expansion: fixtures without a mode on a case run in both modes unless a field’s hiddenOn restricts it (then the restricted mode expects the R2 outcome, as in fixture s2-*).

4. Test infrastructure to build

resourceFromFixture(fixture). Builds a throwaway BaseResource subclass from declarative field specs. Type mapping: fixture type → field factory (textF.text, selectF.select, toggleF.toggle, numberF.number, date/datetime/time → the temporal factories, …). Types whose tranche has not landed map onto the factory carrying the same §11.1 Vine base: belongs-toF.number (until B2-4 adds the exists rule), repeater → an array-valued stand-in (until B2-6). Choice fields declare no options in the format, so the harness derives the enum members from the values the fixture itself uses. reactive JSON is replayed through the AUTHORING DSL — visibleWhen(rule), .sets(target, transform, { source, if }), .live(ms), dependentOptions(fn, dependsOn), and a closure for refetchSchema: true — so DSL → JSON compilation is itself covered (B2-3 removed the setReactiveSpec escape hatch W1-10 used). hiddenOn names form MODES, which the Field DSL does not project on directly; the harness expresses it as canSee((ctx, record) => …), since record presence is what distinguishes edit from create at compile time.

Assertion helpers (Japa plugin additions per §21, in @adonia/core/testing):

Helper Asserts Invariant
assertValidatorOmits(resource, mode, state, keys) invalid values for keys produce no error/output entries (a)
assertValidatorKeys(resource, mode, state, keys) exact validator key set for state (a)
assertFillOmits(resource, mode, payload, keys) submitted values for keys never reach the model (b)
assertRequiredWhen(resource, mode, state, key) omitting key fails with keyed error; including passes (c)
runMirroringFixture(fixture) drives the whole §3 loop (a)+(b)+(c)

@adonia/ui gets one helper: evaluateFixtureCase(fixture, case) returning the client visibility/requiredness map, compared against expect (d).

Client/server evaluator parity. Both sides implement the closed-set reference evaluator (spikes/jsonlogic-reactivity/src/evaluator.ts is the normative semantics: strict structural ===, null-safe relational operators, JS truthiness, and/or value semantics with Boolean() at consumption, ./ item-relative paths). The fixture corpus is the conformance oracle — any semantic drift fails parity cases. Do NOT adopt json-logic-js on either side without re-running the corpus; its loose == is excluded by design.

5. Test matrix

Dimensions: pattern (27 fixtures) × case state (47 cases, rule true/false/boundary incl. null operand) × mode (create/edit) × side (server validator, server fill, client eval).

Row Coverage Fixtures Owner
R-1 visibility conditions (equals, notEquals, in, notIn, truthy, and, or, not, threshold + boundary + null) 0109 B2-3
R-2 requiredness mirroring, UI bypassed — incl. a condition field supplied only by a DEFAULT and one that is itself hidden (both decide on the R1 state, never on the pruned body) 10, 11, r4-required-when-defaulted-condition, r4-required-when-hidden-condition B2-3 (W1-10 via s3)
R-3 disabled-when fill exclusion, tampered payload 12 B2-3
R-4 sets re-derivation (slugify, uppercase, copy+if, tampered target) 1315 B2-3
R-5 capability-URL shape (refetch.withState; options endpoint contract tested separately) 16, 17 B2-3/B2-4
R-6 registry transforms (fn:* both-sides, fail-closed unregistered) 18, 19 B2-3, D2-3
R-7 repeater-row scoping r7-repeater-row-scope (child default arming a ./ sibling rule, chained ./ visibility through a pruned child, absolute var reading the parent form), r7-repeater-row-sets (per-row sets re-derivation) + packages/core/tests/fields/repeater.spec.ts for the error-bag paths (items.2.title, items.0.children.1.name). Fixture fields carry children and cases carry expect.nested, so per-row decisions are corpus-observable rather than spec-suite-only B2-6
R-8 static projection interplay (§7.4 × §11.2) s2 W1-10/B2-3
R-9 closure fallback (refetchSchema: true fields stay validated+fillable; dev warning emitted) s1 B2-3
R-10 W1-10 smoke: one-field (F.text) slice wired as required CI check s3 W1-10

Server-closure fixture 20 carries cases: []: it asserts (by absence of reactive keys) that cross-field validation stays out of the wire grammar; its submit-time enforcement is covered by the §11.1 mapping suite (S0-6), not this one.

6. CI wiring

  • W1-10 (superseded): runMirroringFixture over s3-w1-10-smoke (+ copied 01, 10).
  • B2-3 (current): the full corpus, discovered from docs/reactivity/fixtures/, both modes, one Japa test per case × mode row, as a required check. New catalogue patterns MUST add a fixture in the same PR (conformance-as-CI, plan §5) — the suite enrols it automatically, and when_dsl.spec.ts fails until the pattern has a DSL spelling that emits the fixture JSON byte for byte.
  • D2-3: same corpus through the client engine (row (d)); the shared corpus is what lets B2-3 and D2-3 proceed in parallel.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close