fix(chess/ui): ParamField enum rendering under Zod 4

The inspector read ZodEnum options via _def.values, which Zod 4
renamed to _def.entries (a Record<string,string>) and additionally
exposes as a public .options array. Under Zod 4 _def.values is
undefined, so the inspector crashed on any primitive with an enum
param (block-move-type: moveType, override-promotion: target,
on-turn-end: color) — the ParamField tried to iterate an undefined
options array and threw Cannot read properties of undefined.

The browser trapped the render error in a React error boundary, so
the surrounding visual builder partly stopped updating; in Playwright
the error manifested as a freshly added trigger block never appearing
in the DOM. SSR snapshot tests did not catch it because react-dom
server-render absorbs the first error silently.

Fix: prefer the public .options array; fall back to Object.values of
_def.entries for Zod 4; finally fall back to _def.values for Zod 3
compat; empty array as last resort.

Also: two snapshot tests documented the pre-existing crash via
toThrow. They now snapshot the (valid) rendered output instead and
the fresh golden HTML was captured for block-move-type (moveType
enum: step/slide/capture) and override-promotion (target enum: queen/
rook/bishop/knight/pawn/disabled).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 19:45:59 -06:00
commit 46109d5d23
No known key found for this signature in database
3 changed files with 39 additions and 12 deletions

View file

@ -145,21 +145,19 @@ describe("ParamField rendering (T14 regression baseline)", () => {
});
/**
* `block-move-type` and `override-promotion` use `z.enum([...])`.
* The inspector's current introspection reads `_def.values`, which
* is `undefined` on Zod v4's ZodEnum (shape renamed to `entries`).
* That means the inline component throws before rendering for these
* kinds — a pre-existing behaviour the pure refactor must preserve.
* The assertion below pins the throw so the extracted `ParamField`
* continues to reproduce it exactly.
* Previously the inspector read `_def.values`, which is `undefined`
* on Zod v4's ZodEnum (renamed to `entries`), causing a render
* crash. ParamField now reads `.options` / `_def.entries` and
* renders cleanly for both kinds.
*/
it("block-move-type throws today (pre-existing z.enum bug)", () => {
expect(() => render("block-move-type")).toThrow();
it("block-move-type renders moveType enum", () => {
expect(render("block-move-type")).toMatchSnapshot();
});
it("modify-movement-range renders delta", () => {
expect(render("modify-movement-range")).toMatchSnapshot();
});
it("override-promotion throws today (pre-existing z.enum bug)", () => {
expect(() => render("override-promotion")).toThrow();
it("override-promotion renders target enum", () => {
expect(render("override-promotion")).toMatchSnapshot();
});
it("add-aura renders radius + targetAttr + delta", () => {
expect(render("add-aura")).toMatchSnapshot();

View file

@ -219,7 +219,15 @@ export function ParamField({
type ZodWrappedDefInternal = {
_def: { innerType?: ZodType<unknown>; schema?: ZodType<unknown> };
};
type ZodEnumDefInternal = { _def: { values: readonly string[] } };
/**
* Zod v4 renamed `_def.values` to `_def.entries` (a `Record<string,
* string>`) and also exposes the option list as `.options`. We read
* whichever is populated, preferring the public `options` array.
*/
type ZodEnumDefInternal = {
options?: readonly string[];
_def: { values?: readonly string[]; entries?: Record<string, string> };
};
const shape = (primitive.paramsSchema as unknown as ZodObjectInternal).shape;
const params = (node.params as Record<string, unknown>) || {};
@ -246,7 +254,12 @@ export function ParamField({
else if (currentSchema instanceof z.ZodBoolean) type = 'boolean';
else if (currentSchema instanceof z.ZodEnum) {
type = 'enum';
options = (currentSchema as unknown as ZodEnumDefInternal)._def.values;
const enumSchema = currentSchema as unknown as ZodEnumDefInternal;
options =
enumSchema.options ??
(enumSchema._def.entries
? (Object.values(enumSchema._def.entries) as readonly string[])
: enumSchema._def.values ?? []);
}
else if (currentSchema instanceof z.ZodArray) type = 'array';

View file

@ -50,6 +50,14 @@ exports[`ParamField rendering (T14 regression baseline) > add-to-attribute rende
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color&#x27;s turn.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-add-to-attribute-attr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-add-to-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="Hp"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > block-move-type renders moveType enum 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Block Move Type</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">block-move-type</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Filters out generated moves matching the given type. Multiple block primitives accumulate into a blocked-move-type set (deduped). Useful for pacifist pieces that still slide, or for pieces that can capture but not reposition silently.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Pacifist piece</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;moveType&quot;: &quot;capture&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Piece can step and slide freely but cannot capture — a pure support piece.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Charge-only attacker</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;moveType&quot;: &quot;step&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Removes simple step moves; piece can only capture or slide.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">moveType</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="capture" selected="">capture</option><option value="step">step</option><option value="slide">slide</option></select></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > conditional renders complex-schema JSON fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Conditional</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">conditional</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Branches on a condition. If true → runs every primitive in \`then\`; if false and \`else\` is set → runs \`else\`. Condition types: attr-lt (numeric less-than), attr-gt (numeric greater-than), attr-eq (exact match against string/number/boolean/null), always (unconditional then), never (forces else path only).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Low-HP fortress</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;condition&quot;: {
@ -169,6 +177,14 @@ exports[`ParamField rendering (T14 regression baseline) > on-turn-start renders
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > override-promotion renders target enum 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Override Promotion</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">override-promotion</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Forces this piece (typically a pawn) to promote to a specific type regardless of player choice. Mirrors the built-in Promotion Override modifier, but expressable inside a custom primitive tree. Last write wins if multiple sources set it.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Knights-only promotion</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;target&quot;: &quot;knight&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pawn always promotes to a knight.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Underpromote to rook</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;target&quot;: &quot;rook&quot;
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pawn always promotes to a rook — useful for themed variants.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">target</label><select class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"><option value="pawn">pawn</option><option value="knight" selected="">knight</option><option value="bishop">bishop</option><option value="rook">rook</option><option value="queen">queen</option><option value="king">king</option></select></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > reflect-damage renders percentage 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Reflect Damage</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">reflect-damage</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Reflects a percentage of incoming damage back to the attacker. Integer percent, 0-100. Multiple reflect primitives on the same piece do NOT stack — the most recent value wins. Great inside on-damaged if you want a one-time thorns reaction instead of a permanent aura.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Half-reflective armour</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;percentage&quot;: 50