|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + * |
| 8 | + * `record:details` — the published authoring surface stays in parity with |
| 9 | + * `@objectstack/spec` `RecordDetailsProps` (objectui#3807, objectstack#5611). |
| 10 | + * |
| 11 | + * Sibling of `recordHighlightsInputs.spec-parity.test.ts` (objectui#3407 / |
| 12 | + * PR #3795) and the same two directions, on the block where the drift was |
| 13 | + * worse: there the `fields` description spelled an entry shape that was merely |
| 14 | + * INCOMPLETE (`readonly` missing); here the `sections` description spelled an |
| 15 | + * entry shape the spec had DELETED. Until 17.x `sections` was |
| 16 | + * `z.array(z.string())` — "section IDs" — and objectstack#5611 replaced that |
| 17 | + * with the object form outright (no producer, no consumer, so one shape rather |
| 18 | + * than two de-facto contracts). The registry text kept teaching the ID list. |
| 19 | + * |
| 20 | + * WHY A DESCRIPTION IS WORTH A TEST. `inputs` is not documentation, it is the |
| 21 | + * published contract: `gen-manifest.ts` serializes it into `sdui.manifest.json` |
| 22 | + * (the save-gate + parser whitelist) and into `sdui-intrinsics.d.ts` (the JSX |
| 23 | + * authoring surface), and for an array-of-objects input the ENTRY shape exists |
| 24 | + * nowhere else — `ComponentInput` has no member-shape slot, which is why the |
| 25 | + * repo-wide gate in `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` |
| 26 | + * (objectui#3797 / PR #3806) can only see top-level keys and says so in its |
| 27 | + * LIMIT note. An author following the retired spelling gets four silent layers: |
| 28 | + * `['a','b']` is a valid `array` to the manifest gate, upstream |
| 29 | + * `validateComponentProps` is advisory, the spec is only parsed on paths that |
| 30 | + * parse, and `RecordDetailsRenderer` reads `s.name` / `s.label` / `s.fields` |
| 31 | + * off each entry — all `undefined` on a string, so the section renders nothing. |
| 32 | + * Under `layout: 'custom'` sections are the ONLY source of the body, so the |
| 33 | + * page comes up blank with no diagnostic anywhere pointing at `sections`. |
| 34 | + * |
| 35 | + * Every expectation below is DERIVED from the spec schema at runtime rather |
| 36 | + * than restating today's key list, so a spec change fails here instead of |
| 37 | + * quietly reopening the gap. |
| 38 | + */ |
| 39 | + |
| 40 | +import { describe, it, expect } from 'vitest'; |
| 41 | +import { ComponentRegistry } from '@object-ui/core'; |
| 42 | +import { RecordDetailsProps } from '@objectstack/spec/ui'; |
| 43 | +import '../index'; |
| 44 | + |
| 45 | +type ShapeCarrier = { shape?: unknown; _def?: { shape?: unknown } }; |
| 46 | + |
| 47 | +/** Resolve a Zod object's `.shape` through both spellings, lazy or plain. */ |
| 48 | +function shapeKeys(schema: unknown): string[] { |
| 49 | + const carrier = schema as ShapeCarrier | undefined; |
| 50 | + const shape = carrier?.shape ?? carrier?._def?.shape; |
| 51 | + const resolved = typeof shape === 'function' ? (shape as () => object)() : shape; |
| 52 | + return resolved && typeof resolved === 'object' ? Object.keys(resolved) : []; |
| 53 | +} |
| 54 | + |
| 55 | +/** One entry of `.shape`, unwrapped past `.optional()`. */ |
| 56 | +function shapeMember(schema: unknown, key: string): unknown { |
| 57 | + const carrier = schema as ShapeCarrier | undefined; |
| 58 | + const shape = carrier?.shape ?? carrier?._def?.shape; |
| 59 | + const resolved = (typeof shape === 'function' ? (shape as () => object)() : shape) as |
| 60 | + | Record<string, unknown> |
| 61 | + | undefined; |
| 62 | + const member = resolved?.[key] as { unwrap?: () => unknown } | undefined; |
| 63 | + return typeof member?.unwrap === 'function' ? member.unwrap() : member; |
| 64 | +} |
| 65 | + |
| 66 | +/** The element schema of a `z.array(...)`, through both spellings. */ |
| 67 | +function arrayElement(schema: unknown): unknown { |
| 68 | + const arr = schema as { |
| 69 | + element?: unknown; |
| 70 | + def?: { element?: unknown }; |
| 71 | + _def?: { type?: unknown; element?: unknown }; |
| 72 | + } | undefined; |
| 73 | + return arr?.element ?? arr?.def?.element ?? arr?._def?.element ?? arr?._def?.type; |
| 74 | +} |
| 75 | + |
| 76 | +/** Top-level keys of the spec's `RecordDetailsProps`. */ |
| 77 | +const specTopLevelKeys = (): string[] => shapeKeys(RecordDetailsProps); |
| 78 | + |
| 79 | +/** Member keys of one `sections[]` entry, per the spec. */ |
| 80 | +const specSectionKeys = (): string[] => |
| 81 | + shapeKeys(arrayElement(shapeMember(RecordDetailsProps, 'sections'))); |
| 82 | + |
| 83 | +/** |
| 84 | + * Section keys `RecordDetailsRenderer` honours beyond the spec's four. Read off |
| 85 | + * `renderers/record-details.tsx` (`s.title ?? s.label`, `s.showBorder`, |
| 86 | + * `s.hideEmpty`) — a hand-kept list, but the ASSERTION filters it through the |
| 87 | + * spec at runtime, so the day upstream declares one of these it drops out of |
| 88 | + * the forbidden set on its own instead of pinning a stale prohibition. |
| 89 | + */ |
| 90 | +const RENDERER_ONLY_SECTION_KEYS = ['title', 'showBorder', 'hideEmpty']; |
| 91 | + |
| 92 | +const config = () => ComponentRegistry.getConfig('record:details'); |
| 93 | +const inputs = () => config()?.inputs ?? []; |
| 94 | +const input = (name: string) => inputs().find((i) => i.name === name); |
| 95 | +const sectionsDescription = () => input('sections')?.description ?? ''; |
| 96 | + |
| 97 | +describe('record:details — registry inputs vs @objectstack/spec', () => { |
| 98 | + it('is registered with a non-empty `inputs` surface', () => { |
| 99 | + expect(config()).toBeDefined(); |
| 100 | + expect(inputs().map((i) => i.name)).toContain('sections'); |
| 101 | + }); |
| 102 | + |
| 103 | + it('the spec really takes OBJECT sections — the id-list spelling is gone, not unioned in', () => { |
| 104 | + // Guards the premise the rest of the file rests on. A `z.array(z.string())` |
| 105 | + // arm coming back (or the object form moving) must fail here first, because |
| 106 | + // the description below would then be documenting the wrong shape again. |
| 107 | + expect(specSectionKeys().length).toBeGreaterThan(0); |
| 108 | + |
| 109 | + // A VALUE verdict, so the criterion is a full parse, not key recognition: |
| 110 | + // the retired spelling has to be rejected on its value, and the object form |
| 111 | + // has to survive intact. |
| 112 | + const idList = RecordDetailsProps.safeParse({ |
| 113 | + layout: 'custom', |
| 114 | + sections: ['contact_info', 'address'], |
| 115 | + }); |
| 116 | + expect(idList.success).toBe(false); |
| 117 | + expect(idList.error?.issues.map((i) => i.code)).toContain('invalid_type'); |
| 118 | + |
| 119 | + const objectForm = RecordDetailsProps.safeParse({ |
| 120 | + layout: 'custom', |
| 121 | + sections: [{ name: 'contact_info', label: 'Contact', columns: 2, fields: ['phone'] }], |
| 122 | + }); |
| 123 | + expect(objectForm.success).toBe(true); |
| 124 | + expect(objectForm.data?.sections?.[0]).toMatchObject({ |
| 125 | + name: 'contact_info', |
| 126 | + columns: 2, |
| 127 | + fields: ['phone'], |
| 128 | + }); |
| 129 | + }); |
| 130 | + |
| 131 | + it('every spec section member key is discoverable from the `sections` description', () => { |
| 132 | + const description = sectionsDescription(); |
| 133 | + expect(description).not.toBe(''); |
| 134 | + const undocumented = specSectionKeys().filter((key) => !description.includes(key)); |
| 135 | + expect(undocumented).toEqual([]); |
| 136 | + }); |
| 137 | + |
| 138 | + it('the `sections` description no longer teaches the retired section-id spelling', () => { |
| 139 | + // The regression this issue was filed for, named explicitly so it stays |
| 140 | + // legible if the derived check above is ever loosened. The entry shape must |
| 141 | + // be stated as an object, and the string form must be ruled out in the same |
| 142 | + // breath — an author reading only "object form" would not know their |
| 143 | + // existing `['contact_info']` page is now silently empty. |
| 144 | + const description = sectionsDescription(); |
| 145 | + expect(description).not.toMatch(/section ids/i); |
| 146 | + expect(description).toMatch(/object/i); |
| 147 | + expect(description).toMatch(/string/i); |
| 148 | + }); |
| 149 | + |
| 150 | + it('publishes no section member key the spec strips on parse', () => { |
| 151 | + // The renderer honours `title` / `showBorder` / `hideEmpty` per section, |
| 152 | + // but the spec's section object does not declare them, so they are dropped |
| 153 | + // with no error. Documenting them here would tell authors to write keys the |
| 154 | + // contract discards — the member-level twin of publishing a top-level input |
| 155 | + // the props schema rejects. |
| 156 | + const stripped = RENDERER_ONLY_SECTION_KEYS.filter( |
| 157 | + (key) => !specSectionKeys().includes(key), |
| 158 | + ); |
| 159 | + expect(stripped).not.toEqual([]); // the premise: these really are undeclared |
| 160 | + |
| 161 | + const parsed = RecordDetailsProps.safeParse({ |
| 162 | + sections: [{ label: 'Contact', fields: ['phone'], title: 'T', showBorder: true, hideEmpty: false }], |
| 163 | + }); |
| 164 | + expect(parsed.success).toBe(true); |
| 165 | + expect(Object.keys(parsed.data?.sections?.[0] ?? {}).sort()).toEqual(['fields', 'label']); |
| 166 | + |
| 167 | + // Word-boundary, not substring: this direction asks "does the text teach |
| 168 | + // this KEY", and prose legitimately contains words that merely embed one |
| 169 | + // ("untitled" embeds `title`). The forward check above can stay a substring |
| 170 | + // test because a false positive there only ever accepts a description that |
| 171 | + // does mention the key. |
| 172 | + const description = sectionsDescription(); |
| 173 | + const published = stripped.filter((key) => new RegExp(`\\b${key}\\b`).test(description)); |
| 174 | + expect(published).toEqual([]); |
| 175 | + }); |
| 176 | + |
| 177 | + it('declares no top-level input the spec does not accept', () => { |
| 178 | + const allowed = new Set(specTopLevelKeys()); |
| 179 | + const offSpec = inputs().map((i) => i.name).filter((name) => !allowed.has(name)); |
| 180 | + expect(offSpec).toEqual([]); |
| 181 | + }); |
| 182 | + |
| 183 | + it('`fields` documents no entry shape, because the spec accepts bare names only', () => { |
| 184 | + // objectui#3807's fence check on the sibling input at the same call site. |
| 185 | + // Top-level `fields` is `z.array(z.string())`: there is no member shape to |
| 186 | + // publish, and the renderer's tolerance for `{name}` / `{field}` entries is |
| 187 | + // not a second contract to advertise — the spec rejects those values. |
| 188 | + const element = arrayElement(shapeMember(RecordDetailsProps, 'fields')); |
| 189 | + expect(shapeKeys(element)).toEqual([]); |
| 190 | + expect(RecordDetailsProps.safeParse({ fields: ['phone'] }).success).toBe(true); |
| 191 | + expect(RecordDetailsProps.safeParse({ fields: [{ name: 'phone' }] }).success).toBe(false); |
| 192 | + |
| 193 | + const description = input('fields')?.description ?? ''; |
| 194 | + expect(description).not.toBe(''); |
| 195 | + expect(description).not.toContain('{'); |
| 196 | + }); |
| 197 | +}); |
0 commit comments