|
| 1 | +/** |
| 2 | + * `unconsumed-widget-option` — the objectui#5709 ruling, ported into this copy |
| 3 | + * in lockstep (objectstack#12810). |
| 4 | + * |
| 5 | + * The 2026-08-23 maintainer ruling on objectui#5709: a dashboard widget |
| 6 | + * `options` key riding `DashboardWidgetOptionsSchema.passthrough()` that no |
| 7 | + * renderer consumes gets an authoring-time WARNING naming the consumed set. |
| 8 | + * `invert` — the key that card was filed over — is the first pinned case, and |
| 9 | + * it is pinned here AS A CASE of the general mechanism: the same fixture shape |
| 10 | + * with a different dead key on a different widget type must draw the same |
| 11 | + * diagnostic, or the mechanism is a special case wearing a general name. |
| 12 | + * |
| 13 | + * WHY THESE PINS EXIST HERE, AND WHY THEY CARRY MORE WEIGHT THAN OBJECTUI'S. |
| 14 | + * Two copies of this parser exist — objectui's `packages/sdui-parser` and this |
| 15 | + * hoisted one — and the invariant is that both agree on the accepted grammar |
| 16 | + * AND on diagnostic codes. If they drift, the save gate and the renderer speak |
| 17 | + * different dialects: a page can save clean and render inert, or the reverse — |
| 18 | + * surface-dependent, therefore intermittent from the author's point of view |
| 19 | + * (objectstack#12719, objectstack#12810). These pins are the objectstack half |
| 20 | + * of that lockstep; the emitted diagnostic is byte-equal to objectui's. |
| 21 | + * |
| 22 | + * They carry more weight here because they are the ONLY witness. This repo |
| 23 | + * resolves no `sdui.manifest.json` (there is none in the tree, and |
| 24 | + * `@objectstack/console/dist/sdui.manifest.json` is absent), so |
| 25 | + * `resolveSduiManifest()` returns undefined and `validateJsxPages` runs |
| 26 | + * parse-only — `validateTree` is not reached from the production gate at all |
| 27 | + * today. A green CI therefore proves almost nothing about this module; this |
| 28 | + * file is what proves it. |
| 29 | + * |
| 30 | + * SEVERITY IS PINNED AS WARNING deliberately, and `ok` is pinned true beside |
| 31 | + * it. This port reports an ALREADY-inert state (the key was legal, silent and |
| 32 | + * ignored before it landed), so it must leave this copy's accept/reject set |
| 33 | + * exactly where it stood — that is the whole reason objectstack#12810 was split |
| 34 | + * from objectstack#12814, which did change what this copy rejects. A test that |
| 35 | + * moves `ok` is reporting a contract change, not a port. |
| 36 | + * |
| 37 | + * The accepted-set expectations are DERIVED from `CONSUMED_WIDGET_OPTION_KEYS` |
| 38 | + * — the same array the implementation prints — never restated, so a census |
| 39 | + * update cannot desynchronize this file. The derivation is guarded against |
| 40 | + * vacuity first (an empty census would warn on everything and make "names the |
| 41 | + * consumed set" trivially true of the empty string). |
| 42 | + * |
| 43 | + * WHAT THIS FILE CANNOT DERIVE, so that nobody reads it as claiming to. |
| 44 | + * objectui's copy sits next to a census test that re-measures the accepted set |
| 45 | + * against its renderer source (`DatasetWidget.tsx` read sites). This repo has |
| 46 | + * no dashboard renderer at all, so that half is not re-derivable here and is |
| 47 | + * not faked: the array is pinned literally below, and the pin names the two |
| 48 | + * files to re-read when either half moves — this repo's |
| 49 | + * `packages/spec/src/ui/dashboard.zod.ts` for the declared keys, objectui's |
| 50 | + * `DatasetWidget.tsx` for the read sites. |
| 51 | + */ |
| 52 | +import { describe, expect, it } from 'vitest'; |
| 53 | +import { |
| 54 | + compile, |
| 55 | + CONSUMED_WIDGET_OPTION_KEYS, |
| 56 | + UNCONSUMED_WIDGET_OPTION, |
| 57 | + manifestFromConfigs, |
| 58 | + validateTree, |
| 59 | +} from '../index.js'; |
| 60 | +import type { Diagnostic, Manifest, SchemaElement } from '../types.js'; |
| 61 | + |
| 62 | +/** |
| 63 | + * A manifest carrying both widget-host blocks, with the inputs their objectui |
| 64 | + * registrations declare, plus a non-host control block. |
| 65 | + */ |
| 66 | +const manifest: Manifest = manifestFromConfigs([ |
| 67 | + { |
| 68 | + type: 'dashboard', |
| 69 | + namespace: 'view', |
| 70 | + inputs: [ |
| 71 | + { name: 'columns', type: 'number' }, |
| 72 | + { name: 'gap', type: 'number' }, |
| 73 | + { name: 'className', type: 'string' }, |
| 74 | + ], |
| 75 | + }, |
| 76 | + { type: 'dashboard-grid', namespace: 'plugin-dashboard', inputs: [] }, |
| 77 | + { type: 'card', namespace: 'ui', inputs: [] }, |
| 78 | +]); |
| 79 | + |
| 80 | +/** The objectui#5709 fixture: the hotcrm gauge, verbatim in shape. */ |
| 81 | +const slaGauge = { |
| 82 | + id: 'sla_compliance_gauge', |
| 83 | + title: 'SLA Compliance', |
| 84 | + type: 'gauge', |
| 85 | + dataset: 'case_metrics', |
| 86 | + values: ['avg_sla_violated'], |
| 87 | + options: { |
| 88 | + format: '0%', |
| 89 | + invert: true, |
| 90 | + thresholds: [{ value: 0.95, color: 'success' }], |
| 91 | + }, |
| 92 | +}; |
| 93 | + |
| 94 | +const diagnose = (node: Record<string, unknown>): Diagnostic[] => |
| 95 | + validateTree(node as SchemaElement, manifest).diagnostics; |
| 96 | + |
| 97 | +const unconsumed = (node: Record<string, unknown>): Diagnostic[] => |
| 98 | + diagnose(node).filter((d) => d.code === UNCONSUMED_WIDGET_OPTION); |
| 99 | + |
| 100 | +const dash = (...widgets: unknown[]): Record<string, unknown> => ({ |
| 101 | + type: 'dashboard', |
| 102 | + widgets, |
| 103 | +}); |
| 104 | + |
| 105 | +describe('the census the expectations derive from is not vacuous', () => { |
| 106 | + it('CONSUMED_WIDGET_OPTION_KEYS is non-empty, duplicate-free and sorted', () => { |
| 107 | + // Everything below compares against this array; an empty or degenerate |
| 108 | + // census would make those comparisons agree about nothing. |
| 109 | + expect(CONSUMED_WIDGET_OPTION_KEYS.length).toBeGreaterThanOrEqual(5); |
| 110 | + expect(new Set(CONSUMED_WIDGET_OPTION_KEYS).size).toBe(CONSUMED_WIDGET_OPTION_KEYS.length); |
| 111 | + expect([...CONSUMED_WIDGET_OPTION_KEYS].sort()).toEqual([...CONSUMED_WIDGET_OPTION_KEYS]); |
| 112 | + }); |
| 113 | + |
| 114 | + it('the accepted set is exactly objectui\'s — the lockstep pin this copy cannot re-derive', () => { |
| 115 | + // This repo has no dashboard renderer, so the read-site half of the census |
| 116 | + // is not measurable here (objectui owns it). What IS measurable here is the |
| 117 | + // DECLARED half: the five query keys below are exactly the five properties |
| 118 | + // `DashboardWidgetOptionsSchema` declares in |
| 119 | + // `packages/spec/src/ui/dashboard.zod.ts` — the spec ships from THIS repo, |
| 120 | + // so a declared key landing there without landing here would turn this |
| 121 | + // module into a false positive on legal metadata. `description` is the |
| 122 | + // sixth, undeclared, member: the metric sub-caption channel that |
| 123 | + // `translateDashboard` writes into `options` (see `WidgetLike.options` in |
| 124 | + // `packages/spec/src/system/i18n-resolver.ts`), also a read site in this |
| 125 | + // repo. Re-read both files when this pin fails. |
| 126 | + expect([...CONSUMED_WIDGET_OPTION_KEYS]).toEqual([ |
| 127 | + 'dateGranularity', |
| 128 | + 'description', |
| 129 | + 'limit', |
| 130 | + 'sortBy', |
| 131 | + 'sortOrder', |
| 132 | + 'stageOrder', |
| 133 | + ]); |
| 134 | + }); |
| 135 | + |
| 136 | + it('the manifest resolves the host blocks — reachability before absence', () => { |
| 137 | + expect(manifest.components['dashboard']).toBeTruthy(); |
| 138 | + expect(manifest.components['dashboard-grid']).toBeTruthy(); |
| 139 | + expect(diagnose(dash()).map((d) => d.code)).not.toContain('unknown-component'); |
| 140 | + }); |
| 141 | +}); |
| 142 | + |
| 143 | +describe('the ruled first case: gauge options.invert (objectui#5709)', () => { |
| 144 | + it('warns on invert, thresholds AND format — none has a dataset-path read site', () => { |
| 145 | + const found = unconsumed(dash(slaGauge)); |
| 146 | + const keys = found.map((d) => /options\.(\w+)/.exec(d.message)?.[1]).sort(); |
| 147 | + expect(keys).toEqual(['format', 'invert', 'thresholds']); |
| 148 | + }); |
| 149 | + |
| 150 | + it('each warning names the widget, its type, and the FULL consumed set', () => { |
| 151 | + const found = unconsumed(dash(slaGauge)); |
| 152 | + // Not vacuous: an emitter that stopped emitting would make the loop below |
| 153 | + // assert nothing. |
| 154 | + expect(found.length).toBeGreaterThan(0); |
| 155 | + for (const d of found) { |
| 156 | + expect(d.message).toContain('"sla_compliance_gauge"'); |
| 157 | + expect(d.message).toContain('(gauge)'); |
| 158 | + // "naming the consumed set" is the ruling's own requirement — derived |
| 159 | + // from the array the implementation prints, never restated. |
| 160 | + for (const key of CONSUMED_WIDGET_OPTION_KEYS) { |
| 161 | + expect(d.message).toContain(key); |
| 162 | + } |
| 163 | + expect(d.tag).toBe('dashboard'); |
| 164 | + } |
| 165 | + }); |
| 166 | + |
| 167 | + it('the emitted code IS the exported constant — the one token that diverges from objectui cannot drift', () => { |
| 168 | + // objectui stamps `code: UNCONSUMED_WIDGET_OPTION`; this copy stamps the |
| 169 | + // literal, because `check:dispatcher-error-vocabulary` cannot reduce a |
| 170 | + // SCREAMING_SNAKE constant holding a kebab-case value (reasoning at the |
| 171 | + // site). This pin is what makes that divergence safe: the two spellings are |
| 172 | + // asserted equal, so a change to either is a red test, not a silent fork. |
| 173 | + const found = unconsumed(dash({ ...slaGauge, options: { invert: true } })); |
| 174 | + expect(found).toHaveLength(1); |
| 175 | + expect(found[0]!.code).toBe(UNCONSUMED_WIDGET_OPTION); |
| 176 | + expect(UNCONSUMED_WIDGET_OPTION).toBe('unconsumed-widget-option'); |
| 177 | + }); |
| 178 | + |
| 179 | + it('the emitted diagnostic is byte-equal to objectui\'s, field for field', () => { |
| 180 | + // The lockstep claim is about the WHOLE envelope, not just the code: a |
| 181 | + // message that drifted by one word is a different author-visible dialect. |
| 182 | + const found = unconsumed(dash({ ...slaGauge, options: { invert: true } })); |
| 183 | + expect(found).toEqual([ |
| 184 | + { |
| 185 | + severity: 'warning', |
| 186 | + code: 'unconsumed-widget-option', |
| 187 | + message: |
| 188 | + '<dashboard> widget "sla_compliance_gauge" (gauge): options.invert reaches no renderer — ' + |
| 189 | + 'dashboard widget renderers read only: dateGranularity, description, limit, sortBy, sortOrder, stageOrder', |
| 190 | + tag: 'dashboard', |
| 191 | + }, |
| 192 | + ]); |
| 193 | + }); |
| 194 | +}); |
| 195 | + |
| 196 | +describe('the port is ADDITIVE — it must not move this copy\'s accept/reject set', () => { |
| 197 | + // objectstack#12810 was split from objectstack#12814 precisely because this |
| 198 | + // half only reports an already-inert state. If any assertion here flips, the |
| 199 | + // port has become a contract change and must be re-graded, not merged. |
| 200 | + it('every diagnostic this code emits is a warning — the ruled ceiling, no new red gates', () => { |
| 201 | + const found = unconsumed(dash(slaGauge)); |
| 202 | + expect(found.length).toBeGreaterThan(0); |
| 203 | + for (const d of found) expect(d.severity).toBe('warning'); |
| 204 | + }); |
| 205 | + |
| 206 | + it('a page whose ONLY defect is a dead option key still passes the save gate', () => { |
| 207 | + const before = compile('<dashboard columns={2} />', manifest); |
| 208 | + expect(before.ok).toBe(true); |
| 209 | + // The same page, now carrying three unconsumed keys, is still accepted: |
| 210 | + // more diagnostics, same verdict. |
| 211 | + const after = validateTree(dash(slaGauge) as unknown as SchemaElement, manifest); |
| 212 | + expect(after.diagnostics.filter((d) => d.code === UNCONSUMED_WIDGET_OPTION)).toHaveLength(3); |
| 213 | + expect(after.diagnostics.some((d) => d.severity === 'error')).toBe(false); |
| 214 | + }); |
| 215 | + |
| 216 | + it('nothing outside the census gained a diagnostic — the pre-port codes are unchanged', () => { |
| 217 | + // A tree with one of each pre-existing defect, none of them a widget |
| 218 | + // option: the codes and count are exactly what they were before the port. |
| 219 | + const r = compile('<dashboard columns="two" nope={1}><card /></dashboard>', manifest); |
| 220 | + expect(r.diagnostics.map((d) => d.code).sort()).toEqual([ |
| 221 | + 'not-a-container', |
| 222 | + 'type-mismatch', |
| 223 | + 'unknown-prop', |
| 224 | + ]); |
| 225 | + expect(r.diagnostics.some((d) => d.code === UNCONSUMED_WIDGET_OPTION)).toBe(false); |
| 226 | + }); |
| 227 | +}); |
| 228 | + |
| 229 | +describe('the mechanism is general — invert is a case, not the implementation', () => { |
| 230 | + it('a different dead key on a different widget type draws the same code', () => { |
| 231 | + const found = unconsumed( |
| 232 | + dash({ id: 'k1', type: 'kpi', dataset: 'sales', values: ['total'], options: { sparkline: true } }), |
| 233 | + ); |
| 234 | + expect(found).toHaveLength(1); |
| 235 | + expect(found[0]!.message).toContain('options.sparkline'); |
| 236 | + expect(found[0]!.message).toContain('"k1"'); |
| 237 | + }); |
| 238 | + |
| 239 | + it('fires on the dashboard-grid host too — both surfaces share one dispatch', () => { |
| 240 | + const found = validateTree( |
| 241 | + { type: 'dashboard-grid', widgets: [slaGauge] } as unknown as SchemaElement, |
| 242 | + manifest, |
| 243 | + ).diagnostics.filter((d) => d.code === UNCONSUMED_WIDGET_OPTION); |
| 244 | + expect(found.length).toBe(3); |
| 245 | + expect(found[0]!.tag).toBe('dashboard-grid'); |
| 246 | + }); |
| 247 | + |
| 248 | + it('a widget with no usable id is named by index', () => { |
| 249 | + const found = unconsumed( |
| 250 | + dash({ type: 'bar', dataset: 'd1', values: ['v'], options: { glow: 1 } }), |
| 251 | + ); |
| 252 | + expect(found).toHaveLength(1); |
| 253 | + expect(found[0]!.message).toContain('"#0"'); |
| 254 | + }); |
| 255 | + |
| 256 | + it('every widget in the array is visited, not just the first', () => { |
| 257 | + const found = unconsumed( |
| 258 | + dash( |
| 259 | + { id: 'a', type: 'bar', dataset: 'd1', options: { dead1: 1 } }, |
| 260 | + { id: 'b', type: 'bar', dataset: 'd1', options: { dead2: 1 } }, |
| 261 | + ), |
| 262 | + ); |
| 263 | + expect(found.map((d) => d.message.match(/"(\w+)"/)?.[1])).toEqual(['a', 'b']); |
| 264 | + }); |
| 265 | +}); |
| 266 | + |
| 267 | +describe('what draws NOTHING — every accepted key, and every out-of-scope shape', () => { |
| 268 | + it('the full accepted set on one widget is clean (control: plus one dead key is not)', () => { |
| 269 | + const accepted = Object.fromEntries(CONSUMED_WIDGET_OPTION_KEYS.map((k) => [k, 1])); |
| 270 | + const widget = { id: 'w', type: 'bar', dataset: 'd1', values: ['v'] }; |
| 271 | + expect(unconsumed(dash({ ...widget, options: accepted }))).toEqual([]); |
| 272 | + // The control that separates "these keys are accepted" from "the check |
| 273 | + // stopped running": the SAME widget with one extra key is reported. |
| 274 | + expect(unconsumed(dash({ ...widget, options: { ...accepted, dead: 1 } }))).toHaveLength(1); |
| 275 | + }); |
| 276 | + |
| 277 | + it('a widget without `dataset` is out of census scope — the legacy inline form', () => { |
| 278 | + // The legacy (spec-illegal) form consumes a spread-shaped superset this |
| 279 | + // census deliberately does not model; a warning here would be a guess. |
| 280 | + expect(unconsumed(dash({ id: 'l', type: 'gauge', options: { invert: true } }))).toEqual([]); |
| 281 | + // …and the empty-string dataset is the same non-binding, not a binding to ''. |
| 282 | + expect(unconsumed(dash({ id: 'l', type: 'gauge', dataset: '', options: { invert: true } }))).toEqual([]); |
| 283 | + }); |
| 284 | + |
| 285 | + it('the legacy component format is out of scope', () => { |
| 286 | + expect( |
| 287 | + unconsumed( |
| 288 | + dash({ id: 'c', dataset: 'd1', component: { type: 'card' }, options: { invert: true } }), |
| 289 | + ), |
| 290 | + ).toEqual([]); |
| 291 | + }); |
| 292 | + |
| 293 | + it('deferred expressions are opaque, never guessed at', () => { |
| 294 | + // Built indirectly: this is the PARSER's `{ $expr }` marker (a whole |
| 295 | + // deferred options bag), the same marker `inert-expression` names — this |
| 296 | + // module never evaluates it and never reports on what might be inside. |
| 297 | + const deferredBag = { $expr: 'ctx.opts' }; |
| 298 | + expect(unconsumed(dash({ id: 'e', type: 'gauge', dataset: 'd1', options: deferredBag }))).toEqual([]); |
| 299 | + expect(unconsumed({ type: 'dashboard', widgets: { $expr: 'ctx.widgets' } })).toEqual([]); |
| 300 | + }); |
| 301 | + |
| 302 | + it("the spec's own suppressWarnings escape hatch is honoured (control: unsuppressed twin warns)", () => { |
| 303 | + const suppressed = { ...slaGauge, id: 'g1', suppressWarnings: [UNCONSUMED_WIDGET_OPTION] }; |
| 304 | + const twin = { ...slaGauge, id: 'g2' }; |
| 305 | + const found = unconsumed(dash(suppressed, twin)); |
| 306 | + expect(found.every((d) => d.message.includes('"g2"'))).toBe(true); |
| 307 | + expect(found).toHaveLength(3); |
| 308 | + }); |
| 309 | + |
| 310 | + it('a non-host component with a widgets array is not searched', () => { |
| 311 | + expect(unconsumed({ type: 'card', widgets: [slaGauge] })).toEqual([]); |
| 312 | + }); |
| 313 | + |
| 314 | + it('an unknown host draws unknown-component, not deep option warnings', () => { |
| 315 | + const bare: Manifest = manifestFromConfigs([{ type: 'card', namespace: 'ui', inputs: [] }]); |
| 316 | + const d = validateTree(dash(slaGauge) as unknown as SchemaElement, bare).diagnostics; |
| 317 | + expect(d.map((x) => x.code)).toContain('unknown-component'); |
| 318 | + expect(d.filter((x) => x.code === UNCONSUMED_WIDGET_OPTION)).toEqual([]); |
| 319 | + }); |
| 320 | +}); |
0 commit comments