diff --git a/.changeset/palette-exclusion-reason-truthfulness.md b/.changeset/palette-exclusion-reason-truthfulness.md new file mode 100644 index 0000000000..ad1bcf1077 --- /dev/null +++ b/.changeset/palette-exclusion-reason-truthfulness.md @@ -0,0 +1,8 @@ +--- +--- + +Corrects two `PALETTE_EXCLUSIONS` reason strings in the Studio page-palette ledger that +claimed "no renderer" for `element:text_input` and `element:record_picker`, both of which +have registered renderers, and pins the class with a test. The exclusions themselves are +unchanged decisions and no published behaviour moves: the reason strings are developer-facing +ledger prose, read by no runtime code path. diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts new file mode 100644 index 0000000000..35845fa58e --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#6071 — an exclusion reason that claims "no renderer" must be TRUE. + * + * ## The drift this closes + * + * `PALETTE_EXCLUSIONS` was made an explicit ledger (#2943) so a palette + * decision is recorded where the next reader will find it. `block-config. + * test.ts` enforces that every exclusion CARRIES a reason (`reason.length > + * 10`); nothing enforced that the reason is TRUE. Two of them drifted: the + * entries for `element:text_input` and `element:record_picker` opened with "no + * renderer" while both types have had a registered renderer under + * `namespace: 'element'` all along. The cost is not cosmetic — the ledger is + * read as the decision record, and #5837 had to re-derive registration state + * from source precisely because the stated reason could not be trusted. + * + * Correcting the two strings fixes today's text. This file pins the CLASS, so + * the next "no renderer" written over a type that has one fails here instead of + * being believed for another release. + * + * ## Why the assertion is shaped this way + * + * The interesting direction is cheap to get wrong. Three hazards, each with its + * own guard below: + * + * 1. **A degenerate (empty) registry passes every negative assertion.** If the + * renderer packages were never imported, `ComponentRegistry.get(...)` is + * `undefined` for EVERYTHING and "no exclusion claiming no-renderer has a + * renderer" holds vacuously — a green that measures nothing. So each + * side-effect import below carries a POSITIVE probe proving that package's + * registrations actually ran (the same discipline `palette-discussion- + * alias.test.tsx` states in its own header: every negative pin carries its + * positive half). + * 2. **A vacuous loop.** If someone reworded every reason so none claims "no + * renderer" any more, the class assertion would iterate over nothing and + * stay green while the guard silently stopped guarding. `the ledger still + * contains a no-renderer claim to check` fails in that case. + * 3. **Scope is bounded by the import set.** A renderer registered in a + * package NOT imported here reads as unregistered, which would let a false + * "no renderer" pass. The imports below are therefore the packages that + * could plausibly register a page block for the excluded types + * (`@object-ui/components` for `element:*`, `@object-ui/plugin-chatbot` for + * the AI surface, `@object-ui/plugin-form` for the form family). Widen the + * set — and its positive probes — when a new package starts registering + * page blocks. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +// Side-effect imports: these register the components under test. The app-shell +// test setup does not pull them in, and relying on another suite having +// imported one first would make this file order-dependent. +import '@object-ui/components'; +import '@object-ui/plugin-chatbot'; +import '@object-ui/plugin-form'; +import { PALETTE_EXCLUSIONS } from '../block-types'; + +/** + * A reason "claims no renderer" when its text says the type has none — + * `no renderer`, `no inline renderer`, `has no renderer`. Deliberately loose on + * the qualifier and anchored on the noun, so a reworded claim is still caught. + */ +const CLAIMS_NO_RENDERER = /\bno\s+(?:\w+\s+){0,2}renderer\b/i; + +const claimingNoRenderer = Object.entries(PALETTE_EXCLUSIONS).filter(([, reason]) => + CLAIMS_NO_RENDERER.test(reason), +); + +describe('objectui#6071 — PALETTE_EXCLUSIONS reasons that claim "no renderer"', () => { + it('the registry under test is actually populated (guards a vacuous green)', () => { + // One probe per side-effect import above. If any of these is falsy the + // negative assertion below proves nothing, so it must fail LOUDLY here + // rather than passing quietly there. + expect( + ComponentRegistry.get('element:text'), + '@object-ui/components did not register — every "not registered" check below would pass vacuously', + ).toBeTruthy(); + expect( + ComponentRegistry.get('chatbot'), + '@object-ui/plugin-chatbot did not register — the AI surface is not actually covered', + ).toBeTruthy(); + expect( + ComponentRegistry.get('object-form'), + '@object-ui/plugin-form did not register — the form family is not actually covered', + ).toBeTruthy(); + }); + + it('the ledger still contains a no-renderer claim to check (guards a vacuous loop)', () => { + // If this fails, every reason was reworded away from the claim. That may be + // fine — but then this file is no longer guarding anything, and that should + // be a decision rather than a silent green. + expect( + claimingNoRenderer.map(([type]) => type), + 'no exclusion claims "no renderer" any more — this guard has nothing left to check', + ).not.toEqual([]); + }); + + it('no exclusion whose reason claims "no renderer" actually has one', () => { + for (const [type, reason] of claimingNoRenderer) { + // `register(type, c, { namespace: n })` writes the map key `n:type`, and + // `get(type)` with no namespace argument looks up that literal key + // (core/src/registry/Registry.ts) — so this is the same question the + // reason string is answering, asked of the runtime registry. + expect( + ComponentRegistry.get(type), + `PALETTE_EXCLUSIONS['${type}'] says ${JSON.stringify(reason)}, but a renderer IS registered for it. ` + + 'The exclusion may well still be right — reword the reason to the real rationale (as #6071 did for ' + + '`element:text_input` and `element:record_picker`) instead of claiming a renderer status that is false.', + ).toBeFalsy(); + } + }); + + it('the two corrected entries do have renderers, which is what makes their new wording true', () => { + // The other direction of the same fact. Their reasons now say they RENDER + // but are not page content; if a later change unregistered them, that text + // would be false in the opposite direction and this catches it. + for (const type of ['element:text_input', 'element:record_picker']) { + expect( + ComponentRegistry.get(type), + `${type} is no longer registered — its exclusion reason says it renders`, + ).toBeTruthy(); + expect(PALETTE_EXCLUSIONS[type], `${type} must stay excluded — #6071 changed text, not decisions`).toBeTruthy(); + } + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts index 175385a2ed..4e2990665e 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts @@ -151,11 +151,32 @@ export const PALETTE_EXCLUSIONS: Record = { 'app:launcher': 'shell singleton — the app shell renders it, not a page', 'global:notifications': 'shell singleton — lives in the app shell header', 'user:profile': 'shell singleton — lives in the app shell header', - // No renderer, by decision. + // No renderer, by decision — and these two are the ones that MEASURE that way. + // Nothing registers `form` under `namespace: 'element'` (the form renderers are + // `ui:form` in `components/renderers/form/form.tsx` and `view:form` in + // `plugin-form/src/index.tsx`; the object-bound alternative named below, + // `object-form`, is registered in that same file), and no `ai:` namespace + // registration exists anywhere — `components/renderers/placeholders.tsx` keeps + // `ai:chat_window` out on purpose so a referencing schema fails loudly. 'ai:chat_window': 'no inline renderer — the floating chat overlay (plugin-chatbot) is canonical', 'element:form': 'no renderer — use the object-bound `object-form` block', - 'element:record_picker': 'no renderer — record picking is a field widget, not a page block', - 'element:text_input': 'no renderer — bare inputs belong to a form, not a page block', + // Renders fine — excluded because it is not PAGE CONTENT, not because it is + // unrenderable. Both types have a registered renderer under `namespace: + // 'element'` (`components/renderers/basic/text-input.tsx:161`, + // `components/renderers/basic/record-picker.tsx:303`), so the "no renderer" + // these two reasons used to open with was simply false (#6071). That matters + // because this ledger is read as the DECISION RECORD: #5837 had to re-derive + // registration state from source precisely because the stated reason could not + // be trusted. `core/src/registry/public-blocks.ts` already words these same two + // exclusions without any renderer claim. + // + // ⛔ The exclusions themselves are unchanged — still decisions, still standing. + // Only the false leading clause moved; the substantive half (field widget / + // belongs to a form) was correct all along and is kept verbatim. + // `exclusion-reason-truthfulness.test.ts` pins the CLASS so it cannot come + // back: an exclusion whose reason claims "no renderer" must not have one. + 'element:record_picker': 'renders, but not as page content — record picking is a field widget, not a page block (also excluded from PUBLIC_BLOCKS)', + 'element:text_input': 'renders, but not as page content — bare inputs belong to a form, not a page block', // Renders fine — excluded to keep ONE palette entry per renderer, not because // it is unauthorable. `record:chatter` and `record:discussion` are the same // renderer under two names: `plugin-detail/src/index.tsx` registers both