|
| 1 | +// #11992 — `ActionParamSchema.carryOver`, the #11753 ruling's spec half |
| 2 | +// (maintainer 2026-08-25, recommendation A): a declared carry-over param is |
| 3 | +// seeded from the row, rendered as a NON-EDITABLE summary, and submitted |
| 4 | +// VERBATIM. These pins hold the ruled shape: the accept set (key + parsed |
| 5 | +// output), the parse-time `defaultFromRow` co-requirement, the alias |
| 6 | +// prescriptions for the words authors will actually try (`readonly` / |
| 7 | +// `disabled`), and the describe() contract the renderer leg and the docs are |
| 8 | +// generated from. |
| 9 | +// |
| 10 | +// Measured constraint restated from the parent card, because it is the reason |
| 11 | +// the key exists at all: `visible: false` is NOT this contract — it omits the |
| 12 | +// param from the dialog AND from the submission, which is the #11703 |
| 13 | +// silent-drop shape. `carryOver` must keep the param in the submission. |
| 14 | +import { describe, it, expect } from 'vitest'; |
| 15 | +import { ActionParamSchema } from './action.zod'; |
| 16 | + |
| 17 | +describe('ActionParamSchema.carryOver (#11992, #11753 ruling)', () => { |
| 18 | + describe('accept pins', () => { |
| 19 | + it('accepts carryOver on a field-backed defaultFromRow param and carries it in the parse output', () => { |
| 20 | + const r = ActionParamSchema.safeParse({ |
| 21 | + field: 'row_level_security', |
| 22 | + defaultFromRow: true, |
| 23 | + carryOver: true, |
| 24 | + }); |
| 25 | + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); |
| 26 | + // The renderer leg reads this member off the parsed shape; nothing may |
| 27 | + // strip or rename it on the way through (contrast `requiresFeature`, |
| 28 | + // which IS lowered away — this key is not sugar, it is the contract). |
| 29 | + expect((r.data as { carryOver?: boolean }).carryOver).toBe(true); |
| 30 | + }); |
| 31 | + |
| 32 | + it('accepts carryOver: false as an explicit no-op', () => { |
| 33 | + const r = ActionParamSchema.safeParse({ |
| 34 | + field: 'description', |
| 35 | + defaultFromRow: true, |
| 36 | + carryOver: false, |
| 37 | + }); |
| 38 | + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); |
| 39 | + }); |
| 40 | + |
| 41 | + it('accepts carryOver on an inline param when the row seed is declared', () => { |
| 42 | + const r = ActionParamSchema.safeParse({ |
| 43 | + name: 'tab_permissions', |
| 44 | + type: 'textarea', |
| 45 | + defaultFromRow: true, |
| 46 | + carryOver: true, |
| 47 | + }); |
| 48 | + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); |
| 49 | + }); |
| 50 | + }); |
| 51 | + |
| 52 | + describe('co-requirement pin — carryOver without its row seed is an authoring error', () => { |
| 53 | + it('refuses carryOver: true without defaultFromRow, on the carryOver path, naming the missing seed', () => { |
| 54 | + const r = ActionParamSchema.safeParse({ |
| 55 | + field: 'row_level_security', |
| 56 | + carryOver: true, |
| 57 | + }); |
| 58 | + expect(r.success).toBe(false); |
| 59 | + if (r.success) return; |
| 60 | + const issue = r.error.issues.find((i) => i.path.join('.') === 'carryOver'); |
| 61 | + expect(issue, JSON.stringify(r.error.issues)).toBeDefined(); |
| 62 | + // The message must carry the repair (`defaultFromRow: true`) and the |
| 63 | + // fixed-value alternative (`bodyExtra`) — the refusal is the docs at the |
| 64 | + // moment of the mistake. |
| 65 | + expect(issue!.message).toContain('defaultFromRow: true'); |
| 66 | + expect(issue!.message).toContain('bodyExtra'); |
| 67 | + }); |
| 68 | + |
| 69 | + it('refuses carryOver: true with defaultFromRow explicitly false', () => { |
| 70 | + const r = ActionParamSchema.safeParse({ |
| 71 | + field: 'row_level_security', |
| 72 | + defaultFromRow: false, |
| 73 | + carryOver: true, |
| 74 | + }); |
| 75 | + expect(r.success).toBe(false); |
| 76 | + }); |
| 77 | + |
| 78 | + it('a carryOver: false param does NOT require the seed (no phantom check on the disabled spelling)', () => { |
| 79 | + const r = ActionParamSchema.safeParse({ |
| 80 | + name: 'note', |
| 81 | + type: 'text', |
| 82 | + carryOver: false, |
| 83 | + }); |
| 84 | + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); |
| 85 | + }); |
| 86 | + }); |
| 87 | + |
| 88 | + describe('alias pins — the borrowed words point at the declared key', () => { |
| 89 | + // The parent card's option A was literally titled "a readonly / carryOver |
| 90 | + // flag", and `FieldSchema.readonly` / widget `disabled` are the spellings |
| 91 | + // an author will reach for first. Both must land on the strict-unknown-key |
| 92 | + // path with a suggestion naming `carryOver` — never parse clean (this |
| 93 | + // schema is strict) and never dead-end without a pointer. |
| 94 | + it.each(['readonly', 'disabled'] as const)('rejects %s with a suggestion naming carryOver', (word) => { |
| 95 | + const r = ActionParamSchema.safeParse({ |
| 96 | + field: 'row_level_security', |
| 97 | + defaultFromRow: true, |
| 98 | + [word]: true, |
| 99 | + }); |
| 100 | + expect(r.success).toBe(false); |
| 101 | + if (r.success) return; |
| 102 | + const text = JSON.stringify(r.error.issues); |
| 103 | + expect(text).toContain(word); |
| 104 | + expect(text).toContain('carryOver'); |
| 105 | + }); |
| 106 | + }); |
| 107 | + |
| 108 | + describe('describe pin — the three ruled semantics are stated on the key', () => { |
| 109 | + it('the .describe() text states seed-from-row, non-editable render, and verbatim submission', () => { |
| 110 | + // The describe string is what the generated reference docs and the |
| 111 | + // authorable-surface baseline carry — an author (or an AI writing |
| 112 | + // metadata in bulk) reads THIS, so all three halves of the ruled |
| 113 | + // contract must be in it, including the contrast with `visible: false` |
| 114 | + // (the measured non-answer). |
| 115 | + const shape = (ActionParamSchema as unknown as { |
| 116 | + def: { getter?: () => unknown }; |
| 117 | + }); |
| 118 | + // `lazySchema` wraps the pipeline; walk to the inner object's shape via |
| 119 | + // a parse-independent probe: JSON-schema-free, so just read the |
| 120 | + // description off a parsed-known-good source — the schema graph. |
| 121 | + const description = findCarryOverDescription(shape); |
| 122 | + expect(description).toBeTruthy(); |
| 123 | + expect(description).toContain('seed the value from the current row'); |
| 124 | + expect(description).toContain('non-editable summary'); |
| 125 | + expect(description).toContain('submit it verbatim'); |
| 126 | + expect(description).toContain('visible: false'); |
| 127 | + }); |
| 128 | + }); |
| 129 | +}); |
| 130 | + |
| 131 | +/** |
| 132 | + * Walk the (lazy, refined, transformed) schema graph down to the strict object |
| 133 | + * and read `carryOver`'s description. Kept structural rather than importing |
| 134 | + * zod internals: every wrapper layer exposes its inner schema on `def` |
| 135 | + * (`innerType` / `schema` / `getter()`), and the object layer exposes `shape`. |
| 136 | + */ |
| 137 | +function findCarryOverDescription(node: unknown, depth = 0): string | undefined { |
| 138 | + // `lazySchema` returns a Proxy over a FUNCTION target (structurally a |
| 139 | + // ZodType, `typeof` says 'function'), so both object and function nodes are |
| 140 | + // walkable — an object-only guard silently skips the schema root. |
| 141 | + if (!node || (typeof node !== 'object' && typeof node !== 'function') || depth > 12) return undefined; |
| 142 | + const n = node as Record<string, any>; |
| 143 | + const shape = typeof n.shape === 'object' ? n.shape : n.def?.shape; |
| 144 | + if (shape?.carryOver) { |
| 145 | + const co = shape.carryOver as Record<string, any>; |
| 146 | + return co.description ?? co.def?.description ?? co.meta?.()?.description; |
| 147 | + } |
| 148 | + const d = n.def ?? {}; |
| 149 | + for (const next of [ |
| 150 | + typeof d.getter === 'function' ? d.getter() : undefined, |
| 151 | + d.innerType, |
| 152 | + d.schema, |
| 153 | + d.in, |
| 154 | + n.innerType, |
| 155 | + ]) { |
| 156 | + const found = findCarryOverDescription(next, depth + 1); |
| 157 | + if (found) return found; |
| 158 | + } |
| 159 | + return undefined; |
| 160 | +} |
0 commit comments