|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#17166] `ComponentPropsMap['object-grid'].exportOptions`'s `.describe()` must |
| 5 | + * name every member the renderer reads — because here the prose IS the shape. |
| 6 | + * |
| 7 | + * ## Why this key is different from its neighbours |
| 8 | + * |
| 9 | + * The entry is `z.unknown()`. Nothing is parsed, nothing is refused, nothing is |
| 10 | + * stripped: an author who writes a member that does not exist gets no error and |
| 11 | + * no effect, and an author who omits one that does exist has no way to discover |
| 12 | + * it. So the `.describe()` string is not a SUMMARY of an enforced shape — it is |
| 13 | + * the entire account of the shape that exists at this position, and it projects |
| 14 | + * straight into `content/docs/references/ui/component.mdx`, which is what an |
| 15 | + * author (or a generating model, ADR-0033) reads. |
| 16 | + * |
| 17 | + * Until #17166 that string named two members, `formats` and `streaming`, of the |
| 18 | + * five the only renderer reads. |
| 19 | + * |
| 20 | + * ## What was measured, and on which tree |
| 21 | + * |
| 22 | + * Measured at the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` |
| 23 | + * — objectui `packages/plugin-grid/src/ObjectGrid.tsx`, through the |
| 24 | + * `schema.exportOptions` expression and the `exportConfig` local bound to it, |
| 25 | + * with objectui's own scanner (`ObjectGrid.exportOptionsKeys.test.ts`, whose |
| 26 | + * comment/string stripping is what keeps a prose mention of a key from being |
| 27 | + * counted as a read): |
| 28 | + * |
| 29 | + * formats 2 · streaming 2 · maxRecords 1 · includeHeaders 1 · fileNamePrefix 1 |
| 30 | + * |
| 31 | + * and an absent-name control (`zzzNotAMember`) reading 0 on the same instrument, |
| 32 | + * which is what makes those five counts readings rather than a matcher that |
| 33 | + * matches anything. ⚠️ Those counts are a dated observation and belong to that |
| 34 | + * tree; this pin does NOT re-derive them, and ⛔ must not be read as asserting |
| 35 | + * them today. |
| 36 | + * |
| 37 | + * ## Why the list is DERIVED here and not restated |
| 38 | + * |
| 39 | + * A restated list is a third copy of the contract, and the copy is what drifts — |
| 40 | + * which is the whole defect this file closes. So the expected member list is |
| 41 | + * read from `ListViewSchema.exportOptions`'s object branch |
| 42 | + * (`ListViewExportOptionsSchema`), the spec's OWN five-key declaration of this |
| 43 | + * same authoring block, itself derived from that same read set at #8010. Both |
| 44 | + * spellings — the page-component `object-grid` props and the list view's |
| 45 | + * `exportOptions` — reach one renderer, so the two surfaces describe one block. |
| 46 | + * |
| 47 | + * ⇒ Narrowing or widening the declared block reds this pin instead of leaving |
| 48 | + * the `z.unknown()` prose quietly behind, which is the direction of rot that has |
| 49 | + * no other guard: the declared side has parse failures, this side has nothing. |
| 50 | + * |
| 51 | + * ⛔ This pin does NOT ask the key to stop being `z.unknown()`. Giving it a real |
| 52 | + * shape is an accept-set change with its own review requirements; the last test |
| 53 | + * below records that it is unvalidated TODAY, so that change reds here and is |
| 54 | + * made deliberately rather than by accident. |
| 55 | + */ |
| 56 | + |
| 57 | +import { describe, it, expect } from 'vitest'; |
| 58 | +import { ComponentPropsMap } from './component.zod'; |
| 59 | +import { ListViewSchema } from './view.zod'; |
| 60 | + |
| 61 | +/** |
| 62 | + * The member names enumerated inside the first `({ … })` group of a describe |
| 63 | + * string, e.g. `Export config ({ formats, streaming })` -> `['formats', 'streaming']`. |
| 64 | + * |
| 65 | + * Deliberately anchored to the parenthesised group rather than "any identifier |
| 66 | + * in the sentence": the prose around it names `z.unknown()` and |
| 67 | + * `ListViewSchema.exportOptions`, and a scan that read those as members would |
| 68 | + * pass for the wrong reason. The self-test below is what proves the anchor |
| 69 | + * discriminates instead of matching anything. |
| 70 | + */ |
| 71 | +function describedMembers(description: string): string[] { |
| 72 | + const group = /\(\{([^}]*)\}\)/.exec(description); |
| 73 | + if (!group) return []; |
| 74 | + return group[1]!.split(',').map((k) => k.trim()).filter(Boolean); |
| 75 | +} |
| 76 | + |
| 77 | +/** The spec's own declaration of this block: the object branch of the list view's union. */ |
| 78 | +function declaredMembers(): string[] { |
| 79 | + const optional = (ListViewSchema as unknown as { shape: Record<string, unknown> }) |
| 80 | + .shape.exportOptions as { unwrap(): { options: Array<{ shape?: Record<string, unknown> }> } }; |
| 81 | + const branches = optional.unwrap().options; |
| 82 | + const objectBranch = branches.find((b) => b.shape !== undefined); |
| 83 | + return objectBranch === undefined ? [] : Object.keys(objectBranch.shape!); |
| 84 | +} |
| 85 | + |
| 86 | +const gridProps = ComponentPropsMap['object-grid'] as unknown as { |
| 87 | + shape: Record<string, { description?: string }>; |
| 88 | + safeParse(v: unknown): { success: boolean }; |
| 89 | +}; |
| 90 | +const description = gridProps.shape.exportOptions?.description ?? ''; |
| 91 | + |
| 92 | +describe('object-grid `exportOptions` — the describe names every declared member (#17166)', () => { |
| 93 | + it('the parser discriminates: it reads a member group and does not invent one', () => { |
| 94 | + // Lit control — a planted group is read back exactly. |
| 95 | + expect(describedMembers('Export config ({ alpha, beta })')).toEqual(['alpha', 'beta']); |
| 96 | + // Dark control — a sentence with no member group yields nothing, so a |
| 97 | + // green equality below can never come from a matcher that matches anything. |
| 98 | + expect(describedMembers('Export config, unvalidated.')).toEqual([]); |
| 99 | + // And a name absent from the group is not produced by prose that mentions it. |
| 100 | + expect(describedMembers('Export config ({ alpha }) — zzzNotAMember is not a member')) |
| 101 | + .toEqual(['alpha']); |
| 102 | + }); |
| 103 | + |
| 104 | + it('scans something: both sides are non-empty and the authority is the five-key block', () => { |
| 105 | + // Non-vacuity floor. The assertion below is an equality, and an equality |
| 106 | + // between two empty lists passes for the worst possible reason. |
| 107 | + expect(description).not.toBe(''); |
| 108 | + expect(describedMembers(description).length).toBeGreaterThanOrEqual(5); |
| 109 | + expect(declaredMembers().length).toBeGreaterThanOrEqual(5); |
| 110 | + expect(declaredMembers()).toContain('formats'); |
| 111 | + }); |
| 112 | + |
| 113 | + it('names exactly the members `ListViewExportOptionsSchema` declares', () => { |
| 114 | + // Named rather than counted: a failure must say WHICH member the prose is |
| 115 | + // short of, because the fix is to name it — the reader gets nothing else. |
| 116 | + expect([...describedMembers(description)].sort()).toEqual([...declaredMembers()].sort()); |
| 117 | + }); |
| 118 | + |
| 119 | + it('is still unvalidated, which is why the prose carries the whole account', () => { |
| 120 | + // The premise of this file, asserted rather than assumed: an undeclared |
| 121 | + // member is neither refused nor honoured here. If this ever goes red the |
| 122 | + // key grew an accept set and the describe's "Unvalidated here" sentence — |
| 123 | + // and this pin's reason to exist — need re-deciding, deliberately. |
| 124 | + expect(gridProps.safeParse({ exportOptions: { zzzNotAMember: 1 } }).success).toBe(true); |
| 125 | + expect(description).toContain('Unvalidated here'); |
| 126 | + }); |
| 127 | +}); |
0 commit comments