|
| 1 | +/** |
| 2 | + * `quickAdd` on the `ObjectKanbanRenderer` tag is DIAGNOSED, and diagnosed |
| 3 | + * truthfully — the objectui#8285 mechanism (ruling of 2026-09-08, batch #91), |
| 4 | + * ported into this copy in lockstep (objectstack#17645). |
| 5 | + * |
| 6 | + * WHY THESE PINS EXIST HERE. Two copies of this parser exist — objectui's |
| 7 | + * `packages/sdui-parser` (which the browser RENDERER runs) and this hoisted one |
| 8 | + * (which the SAVE GATE runs). These pins are the objectstack half of the |
| 9 | + * lockstep for this diagnostic; the ported module is byte-equal to objectui's. |
| 10 | + * Before this port, this copy answered an authored `quickAdd` with the generic |
| 11 | + * `unknown-prop` while objectui answered `inert-quick-add` — the same authored |
| 12 | + * page getting two different readings from the two tiers, which is the dialect |
| 13 | + * split the lockstep invariant forbids. |
| 14 | + * |
| 15 | + * ## What is red here before the change, and what is not |
| 16 | + * |
| 17 | + * ⚠️ Only the rows that assert `inert-quick-add` are red before this change. |
| 18 | + * `quickAdd` is undeclared on the kanban registrations (objectui#8201 escalated |
| 19 | + * it rather than declaring it), so this tier ALREADY reported it — as |
| 20 | + * `unknown-prop`, a message that is false against the contract |
| 21 | + * `@objectstack/spec` still publishes. The port replaces a wrong reading; it |
| 22 | + * does not add a first one. |
| 23 | + * |
| 24 | + * The remaining rows are green in BOTH worlds by construction, and each is kept |
| 25 | + * because it names a WRONG FIX that would otherwise pass: |
| 26 | + * |
| 27 | + * - the unknown-prop control — a fix that suppressed the generic walk for |
| 28 | + * this block rather than for this one key; |
| 29 | + * - the two RETIRED kanban-ish tags (`kanban`, `kanban-ui`) — a fix scoped |
| 30 | + * to "a kanban-ish tag" rather than to the one registration that serves |
| 31 | + * `ObjectKanbanRenderer`. Both left objectui's registry (objectui#8802 / |
| 32 | + * objectui#8257), so neither is a host any more; they stay declared in the |
| 33 | + * hand-built manifest below precisely so this row can still discriminate; |
| 34 | + * - `quickAdd: false` — a fix keyed on the KEY's presence rather than on the |
| 35 | + * author asking for the control, which would warn about a value that got |
| 36 | + * exactly what it asked for; |
| 37 | + * - the braced marker — a fix that reads the parser's opaque `$expr` object |
| 38 | + * as "truthy, therefore the author asked for it"; |
| 39 | + * - `ok` staying true — an escalation to `error`, which the objectui#5709 |
| 40 | + * ruling ("no new red gates") and objectui#6614 Q2 both put elsewhere. |
| 41 | + */ |
| 42 | +import { describe, expect, it } from 'vitest'; |
| 43 | +import { |
| 44 | + INERT_QUICK_ADD, |
| 45 | + QUICK_ADD_HOST_TYPES, |
| 46 | + QUICK_ADD_KEY, |
| 47 | + compile, |
| 48 | + manifestFromConfigs, |
| 49 | + validateTree, |
| 50 | +} from '../index.js'; |
| 51 | +import type { Diagnostic, Manifest } from '../types.js'; |
| 52 | + |
| 53 | +/** |
| 54 | + * The three kanban blocks, carrying the inputs their registrations declared — |
| 55 | + * `quickAdd` on NONE of them, which is the state this port leaves untouched. |
| 56 | + * `card` is a non-kanban control block. |
| 57 | + * |
| 58 | + * ⚠️ `kanban` and `kanban-ui` are RETIRED registrations (objectui#8802, |
| 59 | + * objectui#8257) and are kept here ON PURPOSE, as the discrimination controls |
| 60 | + * below: a manifest is an argument to `validateTree`, so this file can still |
| 61 | + * ask what the rule says about a tag the live registry no longer produces — |
| 62 | + * and the answer must be "not a host". ⛔ Do not read their presence as a |
| 63 | + * claim that either tag resolves; against a manifest built from the live |
| 64 | + * registry both answer `unknown-component`. |
| 65 | + */ |
| 66 | +const manifest: Manifest = manifestFromConfigs([ |
| 67 | + { |
| 68 | + type: 'object-kanban', |
| 69 | + namespace: 'plugin-kanban', |
| 70 | + inputs: [ |
| 71 | + { name: 'objectName', type: 'string', required: true }, |
| 72 | + { name: 'groupBy', type: 'string' }, |
| 73 | + ], |
| 74 | + }, |
| 75 | + { |
| 76 | + type: 'kanban', |
| 77 | + namespace: 'view', |
| 78 | + inputs: [ |
| 79 | + { name: 'objectName', type: 'string', required: true }, |
| 80 | + { name: 'groupBy', type: 'string' }, |
| 81 | + ], |
| 82 | + }, |
| 83 | + { type: 'kanban-ui', namespace: 'plugin-kanban', inputs: [{ name: 'columns', type: 'array' }] }, |
| 84 | + { type: 'card', namespace: 'ui', inputs: [] }, |
| 85 | +]); |
| 86 | + |
| 87 | +const diagnose = (node: Record<string, unknown>): Diagnostic[] => |
| 88 | + validateTree(node as never, manifest).diagnostics; |
| 89 | + |
| 90 | +const codesFor = (node: Record<string, unknown>, key: string): string[] => |
| 91 | + diagnose(node).filter((d) => d.message.includes(`"${key}"`)).map((d) => d.code); |
| 92 | + |
| 93 | +const HOST_TAGS = [...QUICK_ADD_HOST_TYPES].sort(); |
| 94 | + |
| 95 | +describe('an authored `quickAdd` is diagnosed on the ObjectKanban tags', () => { |
| 96 | + it('the host set is the one surviving ObjectKanbanRenderer tag, and is not empty', () => { |
| 97 | + // Anti-vacuity for every row below: an empty set would make the negative |
| 98 | + // rows trivially true and the positive rows unreachable. |
| 99 | + // |
| 100 | + // ⚠️ This row is the ONLY thing in this package that a change to |
| 101 | + // `QUICK_ADD_HOST_TYPES` reddens — every other row is `it.each(HOST_TAGS)` |
| 102 | + // and re-derives itself from the constant, so a narrowing would otherwise |
| 103 | + // just delete cases silently. `kanban` left the set with its registration |
| 104 | + // (objectui#8802): `checkKanbanQuickAdd` is reached only from |
| 105 | + // `validate.ts`'s prop walk, which runs only for a tag the manifest |
| 106 | + // RESOLVED, so a tag no registration produces is answered by |
| 107 | + // `unknown-component` one level up and never reaches this module. |
| 108 | + expect(HOST_TAGS).toEqual(['object-kanban']); |
| 109 | + }); |
| 110 | + |
| 111 | + it('the stamped literal equals the exported constant', () => { |
| 112 | + // objectui stamps `code: INERT_QUICK_ADD`; this copy stamps the inline |
| 113 | + // literal, because `check:dispatcher-error-vocabulary` cannot reduce a |
| 114 | + // kebab-case constant at a `code:` position (the divergence is documented |
| 115 | + // at the stamp, and is the same one `unconsumed-widget-option` carries). |
| 116 | + // This row is what keeps the two spellings from drifting apart — and the |
| 117 | + // lockstep gate resolves the identifier through this constant, so both |
| 118 | + // copies still agree on the same code. |
| 119 | + expect(INERT_QUICK_ADD).toBe('inert-quick-add'); |
| 120 | + const [diagnostic] = diagnose({ type: 'object-kanban', objectName: 'task', quickAdd: true }); |
| 121 | + expect(diagnostic.code).toBe(INERT_QUICK_ADD); |
| 122 | + }); |
| 123 | + |
| 124 | + it.each(HOST_TAGS)('<%s> — an authored `quickAdd: true` draws exactly one warning', (tag) => { |
| 125 | + const diagnostics = diagnose({ type: tag, objectName: 'task', quickAdd: true }); |
| 126 | + expect(diagnostics).toHaveLength(1); |
| 127 | + expect(diagnostics[0].code).toBe(INERT_QUICK_ADD); |
| 128 | + expect(diagnostics[0].severity).toBe('warning'); |
| 129 | + expect(diagnostics[0].tag).toBe(tag); |
| 130 | + }); |
| 131 | + |
| 132 | + it.each(HOST_TAGS)('<%s> — it REPLACES the false `unknown-prop`, it does not join it', (tag) => { |
| 133 | + // The reading this port exists to correct: before it, the only thing this |
| 134 | + // tier said about this key was that the block "has no prop quickAdd" — |
| 135 | + // false against `ComponentPropsMap['object-kanban']`, which publishes it. |
| 136 | + expect(codesFor({ type: tag, objectName: 'task', quickAdd: true }, QUICK_ADD_KEY)).toEqual([ |
| 137 | + INERT_QUICK_ADD, |
| 138 | + ]); |
| 139 | + }); |
| 140 | + |
| 141 | + it.each(HOST_TAGS)('<%s> — the message names the missing half and where the pair works', (tag) => { |
| 142 | + // Not a prose pin: these two tokens are what makes the diagnostic |
| 143 | + // ACTIONABLE, and a message that dropped either would send its reader back |
| 144 | + // to the contract with no explanation, which is the state being fixed. |
| 145 | + const [{ message }] = diagnose({ type: tag, objectName: 'task', quickAdd: true }); |
| 146 | + expect(message).toContain('onQuickAdd'); |
| 147 | + // ⚠️ The remedy names the COMPONENT, not the `kanban-ui` TAG it used to |
| 148 | + // name: objectui#8257 retired that registration, so a page written to the |
| 149 | + // old advice would now draw `unknown-component` — an ERROR. `KanbanRenderer` |
| 150 | + // is still exported from `@object-ui/plugin-kanban` and still forwards both |
| 151 | + // halves by identity, so it is the surviving way to get the pair. |
| 152 | + expect(message).toContain('KanbanRenderer'); |
| 153 | + }); |
| 154 | + |
| 155 | + it.each(HOST_TAGS)('<%s> — control: a genuinely unknown prop is still reported', (tag) => { |
| 156 | + // Guards a fix that turned the generic walk off for this block instead of |
| 157 | + // answering for this one key. Green in both worlds by construction. |
| 158 | + expect(codesFor({ type: tag, objectName: 'task', bogusProp: 'x' }, 'bogusProp')).toEqual([ |
| 159 | + 'unknown-prop', |
| 160 | + ]); |
| 161 | + }); |
| 162 | + |
| 163 | + it.each(HOST_TAGS)( |
| 164 | + '<%s> — control: a falsy `quickAdd` asks for no control, so it draws none', |
| 165 | + (tag) => { |
| 166 | + // `KanbanImpl` gates on `quickAdd && onQuickAdd`: `false` got exactly what |
| 167 | + // it wrote and nothing was dropped. Guards a fix keyed on the key's mere |
| 168 | + // presence. Green in both worlds — the `unknown-prop` half is the status |
| 169 | + // quo this port deliberately leaves alone. |
| 170 | + expect(codesFor({ type: tag, objectName: 'task', quickAdd: false }, QUICK_ADD_KEY)).toEqual([ |
| 171 | + 'unknown-prop', |
| 172 | + ]); |
| 173 | + }, |
| 174 | + ); |
| 175 | + |
| 176 | + it.each(HOST_TAGS)( |
| 177 | + '<%s> — control: a braced value this tier never evaluates is not read as a request', |
| 178 | + (tag) => { |
| 179 | + // The parser's deferred marker is an opaque object — truthy in JS, and no |
| 180 | + // reading at all about what the author asked for. Guards a fix that tested |
| 181 | + // truthiness alone. Green in both worlds. |
| 182 | + expect( |
| 183 | + codesFor( |
| 184 | + { type: tag, objectName: 'task', quickAdd: { $expr: 'rows.length > 0' } }, |
| 185 | + QUICK_ADD_KEY, |
| 186 | + ), |
| 187 | + ).toEqual(['unknown-prop']); |
| 188 | + }, |
| 189 | + ); |
| 190 | + |
| 191 | + it.each(['kanban-ui', 'kanban'])( |
| 192 | + 'control: `%s` is NOT a host — a kanban-ish tag alone does not arm this diagnostic', |
| 193 | + (tag) => { |
| 194 | + // Guards a fix scoped to "a kanban-ish tag". Both spellings are RETIRED |
| 195 | + // registrations (objectui#8257, objectui#8802), so neither can reach this |
| 196 | + // module through a manifest built from the live registry at all — they are |
| 197 | + // declared in this file's hand-built manifest so the discrimination is |
| 198 | + // still measurable here, which is the one thing a synthetic manifest can |
| 199 | + // do that the live one cannot. `kanban-ui`'s pair itself survives on the |
| 200 | + // exported `KanbanRenderer` component, which no tag resolves to. |
| 201 | + const node = { type: tag, columns: [], quickAdd: true }; |
| 202 | + expect(diagnose(node).map((d) => d.code)).not.toContain(INERT_QUICK_ADD); |
| 203 | + expect(codesFor(node, QUICK_ADD_KEY)).toEqual(['unknown-prop']); |
| 204 | + }, |
| 205 | + ); |
| 206 | + |
| 207 | + it('control: a non-kanban block is untouched', () => { |
| 208 | + expect(diagnose({ type: 'card', quickAdd: true }).map((d) => d.code)).toEqual(['unknown-prop']); |
| 209 | + }); |
| 210 | + |
| 211 | + it('through the whole pipeline: the diagnostic survives parse + validate on real source', () => { |
| 212 | + // A SUBJECT row, not a control — it is red without the port, like the rows |
| 213 | + // above. It is here because every row above hands `validateTree` a |
| 214 | + // hand-built node: this one starts from source text, so it also proves the |
| 215 | + // parser really materializes the braced `true` as a boolean rather than as |
| 216 | + // the `$expr` marker the falsy/braced controls are about. |
| 217 | + const { diagnostics } = compile('<object-kanban objectName="task" quickAdd={true} />', manifest); |
| 218 | + expect(diagnostics.map((d) => d.code)).toEqual([INERT_QUICK_ADD]); |
| 219 | + }); |
| 220 | + |
| 221 | + it('control: the page still COMPILES — a warning, not a new red gate', () => { |
| 222 | + // `ok` is `!diagnostics.some(d => d.severity === 'error')`, i.e. the save |
| 223 | + // gate's pass/fail, and it is true in BOTH worlds: `unknown-prop` was a |
| 224 | + // warning too, so nothing hardens here. Kept because it names the wrong |
| 225 | + // fix — escalating an inert authored key to `error`, which objectui#5709 |
| 226 | + // ("no new red gates") and objectui#6614 Q2 both put elsewhere, and which |
| 227 | + // would stop a page that saves today from saving. |
| 228 | + expect(compile('<object-kanban objectName="task" quickAdd={true} />', manifest).ok).toBe(true); |
| 229 | + // Non-vacuity for that `true`: the same pipeline DOES turn `ok` false when |
| 230 | + // an error-severity diagnostic is present, so this is a reading about this |
| 231 | + // diagnostic's severity and not about `ok` being unreachable. |
| 232 | + expect(compile('<not-a-block />', manifest).ok).toBe(false); |
| 233 | + }); |
| 234 | +}); |
0 commit comments