Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/zod-internals-reader-confinement.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
---

Test-only change. Four spec-parity suites each carried a byte-for-byte identical
hand-written walk into Zod's internals to read a key's enum vocabulary; they now
import one `shapeEnumOptions` reader from the private, never-published
`@object-ui/test-support`. No published package's runtime behaviour changes —
the only edits to released packages are a `devDependencies` entry each.
1 change: 1 addition & 0 deletions packages/components/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,6 +91,7 @@
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@object-ui/test-support": "workspace:*",
"@radix-ui/react-focus-scope": "^1.1.16",
"@tailwindcss/postcss": "^4.3.3",
"@types/react": "19.2.18",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,21 +25,14 @@ import { describe, it, expect, vi } from 'vitest';
import { fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { SelectionConfigSchema } from '@objectstack/spec/ui';
import { shapeEnumOptions } from '@object-ui/test-support';
import { renderComponent } from './test-utils';
import { SUPPORTED_SELECTION_MODES } from '../renderers/complex/data-table';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`, which is why this carried a raised
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

/** The spec's selection vocabulary, read through the `.default()` wrapper. */
function specSelectionModes(): string[] {
const typeSchema = (SelectionConfigSchema as unknown as { shape?: Record<string, unknown> })
.shape?.type as { def?: { innerType?: { options?: readonly string[] } } } | undefined;
const options = typeSchema?.def?.innerType?.options;
return Array.isArray(options) ? [...options] : [];
}

const baseSchema = {
type: 'data-table' as const,
columns: [{ header: 'Name', accessorKey: 'name' }],
Expand All@@ -53,7 +46,7 @@ const baseSchema = {
};

describe('data-table selection mode covers the spec selection vocabulary', () => {
const specNames = specSelectionModes();
const specNames = shapeEnumOptions(SelectionConfigSchema, 'type');

it('reads a non-empty enum from the spec', () => {
expect(specNames, 'could not read SelectionConfigSchema.shape.type options from the spec').not.toEqual([]);
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-list/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@
"@object-ui/mobile": "workspace:*",
"@object-ui/permissions": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/test-support": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0",
"@types/react": "19.2.18",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,6 +24,7 @@ import { render, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { AddRecordConfigSchema } from '@objectstack/spec/ui';
import { shapeEnumOptions } from '@object-ui/test-support';
import { ListView, resolveAddRecordPlacement } from '../ListView';
import type { ListViewSchema } from '@object-ui/types';
import { SchemaRendererProvider } from '@object-ui/react';
Expand All@@ -43,16 +44,8 @@ beforeAll(() => {
});
});

/** The spec's position vocabulary, read through the `.default()` wrapper. */
function specPositions(): string[] {
const positionSchema = (AddRecordConfigSchema as unknown as { shape?: Record<string, unknown> })
.shape?.position as { def?: { innerType?: { options?: readonly string[] } } } | undefined;
const options = positionSchema?.def?.innerType?.options;
return Array.isArray(options) ? [...options] : [];
}

describe('resolveAddRecordPlacement covers the spec position vocabulary', () => {
const specNames = specPositions();
const specNames = shapeEnumOptions(AddRecordConfigSchema, 'position');

it('reads a non-empty enum from the spec', () => {
expect(specNames, 'could not read AddRecordConfigSchema.shape.position options from the spec').not.toEqual([]);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,7 @@ import * as React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { UserFilterFieldSchema } from '@objectstack/spec/ui';
import { shapeEnumOptions } from '@object-ui/test-support';
import { UserFilters, FILTER_CONTROL_KINDS } from '../UserFilters';

const objectDef = {
Expand All@@ -44,16 +45,8 @@ const objectDef = {
},
};

/** The spec's control-type vocabulary, read through the `.optional()` wrapper. */
function specControlTypes(): string[] {
const typeSchema = (UserFilterFieldSchema as unknown as { shape?: Record<string, unknown> })
.shape?.type as { def?: { innerType?: { options?: readonly string[] } } } | undefined;
const options = typeSchema?.def?.innerType?.options;
return Array.isArray(options) ? [...options] : [];
}

describe('FILTER_CONTROL_KINDS covers the spec user-filter control vocabulary', () => {
const specNames = specControlTypes();
const specNames = shapeEnumOptions(UserFilterFieldSchema, 'type');

it('reads a non-empty enum from the spec', () => {
expect(specNames, 'could not read UserFilterFieldSchema.shape.type options from the spec').not.toEqual([]);
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-timeline/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@
},
"devDependencies": {
"@object-ui/data-objectstack": "workspace:*",
"@object-ui/test-support": "workspace:*",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "^6.0.5",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,17 +16,11 @@
*/
import { describe, it, expect } from 'vitest';
import { TimelineConfigSchema } from '@objectstack/spec/ui';
import { shapeEnumOptions } from '@object-ui/test-support';
import { TIMELINE_SCALES, resolveTimelineScale, generateTimeScaleHeaders } from '../renderer';

function specScales(): string[] {
const scaleSchema = (TimelineConfigSchema as unknown as { shape?: Record<string, unknown> })
.shape?.scale as { def?: { innerType?: { options?: readonly string[] } } } | undefined;
const options = scaleSchema?.def?.innerType?.options;
return Array.isArray(options) ? [...options] : [];
}

describe('timeline covers the spec scale vocabulary', () => {
const specNames = specScales();
const specNames = shapeEnumOptions(TimelineConfigSchema, 'scale');

it('reads a non-empty enum from the spec', () => {
expect(specNames, 'could not read TimelineConfigSchema.shape.scale options from the spec').not.toEqual([]);
Expand Down
18 changes: 18 additions & 0 deletions packages/test-support/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,24 @@ 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/__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.
- `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
Expand Down
104 changes: 104 additions & 0 deletions packages/test-support/src/__tests__/spec-enum-options.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
/**
* 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.
*/

/**
* THE ENUM READER PROVES ITSELF — once, for every parity gate that imports it
* (objectui#5872).
*
* Two halves, and the second is the one that earns the consolidation:
*
* - SYNTHETIC fixtures pin each wrapper spelling the reader claims to walk,
* ALONE. The four hand copies this module replaced read exactly one
* (`def.innerType`), so the whole value of a shared reader is the spellings
* it adds; a fixture set that only exercised the one they already had would
* leave every addition untested and free to be silently wrong.
* - the REAL `@objectstack/spec` half pins the reader against the four
* (schema, key) pairs the converged suites actually ask about, asserting
* only that each vocabulary is NON-EMPTY. The names themselves stay
* un-pinned here on purpose: each consuming suite pins its own vocabulary
* against the renderer it judges, and a copy of those names in this file
* would be a fifth place to forget — the exact shape of the problem this
* module exists to end.
*
* 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.
*/

import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import {
AddRecordConfigSchema,
SelectionConfigSchema,
TimelineConfigSchema,
UserFilterFieldSchema,
} from '@objectstack/spec/ui';

import { shapeEnumOptions } from '../spec-enum-options';

describe('shapeEnumOptions walks every wrapper spelling it claims to', () => {
const VOCAB = ['alpha', 'beta', 'gamma'] as const;

it('reads a bare, unwrapped enum member', () => {
const schema = z.object({ key: z.enum(VOCAB) });
expect(shapeEnumOptions(schema, 'key')).toEqual([...VOCAB]);
});

it('reads through .optional()', () => {
const schema = z.object({ key: z.enum(VOCAB).optional() });
expect(shapeEnumOptions(schema, 'key')).toEqual([...VOCAB]);
});

it('reads through .default() — the spelling all four hand copies carried', () => {
const schema = z.object({ key: z.enum(VOCAB).default('alpha') });
expect(shapeEnumOptions(schema, 'key')).toEqual([...VOCAB]);
});

it('reads through a stack of wrappers, not just one level', () => {
const schema = z.object({ key: z.enum(VOCAB).nullable().optional() });
expect(shapeEnumOptions(schema, 'key')).toEqual([...VOCAB]);
});

it('reads a member of a lazily-resolved shape', () => {
const inner = z.object({ key: z.enum(VOCAB).optional() });
// The `lazySchema()` thunk spelling `resolvePropsShape` exists for: the
// shape is a FUNCTION that must be called before it has any keys at all.
const thunked = { shape: () => inner.shape };
expect(shapeEnumOptions(thunked, 'key')).toEqual([...VOCAB]);
});
});

describe('shapeEnumOptions answers [] rather than guessing', () => {
it('returns [] for a key the shape does not carry', () => {
expect(shapeEnumOptions(z.object({ other: z.string() }), 'key')).toEqual([]);
});

it('returns [] for a member that is not an enum', () => {
expect(shapeEnumOptions(z.object({ key: z.string().optional() }), 'key')).toEqual([]);
});

it('returns [] for something that is not a schema at all', () => {
expect(shapeEnumOptions(undefined, 'key')).toEqual([]);
expect(shapeEnumOptions({}, 'key')).toEqual([]);
});
});

describe('shapeEnumOptions reads the real contract the parity gates ask about', () => {
const pairs: ReadonlyArray<readonly [string, unknown, string]> = [
['SelectionConfigSchema.type', SelectionConfigSchema, 'type'],
['AddRecordConfigSchema.position', AddRecordConfigSchema, 'position'],
['UserFilterFieldSchema.type', UserFilterFieldSchema, 'type'],
['TimelineConfigSchema.scale', TimelineConfigSchema, 'scale'],
];

it.each(pairs)('reads a non-empty vocabulary for %s', (_label, schema, key) => {
const options = shapeEnumOptions(schema, key);
expect(options.length, 'the reader went quietly empty on a live spec enum').toBeGreaterThan(0);
expect(options.every((name) => typeof name === 'string')).toBe(true);
});
});
2 changes: 2 additions & 0 deletions packages/test-support/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,3 +40,5 @@ export {
tombstoneEvidence,
tombstonedShapeKeys,
} from './spec-tombstones';

export { shapeEnumOptions } from './spec-enum-options';
112 changes: 112 additions & 0 deletions packages/test-support/src/spec-enum-options.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
/**
* 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.
*/

/**
* SPEC ENUM VOCABULARY — one reader for every parity gate that asks
* "which names does this key of the contract accept?" (objectui#5872).
*
* ## The reader this replaces
*
* Four spec-parity suites in four packages each wrote out, byte-for-byte
* identical apart from the schema and key names:
*
* const v = (Schema as unknown as { shape?: Record<string, unknown> })
* .shape?.key as { def?: { innerType?: { options?: readonly string[] } } } | undefined;
* const options = v?.def?.innerType?.options;
* return Array.isArray(options) ? [...options] : [];
*
* `spec-tombstones.ts` already argues at length why that is worth ending, and
* says of `resolvePropsShape`: "Reaching into internals is confined to this
* module so that a gate never has to." That was true of SHAPE resolution and
* false of everything else a parity gate reads off a Zod node. This module is
* that sentence made true for one more reader class.
*
* ## Why the copies could not move together
*
* The hand copy reads EXACTLY ONE wrapper spelling — `def.innerType` — so it
* answers correctly only while the member is wrapped exactly once and Zod
* exposes `def`. A member that stops being `.optional()`/`.default()`, or a Zod
* build that exposes only `_def`, yields `undefined` and the derived vocabulary
* silently becomes the EMPTY SET. That is the objectui#4434 failure mode: every
* "the renderer implements every name the spec accepts" assertion built on an
* empty set passes over nothing. Four textually identical copies is the sharp
* case, because a reviewer who diffs one of them sees nothing that says the
* other three did not move.
*
* ## What this reader does that the copies did not
*
* - resolves the shape through `resolvePropsShape`, so all three `.shape`
* spellings and the `lazySchema()` thunk work, not just the plain one;
* - walks the wrapper chain instead of assuming a single level, and accepts
* `unwrap()`, `def.innerType` and `_def.innerType` as the step;
* - reads `.options` at every level, so an UNWRAPPED enum member answers too.
*
* All three are widenings — this reader answers wherever a hand copy answered,
* and in cases where a hand copy went quietly empty. Verdict preservation was
* measured rather than assumed when the four call sites converged: against the
* 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.
*
* ## `[]` and the non-vacuity duty it leaves with the caller
*
* `[]` means "no vocabulary could be read", and it is deliberately NOT
* distinguished from "this enum is empty" — because no spec enum is empty, and
* every caller in-tree already carries the assertion that makes the difference
* observable: one `it('reads a non-empty enum from the spec')` per suite. A
* caller that adopts this reader owes that assertion too; without it a broken
* reader and a satisfied parity check look exactly alike.
*/

import { resolvePropsShape } from './spec-tombstones';

/** A Zod node, as far as unwrapping to an enum needs to see it. */
interface EnumCarrier {
options?: unknown;
unwrap?: () => unknown;
def?: { innerType?: unknown };
_def?: { innerType?: unknown };
}

/**
* How many wrappers deep to look before giving up.
*
* Bounded rather than `while (node)`: the step below is reached through
* `unknown`, so a node that unwraps to itself — a shape this reader cannot
* rule out and should not hang on — ends the walk instead of the process. Eight
* is far past anything the contract stacks today (the deepest in-tree member is
* one wrapper: `.default()` or `.optional()`).
*/
const MAX_WRAPPER_DEPTH = 8;

/**
* The enum names one key of a props schema accepts, unwrapped past
* `.optional()` / `.default()` / `.nullable()` — `[]` when it cannot be read.
*
* 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.
*/
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 [];
}
Loading
Loading