diff --git a/.changeset/alert-dialog-fixture-premise-pin-7693.md b/.changeset/alert-dialog-fixture-premise-pin-7693.md new file mode 100644 index 0000000000..82d6073136 --- /dev/null +++ b/.changeset/alert-dialog-fixture-premise-pin-7693.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only change in `@object-ui/types`: the pin that recorded objectui#7693's filed premise (the four alert-dialog schema-catalog fixtures authoring an `actions` array) is re-derived onto the read dialect now that the card has landed. No published behaviour changes — `overlay.ts` and `zod/overlay.zod.ts` are untouched, and the only file edited under `src/` is a test. diff --git a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/basic-alert-dialog.json b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/basic-alert-dialog.json index a273a24973..ebc687d7da 100644 --- a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/basic-alert-dialog.json +++ b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/basic-alert-dialog.json @@ -6,16 +6,6 @@ "type": "button", "label": "Delete Account" }, - "actions": [ - { - "type": "button", - "label": "Cancel", - "variant": "outline" - }, - { - "type": "button", - "label": "Continue", - "variant": "destructive" - } - ] + "cancelText": "Cancel", + "actionText": "Continue" } diff --git a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/confirmation-dialog.json b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/confirmation-dialog.json index ad076ac6dd..ff9eb7800e 100644 --- a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/confirmation-dialog.json +++ b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/confirmation-dialog.json @@ -7,16 +7,6 @@ "label": "Close", "variant": "outline" }, - "actions": [ - { - "type": "button", - "label": "Don't Save", - "variant": "ghost" - }, - { - "type": "button", - "label": "Save", - "variant": "default" - } - ] + "cancelText": "Don't Save", + "actionText": "Save" } diff --git a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/custom-actions.json b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/custom-actions.json index 3e660d74cf..2d56f44da2 100644 --- a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/custom-actions.json +++ b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/custom-actions.json @@ -7,16 +7,6 @@ "label": "Proceed", "variant": "default" }, - "actions": [ - { - "type": "button", - "label": "Go Back", - "variant": "outline" - }, - { - "type": "button", - "label": "Yes, Continue", - "variant": "default" - } - ] + "cancelText": "Go Back", + "actionText": "Yes, Continue" } diff --git a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/destructive-action.json b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/destructive-action.json index c461f14391..cfd178b206 100644 --- a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/destructive-action.json +++ b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/destructive-action.json @@ -7,15 +7,6 @@ "label": "Delete", "variant": "destructive" }, - "actions": [ - { - "type": "button", - "label": "Cancel" - }, - { - "type": "button", - "label": "Delete", - "variant": "destructive" - } - ] + "cancelText": "Cancel", + "actionText": "Delete" } diff --git a/examples/schema-catalog/test/alert-dialog-footer-read-dialect-7693.test.tsx b/examples/schema-catalog/test/alert-dialog-footer-read-dialect-7693.test.tsx new file mode 100644 index 0000000000..80993b2446 --- /dev/null +++ b/examples/schema-catalog/test/alert-dialog-footer-read-dialect-7693.test.tsx @@ -0,0 +1,180 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7693 — the four `alert-dialog` catalog fixtures authored an + * `actions` array that no surface carries, so the docs page's own examples + * rendered an EMPTY footer. + * + * ## Why nothing was red before this file + * + * `BaseSchema` is `.passthrough()` (`packages/types/src/zod/base.zod.ts:241`), + * so `actions` rode through `safeValidateSchema` unvalidated and every gate + * stayed green while the rendered dialog had no buttons at all. The renderer + * (`packages/components/src/renderers/overlay/alert-dialog.tsx`) draws + * `AlertDialogCancel` ONLY from `schema.cancelText` and `AlertDialogAction` + * ONLY from `schema.actionText`; `schema.actions` has zero read sites. + * + * ## The two pins, and why both + * + * The DOM pin is the one that names the SYMPTOM the card reported (an empty + * footer), and it is the one that would have gone red on `origin/main`: all + * four fixtures drew zero footer buttons. The fixture-key pin is the cheap + * companion that closes the CLASS — it walks every catalog entry, not just the + * four, so a future `alert-dialog` node re-authored under `actions` reds here + * even if nobody adds a render assertion for it. + * + * Each pin is paired with a live control, because a render that draws nothing + * satisfies a "no `actions` key" assertion trivially and a harness that renders + * nothing at all satisfies both: + * + * - `RENDERS_IN_THE_READ_DIALECT` — an inline node in the read dialect MUST + * draw two footer buttons. If the harness cannot see a footer button, this + * control reds first and the fixture rows below mean nothing. + * - `THE_PRE_REPAIR_SHAPE_DRAWS_NOTHING` — the same node authored the way the + * fixtures were authored (an `actions` array, no label keys) MUST draw zero + * footer buttons, and MUST still validate. That is the defect reproduced in + * one place, and it is what makes the fixture rows a measurement of the + * SPELLING rather than of some unrelated render failure. + * + * ⛔ Do NOT "fix" a red row here by declaring `actions` on `AlertDialogSchema`. + * Making a variant-carrying action list live is a renderer + types feature on + * the manual floor, explicitly ruled out of this card's scope; the fixtures + * follow the dialect the renderer READS. + */ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import '@object-ui/components'; +import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; +import { safeValidateSchema } from '@object-ui/types/zod'; +import { allExamples, getExample } from '../src/index.js'; + +/** The four entries the docs page embeds, in the order it embeds them. */ +const FIXTURE_IDS = [ + 'components-overlay-alert-dialog/basic-alert-dialog', + 'components-overlay-alert-dialog/destructive-action', + 'components-overlay-alert-dialog/confirmation-dialog', + 'components-overlay-alert-dialog/custom-actions', +] as const; + +/** + * Render one entry the way the docs gallery does, forced OPEN. + * + * `defaultOpen` is the only thing the test adds: the fixtures are authored + * closed (that is what the docs page wants — a trigger you click), and a closed + * Radix dialog mounts no content, so the footer could not be measured at all. + * Everything the assertions read comes from the fixture's own keys. + */ +function footerButtons(schema: unknown): string[] { + render( + , + ); + // Radix portals the content to document.body, so the RTL container is empty + // by construction — query the dialog itself. An `alert-dialog` has no close + // affordance of its own, so every button inside the content IS a footer + // button (the trigger stays behind, in the container). + const content = document.body.querySelector('[role="alertdialog"]'); + if (!content) return []; + return Array.from(content.querySelectorAll('button')).map((b) => b.textContent ?? ''); +} + +/** Report the issues rather than `false`, so a red run says what broke. */ +function reasons(schema: unknown): string[] { + const r = safeValidateSchema(schema); + return r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`); +} + +const READ_DIALECT_NODE = { + type: 'alert-dialog', + title: 'Are you sure?', + trigger: { type: 'button', label: 'Open' }, + cancelText: 'Cancel', + actionText: 'Continue', +}; + +const PRE_REPAIR_NODE = { + type: 'alert-dialog', + title: 'Are you sure?', + trigger: { type: 'button', label: 'Open' }, + actions: [ + { type: 'button', label: 'Cancel', variant: 'outline' }, + { type: 'button', label: 'Continue', variant: 'destructive' }, + ], +}; + +describe('objectui#7693 — controls: the harness can tell a drawn footer from an empty one', () => { + it('RENDERS_IN_THE_READ_DIALECT: `cancelText` + `actionText` draw two footer buttons', () => { + expect(footerButtons(READ_DIALECT_NODE)).toEqual(['Cancel', 'Continue']); + }); + + it('THE_PRE_REPAIR_SHAPE_DRAWS_NOTHING: an `actions` array draws an empty footer', () => { + // The defect, reproduced in one place. `actions` is not refused — it is + // simply never read, which is why every gate stayed green. + expect(footerButtons(PRE_REPAIR_NODE)).toEqual([]); + expect(reasons(PRE_REPAIR_NODE)).toEqual([]); + }); +}); + +describe('objectui#7693 — every alert-dialog fixture renders a real footer', () => { + it.each(FIXTURE_IDS)('%s draws at least one footer button', (id) => { + const labels = footerButtons(getExample(id).schema); + expect(labels.length).toBeGreaterThanOrEqual(1); + // ...and each one carries text, so a button rendered from an empty label + // cannot satisfy the count. + for (const label of labels) expect(label.trim().length).toBeGreaterThan(0); + }); + + it.each(FIXTURE_IDS)('%s still validates under safeValidateSchema', (id) => { + expect(reasons(getExample(id).schema)).toEqual([]); + }); +}); + +describe('objectui#7693 — no alert-dialog node in the catalog authors `actions`', () => { + /** Every object in an example's schema tree, the example's id carried along. */ + function* nodes(value: unknown, id: string): Generator<[string, Record]> { + if (Array.isArray(value)) { + for (const item of value) yield* nodes(item, id); + } else if (value !== null && typeof value === 'object') { + yield [id, value as Record]; + for (const child of Object.values(value as Record)) yield* nodes(child, id); + } + } + + it('the whole catalog is clean, not just the four the docs page embeds', () => { + const offenders: string[] = []; + for (const example of allExamples()) { + for (const [id, node] of nodes(example.schema, example.id)) { + if (node.type === 'alert-dialog' && 'actions' in node) offenders.push(id); + } + } + expect(offenders).toEqual([]); + }); + + it('anti-vacuity: the walk really does reach alert-dialog nodes', () => { + // Without this, the assertion above passes just as well when the walk is + // broken, or when the catalog holds no alert-dialog at all. + const seen = new Set(); + for (const example of allExamples()) { + for (const [id, node] of nodes(example.schema, example.id)) { + if (node.type === 'alert-dialog') seen.add(id); + } + } + expect([...seen].sort()).toEqual([...FIXTURE_IDS].sort()); + }); + + it('anti-vacuity: the offender test would catch one', () => { + // The same predicate, over the pre-repair shape, must report it. + const offenders: string[] = []; + for (const [id, node] of nodes(PRE_REPAIR_NODE, 'inline/pre-repair')) { + if (node.type === 'alert-dialog' && 'actions' in node) offenders.push(id); + } + expect(offenders).toEqual(['inline/pre-repair']); + }); +}); diff --git a/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx b/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx index 1d05d12765..11027573d9 100644 --- a/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx +++ b/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx @@ -121,7 +121,15 @@ const NODE_CENSUS: Readonly 118 with objectui#7693: the four `components-overlay-alert-dialog/*` + // fixtures each authored two `type: 'button'` nodes under an `actions` array + // the `alert-dialog` renderer never reads, so those eight nodes drew nothing + // and the demos showed an empty footer. They are now the two label keys the + // renderer DOES read (`cancelText` / `actionText`), which are strings rather + // than nodes and so leave this walk. Catalog-authored, no renderer touched — + // the case this table's header sanctions. + button: { rendered: 118, noElement: 0 }, input: { rendered: 48, noElement: 0 }, checkbox: { rendered: 12, noElement: 0 }, switch: { rendered: 7, noElement: 0 }, diff --git a/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts b/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts index 73b9762f56..6817845ca6 100644 --- a/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts +++ b/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts @@ -55,10 +55,18 @@ * and its own changeset grade (the objectui#7104 ruling); the pins below * record today's state so that the PR which retires them re-derives these * lines deliberately rather than passing unnoticed. - * - The four schema-catalog fixtures author `actions`, a key no surface - * carries, so the docs page's own examples render an empty footer — - * objectui#7693. Pinned here as that card's filed premise; its fix goes red - * here and re-derives the pin. + * - RESOLVED, and re-derived rather than deleted: the four schema-catalog + * fixtures used to author `actions`, a key no surface carries, so the docs + * page's own examples rendered an empty footer — objectui#7693. That card + * landed and this file went red exactly as predicted above, on all four + * membership legs. The block at the bottom is the SAME pin re-derived onto + * the other side of the flip: the fixtures now author the read dialect, and + * what it asserts is the TYPES-side reading (every key they author is a + * member of the mirror's shape), not the render-side one. The render-side + * half lives with the catalog, in + * `examples/schema-catalog/test/alert-dialog-footer-read-dialect-7693.test.tsx` + * — deliberately not duplicated here, because this package cannot see a + * renderer and that one cannot see the mirror's shape. */ import { describe, expect, it } from 'vitest'; @@ -318,17 +326,52 @@ describe('the docs page publishes the read dialect (objectui#7104)', () => { }); }); -describe('the schema-catalog fixtures still author `actions` — objectui#7693, pinned as its filed premise', () => { - it.each(FIXTURES)('%s.json carries an `actions` array and neither `cancelText` nor `actionText`', (name) => { - const fixture = JSON.parse(read(`${FIXTURE_DIR}/${name}.json`)) as Record; - expect(Array.isArray(fixture.actions)).toBe(true); - expect(fixture).not.toHaveProperty('cancelText'); - expect(fixture).not.toHaveProperty('actionText'); +describe('the schema-catalog fixtures author the READ dialect — objectui#7693, the premise pin re-derived', () => { + const fixture = (name: string): Record => + JSON.parse(read(`${FIXTURE_DIR}/${name}.json`)) as Record; + + it.each(FIXTURES)('%s.json authors `cancelText` and `actionText` and no `actions` array', (name) => { + const json = fixture(name); + expect(json).not.toHaveProperty('actions'); + expect(typeof json.cancelText).toBe('string'); + expect(typeof json.actionText).toBe('string'); + }); + + it('every key any fixture authors is a MEMBER of the mirror — the reading passthrough used to hide', () => { + // The types-side half of objectui#7693, and the only half this package can + // measure. `BaseSchema` is `.passthrough()`, so `safeParse` says nothing + // about whether an authored key is carried by anything; membership in the + // mirror's own shape is the question, and it is asked here rather than in + // the catalog because `AlertDialogZod.shape` is not visible from there. + const authored = new Set(FIXTURES.flatMap((name) => Object.keys(fixture(name)))); + const undeclared = [...authored].filter((key) => !(key in shape)).sort(); + expect(undeclared).toEqual([]); + }); + + it('control: `actions` is not a member, so the leg above really would catch it', () => { + // Without this, the assertion above passes just as well against a mirror + // that declares everything, and the four fixture legs read as a tautology. + expect('actions' in shape).toBe(false); + expect(['type', 'title', 'trigger', 'cancelText', 'actionText'].every((k) => k in shape)).toBe(true); + }); + + it('control: the pre-repair shape STILL parses green — passthrough did not change', () => { + // Why nothing red covered objectui#7693 before it landed, kept as a live + // reading rather than as history: re-authoring a fixture under `actions` + // would sail through `safeParse` again, which is why the membership leg + // above is the guard and `.success` is not. + const preRepair = { + type: 'alert-dialog', + title: 'Are you sure?', + trigger: { type: 'button', label: 'Open' }, + actions: [{ type: 'button', label: 'Cancel' }, { type: 'button', label: 'Continue' }], + }; + expect(AlertDialogZod.safeParse(preRepair).success).toBe(true); }); - it('and every one of them parses GREEN regardless — passthrough is why nothing red covers objectui#7693', () => { + it('and every one of them parses GREEN — now with the keys the renderer reads', () => { for (const name of FIXTURES) { - expect(AlertDialogZod.safeParse(JSON.parse(read(`${FIXTURE_DIR}/${name}.json`))).success, name).toBe(true); + expect(AlertDialogZod.safeParse(fixture(name)).success, name).toBe(true); } }); });