|
| 1 | +/** |
| 2 | + * Containment is decided by the declared `children` input, and by NOTHING |
| 3 | + * else — the save-gate port (#19969) of objectui#9910 Q1-A. |
| 4 | + * |
| 5 | + * The renderer's copy of this parser (objectui `packages/sdui-parser`, re-cut |
| 6 | + * `5ea623ea`, inside the console pin) moved `not-a-container` off the |
| 7 | + * `isContainer` flag onto the registration's declared `children` input. This |
| 8 | + * copy runs in the SAVE GATE; if it kept reading the flag, the two would give |
| 9 | + * opposite verdicts on the same page (#12719). The 2×2 (flag × slot) below is |
| 10 | + * the same reading objectui pins in its own |
| 11 | + * `containment-declared-slot-9910.test.ts`, because the two failure modes are |
| 12 | + * OPPOSITE: a flag-only cell drawing nothing is the fallback silently |
| 13 | + * re-introduced, and a slot-only cell drawing the warning is the defect. |
| 14 | + * |
| 15 | + * The second half uses the REAL served entries: in the tracked |
| 16 | + * `sdui.manifest.json` (regenerated at the console pin, `0bf85eaa`) exactly |
| 17 | + * five of 59 components disagree between the flag and the declared input. |
| 18 | + * They are copied verbatim below rather than read from the repo root, so this |
| 19 | + * package's tests keep reading only this package. |
| 20 | + */ |
| 21 | +import { describe, expect, it } from 'vitest'; |
| 22 | +import { manifestFromConfigs, validateTree } from '../index.js'; |
| 23 | +import { CHILD_LIST_KEY, acceptsChildren } from '../validate.js'; |
| 24 | +import type { Manifest, ManifestComponent, SchemaElement } from '../types.js'; |
| 25 | + |
| 26 | +const CONTAINMENT = 'not-a-container'; |
| 27 | +const SLOT = { name: 'children', type: 'slot' } as const; |
| 28 | + |
| 29 | +const containment = (node: SchemaElement, manifest: Manifest): string[] => |
| 30 | + validateTree(node, manifest) |
| 31 | + .diagnostics.map((d) => d.code) |
| 32 | + .filter((code) => code === CONTAINMENT); |
| 33 | + |
| 34 | +/** A child list of one string node: strings are never validated, so the only |
| 35 | + * diagnostic this can draw is the containment one on its parent. */ |
| 36 | +const withChildren = (type: string): SchemaElement => ({ type, children: ['measured'] }); |
| 37 | + |
| 38 | +describe('#19969 — the save gate reads the declared `children` input, not the flag (2×2)', () => { |
| 39 | + const manifest: Manifest = manifestFromConfigs([ |
| 40 | + // flag, no slot — the `page:tabs` shape |
| 41 | + { type: 'flag-only', namespace: 'ui', isContainer: true, inputs: [{ name: 'items', type: 'array' }] }, |
| 42 | + // slot, no flag — the `button` / `badge` shape |
| 43 | + { type: 'slot-only', namespace: 'ui', inputs: [{ name: 'label', type: 'string' }, SLOT] }, |
| 44 | + // both — the `flex` shape |
| 45 | + { type: 'both', namespace: 'ui', isContainer: true, inputs: [{ name: 'gap', type: 'number' }, SLOT] }, |
| 46 | + // neither — a leaf |
| 47 | + { type: 'neither', namespace: 'ui', inputs: [{ name: 'content', type: 'string' }] }, |
| 48 | + // the page kinds publish `children` typed `array`: the NAME is the declaration |
| 49 | + { type: 'array-typed', namespace: 'ui', inputs: [{ name: 'children', type: 'array', of: 'object' }] }, |
| 50 | + ]); |
| 51 | + |
| 52 | + it('the predicate reads exactly the input NAMED `children`, whatever its coarse type', () => { |
| 53 | + expect(CHILD_LIST_KEY).toBe('children'); |
| 54 | + expect(acceptsChildren({ inputs: [SLOT] })).toBe(true); |
| 55 | + expect(acceptsChildren({ inputs: [{ name: 'children', type: 'array' }] })).toBe(true); |
| 56 | + expect(acceptsChildren({ inputs: [{ name: 'body', type: 'slot' }] })).toBe(false); |
| 57 | + expect(acceptsChildren({ inputs: [] })).toBe(false); |
| 58 | + }); |
| 59 | + |
| 60 | + it('⛔ the flag is NOT a fallback: flag without slot draws `not-a-container`', () => { |
| 61 | + expect(containment(withChildren('flag-only'), manifest)).toEqual([CONTAINMENT]); |
| 62 | + }); |
| 63 | + |
| 64 | + it('the slot alone is sufficient: slot without flag draws nothing', () => { |
| 65 | + expect(containment(withChildren('slot-only'), manifest)).toEqual([]); |
| 66 | + }); |
| 67 | + |
| 68 | + it('both declared draws nothing; neither declared draws the warning, at warning severity', () => { |
| 69 | + expect(containment(withChildren('both'), manifest)).toEqual([]); |
| 70 | + const found = validateTree(withChildren('neither'), manifest).diagnostics.filter( |
| 71 | + (d) => d.code === CONTAINMENT, |
| 72 | + ); |
| 73 | + expect(found.map((d) => [d.code, d.severity, d.tag])).toEqual([[CONTAINMENT, 'warning', 'neither']]); |
| 74 | + }); |
| 75 | + |
| 76 | + it('an `array`-typed `children` input counts — the page kinds', () => { |
| 77 | + expect(containment(withChildren('array-typed'), manifest)).toEqual([]); |
| 78 | + }); |
| 79 | + |
| 80 | + it('no child list, no verdict: an empty or absent list draws nothing anywhere', () => { |
| 81 | + for (const type of ['flag-only', 'slot-only', 'both', 'neither', 'array-typed']) { |
| 82 | + expect(containment({ type }, manifest)).toEqual([]); |
| 83 | + expect(containment({ type, children: [] }, manifest)).toEqual([]); |
| 84 | + } |
| 85 | + }); |
| 86 | + |
| 87 | + it('a component the manifest does not know draws `unknown-component`, never a containment verdict', () => { |
| 88 | + const codes = validateTree(withChildren('nowhere'), manifest).diagnostics.map((d) => d.code); |
| 89 | + expect(codes).toEqual(['unknown-component']); |
| 90 | + }); |
| 91 | +}); |
| 92 | + |
| 93 | +describe('#19969 — the five served entries where the flag and the declared input disagree', () => { |
| 94 | + // Verbatim from the tracked `sdui.manifest.json` at `0bf85eaa`. |
| 95 | + const SERVED: Record<string, ManifestComponent> = { |
| 96 | + "badge": { |
| 97 | + "type": "badge", |
| 98 | + "namespace": "ui", |
| 99 | + "inputs": [ |
| 100 | + { |
| 101 | + "name": "label", |
| 102 | + "type": "string" |
| 103 | + }, |
| 104 | + { |
| 105 | + "name": "variant", |
| 106 | + "type": "enum", |
| 107 | + "enum": [ |
| 108 | + "default", |
| 109 | + "secondary", |
| 110 | + "destructive", |
| 111 | + "outline" |
| 112 | + ] |
| 113 | + }, |
| 114 | + { |
| 115 | + "name": "className", |
| 116 | + "type": "string" |
| 117 | + }, |
| 118 | + { |
| 119 | + "name": "children", |
| 120 | + "type": "slot", |
| 121 | + "description": "Rich label content, rendered when `label` is not set" |
| 122 | + } |
| 123 | + ] |
| 124 | + }, |
| 125 | + "alert": { |
| 126 | + "type": "alert", |
| 127 | + "namespace": "ui", |
| 128 | + "inputs": [ |
| 129 | + { |
| 130 | + "name": "title", |
| 131 | + "type": "string", |
| 132 | + "required": true |
| 133 | + }, |
| 134 | + { |
| 135 | + "name": "description", |
| 136 | + "type": "string" |
| 137 | + }, |
| 138 | + { |
| 139 | + "name": "variant", |
| 140 | + "type": "enum", |
| 141 | + "enum": [ |
| 142 | + "default", |
| 143 | + "destructive" |
| 144 | + ] |
| 145 | + }, |
| 146 | + { |
| 147 | + "name": "className", |
| 148 | + "type": "string" |
| 149 | + }, |
| 150 | + { |
| 151 | + "name": "children", |
| 152 | + "type": "slot", |
| 153 | + "description": "Rich description content, rendered when `description` is not set" |
| 154 | + } |
| 155 | + ] |
| 156 | + }, |
| 157 | + "button": { |
| 158 | + "type": "button", |
| 159 | + "namespace": "ui", |
| 160 | + "inputs": [ |
| 161 | + { |
| 162 | + "name": "label", |
| 163 | + "type": "string" |
| 164 | + }, |
| 165 | + { |
| 166 | + "name": "variant", |
| 167 | + "type": "enum", |
| 168 | + "enum": [ |
| 169 | + "default", |
| 170 | + "secondary", |
| 171 | + "destructive", |
| 172 | + "outline", |
| 173 | + "ghost", |
| 174 | + "link" |
| 175 | + ] |
| 176 | + }, |
| 177 | + { |
| 178 | + "name": "size", |
| 179 | + "type": "enum", |
| 180 | + "enum": [ |
| 181 | + "default", |
| 182 | + "sm", |
| 183 | + "lg", |
| 184 | + "icon" |
| 185 | + ] |
| 186 | + }, |
| 187 | + { |
| 188 | + "name": "className", |
| 189 | + "type": "string" |
| 190 | + }, |
| 191 | + { |
| 192 | + "name": "children", |
| 193 | + "type": "slot", |
| 194 | + "description": "Rich label content, rendered when `label` is not set" |
| 195 | + } |
| 196 | + ] |
| 197 | + }, |
| 198 | + "page:tabs": { |
| 199 | + "type": "page:tabs", |
| 200 | + "namespace": "page", |
| 201 | + "isContainer": true, |
| 202 | + "inputs": [ |
| 203 | + { |
| 204 | + "name": "items", |
| 205 | + "type": "array", |
| 206 | + "of": "object", |
| 207 | + "required": true, |
| 208 | + "description": "Tab definitions [{ label, value?, icon?, count?, visibleWhen?, children }] — value is the stable ?tab= URL token, count auto-derives from record:related_list descendants when omitted" |
| 209 | + }, |
| 210 | + { |
| 211 | + "name": "tabStyle", |
| 212 | + "type": "enum", |
| 213 | + "enum": [ |
| 214 | + "line", |
| 215 | + "card", |
| 216 | + "pill" |
| 217 | + ] |
| 218 | + }, |
| 219 | + { |
| 220 | + "name": "position", |
| 221 | + "type": "enum", |
| 222 | + "enum": [ |
| 223 | + "top", |
| 224 | + "left" |
| 225 | + ] |
| 226 | + }, |
| 227 | + { |
| 228 | + "name": "alwaysShowStrip", |
| 229 | + "type": "boolean", |
| 230 | + "description": "Keep the tab strip visible when only one tab survives. Default false: a lone pill is clutter rather than an affordance, so a one-tab strip is hidden and its panel renders bare. Count the tabs AFTER each item visibleWhen predicate has been evaluated — a page authored with four tabs of which three are conditional reaches this rule whenever the other three are false." |
| 231 | + } |
| 232 | + ] |
| 233 | + }, |
| 234 | + "page:accordion": { |
| 235 | + "type": "page:accordion", |
| 236 | + "namespace": "page", |
| 237 | + "isContainer": true, |
| 238 | + "inputs": [ |
| 239 | + { |
| 240 | + "name": "items", |
| 241 | + "type": "array", |
| 242 | + "of": "object", |
| 243 | + "required": true, |
| 244 | + "description": "Panel definitions [{ label, icon?, collapsed?, children }] — collapsed: false opens a panel by default" |
| 245 | + }, |
| 246 | + { |
| 247 | + "name": "allowMultiple", |
| 248 | + "type": "boolean" |
| 249 | + }, |
| 250 | + { |
| 251 | + "name": "variant", |
| 252 | + "type": "enum", |
| 253 | + "enum": [ |
| 254 | + "flush", |
| 255 | + "card" |
| 256 | + ] |
| 257 | + } |
| 258 | + ] |
| 259 | + } |
| 260 | + } as unknown as Record<string, ManifestComponent>; |
| 261 | + const manifest: Manifest = { components: SERVED }; |
| 262 | + |
| 263 | + it('control: each entry really does disagree — the flag and the declared input answer differently', () => { |
| 264 | + // If a regenerated manifest ever made these agree, the rows below would stop |
| 265 | + // distinguishing the two predicates and this file would pass under either. |
| 266 | + for (const [type, comp] of Object.entries(SERVED)) { |
| 267 | + expect({ type, flag: Boolean(comp.isContainer) }).not.toEqual({ type, flag: acceptsChildren(comp) }); |
| 268 | + } |
| 269 | + }); |
| 270 | + |
| 271 | + it.each(['badge', 'alert', 'button'])( |
| 272 | + '<%s> declares the slot with the flag off: its child list draws NO `not-a-container` (was a false warning)', |
| 273 | + (type) => { |
| 274 | + expect(SERVED[type]!.isContainer).toBeFalsy(); |
| 275 | + expect(containment(withChildren(type), manifest)).toEqual([]); |
| 276 | + }, |
| 277 | + ); |
| 278 | + |
| 279 | + it.each(['page:tabs', 'page:accordion'])( |
| 280 | + '<%s> carries the flag with no slot: its child list DRAWS `not-a-container` (was silenced by the flag)', |
| 281 | + (type) => { |
| 282 | + expect(SERVED[type]!.isContainer).toBe(true); |
| 283 | + expect(containment(withChildren(type), manifest)).toEqual([CONTAINMENT]); |
| 284 | + }, |
| 285 | + ); |
| 286 | +}); |
0 commit comments