From 6453586e68954d5c594441a2a0eccc08891face5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 16:50:57 +0000 Subject: [PATCH] test(app-shell): pin the two already-honest studio-design consumers on an empty registry, and the populated contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three repairs objectui#6846 asks for — the Data pillar field rail that swallowed a field click, the actions pane that rendered a bare label, and the Interfaces canvas that blamed the roadmap — already landed on main in #7120 (#6795 part C), each with its own empty-registry pin. What #7120 deliberately left unpinned are the two consumers its measurement found already honest: - ObjectSettingsPanel prints "No default object inspector registered." in the Basics section and keeps rendering the sections that do not read the registry; - ObjectHooksPanel falls back to the generic SchemaForm, and that form edits and saves the hook. Both are pinned here with the same shape as the sibling pins: the registries are asserted empty FIRST, with a control that must hit, and the assertion names the specific visible controls rather than "something rendered". The populated contrast pins the wrong fix on this axis — an empty-state branch that fires whenever the lookup is falsy and swallows the populated path: with the builtin inspectors registered, a field click opens the real ObjectFieldInspector, Actions mounts the real ActionDefaultInspector, Hooks mounts the curated HookDefaultInspector (not the generic form), Settings mounts the real ObjectDefaultInspector, and none of the empty-state sentences appears. Test only; the changeset declares no release. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .../6846-studio-design-empty-registry-pins.md | 10 + ...aPillar.designerRegistryPopulated.test.tsx | 209 ++++++++++++++++++ ...ooksPanel.designerRegistryMissing.test.tsx | 110 +++++++++ ...ingsPanel.designerRegistryMissing.test.tsx | 97 ++++++++ 4 files changed, 426 insertions(+) create mode 100644 .changeset/6846-studio-design-empty-registry-pins.md create mode 100644 packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/ObjectSettingsPanel.designerRegistryMissing.test.tsx diff --git a/.changeset/6846-studio-design-empty-registry-pins.md b/.changeset/6846-studio-design-empty-registry-pins.md new file mode 100644 index 0000000000..b92fdb511d --- /dev/null +++ b/.changeset/6846-studio-design-empty-registry-pins.md @@ -0,0 +1,10 @@ +--- +--- + +Pin the two studio-design consumers that were already honest on an empty +metadata registry — `ObjectSettingsPanel` ("No default object inspector +registered.") and `ObjectHooksPanel` (a working generic `SchemaForm`) — and +add the populated-registry contrast for the Data pillar's four consumers, so a +future refactor cannot move either into the silent class, and an over-eager +empty-state branch cannot swallow the populated path (objectui#6846). Test +only; no package is released by this change. diff --git a/packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx new file mode 100644 index 0000000000..1a31b3fdab --- /dev/null +++ b/packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#6846 — the Data pillar's four registry consumers on a POPULATED + * registry: the contrast half of the empty-registry pins. + * + * ## The wrong fix this file exists to catch + * + * The empty-state repairs (#7120) and pins (#7120 + this card) all say what + * a consumer must show when its registry read returns `undefined`. The + * plausible wrong fix on that axis is an empty-state branch that fires + * whenever the lookup is falsy in some wider sense — a guard inverted, a + * `!!` dropped, a branch reordered — so that "the empty case now speaks" + * while every populated screen goes blank or shows the notice. None of the + * empty-registry files can see that: their registries are empty by + * construction. So this file registers the builtin inspectors FIRST, proves + * they are there, and pins that with them present: + * + * - a field click opens the REAL `ObjectFieldInspector`; + * - Actions mounts the REAL `ActionDefaultInspector`; + * - Hooks mounts the curated `HookDefaultInspector`, not the generic form; + * - Settings mounts the REAL `ObjectDefaultInspector`; + * + * and that none of the empty-state sentences appears anywhere on screen. + * Each case asserts a control only the real editor renders BEFORE it asserts + * the notice's absence, so a blank screen reds on the positive, not on an + * absence that a blank screen would satisfy. + * + * The fifth consumer, the Interfaces canvas, has its populated contrast in + * {@link file://./StudioDesignSurface.designerRegistryPartial.test.tsx}. + * + * ## Why a separate file + * + * The registries are plain `Map`s — module state shared by every test in a + * file. Splitting empty from populated is what lets each file assert its own + * precondition instead of depending on test order. Same convention as the + * `designerRegistryMissing` / `designerRegistryPartial` pair. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const objectDef = { + name: 'showcase_task', + label: 'Task', + fields: [{ name: 'title', label: 'Title', type: 'text' }], + actions: [{ name: 'send_email', label: 'Send Email', type: 'quick' }], +}; + +const hook = { + name: 'guard_hook', + label: 'Guard', + object: 'showcase_task', + events: ['beforeInsert'], + handler: 'guard_fn', +}; + +const mockClient = { + save: vi.fn(async () => ({})), + list: vi.fn(async (type: string) => { + if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }]; + if (type === 'hook') return [hook]; + return []; + }), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async () => ({ effective: objectDef, code: objectDef })), + getDraft: vi.fn(async () => null), + // The curated hook editor resolves the bound object's field catalog through + // `useObjectFields` -> `client.get`. + get: vi.fn(async () => null), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +// objectui#5813 — the advanced tabs live in a Radix DropdownMenu; this file +// measures which editor mounts, not radix's open/close machinery, so the menu +// renders as plain passthroughs (same convention as DataPillar.panelGate). +type Passthrough = { children?: React.ReactNode }; +vi.mock('@object-ui/components', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + DropdownMenu: (p: Passthrough) =>
{p.children}
, + DropdownMenuTrigger: (p: Passthrough) =>
{p.children}
, + DropdownMenuContent: (p: Passthrough) =>
{p.children}
, + DropdownMenuItem: (p: Passthrough & { onSelect?: () => void }) => ( + + ), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +import { DataPillar } from './StudioDesignSurface'; +import { registerBuiltinInspectors } from '../metadata-admin/inspectors'; +import { listMetadataInspectorTypes, getMetadataInspector } from '../metadata-admin/inspector-registry'; +import { getMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry'; + +// The populated precondition — registered here, proved in every test below. +registerBuiltinInspectors(); + +/** The four empty-state sentences the Data pillar's consumers can show. */ +const EMPTY_STATE_SENTENCES = [ + 'No field inspector is registered in this session, so this field’s properties cannot be edited here.', + 'No action editor is registered in this session, so this action’s properties cannot be edited here.', + 'No default object inspector registered.', + 'No metadata designers are registered in this session', +]; + +afterEach(cleanup); + +/** + * Assert the registries really are populated — stated, not assumed, so an + * assertion below cannot pass for the wrong reason (a builtin that silently + * stopped registering would otherwise turn every case here into an + * empty-registry case that happens to render). + */ +function assertRegistriesPopulated(): void { + expect(listMetadataInspectorTypes()).toContain('object'); + expect(getMetadataInspector('object')).toBeTypeOf('function'); + expect(getMetadataDefaultInspector('object')).toBeTypeOf('function'); + expect(getMetadataDefaultInspector('action')).toBeTypeOf('function'); + expect(getMetadataDefaultInspector('hook')).toBeTypeOf('function'); +} + +function expectNoEmptyStateSentence(): void { + const text = document.body.textContent ?? ''; + for (const sentence of EMPTY_STATE_SENTENCES) expect(text).not.toContain(sentence); +} + +function renderPillar() { + render( + + + , + ); +} + +describe('Data pillar consumers with the registries POPULATED (#6846 contrast)', () => { + it('a field click opens the real field inspector, not the missing-inspector notice', async () => { + assertRegistriesPopulated(); + renderPillar(); + + fireEvent.click(await screen.findByRole('button', { name: 'Form' })); + const card = (await screen.findByText('Title')).closest('.cursor-grab') as HTMLElement; + expect(card).toBeTruthy(); + fireEvent.click(card); + + const rail = await waitFor(() => { + const el = document.querySelector('aside'); + expect(el).toBeTruthy(); + return el as HTMLElement; + }); + expect(rail).toHaveTextContent('Field properties'); + // `ObjectFieldInspector`'s own API-name control, carrying the field's name. + expect(await within(rail).findByDisplayValue('title')).toBeInTheDocument(); + expectNoEmptyStateSentence(); + }); + + it('Actions mounts the real action editor, not the missing-editor notice', async () => { + assertRegistriesPopulated(); + renderPillar(); + + fireEvent.click(await screen.findByRole('button', { name: 'Actions' })); + // `ActionDefaultInspector`'s Name control, carrying the action's identifier. + expect(await screen.findByDisplayValue('send_email')).toBeInTheDocument(); + expectNoEmptyStateSentence(); + }); + + it('Hooks mounts the curated hook editor, not the generic form', async () => { + assertRegistriesPopulated(); + renderPillar(); + + fireEvent.click(await screen.findByRole('button', { name: 'Hooks' })); + fireEvent.click(await screen.findByText('Guard')); + // Controls only `HookDefaultInspector` renders… + expect(await screen.findByTestId('hook-name')).toBeInTheDocument(); + expect(screen.getByTestId('hook-body-source')).toBeInTheDocument(); + // …and none the generic `SchemaForm` would have synthesised from the + // hook's keys (the empty-registry pin asserts this one is PRESENT). + expect(screen.queryByLabelText('Handler')).toBeNull(); + expectNoEmptyStateSentence(); + }); + + it('Settings mounts the real object inspector, not the missing-inspector notice', async () => { + assertRegistriesPopulated(); + renderPillar(); + + fireEvent.click(await screen.findByRole('button', { name: 'Settings' })); + // `ObjectDefaultInspector`'s Name control, carrying the object's name. + expect(await screen.findByTestId('object-name-input')).toHaveValue('showcase_task'); + expect(screen.getByTestId('object-access-posture')).toBeInTheDocument(); + expectNoEmptyStateSentence(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx new file mode 100644 index 0000000000..8607fd9e83 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/ObjectHooksPanel.designerRegistryMissing.test.tsx @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#6846 — the Hooks panel on an EMPTY default-inspector registry. + * + * The second of the two consumers #7120 (#6795 part C) measured as already + * honest and deliberately left unpinned: with `getMetadataDefaultInspector('hook')` + * undefined the panel falls back to the generic `SchemaForm`, which synthesises + * a control per top-level key of the selected hook. That is a working editor, + * not an empty state — so the pin has to prove it WORKS, not merely that + * something rendered: an edit in the generic form dirties the panel, enables + * its own Save, and the save writes the edited value through. + * + * "Something rendered" would pass on the hook's bare label — exactly the shape + * of the `ObjectActionsPanel` defect the card repaired — which is why the + * assertions below name the generic form's controls and drive one of them. + * + * The populated contrast, where the curated `HookDefaultInspector` mounts and + * the generic form must NOT, is + * {@link file://./DataPillar.designerRegistryPopulated.test.tsx}. + * + * ⚠️ This file must never register a designer — its subject is the empty + * branch, and the registries are module state shared by every test in a file. + * Emptiness is asserted FIRST, with a control that must hit. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; + +const hook = { + name: 'guard_hook', + label: 'Guard', + object: 'showcase_task', + events: ['beforeInsert'], + handler: 'guard_fn', +}; + +const mockClient = { + list: vi.fn(async () => [hook]), + listDrafts: vi.fn(async () => []), + getDraft: vi.fn(async () => null), + get: vi.fn(async () => null), + save: vi.fn(async () => ({})), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient }; +}); + +import { ObjectHooksPanel } from './ObjectHooksPanel'; +import { listMetadataPreviewTypes } from '../metadata-admin/preview-registry'; +import { listMetadataInspectorTypes } from '../metadata-admin/inspector-registry'; +import { getMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry'; +import { getStudioCanvasPreview } from './studio-canvas-preview'; + +afterEach(() => { + cleanup(); + mockClient.save.mockClear(); +}); + +/** Same shape as the sibling pins: a control that MUST hit, then the zeros. */ +function assertRegistriesEmptyWithControl(): void { + expect(getStudioCanvasPreview('object')).toBeTypeOf('function'); // control — MUST hit + expect(listMetadataPreviewTypes()).toEqual([]); + expect(listMetadataInspectorTypes()).toEqual([]); + expect(getMetadataDefaultInspector('hook')).toBeUndefined(); + expect(getMetadataDefaultInspector('object')).toBeUndefined(); + expect(getMetadataDefaultInspector('action')).toBeUndefined(); +} + +describe('ObjectHooksPanel — no curated hook editor registered (#6846)', () => { + it('falls back to the generic SchemaForm, and that form edits and saves the hook', async () => { + assertRegistriesEmptyWithControl(); + + render(); + fireEvent.click(await screen.findByText('Guard')); + + // The generic form: one labelled control per top-level key of the hook, + // carrying the hook's own values. These labels are synthesised from the + // keys, which is what makes them the generic form's signature. + const handler = await screen.findByLabelText('Handler'); + expect(handler).toHaveValue('guard_fn'); + expect(screen.getByLabelText('Name')).toHaveValue('guard_hook'); + // The curated editor is NOT what rendered — its controls carry test ids + // the generic form never produces. + expect(screen.queryByTestId('hook-name')).toBeNull(); + // Nothing on screen promises recovery, and nothing calls this an empty + // state — it is an editor. + expect(document.body.textContent ?? '').not.toMatch(/loading|try again/i); + + // "Working": an edit in the generic form dirties the panel and enables + // its own Save… + const save = screen.getByRole('button', { name: /Save/i }); + expect(save).toBeDisabled(); + fireEvent.change(handler, { target: { value: 'guard_fn_v2' } }); + await waitFor(() => expect(save).toBeEnabled()); + // …and Save writes the edited value through, as a draft of THIS hook. + fireEvent.click(save); + await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1)); + expect(mockClient.save).toHaveBeenCalledWith( + 'hook', + 'guard_hook', + expect.objectContaining({ handler: 'guard_fn_v2' }), + { mode: 'draft', packageId: 'com.example.showcase' }, + ); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.designerRegistryMissing.test.tsx b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.designerRegistryMissing.test.tsx new file mode 100644 index 0000000000..c6344b87c0 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.designerRegistryMissing.test.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#6846 — the Settings panel on an EMPTY default-inspector registry. + * + * #7120 (#6795 part C) repaired the three studio-design consumers that went + * silent or stated a false reason when the designer registries are + * unpopulated, and pinned each of them. It deliberately left the two consumers + * the measurement found ALREADY honest unpinned — this panel and + * `ObjectHooksPanel`. Without a pin, one refactor moves either of them into the + * silent class with nothing going red; #6846 calls that the cheap half of the + * card and where its durability lives. + * + * ## What is pinned + * + * Two halves, both load-bearing: + * + * 1. the Basics section names the missing editor — "No default object + * inspector registered." — exactly once, inside the ONE section whose + * editor is missing; + * 2. the panel's other sections (sharing model, semantic roles, capabilities) + * do not read the registry and still render. + * + * A consumer that answered every state with one constant string would keep (1) + * and fail (2); a consumer that dropped the message and rendered an empty + * Basics section — the silent class this card exists to keep it out of — would + * fail (1). The populated contrast, where the real `ObjectDefaultInspector` + * mounts and this message must NOT appear, is + * {@link file://./DataPillar.designerRegistryPopulated.test.tsx}. + * + * ⛔ No message may promise recovery ("loading…", "try again"): the registry + * is a plain `Map` read during render with no subscription, so a registration + * landing later never reaches this component (measured on #6795). + * + * ⚠️ This file must never register a designer — its subject is the empty + * branch, and the registries are module state shared by every test in a file. + * Emptiness is asserted FIRST, with a control that must hit: a zero that is + * not asserted is not a reading. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; + +import { ObjectSettingsPanel } from './ObjectSettingsPanel'; +import { listMetadataPreviewTypes } from '../metadata-admin/preview-registry'; +import { listMetadataInspectorTypes } from '../metadata-admin/inspector-registry'; +import { getMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry'; +import { getStudioCanvasPreview } from './studio-canvas-preview'; + +const NO_INSPECTOR = 'No default object inspector registered.'; + +const draft = { + name: 'showcase_task', + label: 'Task', + fields: { title: { type: 'text', label: 'Title' } }, +}; + +afterEach(cleanup); + +/** + * Assert the registries really are empty — with a control that MUST hit. + * `studio-canvas-preview` self-registers `object` at module scope, so a defined + * control proves the module graph loaded and the lookup works; only then are + * the zeros below readings rather than a failed import. + */ +function assertRegistriesEmptyWithControl(): void { + expect(getStudioCanvasPreview('object')).toBeTypeOf('function'); // control — MUST hit + expect(listMetadataPreviewTypes()).toEqual([]); + expect(listMetadataInspectorTypes()).toEqual([]); + expect(getMetadataDefaultInspector('object')).toBeUndefined(); + expect(getMetadataDefaultInspector('hook')).toBeUndefined(); + expect(getMetadataDefaultInspector('action')).toBeUndefined(); +} + +describe('ObjectSettingsPanel — no default object inspector registered (#6846)', () => { + it('names the missing editor in Basics, once, and keeps the rest of the panel', () => { + assertRegistriesEmptyWithControl(); + + render( {}} locale="en-US" />); + + // Exactly one — `queryAllByText` rather than `getByText`, which throws on a + // duplicate too and would read a repeated string as a missing one. + expect(screen.queryAllByText(NO_INSPECTOR)).toHaveLength(1); + // …and it sits in the Basics section, the one whose editor is missing. + const basics = screen.getByText('Basics').closest('section') as HTMLElement; + expect(basics).toBeTruthy(); + expect(within(basics).getByText(NO_INSPECTOR)).toBeInTheDocument(); + // The sections that do not read the registry still render — the empty + // state is scoped to one section, not a curtain over the whole panel. + expect(screen.getByText('Record sharing (OWD)')).toBeInTheDocument(); + expect(screen.getByTestId('owd-internal-select')).toBeInTheDocument(); + // ⛔ The measured mechanism forbids promising recovery. + expect(document.body.textContent ?? '').not.toMatch(/loading|try again/i); + }); +});