From 25907cd709b70855aad96e296ed1c836fd9968ba Mon Sep 17 00:00:00 2001 From: os-justin Date: Sun, 6 Sep 2026 20:28:34 +0000 Subject: [PATCH] fix(plugin-kanban): ObjectKanbanComponentProps.schema names both registered node types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectKanbanRenderer` is registered under two keys — `'object-kanban'` and `'kanban'` — whose declared node types are `ObjectKanbanSchema` and `KanbanSchema`. The prop named `KanbanSchema` alone, so no `object-kanban` node was assignable to the component that renders it; the discriminants are disjoint literals, so half the boards this component serves had no cast-free annotation. Measured read set: `ObjectKanban` reads thirteen keys off `schema`; the two declarations together cover twelve, and each arm is load-bearing (`titleField` comes only from `ObjectKanbanSchema`, `columns` / `cardTitle` / `swimlaneField` / `grouping` only from `KanbanSchema`). `filter` is declared by neither and still rides `BaseSchema`'s index signature — reported, not changed here. The prop is now the union of the two arms. Three schema-key `as any` casts drop out of `ObjectKanban.tsx`; four of six in-package fixtures drop their `as never` for `satisfies ObjectKanbanSchema` (the two that remain are static boards with no `objectName`, objectui#7780). A new pin derives the registered key set from `index.tsx` off disk and requires it to equal the union's arms, and the objectui#7664 prop leg moves from identity to assignability — the form that ruling's own words claim, and the form its `kanban-ui` leg already uses. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .../7322-object-kanban-component-props.md | 55 +++++++ .../ObjectKanban.markedRefusalToast.test.tsx | 4 +- .../src/ObjectKanban.navWidthDefault.test.tsx | 3 +- .../ObjectKanban.overlayTitleI18n.test.tsx | 9 ++ ...an.overlayTitleNoProviderFallback.test.tsx | 9 ++ ...ObjectKanban.rejectedMoveRollback.test.tsx | 4 +- .../ObjectKanban.requiredWhenPrompt.test.tsx | 4 +- packages/plugin-kanban/src/ObjectKanban.tsx | 63 +++++++- ...-plugin-dialect-authoritative-7664.test.ts | 21 ++- ...object-kanban-component-props-7322.test.ts | 143 ++++++++++++++++++ 10 files changed, 301 insertions(+), 14 deletions(-) create mode 100644 .changeset/7322-object-kanban-component-props.md create mode 100644 packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts diff --git a/.changeset/7322-object-kanban-component-props.md b/.changeset/7322-object-kanban-component-props.md new file mode 100644 index 0000000000..9cc7ff4177 --- /dev/null +++ b/.changeset/7322-object-kanban-component-props.md @@ -0,0 +1,55 @@ +--- +'@object-ui/plugin-kanban': minor +--- + +`ObjectKanbanComponentProps.schema` names both node types the component is registered for +(objectui#7322 item ②, following the objectui#5903 / #5018 land shape). + +`ObjectKanbanRenderer` is registered under two keys — `'object-kanban'` and `'kanban'` — +and the two keys have different declared node types: `ObjectKanbanSchema` (`type: +'object-kanban'`, `objectName` and `groupBy` required) and `KanbanSchema` (`type: +'kanban'`, both optional). The prop named `KanbanSchema` alone, so **no `object-kanban` +node was assignable to the component that renders it**, and the discriminants are disjoint +string literals, so no cast-free annotation existed for half the boards this component +serves. It is now the union of the two. + +## What settled it: the read set + +`ObjectKanban` reads thirteen keys off `schema`. Neither declaration covers them; the two +TOGETHER cover twelve, and each arm is load-bearing: + +- `objectName`, `groupBy`, `limit`, `cardFields` — declared on both; +- `columns`, `cardTitle`, `swimlaneField`, `grouping` — `KanbanSchema` only; +- `titleField` — `ObjectKanbanSchema` only (which is why that read was spelled + `(schema as any).titleField`); +- `data`, `bind`, `className` — `BaseSchema`; +- `filter` — declared by **neither** face, still riding `BaseSchema`'s index signature. + Measured and reported, **not** changed here: this card moves the prop, not the two + published schema faces. + +So naming `ObjectKanbanSchema` alone — the remedy the original card implied — would have +been wrong in the other direction: it drops four declared reads and the `'kanban'` +registration. + +## Not affected + +Widening a member of an exported prop type is additive: every caller that passed a +`KanbanSchema` still compiles, and the union claims exactly the accept set the registry +dispatches to this component — a third node type is still turned away. The runtime is +untouched; `ObjectKanbanRenderer` still takes `schema: any`, so no shape is turned away +there either (the objectui#5903 disposition, restated). The view-level `kanban.groupField` +alias, `BaseSchema`'s index signature, and `@object-ui/types` are all untouched. + +## Casts this removes + +Inside `ObjectKanban.tsx`, three schema-key reads drop their `as any`: `titleField` (two +sites, now honest because the `object-kanban` arm declares it) and `cardFields` / +`cardTitle` (already declared; the casts were redundant). `(schema as any).navigation` +**stays** — `navigation` is declared on neither face, so removing the cast would change +nothing but the spelling of an index-signature read. + +Four of the six in-package fixtures that mount an `object-kanban` board drop their +`as never` escape for a real `satisfies ObjectKanbanSchema`. The other two are static +boards (`columns` + inline `data`, no fetch) that author no `objectName`, which +`ObjectKanbanSchema` declares required — objectui#7780's subject; their casts stay, now +carrying the reason and the card number. diff --git a/packages/plugin-kanban/src/ObjectKanban.markedRefusalToast.test.tsx b/packages/plugin-kanban/src/ObjectKanban.markedRefusalToast.test.tsx index e95237a97f..5c816c6233 100644 --- a/packages/plugin-kanban/src/ObjectKanban.markedRefusalToast.test.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.markedRefusalToast.test.tsx @@ -51,7 +51,7 @@ import '@testing-library/jest-dom'; import { registerAllFields } from '@object-ui/fields'; import { toast } from '@object-ui/components'; import { normaliseClientError } from '@object-ui/data-objectstack'; -import type { DataSource } from '@object-ui/types'; +import type { DataSource, ObjectKanbanSchema } from '@object-ui/types'; import { ObjectKanban } from './ObjectKanban'; // Pay the board's lazy chunk at import time rather than racing it against a @@ -112,7 +112,7 @@ const schema = { { id: 'backlog', title: 'Backlog' }, { id: 'in_progress', title: 'In Progress' }, ], -} as never; +} satisfies ObjectKanbanSchema; const serverRecords = () => [{ id: 't1', title: CARD, status: 'backlog' }]; diff --git a/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx b/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx index 3ad71b4a97..c882cfd010 100644 --- a/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx @@ -45,6 +45,7 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import type { ObjectKanbanSchema } from '@object-ui/types'; import { ObjectKanban } from './ObjectKanban'; // Pay the board's lazy chunk at import time, not inside a `findBy` budget @@ -84,7 +85,7 @@ async function openDrawer(navigation?: Record) { columns: [{ id: 'todo', title: 'To Do' }], data: cards, ...(navigation ? { navigation } : {}), - } as never} + } satisfies ObjectKanbanSchema} />, ); const card = await screen.findByText('On the board'); diff --git a/packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx b/packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx index 5f205cab6e..13cdeeb9c5 100644 --- a/packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx @@ -96,6 +96,15 @@ function renderKanbanIn(language: string, schemaExtra: Record) columns: [{ id: 'todo', title: 'To Do' }], data: cards, ...schemaExtra, + // ⚠️ Still escaped, and NOT by oversight (objectui#7322 item ②). This + // board is STATIC — `columns` plus inline `data`, no fetch — so it + // authors no `objectName`, which `ObjectKanbanSchema` declares REQUIRED. + // That requiredness is objectui#7780's subject (the renderer reads inline + // `data` ahead of the fetch and never needs the object name for a board + // like this one); until it is answered, no declared type accepts this + // node. The other four in-package fixtures that mount an `object-kanban` + // board dropped their `as never` for a real `satisfies ObjectKanbanSchema` + // in objectui#7322 — when #7780 lands, this one follows. } as never} /> , diff --git a/packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx b/packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx index 620ac35e32..c4dc0a5020 100644 --- a/packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx @@ -68,6 +68,15 @@ function renderKanban(schemaExtra: Record) { columns: [{ id: 'todo', title: 'To Do' }], data: cards, ...schemaExtra, + // ⚠️ Still escaped, and NOT by oversight (objectui#7322 item ②). This + // board is STATIC — `columns` plus inline `data`, no fetch — so it + // authors no `objectName`, which `ObjectKanbanSchema` declares REQUIRED. + // That requiredness is objectui#7780's subject (the renderer reads inline + // `data` ahead of the fetch and never needs the object name for a board + // like this one); until it is answered, no declared type accepts this + // node. The other four in-package fixtures that mount an `object-kanban` + // board dropped their `as never` for a real `satisfies ObjectKanbanSchema` + // in objectui#7322 — when #7780 lands, this one follows. } as never} />, ); diff --git a/packages/plugin-kanban/src/ObjectKanban.rejectedMoveRollback.test.tsx b/packages/plugin-kanban/src/ObjectKanban.rejectedMoveRollback.test.tsx index ae1a852569..416792e44b 100644 --- a/packages/plugin-kanban/src/ObjectKanban.rejectedMoveRollback.test.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.rejectedMoveRollback.test.tsx @@ -50,7 +50,7 @@ import { render, screen, within, act, cleanup, waitFor } from '@testing-library/ import '@testing-library/jest-dom'; import { registerAllFields } from '@object-ui/fields'; import { toast } from '@object-ui/components'; -import type { DataSource } from '@object-ui/types'; +import type { DataSource, ObjectKanbanSchema } from '@object-ui/types'; import { ObjectKanban } from './ObjectKanban'; // Pay the board's lazy chunk at import time rather than racing it against a @@ -105,7 +105,7 @@ const schema = { { id: 'backlog', title: 'Backlog' }, { id: 'in_progress', title: 'In Progress' }, ], -} as never; +} satisfies ObjectKanbanSchema; /** Server truth: the card is in `backlog` and the server never moves it. */ const serverRecords = () => [{ id: 't1', title: CARD, status: 'backlog' }]; diff --git a/packages/plugin-kanban/src/ObjectKanban.requiredWhenPrompt.test.tsx b/packages/plugin-kanban/src/ObjectKanban.requiredWhenPrompt.test.tsx index 4ccf4aae0f..4d16e83595 100644 --- a/packages/plugin-kanban/src/ObjectKanban.requiredWhenPrompt.test.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.requiredWhenPrompt.test.tsx @@ -68,7 +68,7 @@ import { render, screen, within, act, cleanup, waitFor, fireEvent } from '@testi import '@testing-library/jest-dom'; import { registerAllFields } from '@object-ui/fields'; import { toast } from '@object-ui/components'; -import type { DataSource } from '@object-ui/types'; +import type { DataSource, ObjectKanbanSchema } from '@object-ui/types'; import { ObjectKanban } from './ObjectKanban'; // Pay the board's lazy chunk at import time rather than racing it against a @@ -127,7 +127,7 @@ const schema = { { id: 'negotiation', title: 'Negotiation' }, { id: 'closed_won', title: 'Closed Won' }, ], -} as never; +} satisfies ObjectKanbanSchema; const records = (winReason: unknown = null) => [ { id: 'o1', name: DEAL, stage: 'negotiation', win_reason: winReason }, diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 091f79dc97..48bd449dfe 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -7,7 +7,7 @@ */ import React, { useEffect, useState, useMemo } from 'react'; -import type { DataSource } from '@object-ui/types'; +import type { DataSource, ObjectKanbanSchema } from '@object-ui/types'; import { useDataScope, useNavigationOverlay, @@ -141,7 +141,60 @@ export function resolveKanbanCardFields( * no importer breaks. Tripwire: `__tests__/spec-symbol-4650.test.ts`. */ export interface ObjectKanbanComponentProps { - schema: KanbanSchema; + /** + * The board node. A UNION of the two declared node types this component is + * registered for (objectui#7322 item ②) — `KanbanSchema` (`type: 'kanban'`) + * and `ObjectKanbanSchema` (`type: 'object-kanban'`). + * + * ## Why a union and not either type alone + * + * `index.tsx` registers ONE component under TWO keys — + * `ComponentRegistry.register('object-kanban', ObjectKanbanRenderer, …)` and + * `ComponentRegistry.register('kanban', ObjectKanbanRenderer, …)`, and + * `kanban-plugin-dialect-authoritative-7664.test.ts` pins that they resolve to + * the same renderer. The two keys have DIFFERENT declared node types, and the + * discriminants are disjoint literals, so naming one of them makes the prop + * lie about the other half of the nodes this component serves. That is the + * defect this member carried: it named `KanbanSchema` alone, so no + * `object-kanban` node was assignable to it, and every in-package test that + * mounts one had to escape the prop with `as never`. + * + * ## The read set that settled it (measured on `origin/main` `21d7989fb`) + * + * `ObjectKanban` reads thirteen keys off `schema`. Neither declaration covers + * them; the two TOGETHER cover twelve: + * + * | key | `BaseSchema` | `KanbanSchema` | `ObjectKanbanSchema` | + * |---|---|---|---| + * | `objectName`, `groupBy`, `limit`, `cardFields` | — | yes | yes | + * | `columns`, `cardTitle`, `swimlaneField`, `grouping` | — | yes | — | + * | `titleField` | — | — | yes | + * | `data`, `bind`, `className` | yes | — / yes | — | + * | `filter` | — | — | — | + * + * So each arm is load-bearing: dropping `ObjectKanbanSchema` loses the + * `titleField` read at `:350` / `:1024` (which is why that read was spelled + * `(schema as any).titleField` before this card), and dropping `KanbanSchema` + * loses four reads AND the `'kanban'` registration. `filter` (`:310`, + * `$filter` on the fetch) is declared by NEITHER face and still rides + * {@link BaseSchema}'s `[key: string]: any` — measured, filed, and NOT fixed + * here: this card moves the prop, not the two published schema faces. + * + * ## What the union does and does not claim + * + * It claims exactly the accept set the registry dispatches to this component, + * no wider: a node of some third type is still turned away. Every key above + * that only one arm declares reads as `any` on the union (through the other + * arm's index signature) — the same resolution it had before, so no read + * changes meaning. Widening a member of an exported prop type is additive: + * every caller that passed a `KanbanSchema` still compiles. + * + * ⛔ Do not narrow this back to one arm without first removing a + * registration. `__tests__/object-kanban-component-props-7322.test.ts` + * derives the registered key set from `index.tsx` off disk and goes red if + * the two ever stop agreeing. + */ + schema: KanbanSchema | ObjectKanbanSchema; dataSource?: DataSource; className?: string; // Allow override /** Pre-fetched records passed by a parent (e.g. ListView). When provided, skips internal data fetching. */ @@ -347,7 +400,7 @@ export const ObjectKanban: React.FC = ({ // Support cardTitle property from schema (passed by ObjectView) // Fallback to legacy titleField for backwards compatibility const explicitTitleField: string | undefined = - schema.cardTitle || (schema as any).titleField; + schema.cardTitle || schema.titleField; // Title is resolved per-item below via: // 1. explicit titleField (schema.cardTitle / schema.titleField), if it @@ -481,7 +534,7 @@ export const ObjectKanban: React.FC = ({ // semantic role (ADR-0085); `[]` drops to the legacy heuristic below. // (See `resolveKanbanCardFields` for the full priority contract.) const explicitCardFields: string[] = resolveKanbanCardFields( - (schema as any).cardFields, + schema.cardFields, objectDef, ); // The field used as the card title is implicit (resolved above). Don't @@ -1021,7 +1074,7 @@ export const ObjectKanban: React.FC = ({ const rec = navigation.selectedRecord as Record; const recordId = rec.id ?? rec._id; if (!objectName || recordId == null) return null; - const titleField = (schema as any).cardTitle ?? (schema as any).titleField; + const titleField = schema.cardTitle ?? schema.titleField; const titleText = titleField && rec[titleField] ? String(rec[titleField]) : detailTitle; diff --git a/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts b/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts index ad58decdf6..aa53adc52f 100644 --- a/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts +++ b/packages/plugin-kanban/src/__tests__/kanban-plugin-dialect-authoritative-7664.test.ts @@ -73,8 +73,25 @@ type _RegistryEntryIsThisFace = Assert>; +// AND `'object-kanban'`) ACCEPTS the declared face as its `schema`. +// +// ⚠️ This leg read `Equal<…, KanbanSchema>` until objectui#7322 item ②. +// Identity was never what the ruling claimed — the ruling's words are that +// "the four registered renderers' props still type-check against the +// declared schema", i.e. assignability, which is the same form the +// `kanban-ui` leg below already uses and for the same reason. Identity was +// merely the shape the prop happened to have while it named ONE arm, and +// that was itself the defect objectui#7322 item ② filed: the same renderer +// is registered for `'object-kanban'` too, whose declared node type is +// `ObjectKanbanSchema`, and no such node was assignable to the prop. The +// prop is now the union of the two registered keys' declared types, so the +// ruling's claim holds on this arm and now holds on the other one as well. +// The union's own honesty is pinned in +// `object-kanban-component-props-7322.test.ts`, which derives the +// registered key set from `../index` off disk; this leg keeps guarding +// what objectui#7664 asserted — that THIS declaration is the one the +// renderer consumes. +type _ObjectKanbanTakesTheDeclaredFace = Assert; // - `KanbanRenderer` (`'kanban-ui'`) accepts a declared board — its inline // prop schema is a looser projection (`columns?: Array`), so the // claim is assignability, not identity; diff --git a/packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts b/packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts new file mode 100644 index 0000000000..6718981d4d --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/object-kanban-component-props-7322.test.ts @@ -0,0 +1,143 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `ObjectKanbanComponentProps.schema` names EVERY node type the component is + * registered for, and nothing else (objectui#7322 item ②). + * + * ## The defect this pins closed + * + * `ObjectKanbanRenderer` is registered under two keys — `'object-kanban'` and + * `'kanban'` — and the two keys have different declared node types + * (`ObjectKanbanSchema`, `type: 'object-kanban'`; `KanbanSchema`, + * `type: 'kanban'`). The prop named `KanbanSchema` alone, so no + * `object-kanban` node was assignable to the component that renders it. The + * cost was visible and paid in this package: five of the six in-package + * fixtures that mount an `object-kanban` board escaped the prop with + * `as never`, and the `titleField` read inside `ObjectKanban.tsx` was spelled + * `(schema as any).titleField` because the named arm does not declare it. + * + * ## Why the derivation, and not a list of two strings + * + * A hand-written pair of keys is exactly what the defect survived under: the + * second registration was added and the prop never followed, and no assertion + * anywhere related the two. So suite 2 EXTRACTS the registered keys from + * `../index.tsx` off disk and requires that set to equal the arms of the + * union — a third registration, or a re-key of an existing one, turns it red + * naming the key, and the fix is to move the prop rather than the test. + * + * ## The instrument + * + * Suite 1 is compile-time. Vitest strips types without checking them, so a + * green run proves nothing on its own: the assertions are read by + * `tsc -p packages/plugin-kanban/tsconfig.test.json`, chained off this + * package's `type-check` script. That project sets `"paths": {}`, so + * `@object-ui/types` resolves through the workspace dependency to + * `packages/types/dist/index.d.ts` — BUILD `@object-ui/types` before believing + * either colour suite 1 reports. Suite 2 is a real runtime assertion and needs + * no build. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ComponentRegistry } from '@object-ui/core'; +import type { KanbanSchema, ObjectKanbanSchema, ObjectGridSchema } from '@object-ui/types'; +import type { ObjectKanbanComponentProps } from '../ObjectKanban'; +import { ObjectKanbanRenderer } from '../index'; +import '../index'; + +/* -------------------------------------------------------------------------- */ +/* Suite 1 — compile-time, read by tsconfig.test.json. */ +/* -------------------------------------------------------------------------- */ + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsAny = 0 extends 1 & T ? true : false; + +type Prop = ObjectKanbanComponentProps['schema']; + +// Non-vacuity controls. `any` anywhere below would satisfy every `extends` +// while checking nothing — and `any` is precisely what this member would have +// been widened to by the lazy fix. +type _PropIsNotAny = Assert, false>>; +type _KanbanArmIsReal = Assert, false>>; +type _ObjectKanbanArmIsReal = Assert, false>>; + +// 1. Both registered node types are accepted. The second leg is the one this +// card added; the first is what objectui#7664 already required and must not +// regress. +type _AcceptsTheKanbanNode = Assert; +type _AcceptsTheObjectKanbanNode = Assert; + +// 2. And nothing else. `ObjectGridSchema` is the control: a sibling +// `@object-ui/types` node type, declared in the same file as +// `ObjectKanbanSchema`, registered to a DIFFERENT renderer. If this ever +// reads `true` the prop has been widened to something structural (or to +// `any`) rather than to the two declared arms. +type _RejectsAnUnregisteredNode = Assert>; + +// 3. The discriminant carries exactly the two registered keys. This is the +// claim suite 2 checks the other half of: here, what the TYPE says; there, +// what `index.tsx` actually registers. +type _DiscriminantIsTheTwoKeys = Assert>; + +/* -------------------------------------------------------------------------- */ +/* Suite 2 — runtime, derived from the read site. */ +/* -------------------------------------------------------------------------- */ + +const INDEX_TSX = join(dirname(fileURLToPath(import.meta.url)), '..', 'index.tsx'); + +/** Every key `index.tsx` registers `ObjectKanbanRenderer` under. */ +function registeredKeys(): string[] { + const src = readFileSync(INDEX_TSX, 'utf8'); + const keys: string[] = []; + const re = /ComponentRegistry\.register\(\s*'([^']+)'\s*,\s*ObjectKanbanRenderer\b/g; + for (const m of src.matchAll(re)) keys.push(m[1]); + return keys.sort(); +} + +describe('ObjectKanbanComponentProps.schema names every registered node type (objectui#7322)', () => { + it('is pinned at compile time', () => { + expect(true).toBe(true); + }); + + it('index.tsx registers ObjectKanbanRenderer under exactly the two keys the prop union names', () => { + // Red when a third registration is added, or an existing key is renamed, + // without moving `ObjectKanbanComponentProps['schema']` to match. The + // literals here are the union's arms restated: `KanbanSchema['type']` is + // `'kanban'` and `ObjectKanbanSchema['type']` is `'object-kanban'`, which + // suite 1's `_DiscriminantIsTheTwoKeys` holds to the prop. + expect(registeredKeys()).toEqual(['kanban', 'object-kanban']); + }); + + it('the extraction is lit — it finds the reader it claims to read, and no other renderer', () => { + // Anti-vacuity for the assertion above: a regex that matched nothing would + // return `[]` and a same-shaped `toEqual` against `[]` would pass forever. + const keys = registeredKeys(); + expect(keys.length).toBeGreaterThan(0); + // `'kanban-ui'` and `'kanban-enhanced'` are registered in the same file to + // OTHER renderers — the control that the pattern discriminates on the + // component, not merely on the word `register`. + expect(keys).not.toContain('kanban-ui'); + expect(keys).not.toContain('kanban-enhanced'); + expect(ComponentRegistry.has('kanban-ui')).toBe(true); + expect(ComponentRegistry.has('kanban-enhanced')).toBe(true); + }); + + it('every extracted key resolves to that renderer in the live registry', () => { + // Ties the off-disk reading to the running registry, so a key that is + // registered in a source form this regex cannot see still has to show up + // through `ComponentRegistry` — and a key it read from a comment or a dead + // branch would fail here. + for (const key of registeredKeys()) { + expect(ComponentRegistry.get(key), `\`${key}\` does not resolve to ObjectKanbanRenderer`).toBe(ObjectKanbanRenderer); + } + }); +});