|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21464, stage 3] Four of the form family's nine `z.unknown()` members are |
| 5 | + * typed: `object-form` `contentLayout`, `submitBehavior`, `navigateOnSuccess` |
| 6 | + * and `mobile`. The other five stay in the enumeration pin's ledger: the |
| 7 | + * form's `fields` and `sections` and the master-detail form's two are held, |
| 8 | + * because the form draws a value each typed shape would refuse (a `{ name }` |
| 9 | + * field entry; an inline runtime field inside a section), and `customFields` |
| 10 | + * waits with the objectui-held contracts (its entries are objectui's runtime |
| 11 | + * `FormField`, which the spec has not declared). |
| 12 | + * |
| 13 | + * ## The defect this file closes |
| 14 | + * |
| 15 | + * Each of the four is read with one shape (measured at the `.objectui-sha` pin |
| 16 | + * `89cad75d55`; the read points are in the members' docblocks), and the row |
| 17 | + * declared them `z.unknown()`. So a `submitBehavior` whose `kind` the form does |
| 18 | + * not know, a `contentLayout: 'tabs'`, a numeric `navigateOnSuccess` and a |
| 19 | + * misspelled `mobile` member all passed the component-props gate, and the form |
| 20 | + * fell back to its thank-you panel, stacked the sections, threw after the |
| 21 | + * record was written, or ignored the key — with no report. |
| 22 | + * |
| 23 | + * ## What is pinned, and why each half |
| 24 | + * |
| 25 | + * - §1 THE DECLARED SHAPES PARSE: every shape a measured writer authors parses |
| 26 | + * byte-identical — none of the four carries a default, so the parsed value |
| 27 | + * IS the authored one. A refusal pin with no lit control passes just as well |
| 28 | + * when the door refuses everything. |
| 29 | + * - §2 THE REFUSALS: an off-shape value of each member is refused with the |
| 30 | + * code AND the path, so a refusal for the wrong reason reds. |
| 31 | + * - §3 ONE SCHEMA: `submitBehavior` holds the form view's own def by identity, |
| 32 | + * and the two shapes declared here hold exactly the measured vocabulary. |
| 33 | + * - §4 THE REGISTRATION: the ADR-0087 D3 entry step 18 carries. |
| 34 | + * |
| 35 | + * The enumeration pin (`component-props-unknown-members.pin.test.ts`) holds the |
| 36 | + * other half: these four left its ledger, so a member reverted to |
| 37 | + * `z.unknown()` reds there. |
| 38 | + */ |
| 39 | + |
| 40 | +import { describe, it, expect } from 'vitest'; |
| 41 | +import type { z } from 'zod'; |
| 42 | + |
| 43 | +import { ComponentPropsMap, ObjectFormPropsSchema } from './component.zod'; |
| 44 | +import { FormViewSchema } from './view.zod'; |
| 45 | +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; |
| 46 | + |
| 47 | +const BASE = { objectName: 'account' } as const; |
| 48 | +const parse = (props: Record<string, unknown>) => ComponentPropsMap['object-form'].safeParse({ ...BASE, ...props }); |
| 49 | + |
| 50 | +/** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */ |
| 51 | +function issues(result: z.ZodSafeParseResult<unknown>): { code: string; path: string }[] { |
| 52 | + if (result.success) return []; |
| 53 | + return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); |
| 54 | +} |
| 55 | + |
| 56 | +// ─────────────────────────────────────────────────────────────────────────── |
| 57 | +// §1 the declared shapes parse |
| 58 | +// ─────────────────────────────────────────────────────────────────────────── |
| 59 | + |
| 60 | +describe('§1 each member accepts every shape a measured writer authors', () => { |
| 61 | + const BYTE_IDENTICAL: ReadonlyArray<readonly [label: string, props: Record<string, unknown>]> = [ |
| 62 | + ['contentLayout \'tabbed\'', { contentLayout: 'tabbed' }], |
| 63 | + ['contentLayout \'simple\'', { contentLayout: 'simple' }], |
| 64 | + // The showcase's own wizard page (`examples/app-showcase/src/ui/pages/new-project-wizard.page.ts`). |
| 65 | + ['a thank-you panel with a title and a message', { |
| 66 | + submitBehavior: { kind: 'thank-you', title: 'Project created', message: 'Your new project is ready.' }, |
| 67 | + }], |
| 68 | + ['a bare thank-you panel', { submitBehavior: { kind: 'thank-you' } }], |
| 69 | + ['a relative redirect with a delay', { submitBehavior: { kind: 'redirect', url: '/apps/x/done', delayMs: 241 } }], |
| 70 | + ['a redirect interpolating declared record fields', { |
| 71 | + submitBehavior: { kind: 'redirect', url: '/t/{{record.slug}}?ref={{record.id}}' }, |
| 72 | + }], |
| 73 | + ['continue', { submitBehavior: { kind: 'continue' } }], |
| 74 | + ['next-record', { submitBehavior: { kind: 'next-record' } }], |
| 75 | + ['a navigateOnSuccess template', { navigateOnSuccess: '/apps/x/o/record/{id}' }], |
| 76 | + ['mobile fullscreenLongText', { mobile: { fullscreenLongText: true } }], |
| 77 | + ['mobile stickyActions', { mobile: { stickyActions: true } }], |
| 78 | + ['an empty mobile block (its presence marks the wrapper)', { mobile: {} }], |
| 79 | + ['mobile stepper true with a minimum', { mobile: { stepper: true, stepperMinFields: 99 } }], |
| 80 | + ['mobile stepper auto with a minimum', { mobile: { stepper: 'auto', stepperMinFields: 3 } }], |
| 81 | + ['mobile stepper with fields per step', { mobile: { stepper: true, stepperFieldsPerStep: 2 } }], |
| 82 | + ['mobile stepper false', { mobile: { stepper: false } }], |
| 83 | + ]; |
| 84 | + |
| 85 | + for (const [label, props] of BYTE_IDENTICAL) { |
| 86 | + it(`parses ${label}, byte-identical`, () => { |
| 87 | + const r = parse(props); |
| 88 | + expect(issues(r)).toEqual([]); |
| 89 | + expect(r.success && r.data).toStrictEqual({ ...BASE, ...props }); |
| 90 | + }); |
| 91 | + } |
| 92 | + |
| 93 | + it('an absent member stays absent', () => { |
| 94 | + const r = parse({}); |
| 95 | + expect(issues(r)).toEqual([]); |
| 96 | + expect(r.success && Object.keys(r.data)).toEqual(['objectName']); |
| 97 | + }); |
| 98 | +}); |
| 99 | + |
| 100 | +// ─────────────────────────────────────────────────────────────────────────── |
| 101 | +// §2 the refusals |
| 102 | +// ─────────────────────────────────────────────────────────────────────────── |
| 103 | + |
| 104 | +describe('§2 each member refuses an off-shape value', () => { |
| 105 | + const REFUSED: ReadonlyArray<readonly [label: string, props: Record<string, unknown>, code: string, path: string]> = [ |
| 106 | + ['a contentLayout outside the two', { contentLayout: 'tabs' }, 'invalid_value', 'contentLayout'], |
| 107 | + ['a contentLayout object', { contentLayout: { mode: 'tabbed' } }, 'invalid_value', 'contentLayout'], |
| 108 | + ['a submitBehavior kind the form does not know', { submitBehavior: { kind: 'toast' } }, 'invalid_union', 'submitBehavior.kind'], |
| 109 | + ['a submitBehavior with no kind', { submitBehavior: { title: 'Done' } }, 'invalid_union', 'submitBehavior.kind'], |
| 110 | + ['a bare-string submitBehavior', { submitBehavior: 'thank-you' }, 'invalid_type', 'submitBehavior'], |
| 111 | + ['a protocol-relative redirect', { submitBehavior: { kind: 'redirect', url: '//example.com/thanks' } }, 'custom', 'submitBehavior.url'], |
| 112 | + ['an absolute redirect', { submitBehavior: { kind: 'redirect', url: 'https://app.example.com/thanks' } }, 'custom', 'submitBehavior.url'], |
| 113 | + ['a negative redirect delay', { submitBehavior: { kind: 'redirect', url: '/done', delayMs: -1 } }, 'too_small', 'submitBehavior.delayMs'], |
| 114 | + ['a thank-you `heading`', { submitBehavior: { kind: 'thank-you', heading: 'Done' } }, 'unrecognized_keys', 'submitBehavior'], |
| 115 | + ['options on continue', { submitBehavior: { kind: 'continue', title: 'Again' } }, 'unrecognized_keys', 'submitBehavior'], |
| 116 | + ['a numeric navigateOnSuccess', { navigateOnSuccess: 42 }, 'invalid_type', 'navigateOnSuccess'], |
| 117 | + ['an object navigateOnSuccess', { navigateOnSuccess: { url: '/x' } }, 'invalid_type', 'navigateOnSuccess'], |
| 118 | + ['a stepper outside true / false / auto', { mobile: { stepper: 'yes' } }, 'invalid_union', 'mobile.stepper'], |
| 119 | + ['a mobile member the form does not read', { mobile: { sticky: true } }, 'unrecognized_keys', 'mobile'], |
| 120 | + ['zero fields per step', { mobile: { stepper: true, stepperFieldsPerStep: 0 } }, 'too_small', 'mobile.stepperFieldsPerStep'], |
| 121 | + ['a fractional minimum', { mobile: { stepperMinFields: 2.5 } }, 'invalid_type', 'mobile.stepperMinFields'], |
| 122 | + ['a boolean mobile', { mobile: true }, 'invalid_type', 'mobile'], |
| 123 | + ]; |
| 124 | + |
| 125 | + for (const [label, props, code, path] of REFUSED) { |
| 126 | + it(`refuses ${label} — ${code} at ${path}`, () => { |
| 127 | + const r = parse(props); |
| 128 | + expect(r.success).toBe(false); |
| 129 | + expect(issues(r)).toEqual([{ code, path }]); |
| 130 | + }); |
| 131 | + } |
| 132 | + |
| 133 | + it('says a thank-you `heading` is its `title`', () => { |
| 134 | + const r = parse({ submitBehavior: { kind: 'thank-you', heading: 'Done' } }); |
| 135 | + expect(r.success ? '' : r.error.issues[0]!.message).toMatch(/`heading` → `title`/); |
| 136 | + }); |
| 137 | + |
| 138 | + it('LIT CONTROL — an unknown top-level key is still refused at the row itself', () => { |
| 139 | + expect(issues(parse({ contentLayout: 'tabbed', notAFormKey: 1 }))).toEqual([{ code: 'unrecognized_keys', path: '' }]); |
| 140 | + }); |
| 141 | +}); |
| 142 | + |
| 143 | +// ─────────────────────────────────────────────────────────────────────────── |
| 144 | +// §3 one schema, not a copy of its shape |
| 145 | +// ─────────────────────────────────────────────────────────────────────────── |
| 146 | + |
| 147 | +describe('§3 the members hold the form view\'s own def, and the measured vocabulary', () => { |
| 148 | + const form = () => ObjectFormPropsSchema.shape; |
| 149 | + |
| 150 | + it('submitBehavior is the form view\'s own member — the same def', () => { |
| 151 | + expect(form().submitBehavior.unwrap()._zod.def).toBe(FormViewSchema.shape.submitBehavior.unwrap()._zod.def); |
| 152 | + }); |
| 153 | + |
| 154 | + it('contentLayout declares exactly the read\'s two layouts', () => { |
| 155 | + expect([...form().contentLayout.unwrap().options].sort()).toEqual(['simple', 'tabbed']); |
| 156 | + }); |
| 157 | + |
| 158 | + it('a mobile block declares exactly the five members the form reads', () => { |
| 159 | + expect(Object.keys(form().mobile.unwrap().shape).sort()) |
| 160 | + .toEqual(['fullscreenLongText', 'stepper', 'stepperFieldsPerStep', 'stepperMinFields', 'stickyActions']); |
| 161 | + }); |
| 162 | +}); |
| 163 | + |
| 164 | +// ─────────────────────────────────────────────────────────────────────────── |
| 165 | +// §4 the registration |
| 166 | +// ─────────────────────────────────────────────────────────────────────────── |
| 167 | + |
| 168 | +describe('§4 the ADR-0087 entry', () => { |
| 169 | + it('is registered as the D3 entry step 18 carries, beside the earlier stages\' entries', () => { |
| 170 | + const ids = MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id); |
| 171 | + expect(ids).toContain('ui-object-form-members-typed'); |
| 172 | + expect(ids).toContain('ui-object-grid-kanban-calendar-list-members-typed'); |
| 173 | + expect(ids).toContain('ui-object-map-gantt-tree-navigation-typed'); |
| 174 | + }); |
| 175 | +}); |
0 commit comments