---
title: "§11.2 Mirroring Test Design (S0-4 → W1-10, B2-3, D2-3)"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# §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.

```jsonc
{
  "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 (`text` → `F.text`, `select` → `F.select`, `toggle` → `F.toggle`, `number` → `F.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-to` → `F.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) | `01`–`09` | 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) | `13`–`15` | 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.

Source: https://adonia.pages.dev/reactivity/mirroring-test-design/index.mdx
