|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #7887 — the section / page-component **editability boundary**, asserted. |
| 5 | + * |
| 6 | + * The maintainer's ruling of 2026-08-12, operative sentence: *"`FormSectionSchema` |
| 7 | + * / `PageComponentSchema` gate **visibility only**; editability lives on fields. |
| 8 | + * No `disabled` / `readonly` / `disabledWhen` slot is added to those shapes, and |
| 9 | + * no alias row is registered for them."* The deliverable is therefore text-face |
| 10 | + * only: the accepted set does not move, the rejected set does not move, and the |
| 11 | + * single thing that changes is the sentence an author reads when they write an |
| 12 | + * editability key on a shape that has no editability semantics. |
| 13 | + * |
| 14 | + * ## What each section below would catch |
| 15 | + * |
| 16 | + * 1. **the prescription reaches a real author** — asserted on the actual |
| 17 | + * `unrecognized_keys` message from `safeParse`, never on the options table. |
| 18 | + * A row filed in a table nothing consults is exactly the dead-entry shape |
| 19 | + * `alias-integrity.test.ts` exists for; reading the message back is the only |
| 20 | + * assertion that cannot pass that way. |
| 21 | + * 2. **it points at `readonlyWhen`, and never at `disabledWhen`** — `field.zod.ts` |
| 22 | + * renames `disabled → readonly` and records that "a field has `readonlyWhen`, |
| 23 | + * not `disabledWhen`" (#7832). A prescription naming `disabledWhen` would send |
| 24 | + * the author to a key that exists on no field surface, which is worse than the |
| 25 | + * bare rejection it replaced. |
| 26 | + * 3. **the field surface is untouched** — the trap this card was tiered up for. |
| 27 | + * `VISIBILITY_STRICT_OPTIONS` is shared with `FormFieldSchema`, which answers |
| 28 | + * `disabled` through its OWN alias row. A guidanceSet match `continue`s past |
| 29 | + * the rename channel, so filing this family in the shared table would have |
| 30 | + * replaced the one correct pointer in the family with a redirect away from it. |
| 31 | + * 4. **acceptance is byte-identical** — the lane's admission criterion. A |
| 32 | + * guidance string must never become an accepted key. |
| 33 | + * |
| 34 | + * The options table itself lives in `editability-boundary.ts`, which the |
| 35 | + * `shared/index.ts` barrel deliberately does not re-export — so the package's |
| 36 | + * public API surface does not move either (`check:api-surface`), which is the |
| 37 | + * same claim one level out. |
| 38 | + */ |
| 39 | + |
| 40 | +import { describe, it, expect } from 'vitest'; |
| 41 | + |
| 42 | +import { FormFieldSchema, FormSectionSchema } from '../ui/view.zod'; |
| 43 | +import { PageComponentSchema } from '../ui/page.zod'; |
| 44 | +import { VISIBILITY_ONLY_STRICT_OPTIONS } from './editability-boundary'; |
| 45 | +import { keySetMatches } from './suggestions.zod'; |
| 46 | + |
| 47 | +/** |
| 48 | + * The `unrecognized_keys` message for `value`, or a loud failure. |
| 49 | + * |
| 50 | + * Same helper, same reasoning, as `visible-when-alias-guidance.test.ts`: the |
| 51 | + * probe bodies are minimal and may also miss a required key, so a whole-error |
| 52 | + * stringify could let an assertion pass on text from an unrelated issue. |
| 53 | + */ |
| 54 | +function unknownKeyMessage( |
| 55 | + schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, |
| 56 | + value: unknown, |
| 57 | +): string { |
| 58 | + const r = schema.safeParse(value); |
| 59 | + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); |
| 60 | + const issues = (r.error as { issues?: Array<{ code?: string; message?: string }> }).issues ?? []; |
| 61 | + const hit = issues.find((i) => i.code === 'unrecognized_keys'); |
| 62 | + expect(hit, `no \`unrecognized_keys\` issue in ${JSON.stringify(issues)}`).toBeDefined(); |
| 63 | + return hit?.message ?? ''; |
| 64 | +} |
| 65 | + |
| 66 | +/** Minimal bodies that reach each surface's unknown-key path. */ |
| 67 | +const SECTION = { fields: [] } as const; |
| 68 | +const COMPONENT = { type: 'text' } as const; |
| 69 | +const FORM_FIELD = { field: 'probe' } as const; |
| 70 | + |
| 71 | +/** The two shapes the ruling names, and nothing else. */ |
| 72 | +const VISIBILITY_ONLY: ReadonlyArray<[string, { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, object]> = [ |
| 73 | + ['FormSectionSchema', FormSectionSchema, SECTION], |
| 74 | + ['PageComponentSchema', PageComponentSchema, COMPONENT], |
| 75 | +]; |
| 76 | + |
| 77 | +/** The spellings the boundary set answers. */ |
| 78 | +const EDITABILITY_KEYS = ['disabled', 'disabledWhen', 'readonly', 'readOnly', 'readonlyWhen', 'editable'] as const; |
| 79 | + |
| 80 | +// =========================================================================== |
| 81 | +// 1. The guidance reaches an author — on the real parse error |
| 82 | +// =========================================================================== |
| 83 | +describe('#7887 — the boundary prescription an author actually sees', () => { |
| 84 | + it.each(VISIBILITY_ONLY)('%s answers `disabled` with the boundary, not a bare refusal', (_n, schema, base) => { |
| 85 | + const m = unknownKeyMessage(schema, { ...base, disabled: true }); |
| 86 | + expect(m).toContain('Editability is a FIELD-level concern'); |
| 87 | + expect(m).toContain('gates VISIBILITY only'); |
| 88 | + // Rendered through the shared template's prescription channel — the bullet |
| 89 | + // is what puts it directly after the key statement and before the history |
| 90 | + // sentence (#5955's ordering, pinned for this family in `ui/view.test.ts`). |
| 91 | + expect(m).toContain('\n • Editability is a FIELD-level concern'); |
| 92 | + }); |
| 93 | + |
| 94 | + it.each(VISIBILITY_ONLY)('%s answers the whole editability family, not just `disabled`', (_n, schema, base) => { |
| 95 | + for (const key of EDITABILITY_KEYS) { |
| 96 | + expect( |
| 97 | + unknownKeyMessage(schema, { ...base, [key]: 'x' }), |
| 98 | + `\`${key}\` should reach the boundary prescription`, |
| 99 | + ).toContain('Editability is a FIELD-level concern'); |
| 100 | + } |
| 101 | + }); |
| 102 | + |
| 103 | + it.each(VISIBILITY_ONLY)('%s emits the prescription ONCE for a body carrying several of them', (_n, schema, base) => { |
| 104 | + // The property that makes this a SET rather than N exact entries: one |
| 105 | + // paragraph per message, however many members were written. |
| 106 | + const m = unknownKeyMessage(schema, { ...base, disabled: true, readonly: true, editable: false }); |
| 107 | + expect(m.split('Editability is a FIELD-level concern')).toHaveLength(2); |
| 108 | + // …and every offending key is still named. |
| 109 | + for (const key of ['disabled', 'readonly', 'editable']) expect(m).toContain(`\`${key}\``); |
| 110 | + }); |
| 111 | + |
| 112 | + it.each(VISIBILITY_ONLY)('%s still puts the history sentence last (the #5955 order survives the new set)', (_n, schema, base) => { |
| 113 | + const m = unknownKeyMessage(schema, { ...base, disabled: true }); |
| 114 | + const history = 'Before ADR-0089 D3a these were dropped silently'; |
| 115 | + expect(m.indexOf('Editability is a FIELD-level concern')).toBeLessThan(m.indexOf(history)); |
| 116 | + }); |
| 117 | +}); |
| 118 | + |
| 119 | +// =========================================================================== |
| 120 | +// 2. It names `readonlyWhen` — and must never name `disabledWhen` |
| 121 | +// =========================================================================== |
| 122 | +describe('#7887 — the prescription points at a key that exists', () => { |
| 123 | + it.each(VISIBILITY_ONLY)('%s names the field-level `readonly` / `readonlyWhen` pair', (_n, schema, base) => { |
| 124 | + const m = unknownKeyMessage(schema, { ...base, disabled: true }); |
| 125 | + expect(m).toContain('`readonly: true`'); |
| 126 | + expect(m).toContain('`readonlyWhen`'); |
| 127 | + }); |
| 128 | + |
| 129 | + it.each(VISIBILITY_ONLY)('%s never names `disabledWhen` — no field surface declares it', (_n, schema, base) => { |
| 130 | + // `field.zod.ts` renames `disabled → readonly` precisely because a field |
| 131 | + // has `readonlyWhen`, not `disabledWhen` (#7832). Pointing at the latter |
| 132 | + // would be a rejection that hands the author their next rejection. |
| 133 | + const m = unknownKeyMessage(schema, { ...base, disabledWhen: 'record.locked' }); |
| 134 | + expect(m).toContain('Editability is a FIELD-level concern'); |
| 135 | + // The offending key is echoed back in the front matter, so the prohibition |
| 136 | + // is on the PRESCRIPTION text, which is everything after the bullet. |
| 137 | + const prescription = m.slice(m.indexOf('\n • ')); |
| 138 | + expect(prescription).not.toContain('disabledWhen'); |
| 139 | + }); |
| 140 | + |
| 141 | + it('the boundary also names the visibility escape hatch, and that key really is accepted', () => { |
| 142 | + const m = unknownKeyMessage(FormSectionSchema, { ...SECTION, disabled: true }); |
| 143 | + expect(m).toContain('`visibleWhen`'); |
| 144 | + expect(FormSectionSchema.safeParse({ ...SECTION, visibleWhen: 'record.x' }).success).toBe(true); |
| 145 | + expect(PageComponentSchema.safeParse({ ...COMPONENT, visibleWhen: 'record.x' }).success).toBe(true); |
| 146 | + }); |
| 147 | +}); |
| 148 | + |
| 149 | +// =========================================================================== |
| 150 | +// 3. The field surface is UNCHANGED — the shared-table trap |
| 151 | +// =========================================================================== |
| 152 | +describe('#7887 — `FormFieldSchema` sees exactly what it saw before', () => { |
| 153 | + it('`disabled` on a form field still renames onto `readonly`, with no boundary text', () => { |
| 154 | + const m = unknownKeyMessage(FormFieldSchema, { ...FORM_FIELD, disabled: true }); |
| 155 | + expect(m).toContain('Did you mean `disabled` → `readonly`?'); |
| 156 | + // The regression this whole file exists to catch. Hoisting |
| 157 | + // `EDITABILITY_BOUNDARY_KEYS` into `VISIBILITY_STRICT_OPTIONS` makes the set |
| 158 | + // fire here, and a set match `continue`s past the rename channel — so the |
| 159 | + // line above would vanish and this line would appear, redirecting a field |
| 160 | + // author AWAY from the one surface where `readonly` is real. |
| 161 | + expect(m).not.toContain('Editability is a FIELD-level concern'); |
| 162 | + }); |
| 163 | + |
| 164 | + it('the boundary options are filed on the two visibility-only shapes, never the shared table', () => { |
| 165 | + const names = (VISIBILITY_ONLY_STRICT_OPTIONS.guidanceSets ?? []).map((s) => s.name); |
| 166 | + expect(names).toContain('EDITABILITY_BOUNDARY_KEYS'); |
| 167 | + // The ADR-0089 set is still there and still first — the boundary set is an |
| 168 | + // addition, not a replacement. |
| 169 | + expect(names[0]).toBe('VISIBILITY_KEY_PATTERN'); |
| 170 | + }); |
| 171 | + |
| 172 | + it('no editability key matches `VISIBILITY_KEY_PATTERN`, so set order is not load-bearing', () => { |
| 173 | + // Both sets live on one table. If a future edit widened the visibility |
| 174 | + // pattern to reach (say) `editable`, declaration order would silently start |
| 175 | + // deciding which prescription an author reads — the tie-break |
| 176 | + // `alias-integrity.test.ts` forbids any in-repo table from depending on. |
| 177 | + const visibility = (VISIBILITY_ONLY_STRICT_OPTIONS.guidanceSets ?? []) |
| 178 | + .find((s) => s.name === 'VISIBILITY_KEY_PATTERN'); |
| 179 | + expect(visibility).toBeDefined(); |
| 180 | + for (const key of EDITABILITY_KEYS) { |
| 181 | + expect(keySetMatches(visibility!, key), `\`${key}\` is claimed by both sets`).toBe(false); |
| 182 | + } |
| 183 | + }); |
| 184 | + |
| 185 | + it('no alias row was registered for the boundary — the ruling forbids one', () => { |
| 186 | + // "no alias row is registered for them — an alias would declare a key the |
| 187 | + // runtime does not honour". An alias TARGET must be a key the shape accepts |
| 188 | + // (`alias-integrity.test.ts`), and neither shape accepts any of these, so a |
| 189 | + // row here would be a pointer into a second rejection. |
| 190 | + for (const table of [VISIBILITY_ONLY_STRICT_OPTIONS]) { |
| 191 | + for (const key of EDITABILITY_KEYS) { |
| 192 | + expect(table.aliases?.[key], `\`${key}\` must not have an alias row`).toBeUndefined(); |
| 193 | + } |
| 194 | + } |
| 195 | + }); |
| 196 | +}); |
| 197 | + |
| 198 | +// =========================================================================== |
| 199 | +// 4. Acceptance is byte-identical — a guidance string is not a key |
| 200 | +// =========================================================================== |
| 201 | +describe('#7887 — no acceptance change', () => { |
| 202 | + it.each(VISIBILITY_ONLY)('%s still REJECTS every editability spelling', (_n, schema, base) => { |
| 203 | + for (const key of EDITABILITY_KEYS) { |
| 204 | + expect( |
| 205 | + schema.safeParse({ ...base, [key]: true }).success, |
| 206 | + `\`${key}\` must stay rejected — this card curates messages, it does not widen the shape`, |
| 207 | + ).toBe(false); |
| 208 | + } |
| 209 | + }); |
| 210 | + |
| 211 | + it('a representative section that parsed before still parses, unchanged in output', () => { |
| 212 | + const authored = { |
| 213 | + name: 'billing', |
| 214 | + label: 'Billing', |
| 215 | + description: 'Invoicing details', |
| 216 | + collapsible: true, |
| 217 | + collapsed: false, |
| 218 | + columns: 2, |
| 219 | + visibleOn: 'record.type == "customer"', |
| 220 | + fields: ['amount', { field: 'currency', readonly: true }], |
| 221 | + }; |
| 222 | + const r = FormSectionSchema.safeParse(authored); |
| 223 | + expect(r.success).toBe(true); |
| 224 | + // The ADR-0089 fold still runs, and the field-level `readonly` inside is |
| 225 | + // still the accepted way to say what `disabled` on the section cannot. |
| 226 | + // `ExpressionInputSchema` normalizes the authored string to a |
| 227 | + // `{ dialect, source }` pair; the fold is about WHICH KEY carries it. |
| 228 | + expect((r.data as { visibleWhen?: { source?: string } }).visibleWhen?.source) |
| 229 | + .toBe('record.type == "customer"'); |
| 230 | + expect((r.data as { visibleOn?: unknown }).visibleOn).toBeUndefined(); |
| 231 | + }); |
| 232 | + |
| 233 | + it('a representative page component that parsed before still parses, unchanged in output', () => { |
| 234 | + const authored = { |
| 235 | + type: 'record:form', |
| 236 | + id: 'main_form', |
| 237 | + label: 'Details', |
| 238 | + properties: { columns: 2 }, |
| 239 | + className: 'p-4', |
| 240 | + visibility: 'current_user.is_admin', |
| 241 | + }; |
| 242 | + const r = PageComponentSchema.safeParse(authored); |
| 243 | + expect(r.success).toBe(true); |
| 244 | + expect((r.data as { visibleWhen?: { source?: string } }).visibleWhen?.source) |
| 245 | + .toBe('current_user.is_admin'); |
| 246 | + expect((r.data as { visibility?: unknown }).visibility).toBeUndefined(); |
| 247 | + }); |
| 248 | + |
| 249 | + it.each(VISIBILITY_ONLY)('%s keeps its bare message for a key in no family at all', (_n, schema, base) => { |
| 250 | + // The new set must claim the editability family and nothing beyond it: an |
| 251 | + // unrelated typo still gets the front matter plus history and no bullet. |
| 252 | + const m = unknownKeyMessage(schema, { ...base, totallyUnrelatedKey: true }); |
| 253 | + expect(m).toContain('`totallyUnrelatedKey`'); |
| 254 | + expect(m).not.toContain(' • '); |
| 255 | + }); |
| 256 | +}); |
0 commit comments