@@ -141,13 +141,48 @@ describe('[#5341] formatZodErrors expands invalid_union branches', () => {
141141/**
142142 * The live specimen, on the surface `os validate` actually parses.
143143 *
144- * `views[].list.sort` is `z.union([z.string(), z.array(<strict sort entry>)])`
145- * and the entry declares the #4721 alias `direction → order` — the same tuple
146- * under a different word, which is worth a prescription precisely because
147- * getting it wrong REVERSES the sort silently. Behind a union, that
148- * prescription was produced on every run and delivered on none.
144+ * The specimen's whole job is to reach a REAL `z.union` whose strict branch
145+ * carries a curated prescription. It is therefore pinned to a property of the
146+ * SCHEMA, not to any one key, and it moves when the schema does.
147+ *
148+ * ## Why it moved once already — read this before touching the assertions
149+ *
150+ * It used to be `views[].list.sort`,
151+ * `z.union([z.string(), z.array(<strict sort entry>)])`, with the #4721 alias
152+ * `direction → order`. #17053 retired the bare-string arm under ADR-0049
153+ * enforce-or-remove; the union collapsed to its one surviving member, and the
154+ * same stack now falls straight out of a plain `z.array(...)` as TWO ordinary
155+ * issues — an `invalid_value` on `order` and an `unrecognized_keys` on the
156+ * entry — neither of them `invalid_union`. The block's subject, *a rejection
157+ * reaching the terminal from behind a union*, was no longer exercised at this
158+ * door at all, and the terminal test below went on passing on the ordinary
159+ * path while what it exists to guard went unguarded. Re-pointing the specimen
160+ * is the repair; adapting the counts to the collapsed shape would have been
161+ * the regression, which is why the terminal test now also asserts the union's
162+ * own verdict line.
163+ *
164+ * ## The successor door, and the three things that qualify one
165+ *
166+ * `views[].list.gantt.tooltipFields`, verified on the tree this test runs
167+ * against:
168+ *
169+ * 1. it is still `z.union([z.string(), <strict entry>])` — the string arm is
170+ * the bare field name, the object arm the `{ field, label }` pair
171+ * objectui's GanttView tooltip resolver reads, and no ruling retires
172+ * either (contrast `view.sort`, whose string arm had one);
173+ * 2. the strict arm is a closed `strictObject` declaring the curated aliases
174+ * `name → field`, `fieldName → field`, `text → label`, `title → label`,
175+ * so a near-miss gets the #4001 campaign's prose instead of zod's default
176+ * report — the same reason `direction → order` was worth delivering;
177+ * 3. it is reachable from `ObjectStackDefinitionSchema`, the schema
178+ * `os validate` parses, so the terminal really renders it.
179+ *
180+ * ⚠️ If this block reds on the issue COUNT again, the DOOR has moved and the
181+ * formatter is fine: find another union that satisfies (1)–(3) and re-point
182+ * the specimen at it. ⛔ Never adapt the assertions to whatever shape the
183+ * schema now produces — that greens the file with its own subject untested.
149184 */
150- const SORT_ALIAS_STACK = {
185+ const GANTT_TOOLTIP_ALIAS_STACK = {
151186 manifest : { id : 'union_probe' , name : 'Union Probe' , namespace : 'union_probe' , version : '1.0.0' , type : 'app' } ,
152187 views : [
153188 {
@@ -156,9 +191,18 @@ const SORT_ALIAS_STACK = {
156191 list : {
157192 name : 'union_probe_list' ,
158193 label : 'Union Probe' ,
159- type : 'grid ' ,
194+ type : 'gantt ' ,
160195 columns : [ 'name' ] ,
161- sort : [ { field : 'name' , direction : 'desc' } ] ,
196+ gantt : {
197+ startDateField : 'start_at' ,
198+ endDateField : 'end_at' ,
199+ titleField : 'name' ,
200+ // The near-miss: `name` is how `record:highlights` spells the same
201+ // idea, so an author carrying an entry across surfaces brings it
202+ // along. Declared as an alias precisely so the answer names the
203+ // replacement instead of reporting a stray key.
204+ tooltipFields : [ { name : 'owner' } ] ,
205+ } ,
162206 } ,
163207 } ,
164208 ] ,
@@ -199,35 +243,43 @@ describe('[#5341] `os validate` delivers a union branch prescription', () => {
199243 // runs first. If the stack failed for some unrelated reason the terminal
200244 // assertion below could pass on the wrong error entirely.
201245 it ( 'the specimen fails on exactly one issue, and that issue is the union' , ( ) => {
202- const result = ObjectStackDefinitionSchema . safeParse ( SORT_ALIAS_STACK ) ;
246+ const result = ObjectStackDefinitionSchema . safeParse ( GANTT_TOOLTIP_ALIAS_STACK ) ;
203247 expect ( result . success ) . toBe ( false ) ;
204248 const issues = result . success ? [ ] : result . error . issues ;
205249 expect ( issues ) . toHaveLength ( 1 ) ;
206250 expect ( issues [ 0 ] ! . code ) . toBe ( 'invalid_union' ) ;
207251 // The prescription exists in the payload — it always has. Delivery is the
208252 // only thing #5341 is about.
209- expect ( JSON . stringify ( issues [ 0 ] ) ) . toContain ( '`direction ` → `order `' ) ;
253+ expect ( JSON . stringify ( issues [ 0 ] ) ) . toContain ( '`name ` → `field `' ) ;
210254 } ) ;
211255
212256 it ( 'prints the prescription, not a bare `invalid_union: Invalid input`' , ( ) => {
213- const { exitCode, output } = runCli ( 'validate' , SORT_ALIAS_STACK ) ;
214- expect ( exitCode , `os validate accepted a stack with an aliased sort key:\n${ output } ` ) . not . toBe ( 0 ) ;
215- expect ( output ) . toContain ( 'views.0.list.sort' ) ;
216- expect ( output ) . toContain ( '`direction` → `order`' ) ;
257+ const { exitCode, output } = runCli ( 'validate' , GANTT_TOOLTIP_ALIAS_STACK ) ;
258+ expect ( exitCode , `os validate accepted a stack with an aliased tooltip key:\n${ output } ` ) . not . toBe ( 0 ) ;
259+ // Paired deliberately, and the pair is what keeps this test from passing
260+ // for the wrong reason: the union's own verdict line is the evidence that
261+ // the prescription under it arrived from BEHIND a union. The prescription
262+ // assertion alone passes for any door that reports an unrecognized key
263+ // directly — which is exactly how this test kept passing after #17053
264+ // collapsed the previous specimen's union and left the block's subject
265+ // unexercised.
266+ expect ( output ) . toContain ( 'invalid_union: Invalid input' ) ;
267+ expect ( output ) . toContain ( 'views.0.list.gantt.tooltipFields' ) ;
268+ expect ( output ) . toContain ( '`name` → `field`' ) ;
217269 } , 120_000 ) ;
218270
219271 it ( 'leaves the `--json` payload exactly as it was — full, and nested' , ( ) => {
220272 // The machine path never had this defect: it passes `error.issues` through,
221273 // so the branch tree was always on it. Pinned here because the fix is one
222274 // `console.log` loop away from being "helpfully" moved into the payload.
223- const { exitCode, output } = runCli ( 'validate' , SORT_ALIAS_STACK , [ '--json' ] ) ;
275+ const { exitCode, output } = runCli ( 'validate' , GANTT_TOOLTIP_ALIAS_STACK , [ '--json' ] ) ;
224276 expect ( exitCode ) . not . toBe ( 0 ) ;
225277 const payload = JSON . parse ( output . slice ( output . indexOf ( '{' ) ) ) ;
226278 expect ( payload . valid ) . toBe ( false ) ;
227279 expect ( payload . errors ) . toHaveLength ( 1 ) ;
228280 expect ( payload . errors [ 0 ] . code ) . toBe ( 'invalid_union' ) ;
229281 // The branch tree, untouched — and NOT flattened into extra `errors[]` rows.
230- expect ( JSON . stringify ( payload . errors [ 0 ] . errors ) ) . toContain ( '`direction ` → `order `' ) ;
282+ expect ( JSON . stringify ( payload . errors [ 0 ] . errors ) ) . toContain ( '`name ` → `field `' ) ;
231283 } , 120_000 ) ;
232284} ) ;
233285
0 commit comments