From 7663a489289113deaaa4e4c717f28a6325c72f77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 12:33:43 +0000 Subject: [PATCH] test(test-support): one enum-options walk for the top-level reader family (#6924) 17 hand-written `(Schema as { options?: readonly string[] }).options` casts across 16 spec-parity test files in 11 workspace packages read `.options` straight off a node that IS the enum. The cast is unchecked: an enum that acquires a wrapper, or a Zod build that moves `.options`, yields `undefined` and the derived vocabulary silently becomes the empty set. `@object-ui/test-support` already carried the wrapper walk, behind a signature this family could not use: `shapeEnumOptions(schema, key)` opens with `resolvePropsShape` and then indexes `shape[key]`, so a bare enum has no shape to resolve and no key to index. That is a missing ENTRY POINT, not a missing reader. So the walk is exported as `enumOptions(node)` and `shapeEnumOptions` delegates to it -- one walk, two entry points, no second copy. Verdict preservation measured, not assumed: against the installed pin, all 23 readings return the identical array in the identical order before and after. `palette-discussion-alias.test.tsx` gains the non-vacuity assertion the reader's docblock makes every caller owe; the other 15 files already carried one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/olive-moons-shave.md | 12 +++ .../component-fixture-declared-keys.test.ts | 14 +-- .../color-variant-field.test.tsx | 6 +- .../previews/__tests__/block-config.test.ts | 12 ++- .../palette-discussion-alias.test.tsx | 15 ++- .../toaster-position-spec-parity.test.tsx | 4 +- .../__tests__/notification-surfaces.test.tsx | 4 +- packages/fields/src/FieldEditWidget.test.ts | 5 +- packages/plugin-charts/package.json | 1 + .../__tests__/chart-type-spec-parity.test.tsx | 6 +- packages/plugin-dashboard/package.json | 1 + .../DatasetWidget.colorVariant.test.tsx | 6 +- .../pivot-aggregation-spec-parity.test.tsx | 4 +- .../widget-dispatch-spec-parity.test.ts | 6 +- .../__tests__/inlineEditTypeCoverage.test.tsx | 5 +- packages/plugin-grid/package.json | 1 + .../src/__tests__/summary-spec-parity.test.ts | 4 +- packages/plugin-report/package.json | 1 + .../src/__tests__/report-spec-parity.test.tsx | 11 +- packages/react/package.json | 1 + ...nimation-notification-spec-parity.test.tsx | 10 +- packages/test-support/README.md | 42 +++++--- .../src/__tests__/spec-enum-options.test.ts | 101 +++++++++++++++++- packages/test-support/src/index.ts | 2 +- .../test-support/src/spec-enum-options.ts | 77 ++++++++++--- packages/types/package.json | 1 + .../src/__tests__/spec-derived-unions.test.ts | 15 ++- pnpm-lock.yaml | 18 ++++ 28 files changed, 289 insertions(+), 96 deletions(-) create mode 100644 .changeset/olive-moons-shave.md diff --git a/.changeset/olive-moons-shave.md b/.changeset/olive-moons-shave.md new file mode 100644 index 0000000000..507ad25d28 --- /dev/null +++ b/.changeset/olive-moons-shave.md @@ -0,0 +1,12 @@ +--- +--- + +Internal test-support change, no user-visible behaviour (objectui#6924). + +The 17 hand-written `(Schema as { options?: readonly string[] }).options` casts +across 16 spec-parity test files converge onto `@object-ui/test-support`'s +`enumOptions(node)` — a sibling entry point onto the wrapper walk +`shapeEnumOptions` already carried, which now delegates to it rather than +holding a second copy. Only test files, the private (never-published) +`@object-ui/test-support`, and `devDependencies` edges change; no package's +shipped `dist/` and no public type moves. diff --git a/examples/schema-catalog/test/component-fixture-declared-keys.test.ts b/examples/schema-catalog/test/component-fixture-declared-keys.test.ts index 09efaeb86b..12d5492f5a 100644 --- a/examples/schema-catalog/test/component-fixture-declared-keys.test.ts +++ b/examples/schema-catalog/test/component-fixture-declared-keys.test.ts @@ -82,6 +82,7 @@ import { ToastSchema, ToasterSchema, } from '@object-ui/types/zod'; +import { enumOptions } from '@object-ui/test-support'; import { allExamples, getExample } from '../src/index.js'; type Json = Record; @@ -94,14 +95,15 @@ const schemaOf = (id: string): Json => getExample(id).schema as unknown as Json; * SHIPPED enum rather than to a hand-copied string list is the point: if the * platform ever adds or drops a member, this file follows it instead of * asserting yesterday's vocabulary. + * + * The wrapper walk is `@object-ui/test-support`'s shared reader (objectui#6924); + * the THROW stays here, because that is this file's non-vacuity duty and the + * reader deliberately answers `[]` rather than raising. */ function enumOptionsOf(field: unknown): readonly string[] { - let node = field as { options?: readonly string[]; unwrap?: () => unknown }; - for (let i = 0; i < 4 && node && !node.options; i += 1) { - node = node.unwrap?.() as typeof node; - } - if (!node?.options) throw new Error('not an enum-bearing field'); - return node.options; + const options = enumOptions(field); + if (options.length === 0) throw new Error('not an enum-bearing field'); + return options; } const TOAST_FIXTURES = [ diff --git a/packages/app-shell/src/views/metadata-admin/color-variant-field.test.tsx b/packages/app-shell/src/views/metadata-admin/color-variant-field.test.tsx index 2e330aa504..d319995634 100644 --- a/packages/app-shell/src/views/metadata-admin/color-variant-field.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/color-variant-field.test.tsx @@ -22,12 +22,10 @@ import { describe, it, expect } from 'vitest'; import { WidgetColorVariantSchema } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; import { COLOR_VARIANTS, colorVariantCss } from './color-variant-field'; -const specVariants: string[] = (() => { - const raw = (WidgetColorVariantSchema as unknown as { options?: readonly string[] }).options; - return Array.isArray(raw) ? [...raw] : []; -})(); +const specVariants: string[] = enumOptions(WidgetColorVariantSchema); /** * The picker's canonical row — `ColorVariantPicker` renders exactly diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index cb75a9da3a..41f94f5b8a 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -6,7 +6,12 @@ import { PageHeaderProps, RecordDetailsProps, } from '@objectstack/spec/ui'; -import { isShapeKeyTombstoned, listedShapeKeys, shapeMemberTypeName } from '@object-ui/test-support'; +import { + enumOptions, + isShapeKeyTombstoned, + listedShapeKeys, + shapeMemberTypeName, +} from '@object-ui/test-support'; import { BLOCK_CONFIG, blockHasConfig, type PlaceholderSpec } from '../block-config'; import { BLOCK_TYPE_META, PALETTE_EXCLUSIONS } from '../block-types'; import { t } from '../../i18n'; @@ -171,10 +176,7 @@ describe('record:details sections ↔ spec section-entry coverage (#3819)', () = * offered, or excluded with a documented reason. */ describe('page palette ↔ spec PageComponentType coverage', () => { - const specNames: string[] = (() => { - const raw = (PageComponentType as unknown as { options?: readonly string[] }).options; - return Array.isArray(raw) ? [...raw] : []; - })(); + const specNames: string[] = enumOptions(PageComponentType); it('reads a non-empty enum from the spec', () => { expect(specNames, 'could not read PageComponentType.options from the spec').not.toEqual([]); diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/palette-discussion-alias.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/__tests__/palette-discussion-alias.test.tsx index 728d4e8a3e..f8ad81e50c 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/palette-discussion-alias.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/palette-discussion-alias.test.tsx @@ -43,6 +43,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { render, screen, cleanup } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; import { PageComponentType } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; // Side-effect import: registers `record:chatter` and `record:discussion`. The // app-shell test setup does not pull plugin-detail in, and relying on another // file having imported it first would make this suite order-dependent. @@ -51,14 +52,20 @@ import { BLOCK_TYPE_META, PALETTE_EXCLUSIONS } from '../block-types'; afterEach(cleanup); -const specNames: string[] = (() => { - const raw = (PageComponentType as unknown as { options?: readonly string[] }).options; - return Array.isArray(raw) ? [...raw] : []; -})(); +const specNames: string[] = enumOptions(PageComponentType); const meta = BLOCK_TYPE_META as Record; describe('page palette — the discussion/chatter pair points at the canonical name (#5495)', () => { + // The duty `@object-ui/test-support`'s reader leaves with every caller: `[]` + // is its "could not read", and the `not.toContain` probes below are satisfied + // by an empty list. The `toContain` probes would red on one — but they say + // "the spec dropped this member", which sends the next reader to the contract + // instead of to the reader. This one names the real cause (objectui#6924). + it('reads a non-empty enum from the spec', () => { + expect(specNames, 'could not read PageComponentType.options from the spec').not.toEqual([]); + }); + it('offers `record:discussion`, labelled for authors as "Discussion"', () => { expect(meta['record:discussion']).toBeDefined(); expect(meta['record:discussion']!.label).toBe('Discussion'); diff --git a/packages/components/src/__tests__/toaster-position-spec-parity.test.tsx b/packages/components/src/__tests__/toaster-position-spec-parity.test.tsx index f15778a933..81eedd73aa 100644 --- a/packages/components/src/__tests__/toaster-position-spec-parity.test.tsx +++ b/packages/components/src/__tests__/toaster-position-spec-parity.test.tsx @@ -17,6 +17,7 @@ import { describe, it, expect, vi } from 'vitest'; import React from 'react'; import '@testing-library/jest-dom'; import { NotificationPositionSchema } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; import { renderComponent } from './test-utils'; import { TOASTER_POSITIONS } from '../renderers/feedback/toaster'; // Registers the renderers at module scope, NOT inside a `beforeAll` — there the @@ -39,8 +40,7 @@ vi.mock('../ui/sonner', () => ({ })); describe('toaster covers the spec notification-position vocabulary', () => { - const rawOptions = (NotificationPositionSchema as unknown as { options?: readonly string[] }).options; - const specNames: string[] = Array.isArray(rawOptions) ? [...rawOptions] : []; + const specNames: string[] = enumOptions(NotificationPositionSchema); it('reads a non-empty enum from the spec', () => { expect(specNames, 'could not read NotificationPositionSchema.options from the spec').not.toEqual([]); diff --git a/packages/components/src/notifications/__tests__/notification-surfaces.test.tsx b/packages/components/src/notifications/__tests__/notification-surfaces.test.tsx index 24d503ca8f..7da1b8ba3e 100644 --- a/packages/components/src/notifications/__tests__/notification-surfaces.test.tsx +++ b/packages/components/src/notifications/__tests__/notification-surfaces.test.tsx @@ -21,6 +21,7 @@ import React from 'react'; import { render, screen, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { NotificationTypeSchema } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; import { NOTIFICATION_PRESENTATIONS, NotificationProvider, @@ -36,8 +37,7 @@ import { NotificationInline } from '../NotificationInline'; import { notificationActionVariant, notificationIcon, notificationSeverityStyle } from '../severity'; function specDisplayTypes(): string[] { - const raw = (NotificationTypeSchema as { options?: readonly string[] }).options; - return Array.isArray(raw) ? [...raw] : []; + return enumOptions(NotificationTypeSchema); } type Notify = (input: Omit) => string; diff --git a/packages/fields/src/FieldEditWidget.test.ts b/packages/fields/src/FieldEditWidget.test.ts index f2bc075090..77f231be10 100644 --- a/packages/fields/src/FieldEditWidget.test.ts +++ b/packages/fields/src/FieldEditWidget.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest'; import { FieldType } from '@objectstack/spec/data'; +import { enumOptions } from '@object-ui/test-support'; import { FORM_FIELD_TYPES, INLINE_EXCLUDED_FIELD_TYPES, @@ -108,9 +109,7 @@ describe('inline editor ↔ form widget parity', () => { * a documented exclusion. */ describe('inline editor ↔ SPEC FieldType parity (#2942)', () => { - const specTypes: string[] = Array.isArray((FieldType as { options?: readonly string[] }).options) - ? [...(FieldType as { options: readonly string[] }).options] - : []; + const specTypes: string[] = enumOptions(FieldType); it('reads a non-empty enum from the spec', () => { expect(specTypes, 'could not read FieldType.options from the spec').not.toEqual([]); diff --git a/packages/plugin-charts/package.json b/packages/plugin-charts/package.json index ef57547a34..eaaed55d32 100644 --- a/packages/plugin-charts/package.json +++ b/packages/plugin-charts/package.json @@ -44,6 +44,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@object-ui/test-support": "workspace:*", "@objectstack/spec": "^17.0.0", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", diff --git a/packages/plugin-charts/src/__tests__/chart-type-spec-parity.test.tsx b/packages/plugin-charts/src/__tests__/chart-type-spec-parity.test.tsx index ec1ee2159f..f3d9369112 100644 --- a/packages/plugin-charts/src/__tests__/chart-type-spec-parity.test.tsx +++ b/packages/plugin-charts/src/__tests__/chart-type-spec-parity.test.tsx @@ -29,13 +29,11 @@ import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; import { ChartTypeSchema } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; import AdvancedChartImpl from '../AdvancedChartImpl'; import { RENDERABLE, SINGLE_VALUE_CHART_TYPES, TABULAR_CHART_TYPES } from '../normalizeChartSchema'; -const specNames: string[] = (() => { - const raw = (ChartTypeSchema as unknown as { options?: readonly string[] }).options; - return Array.isArray(raw) ? [...raw] : []; -})(); +const specNames: string[] = enumOptions(ChartTypeSchema); describe('plugin-charts covers the spec chart-type vocabulary', () => { it('reads a non-empty enum from the spec', () => { diff --git a/packages/plugin-dashboard/package.json b/packages/plugin-dashboard/package.json index d3ec891f06..f61e5e5c64 100644 --- a/packages/plugin-dashboard/package.json +++ b/packages/plugin-dashboard/package.json @@ -44,6 +44,7 @@ "devDependencies": { "@object-ui/plugin-charts": "workspace:*", "@object-ui/sdui-parser": "workspace:*", + "@object-ui/test-support": "workspace:*", "@objectstack/spec": "^17.0.0", "@types/react-grid-layout": "^2.1.0", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx index 497c85d1f8..926f153ab8 100644 --- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.colorVariant.test.tsx @@ -36,16 +36,14 @@ import { render, screen, cleanup } from '@testing-library/react'; // `@object-ui/types` (decision (a) of objectui#2561), so this is the supported // way to reach it. import { WidgetColorVariantSchema } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; import { DatasetWidget } from '../DatasetWidget'; import { VARIANT_ICON_CLASSES, VARIANT_TEXT_CLASSES, metricAccentTextClass } from '../colorVariants'; afterEach(cleanup); /** The spec's own token list, read at test time (see the parity block below). */ -const specVariants: string[] = (() => { - const raw = (WidgetColorVariantSchema as unknown as { options?: readonly string[] }).options; - return Array.isArray(raw) ? [...raw] : []; -})(); +const specVariants: string[] = enumOptions(WidgetColorVariantSchema); /** * The metric card's markup with NO `colorVariant` declared, exactly as diff --git a/packages/plugin-dashboard/src/__tests__/pivot-aggregation-spec-parity.test.tsx b/packages/plugin-dashboard/src/__tests__/pivot-aggregation-spec-parity.test.tsx index c480ea077e..7cfce0d3bc 100644 --- a/packages/plugin-dashboard/src/__tests__/pivot-aggregation-spec-parity.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/pivot-aggregation-spec-parity.test.tsx @@ -26,6 +26,7 @@ import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; import { ChartAggregateFunctionSchema } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; import { PivotTable, PIVOT_AGGREGATIONS } from '../PivotTable'; const baseSchema = { @@ -41,8 +42,7 @@ const baseSchema = { }; describe('PivotTable covers the spec UI aggregation vocabulary', () => { - const rawOptions = (ChartAggregateFunctionSchema as unknown as { options?: readonly string[] }).options; - const specNames: string[] = Array.isArray(rawOptions) ? [...rawOptions] : []; + const specNames: string[] = enumOptions(ChartAggregateFunctionSchema); it('reads a non-empty enum from the spec', () => { expect(specNames, 'could not read ChartAggregateFunctionSchema.options from the spec').not.toEqual([]); diff --git a/packages/plugin-dashboard/src/__tests__/widget-dispatch-spec-parity.test.ts b/packages/plugin-dashboard/src/__tests__/widget-dispatch-spec-parity.test.ts index 0cc4a54a19..b200d25e40 100644 --- a/packages/plugin-dashboard/src/__tests__/widget-dispatch-spec-parity.test.ts +++ b/packages/plugin-dashboard/src/__tests__/widget-dispatch-spec-parity.test.ts @@ -21,6 +21,7 @@ */ import { describe, it, expect } from 'vitest'; import { ChartTypeSchema } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; import { classifyWidgetType, CHART_TYPE_ALIASES, @@ -29,10 +30,7 @@ import { TABLE_LIKE_TYPES, } from '../widgetDispatch'; -const specNames: string[] = (() => { - const raw = (ChartTypeSchema as unknown as { options?: readonly string[] }).options; - return Array.isArray(raw) ? [...raw] : []; -})(); +const specNames: string[] = enumOptions(ChartTypeSchema); describe('widget dispatch covers the spec chart vocabulary', () => { it('reads a non-empty enum from the spec', () => { diff --git a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx index 14dede0ac7..c6655e8142 100644 --- a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx +++ b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx @@ -49,6 +49,7 @@ import { describe, it, expect, vi, beforeAll } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { FieldType } from '@objectstack/spec/data'; +import { enumOptions } from '@object-ui/test-support'; import { InlineEditProvider, useInlineEdit } from '@object-ui/react'; import type { DetailViewSection } from '@object-ui/types'; import { DetailSection } from '../DetailSection'; @@ -82,9 +83,7 @@ import { isComputedFieldType, isInlineExcludedDetailFieldType } from '../fieldEn * a member of the spec enum, which is the card's own premise — so it leaves * this universe entirely rather than moving between buckets. */ -const specTypes: string[] = Array.isArray((FieldType as { options?: readonly string[] }).options) - ? [...(FieldType as { options: readonly string[] }).options] - : []; +const specTypes: string[] = enumOptions(FieldType); const ALL_TYPES = [...new Set([...FORM_FIELD_TYPES, ...specTypes])].sort(); /** The hosts never even render an editor for these (the gates, not this switch). */ diff --git a/packages/plugin-grid/package.json b/packages/plugin-grid/package.json index 3fc6d8ff2a..7a6571bd19 100644 --- a/packages/plugin-grid/package.json +++ b/packages/plugin-grid/package.json @@ -42,6 +42,7 @@ "devDependencies": { "@object-ui/data-objectstack": "workspace:*", "@object-ui/sdui-parser": "workspace:*", + "@object-ui/test-support": "workspace:*", "@objectstack/spec": "^17.0.0", "@tailwindcss/postcss": "^4.3.3", "@vitejs/plugin-react": "^6.0.5", diff --git a/packages/plugin-grid/src/__tests__/summary-spec-parity.test.ts b/packages/plugin-grid/src/__tests__/summary-spec-parity.test.ts index 0e1e23ad78..8c89910fc9 100644 --- a/packages/plugin-grid/src/__tests__/summary-spec-parity.test.ts +++ b/packages/plugin-grid/src/__tests__/summary-spec-parity.test.ts @@ -14,11 +14,11 @@ */ import { describe, it, expect } from 'vitest'; import { ColumnSummarySchema } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; import { SUPPORTED_SUMMARY_TYPES } from '../useColumnSummary'; describe('useColumnSummary covers the spec summary vocabulary', () => { - const rawOptions = (ColumnSummarySchema as unknown as { options?: readonly string[] }).options; - const specNames: string[] = Array.isArray(rawOptions) ? [...rawOptions] : []; + const specNames: string[] = enumOptions(ColumnSummarySchema); it('reads a non-empty enum from the spec', () => { // Guards the assertions below against silently passing on an empty list if diff --git a/packages/plugin-report/package.json b/packages/plugin-report/package.json index e138a63545..f05f300ce4 100644 --- a/packages/plugin-report/package.json +++ b/packages/plugin-report/package.json @@ -43,6 +43,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@object-ui/test-support": "workspace:*", "@objectstack/spec": "^17.0.0", "@types/node": "^26.2.0", "@types/react": "19.2.18", diff --git a/packages/plugin-report/src/__tests__/report-spec-parity.test.tsx b/packages/plugin-report/src/__tests__/report-spec-parity.test.tsx index fb4dd04f57..7c83c83db0 100644 --- a/packages/plugin-report/src/__tests__/report-spec-parity.test.tsx +++ b/packages/plugin-report/src/__tests__/report-spec-parity.test.tsx @@ -33,21 +33,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { ChartTypeSchema, ReportType } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; import { DatasetReportRenderer, planReportChart, resolveReportPresentation, } from '../DatasetReportRenderer'; -const specChartTypes: string[] = (() => { - const raw = (ChartTypeSchema as unknown as { options?: readonly string[] }).options; - return Array.isArray(raw) ? [...raw] : []; -})(); +const specChartTypes: string[] = enumOptions(ChartTypeSchema); -const specReportTypes: string[] = (() => { - const raw = (ReportType as unknown as { options?: readonly string[] }).options; - return Array.isArray(raw) ? [...raw] : []; -})(); +const specReportTypes: string[] = enumOptions(ReportType); describe('planReportChart covers the spec chart vocabulary', () => { it('reads a non-empty enum from the spec', () => { diff --git a/packages/react/package.json b/packages/react/package.json index 98fa486d37..3b574b9566 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -42,6 +42,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@object-ui/test-support": "workspace:*", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "typescript": "^6.0.3" diff --git a/packages/react/src/hooks/__tests__/animation-notification-spec-parity.test.tsx b/packages/react/src/hooks/__tests__/animation-notification-spec-parity.test.tsx index 576942f5c2..0f65c899cc 100644 --- a/packages/react/src/hooks/__tests__/animation-notification-spec-parity.test.tsx +++ b/packages/react/src/hooks/__tests__/animation-notification-spec-parity.test.tsx @@ -43,6 +43,7 @@ import { describe, it, expect } from 'vitest'; import { renderHook } from '@testing-library/react'; import { NotificationTypeSchema, NotificationPositionSchema } from '@objectstack/spec/ui'; +import { enumOptions } from '@object-ui/test-support'; import { useAnimation, SUPPORTED_TRANSITION_PRESETS, @@ -55,11 +56,6 @@ import { SUPPORTED_NOTIFICATION_POSITIONS, } from '../../context/NotificationContext'; -function options(schema: unknown): string[] { - const raw = (schema as { options?: readonly string[] }).options; - return Array.isArray(raw) ? [...raw] : []; -} - function assertParity(specNames: string[], implemented: ReadonlySet, what: string) { expect(specNames, `could not read the ${what} enum from the spec`).not.toEqual([]); expect( @@ -89,11 +85,11 @@ describe('react hooks cover the spec animation/notification vocabularies', () => }); it('notification display types match NotificationTypeSchema both ways', () => { - assertParity(options(NotificationTypeSchema), SUPPORTED_NOTIFICATION_DISPLAY_TYPES, 'notification display type'); + assertParity(enumOptions(NotificationTypeSchema), SUPPORTED_NOTIFICATION_DISPLAY_TYPES, 'notification display type'); }); it('notification positions match NotificationPositionSchema both ways', () => { - assertParity(options(NotificationPositionSchema), SUPPORTED_NOTIFICATION_POSITIONS, 'notification position'); + assertParity(enumOptions(NotificationPositionSchema), SUPPORTED_NOTIFICATION_POSITIONS, 'notification position'); }); // `NotificationActionButton.variant` was the shadcn Button vocabulary diff --git a/packages/test-support/README.md b/packages/test-support/README.md index 3ced469adf..420c6af8fa 100644 --- a/packages/test-support/README.md +++ b/packages/test-support/README.md @@ -60,24 +60,38 @@ code imports — nothing in `src/` of a released package may import this. (the last two converged off local structural-only copies by objectui#4947). No copy of the judgement is left in-tree: gates import this module, they do not write the criterion out again. -- `src/spec-enum-options.ts` — the spec enum-vocabulary reader: - `shapeEnumOptions(schema, key)`. Answers "which names does this key of the - contract accept?" — the question four spec-parity suites each answered with a - byte-for-byte identical hand-written walk into Zod's `def.innerType` - (objectui#5872). Consumed by - `packages/components/src/__tests__/data-table-selection-mode.test.tsx`, - `packages/plugin-list/src/__tests__/add-record-position-spec-parity.test.tsx`, - `packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx` - and `packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts`. - No copy of this reader is left in-tree. The other Zod-internals reader classes - the same card censused — array-element unwrapping, the wrapper-key walk — are - NOT confined here yet and are still hand-copied; converting them is a separate - round, one reader class at a time. +- `src/spec-enum-options.ts` — the spec enum-vocabulary reader. **Two exports, + ONE wrapper walk**: `enumOptions(node)` is the walk, and + `shapeEnumOptions(schema, key)` is the same walk entered through + `resolvePropsShape`. Both answer "which names does this contract accept?" — + the question spec-parity suites used to answer with a hand-written cast into + Zod internals. + - `shapeEnumOptions(schema, key)` — for an enum that sits INSIDE an object + schema, behind a wrapper. Replaced four byte-for-byte identical walks into + `def.innerType` (objectui#5872). Consumed by + `packages/components/src/__tests__/data-table-selection-mode.test.tsx`, + `packages/plugin-list/src/__tests__/add-record-position-spec-parity.test.tsx`, + `packages/plugin-list/src/__tests__/user-filter-arity-spec-parity.test.tsx` + and `packages/plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts`. + - `enumOptions(node)` — for a node that IS the enum: a top-level `z.enum` + imported straight from `@objectstack/spec`, or a shape member the caller + already holds. Replaced 17 hand-written + `(Schema as { options?: readonly string[] }).options` casts across 16 files + in 11 workspace packages (objectui#6924) — a larger family than #5872's, + with the same quiet-permissive failure. It is an ENTRY POINT onto the walk + above, not a second reader: `shapeEnumOptions` delegates to it, so there is + exactly one wrapper walk in this repository and a third entry point must not + change that. + - The other Zod-internals reader classes #5872 censused — array-element + unwrapping, the wrapper-key walk — are NOT confined here yet and are still + hand-copied; converting them is a separate round, one reader class at a time. - `src/__tests__/spec-enum-options.test.ts` — the calibration for that reader: one synthetic fixture per wrapper spelling it claims to walk (bare enum, `.optional()`, `.default()`, a stack, and a `lazySchema()` thunk), the `[]` cases that keep a consuming suite's non-vacuity assertion from being a rubber - stamp, and a non-empty check against the four real `@objectstack/spec` pairs. + stamp, a non-empty check against the four real `@objectstack/spec` pairs, and + the same three halves again for `enumOptions` — including the pin that the two + entry points agree, which is what makes the delegation observable. - `src/__tests__/spec-tombstones.test.ts` — the calibration for that judge: one synthetic fixture per recognition channel (so neither can quietly stop working), plus a cross-check of the structural verdict against what the diff --git a/packages/test-support/src/__tests__/spec-enum-options.test.ts b/packages/test-support/src/__tests__/spec-enum-options.test.ts index 720e13640b..f32a45f16e 100644 --- a/packages/test-support/src/__tests__/spec-enum-options.test.ts +++ b/packages/test-support/src/__tests__/spec-enum-options.test.ts @@ -28,18 +28,37 @@ * The negative cases matter as much: `[]` is this reader's "could not read", * and a reader that returned a non-empty array for a member with no enum at all * would turn every consuming suite's non-vacuity assertion into a rubber stamp. + * + * ## The second entry point (objectui#6924) + * + * `enumOptions(node)` is the same walk entered one step later — for a node that + * IS the enum rather than a shape carrying one. It gets its own three halves + * below, and one assertion the others cannot make: that the two entry points + * AGREE on the same member. That is what makes the delegation observable; a + * `shapeEnumOptions` that quietly grew a second copy of the walk would still + * pass every fixture above, and this repository's whole reason for having this + * module is that a second copy is exactly what nobody notices. */ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; import { AddRecordConfigSchema, + ChartAggregateFunctionSchema, + ChartTypeSchema, + ColumnSummarySchema, + NotificationPositionSchema, + NotificationTypeSchema, + PageComponentType, + ReportType, SelectionConfigSchema, TimelineConfigSchema, UserFilterFieldSchema, + WidgetColorVariantSchema, } from '@objectstack/spec/ui'; +import { FieldType } from '@objectstack/spec/data'; -import { shapeEnumOptions } from '../spec-enum-options'; +import { enumOptions, shapeEnumOptions } from '../spec-enum-options'; describe('shapeEnumOptions walks every wrapper spelling it claims to', () => { const VOCAB = ['alpha', 'beta', 'gamma'] as const; @@ -102,3 +121,83 @@ describe('shapeEnumOptions reads the real contract the parity gates ask about', expect(options.every((name) => typeof name === 'string')).toBe(true); }); }); + +describe('enumOptions walks the same spellings, entered at the node', () => { + const VOCAB = ['alpha', 'beta', 'gamma'] as const; + + it('reads a bare, unwrapped enum — the shape the 16 converged call sites hold', () => { + expect(enumOptions(z.enum(VOCAB))).toEqual([...VOCAB]); + }); + + it('reads through .optional()', () => { + expect(enumOptions(z.enum(VOCAB).optional())).toEqual([...VOCAB]); + }); + + it('reads through .default()', () => { + expect(enumOptions(z.enum(VOCAB).default('alpha'))).toEqual([...VOCAB]); + }); + + it('reads through a stack of wrappers, not just one level', () => { + expect(enumOptions(z.enum(VOCAB).nullable().optional())).toEqual([...VOCAB]); + }); + + it('reads a shape member handed in directly, without a key', () => { + const schema = z.object({ key: z.enum(VOCAB).optional() }); + expect(enumOptions(schema.shape.key)).toEqual([...VOCAB]); + }); +}); + +describe('enumOptions answers [] rather than guessing', () => { + it('returns [] for a node that is not an enum', () => { + expect(enumOptions(z.string().optional())).toEqual([]); + }); + + it('returns [] for an object schema — the enum is not the node, it is inside it', () => { + // The distinction `shapeEnumOptions` exists for. Silently answering here + // would make the two entry points interchangeable and hide a mis-call. + expect(enumOptions(z.object({ key: z.enum(['alpha', 'beta']) }))).toEqual([]); + }); + + it('returns [] for something that is not a schema at all', () => { + expect(enumOptions(undefined)).toEqual([]); + expect(enumOptions(null)).toEqual([]); + expect(enumOptions({})).toEqual([]); + expect(enumOptions('bare')).toEqual([]); + }); +}); + +describe('the two entry points are one walk', () => { + const VOCAB = ['alpha', 'beta', 'gamma'] as const; + + // If `shapeEnumOptions` ever stops delegating, this is the assertion that + // notices — the wrapper spellings above would keep passing against a copy. + it.each([ + ['bare', z.object({ key: z.enum(VOCAB) })], + ['optional', z.object({ key: z.enum(VOCAB).optional() })], + ['default', z.object({ key: z.enum(VOCAB).default('alpha') })], + ['stacked', z.object({ key: z.enum(VOCAB).nullable().optional() })], + ] as const)('shapeEnumOptions(schema, key) === enumOptions(schema.shape[key]) — %s', (_label, schema) => { + expect(shapeEnumOptions(schema, 'key')).toEqual(enumOptions(schema.shape.key)); + expect(shapeEnumOptions(schema, 'key')).toEqual([...VOCAB]); + }); +}); + +describe('enumOptions reads the real contract the converged gates ask about', () => { + const nodes: ReadonlyArray = [ + ['ChartTypeSchema', ChartTypeSchema], + ['ChartAggregateFunctionSchema', ChartAggregateFunctionSchema], + ['ColumnSummarySchema', ColumnSummarySchema], + ['NotificationPositionSchema', NotificationPositionSchema], + ['NotificationTypeSchema', NotificationTypeSchema], + ['PageComponentType', PageComponentType], + ['ReportType', ReportType], + ['WidgetColorVariantSchema', WidgetColorVariantSchema], + ['FieldType', FieldType], + ]; + + it.each(nodes)('reads a non-empty vocabulary for %s', (_label, node) => { + const options = enumOptions(node); + expect(options.length, 'the reader went quietly empty on a live spec enum').toBeGreaterThan(0); + expect(options.every((name) => typeof name === 'string')).toBe(true); + }); +}); diff --git a/packages/test-support/src/index.ts b/packages/test-support/src/index.ts index b3d8e5ce3b..5799eec06e 100644 --- a/packages/test-support/src/index.ts +++ b/packages/test-support/src/index.ts @@ -41,4 +41,4 @@ export { tombstonedShapeKeys, } from './spec-tombstones'; -export { shapeEnumOptions } from './spec-enum-options'; +export { enumOptions, shapeEnumOptions } from './spec-enum-options'; diff --git a/packages/test-support/src/spec-enum-options.ts b/packages/test-support/src/spec-enum-options.ts index 7fabc06a59..512fb53d75 100644 --- a/packages/test-support/src/spec-enum-options.ts +++ b/packages/test-support/src/spec-enum-options.ts @@ -8,7 +8,12 @@ /** * SPEC ENUM VOCABULARY — one reader for every parity gate that asks - * "which names does this key of the contract accept?" (objectui#5872). + * "which names does this contract accept?" (objectui#5872, objectui#6924). + * + * Two exports, ONE walk. `enumOptions(node)` is the walk; `shapeEnumOptions` + * is that walk entered through a shape member. See "Two entry points, one + * walk" below for why the second family needed an entry point and not a + * second reader. * * ## The reader this replaces * @@ -52,6 +57,26 @@ * installed pin (`@objectstack/spec@17.2.0`, `zod@4.4.3`) it returns the * identical array, in the identical order, for all four (schema, key) pairs. * + * ## Two entry points, one walk (objectui#6924) + * + * A SECOND, larger family — 16 call sites across 11 packages — asked the same + * question of a node that IS the enum: a top-level `z.enum` imported straight + * from `@objectstack/spec`, or a shape member the caller had already indexed. + * Each site hand-wrote `(Schema as { options?: readonly string[] }).options` + * and then `Array.isArray(raw) ? [...raw] : []`, which is the SAME unchecked + * cast with the same quiet-permissive failure: an enum that acquires a wrapper, + * or a Zod build that moves `.options`, yields `undefined` and the derived + * vocabulary silently becomes the empty set. + * + * `shapeEnumOptions` could not answer for them — it opens with + * `resolvePropsShape` and then indexes `shape[key]`, so a node that is already + * the enum has no shape to resolve and no key to index, and it returns `[]`. + * That is a missing ENTRY POINT, not a missing reader: the loop below already + * reads `.options` before unwrapping, so it answers a bare enum correctly the + * moment it is handed one. So the walk is exported as `enumOptions(node)` and + * `shapeEnumOptions` delegates to it. There is exactly one wrapper-walk in this + * repository, and adding a third entry point later must not change that. + * * ## `[]` and the non-vacuity duty it leaves with the caller * * `[]` means "no vocabulary could be read", and it is deliberately NOT @@ -83,6 +108,36 @@ interface EnumCarrier { */ const MAX_WRAPPER_DEPTH = 8; +/** + * The enum names a node ACCEPTS, unwrapped past `.optional()` / `.default()` / + * `.nullable()` — `[]` when it cannot be read. + * + * Takes the node itself, so it answers for a top-level `z.enum` imported from + * `@objectstack/spec` (objectui#6924's family) and for a shape member the + * caller already holds. `shapeEnumOptions` is this same walk entered through + * `resolvePropsShape`. + * + * `[]` carries the non-vacuity duty described in this module's docblock: it + * means "no vocabulary could be read", and a caller that does not assert + * against it cannot tell a broken reader from a satisfied parity check. + */ +export function enumOptions(node: unknown): string[] { + let carrier = node as EnumCarrier | undefined; + for (let depth = 0; carrier && depth <= MAX_WRAPPER_DEPTH; depth += 1) { + const options = carrier.options; + // Not filtered to strings: the converging call sites did not filter + // either, and dropping a non-string member here would narrow a vocabulary + // silently — the one thing this module exists to stop. + if (Array.isArray(options)) return [...options] as string[]; + const inner = + typeof carrier.unwrap === 'function' + ? carrier.unwrap() + : (carrier.def?.innerType ?? carrier._def?.innerType); + carrier = inner as EnumCarrier | undefined; + } + return []; +} + /** * The enum names one key of a props schema accepts, unwrapped past * `.optional()` / `.default()` / `.nullable()` — `[]` when it cannot be read. @@ -90,23 +145,13 @@ const MAX_WRAPPER_DEPTH = 8; * Signature deliberately mirrors `shapeMemberTypeName(schema, key)` in * `spec-tombstones.ts`: same question shape ("about ONE member of a shape"), * same tolerance of a schema this pin does not carry. + * + * Delegates the wrapper walk to `enumOptions` rather than repeating it — a + * second copy here would be this module's own failure mode reintroduced inside + * the module that exists to end it. */ export function shapeEnumOptions(schema: unknown, key: string): string[] { const shape = resolvePropsShape(schema); if (!shape) return []; - - let node = shape[key] as EnumCarrier | undefined; - for (let depth = 0; node && depth <= MAX_WRAPPER_DEPTH; depth += 1) { - const options = node.options; - // Not filtered to strings: the four converging call sites did not filter - // either, and dropping a non-string member here would narrow a vocabulary - // silently — the one thing this module exists to stop. - if (Array.isArray(options)) return [...options] as string[]; - const inner = - typeof node.unwrap === 'function' - ? node.unwrap() - : (node.def?.innerType ?? node._def?.innerType); - node = inner as EnumCarrier | undefined; - } - return []; + return enumOptions(shape[key]); } diff --git a/packages/types/package.json b/packages/types/package.json index 177ca96d1b..b1c66dcfac 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -96,6 +96,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@object-ui/test-support": "workspace:*", "typescript": "^6.0.3" }, "bugs": { diff --git a/packages/types/src/__tests__/spec-derived-unions.test.ts b/packages/types/src/__tests__/spec-derived-unions.test.ts index f728fa98ea..a37dc03774 100644 --- a/packages/types/src/__tests__/spec-derived-unions.test.ts +++ b/packages/types/src/__tests__/spec-derived-unions.test.ts @@ -63,6 +63,7 @@ import type { z } from 'zod'; import { FieldType as SpecFieldType, } from '@objectstack/spec/data'; +import { enumOptions } from '@object-ui/test-support'; // The objectstack#4171 / #3177 pins must import the banned name to probe it — // this guard is a sanctioned importer (#3090 tripwire). /* eslint-disable no-restricted-imports -- reported at the specifier line, out of -next-line reach */ @@ -347,13 +348,19 @@ void _specFormFieldInputStillHasNoName; void _specFieldSlotIsStillAName; void _localFieldSlotIsStillAnObject; void _specDependsOnStillTakesNoArray; void _specOutputStillDropsVisibleOn; -/** Read a spec enum's members, failing loudly if the shape ever changes. */ +/** + * Read a spec enum's members, failing loudly if the shape ever changes. + * + * The wrapper walk is `@object-ui/test-support`'s shared reader (objectui#6924); + * the THROW stays here, because that is this suite's non-vacuity duty and the + * reader deliberately answers `[]` rather than raising. + */ const optionsOf = (schema: unknown, name: string): string[] => { - const raw = (schema as { options?: readonly string[] })?.options; - if (!Array.isArray(raw) || raw.length === 0) { + const raw = enumOptions(schema); + if (raw.length === 0) { throw new Error(`could not read ${name}.options from @objectstack/spec`); } - return [...raw]; + return raw; }; describe('unions derived from a spec vocabulary stay derived (#2944)', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7aa49d1a53..0bbd7ed44f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1617,6 +1617,9 @@ importers: specifier: ^3.10.1 version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.6)(react@19.2.8)(redux@5.0.1) devDependencies: + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 version: 17.2.0(ai@7.0.65(zod@4.4.3)) @@ -1781,6 +1784,9 @@ importers: '@object-ui/sdui-parser': specifier: workspace:* version: link:../sdui-parser + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 version: 17.2.0(ai@7.0.65(zod@4.4.3)) @@ -2156,6 +2162,9 @@ importers: '@object-ui/sdui-parser': specifier: workspace:* version: link:../sdui-parser + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2488,6 +2497,9 @@ importers: specifier: ^3.6.0 version: 3.6.0 devDependencies: + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 version: 17.2.0(ai@7.0.65(zod@4.4.3)) @@ -2733,6 +2745,9 @@ importers: specifier: ^7.85.0 version: 7.85.0(react@19.2.8) devDependencies: + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2858,6 +2873,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support typescript: specifier: ^6.0.3 version: 6.0.3