From a7c61561a03d44b76fc572dfe78edeb2618b61de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 04:08:10 +0000 Subject: [PATCH 1/5] fix(app-shell,plugin-dashboard,plugin-grid,plugin-chatbot,console): draw the shared EmptyValue for six hand-rolled placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six sites built their own `` holding a bare em dash, so an empty cell had no accessible name at all: no `data-slot`, no `aria-label`, and none of the shared component's `select-none` / `no-underline` / `pointer-events-none`. `EmptyValue` from `@object-ui/components` answers exactly this, and every one of the five packages already depends on that package. Two adjacent lines in the AI-approvals drawer (`proposed_by` / `decided_by`, bare `'—'` text nodes) are converted with them — a different source spelling of the same rendered defect, verified individually rather than swept up. `formatRelative`'s `: string` fallback is deliberately left alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .../8504-emptyvalue-remaining-carriers.md | 46 ++++ ...e.emptyPlaceholderAffordance-8504.test.tsx | 148 ++++++++++++ .../src/pages/developer/PublicFormsPage.tsx | 3 +- ...l.emptyPlaceholderAffordance-8504.test.tsx | 158 +++++++++++++ .../src/views/metadata-admin/AuditPanel.tsx | 4 +- ...e.emptyPlaceholderAffordance-8504.test.tsx | 169 ++++++++++++++ .../views/metadata-admin/ResourceListPage.tsx | 4 +- .../src/AiPendingActionsInbox.tsx | 7 +- ...x.emptyPlaceholderAffordance-8504.test.tsx | 210 ++++++++++++++++++ .../src/RecordDetailDrawer.tsx | 3 +- ...r.emptyPlaceholderAffordance-8504.test.tsx | 129 +++++++++++ ...izard.savedMappingEmptyValue-8504.test.tsx | 138 ++++++++++++ packages/plugin-grid/src/ImportWizard.tsx | 8 +- 13 files changed, 1016 insertions(+), 11 deletions(-) create mode 100644 .changeset/8504-emptyvalue-remaining-carriers.md create mode 100644 apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx create mode 100644 packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/RecordDetailDrawer.emptyPlaceholderAffordance-8504.test.tsx create mode 100644 packages/plugin-grid/src/ImportWizard.savedMappingEmptyValue-8504.test.tsx diff --git a/.changeset/8504-emptyvalue-remaining-carriers.md b/.changeset/8504-emptyvalue-remaining-carriers.md new file mode 100644 index 0000000000..3a2f8876ae --- /dev/null +++ b/.changeset/8504-emptyvalue-remaining-carriers.md @@ -0,0 +1,46 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/plugin-dashboard': minor +'@object-ui/plugin-grid': minor +'@object-ui/plugin-chatbot': minor +'@object-ui/console': minor +--- + +Six hand-rolled em-dash placeholders now draw the shared `EmptyValue` from +`@object-ui/components` (objectui#8504), closing the *no accessible name* half +of the class objectui#8491 / PR #8503 opened. + +**The accessibility defect.** Each site built its own `` holding a bare em +dash. `EmptyValue` carries three things none of them had: a `data-slot` of +`empty-value`, an `aria-label` resolved through the i18n label hook, and +`select-none` / `no-underline` / `pointer-events-none`. So a screen-reader user +reaching one of these cells heard a naked punctuation mark, while a neighbouring +cell drawn by a type-aware renderer was announced as "No value". Two of the +sites make the inconsistency reachable inside one surface: the metadata list +renders column 0's placeholder *inside the row's ``*, where the +hand-rolled span inherited the link colour and stayed selectable, and the +dashboard record drawer sits next to renderers that already returned the shared +component. + +The six: the metadata list's `defaultCell` and the Audit tab's lock column +(`@object-ui/app-shell`), the dashboard record drawer's empty `
` +(`@object-ui/plugin-dashboard`), the import wizard's saved-mapping transform +cell (`@object-ui/plugin-grid`), the AI-approvals `JsonBlock` +(`@object-ui/plugin-chatbot`), and the Public Forms object column +(`@object-ui/console`). + +**A deliberate visual change, not a no-op.** Five sites drop +`text-muted-foreground` (or `/60`) for the shared `text-muted-foreground/50`, so +every placeholder in the workspace is now one colour. The glyph is unchanged +everywhere. The sixth, `JsonBlock`, keeps its `text-xs` through `className` +because it stands where a `text-xs
` would and has no shared neighbour to
+match — its delta is the accessible name and the three affordances only.
+
+**Two adjacent lines in the same file, taken deliberately.** The AI-approvals
+drawer's `proposed_by` / `decided_by` fields fell back to a bare `'—'` text node
+inside a plain `
` — a different source spelling of the same rendered +defect, individually verified on the card rather than swept up. They are +converted too. `formatRelative`'s `if (!s) return '—'` is not: that helper is +declared `: string`, so its fallback is not a node. + +Filled values are untouched in every path. diff --git a/apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx b/apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx new file mode 100644 index 0000000000..e5c3d5fbcb --- /dev/null +++ b/apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Public Forms table's Object column draws the shared `EmptyValue` + * (objectui#8504). + * + * ## The defect + * + * A form declaring no `object` fell to `` — no `data-slot`, no + * `aria-label`, none of the shared component's `select-none` / `no-underline` / + * `pointer-events-none`. In a column headed "Object", a screen-reader user + * heard a naked punctuation mark while the row above announced "showcase_task". + * + * ## Reachability was CHECKED before the swap, not assumed + * + * This is the one carrier on the card that lives in `apps/`, not `packages/` — + * an app's dependency tier, not a library's, and outside the `'packages/​*​/src/​*'` + * pathspec every census in the thread used. Measured: `@object-ui/components` + * is on `apps/console`'s `devDependencies` (`workspace:*`) and 29 files under + * `apps/console/src` already import from it — this page among them. `EmptyValue` + * joins an import list that was already there; no manifest edge was added, and + * none was needed. + * + * ## Which case DISCRIMINATES — MEASURED, not predicted + * + * The caricature was RUN: the Object cell rewritten to `` + * unconditionally, objects included. `THE DEFECT` stays GREEN under it — "the + * objectless row has an accessible name" is also true of a table that has + * stopped printing objects — so it carries a control that reads a real object + * out of the sibling row. `NON-REGRESSION` is what refuses the caricature. + * + * ## The visual delta + * + * `text-muted-foreground` (full opacity) becomes the shared + * `text-muted-foreground/50` — one deliberate step more muted, plus the three + * affordances and the accessible name. The glyph is unchanged. + * + * Assertions are scoped to ONE cell of ONE row (objectui#8495). + */ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +/** + * Two published public forms — one declaring an `object`, one not. Both need + * `sharing.allowAnonymous` and a parseable `publicLink` to reach the table. + */ +const { ADAPTER } = vi.hoisted(() => { + const form = (name: string, slug: string, object?: string) => ({ + name, + label: name, + ...(object ? { object } : {}), + type: 'simple', + sections: [{ label: 'Task', fields: ['title'] }], + sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${slug}` }, + }); + // A STABLE singleton: a fresh object per render loops the page's load effect. + const ADAPTER = { + getClient: () => ({ + meta: { + getItems: async (type: string) => + type === 'view' + ? [ + { spec: form('objectless_form', 'objectless') }, + { spec: form('task_form', 'log-time', 'showcase_task') }, + ] + : [], + saveItem: async () => ({ ok: true }), + }, + }), + }; + return { ADAPTER }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, +})); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +// Imported AFTER the mocks so the page picks them up. +import { PublicFormsPage } from './PublicFormsPage'; + +afterEach(cleanup); + +/** The shared placeholder inside ONE element, or null. */ +const emptyIn = (el: HTMLElement): HTMLElement | null => + el.querySelector('[data-slot="empty-value"]'); + +async function mount() { + const { container } = render(); + // `queryByText` THROWS on multiple matches, and the Name cell prints the + // label and the name — so a single-match query never resolves here. + await waitFor(() => + expect(screen.queryAllByText('objectless_form').length).toBeGreaterThan(0), + ); + + const headers = () => + Array.from(container.querySelectorAll('thead th')).map((th) => + (th.textContent ?? '').trim(), + ); + + /** The cell under `header` in the row whose Name cell reads `name`. */ + const cell = (name: string, header: string): HTMLElement => { + const idx = headers().indexOf(header); + expect(idx, `the ${header} column is present — headers were ${JSON.stringify(headers())}`) + .toBeGreaterThanOrEqual(0); + const tr = Array.from(container.querySelectorAll('tbody tr')).find((r) => + (r.textContent ?? '').includes(name), + ); + expect(tr, `the row for ${name} rendered`).toBeTruthy(); + const td = (tr as HTMLElement).querySelectorAll('td')[idx]; + expect(td, `the ${name} row has a cell under ${header}`).toBeTruthy(); + return td as HTMLElement; + }; + return { cell }; +} + +describe('PublicFormsPage object cell uses the shared EmptyValue (objectui#8504)', () => { + it('THE DEFECT — a form declaring no object carries an accessible name', async () => { + const { cell } = await mount(); + const placeholder = emptyIn(cell('objectless_form', 'Object')); + + expect(placeholder, 'the objectless cell draws the shared placeholder').not.toBeNull(); + expect(placeholder, 'and therefore has an accessible name').toHaveAttribute('aria-label'); + expect( + (placeholder as HTMLElement).getAttribute('aria-label'), + 'the name is a word, never a naked punctuation mark', + ).toBe('No value'); + expect((placeholder as HTMLElement).textContent, 'the glyph is unchanged').toBe('—'); + // CONTROL — without this, a table printing NO objects passes above. + expect( + within(cell('task_form', 'Object')).queryByText('showcase_task'), + 'CONTROL: the sibling row still prints its object', + ).not.toBeNull(); + }); + + it('NON-REGRESSION — a form WITH an object renders its badge and NO placeholder', async () => { + const { cell } = await mount(); + const filled = cell('task_form', 'Object'); + + expect(within(filled).queryByText('showcase_task'), 'the object reaches the cell') + .not.toBeNull(); + // THE DISCRIMINATING HALF: red for an EmptyValue-everywhere implementation. + expect(emptyIn(filled), 'a cell with an object carries NO placeholder').toBeNull(); + }); +}); diff --git a/apps/console/src/pages/developer/PublicFormsPage.tsx b/apps/console/src/pages/developer/PublicFormsPage.tsx index c3d518be41..fc823e111c 100644 --- a/apps/console/src/pages/developer/PublicFormsPage.tsx +++ b/apps/console/src/pages/developer/PublicFormsPage.tsx @@ -55,6 +55,7 @@ import { DialogTitle, Input, Label, + EmptyValue, } from '@object-ui/components'; import { Copy, ExternalLink, FormInput, RefreshCw, Code2, Link2, Settings2, Plus } from 'lucide-react'; import { toast } from 'sonner'; @@ -382,7 +383,7 @@ export function PublicFormsPage() { {row.object ? ( {row.object} ) : ( - + )} diff --git a/packages/app-shell/src/views/metadata-admin/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx b/packages/app-shell/src/views/metadata-admin/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx new file mode 100644 index 0000000000..3c3ffa82e3 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Audit tab's Lock column draws the shared `EmptyValue` (objectui#8504). + * + * ## The defect + * + * An unlocked row took `` — no + * `data-slot`, no `aria-label`, and none of the shared component's + * `select-none` / `no-underline` / `pointer-events-none`. A screen reader + * reaching that cell heard a naked punctuation mark, in a column headed "Lock". + * `EmptyValue` from `@object-ui/components` answers exactly this, and + * `@object-ui/app-shell` already depends on that package. + * + * ## What each case can and cannot discriminate — MEASURED + * + * The caricature was RUN, not predicted: `AuditPanel`'s lock cell rewritten to + * `` unconditionally, locked rows included. Only + * `NON-REGRESSION` refuses it. `THE DEFECT`'s headline claim — "the empty cell + * has an accessible name" — is TRUE of a column that has stopped rendering lock + * states altogether, so it is paired with a control that reads a real value out + * of the sibling row and would itself be measuring nothing without it. + * + * ## A deliberate visual change + * + * The retired span was `text-muted-foreground` (full opacity); the shared + * component is `text-muted-foreground/50`. The placeholder is now one step more + * muted than it was, which is the shared typography this card adopts, not an + * accident. `NON-REGRESSION` pins that the surviving glyph is still an em dash, + * so the change is opacity only. + * + * Every assertion below is scoped to ONE cell of ONE row (objectui#8495: a + * container-wide "no placeholder anywhere" assertion fails against the correct + * implementation as soon as some other column legitimately renders one). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import type { + MetadataAuditEntry, + MetadataClient, +} from '@object-ui/data-objectstack'; +import { AuditPanel } from './AuditPanel'; +import { t, translateConsoleValue } from './i18n'; + +afterEach(cleanup); + +const LOCALE = 'en-US'; +const EM_DASH = '—'; + +function auditRow(over: Partial = {}): MetadataAuditEntry { + return { + id: 'a_1', + occurredAt: '2026-08-17T10:00:00.000Z', + actor: 'admin@objectos.ai', + source: 'protocol.saveMetaItem', + operation: 'save', + outcome: 'denied', + code: 'item_locked', + lockState: 'full', + lockOverridden: false, + requestId: null, + note: null, + ...over, + }; +} + +/** Minimal stand-in — the panel only calls `audit()` on its client. */ +function clientWith(events: MetadataAuditEntry[]): MetadataClient { + const stub: Pick = { audit: async () => ({ events }) }; + return stub as MetadataClient; +} + +async function renderPanel(rows: MetadataAuditEntry[]) { + const { container } = render( + , + ); + // The panel loads in an effect; wait for the first row's actor cell to land. + await screen.findByText(rows[0].actor); + + const headers = () => + Array.from(container.querySelectorAll('th')).map((th) => (th.textContent ?? '').trim()); + + /** The cell under `header` within ONE row — never a container-wide lookup. */ + const cell = (rowIndex: number, header: string): HTMLElement => { + const idx = headers().indexOf(header); + expect(idx, `the ${header} column is present — headers were ${JSON.stringify(headers())}`) + .toBeGreaterThanOrEqual(0); + const tr = container.querySelectorAll('tbody tr')[rowIndex]; + expect(tr, `row ${rowIndex} rendered`).toBeTruthy(); + const td = tr.querySelectorAll('td')[idx]; + expect(td, `row ${rowIndex} has a cell under ${header}`).toBeTruthy(); + return td as HTMLElement; + }; + return { cell }; +} + +/** The shared placeholder inside ONE element, or null. */ +const emptyIn = (el: HTMLElement): HTMLElement | null => + el.querySelector('[data-slot="empty-value"]'); + +const LOCK_HEADER = t('engine.edit.auditColLock', LOCALE); + +describe('AuditPanel lock cell draws the shared EmptyValue (objectui#8504)', () => { + it('THE DEFECT — an unlocked row carries an accessible name', async () => { + const { cell } = await renderPanel([ + auditRow({ id: 'a_1', lockState: 'none' }), + auditRow({ id: 'a_2', actor: 'ops@objectos.ai', lockState: 'no-delete' }), + ]); + const placeholder = emptyIn(cell(0, LOCK_HEADER)); + + expect(placeholder, 'the unlocked cell draws the shared placeholder').not.toBeNull(); + expect(placeholder, 'and therefore has an accessible name').toHaveAttribute('aria-label'); + expect( + (placeholder as HTMLElement).getAttribute('aria-label'), + 'the name is a word, never a naked punctuation mark', + ).toBe('No value'); + // CONTROL — without this, a column that renders NO lock states passes above. + expect( + within(cell(1, LOCK_HEADER)).queryByText( + translateConsoleValue('lock', 'no-delete', LOCALE), + ), + 'CONTROL: the sibling row still prints its lock state', + ).not.toBeNull(); + }); + + it('NON-REGRESSION — a LOCKED row prints its state and NO placeholder', async () => { + const { cell } = await renderPanel([ + auditRow({ id: 'a_1', lockState: 'none' }), + auditRow({ id: 'a_2', actor: 'ops@objectos.ai', lockState: 'full' }), + ]); + const locked = cell(1, LOCK_HEADER); + + expect( + within(locked).queryByText(translateConsoleValue('lock', 'full', LOCALE)), + 'the lock state reaches the cell', + ).not.toBeNull(); + // THE DISCRIMINATING HALF: red for an EmptyValue-everywhere implementation. + expect(emptyIn(locked), 'a locked cell carries NO placeholder').toBeNull(); + // The visual delta is opacity only — the glyph itself did not change. + expect( + (emptyIn(cell(0, LOCK_HEADER)) as HTMLElement).textContent, + 'the unlocked cell still reads as an em dash', + ).toBe(EM_DASH); + }); + + it('keeps the em-dash branch for a null lockState', async () => { + const { cell } = await renderPanel([ + auditRow({ id: 'a_1', lockState: null }), + auditRow({ id: 'a_2', actor: 'ops@objectos.ai', lockState: 'full' }), + ]); + expect(emptyIn(cell(0, LOCK_HEADER)), 'a null lockState is empty too').not.toBeNull(); + expect( + emptyIn(cell(1, LOCK_HEADER)), + 'CONTROL: the locked sibling is still not a placeholder', + ).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/AuditPanel.tsx b/packages/app-shell/src/views/metadata-admin/AuditPanel.tsx index 2728769d6a..8a4658a8d9 100644 --- a/packages/app-shell/src/views/metadata-admin/AuditPanel.tsx +++ b/packages/app-shell/src/views/metadata-admin/AuditPanel.tsx @@ -23,7 +23,7 @@ import * as React from 'react'; import { RefreshCw, Loader2, ShieldCheck, ShieldAlert, ShieldX } from 'lucide-react'; import { Button } from '@object-ui/components'; import { Badge } from '@object-ui/components'; -import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; +import { Empty, EmptyTitle, EmptyDescription, EmptyValue } from '@object-ui/components'; import type { MetadataClient, MetadataAuditEntry, @@ -249,7 +249,7 @@ export function AuditPanel({ {ev.lockOverridden ? ' *' : ''} ) : ( - + )} —` for a null or + * empty value: no `data-slot`, no `aria-label`, and none of the shared + * component's `select-none` / `no-underline` / `pointer-events-none`. A screen + * reader crossing an empty Description cell heard a naked punctuation mark. + * + * The `pointer-events-none` half is not cosmetic here. `defaultCell`'s output + * for column 0 is rendered INSIDE the row's ``, so a hand-rolled span + * inherited the link colour and stayed selectable — a missing value that looked + * clickable, the same affordance bug PR #8503 named in the grid's link cells. + * + * ## Which case DISCRIMINATES — MEASURED, not predicted + * + * The caricature was RUN: `defaultCell` rewritten to `return ` + * for every value, filled cells included. `THE DEFECT` stays GREEN under it — + * "the empty cell has an accessible name" is equally true of a table that has + * stopped printing values — so it carries a control that reads a real + * description out of the sibling row. `NON-REGRESSION` is what refuses it. + * + * ## The visual delta + * + * `text-muted-foreground` (full opacity) becomes the shared + * `text-muted-foreground/50`: one deliberate step more muted, plus the three + * affordances and the accessible name. The glyph is unchanged. + * + * ## Harness notes + * + * The type is a name no `registry.ts` entry claims, so `resolveResourceConfig` + * returns the bare default — no `ListPage`, no `listColumns`, no `listFilter` — + * which is exactly the path `defaultCell` serves. `useMetadataLocale` is pinned + * to `en-US` so the column lookup does not depend on the host's + * `navigator.language`. + * + * Assertions are scoped to ONE cell of ONE row (objectui#8495: a table-wide + * "no placeholder anywhere" assertion would fail against the correct + * implementation the moment any other column is legitimately empty). + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +const PKG = 'proj_pkg'; +const TYPE = 'demo_thing'; + +/** Rows the fake metadata client hands back for `list(TYPE)`. */ +const mockItems: Record[] = [ + { name: 'alpha', label: 'Alpha', description: 'A real description', _packageId: PKG }, + { name: 'beta', label: 'Beta', description: '', _packageId: PKG }, +]; + +/** + * A STABLE singleton, deliberately. The page's load effect keys on the client + * identity, so a fresh object per render re-enters `setLoading(true)` forever: + * measured as a page stuck on "Loading demo_thing…" whose header row never + * exists, while the stats strip already reads "Filtered 2". + */ +const CLIENT = { + list: async (type: string) => + type === 'package' + ? [{ manifest: { id: PKG, scope: 'project', name: 'Project' } }] + : type === TYPE + ? mockItems + : [], +}; + +vi.mock('./useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => CLIENT, + useMetadataTypes: () => ({ loading: false, error: null, entries: [] }), +})); + +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => 'en-US', +})); + +// Imported AFTER the mocks so the page picks them up. +import { MetadataResourceListPage } from './ResourceListPage'; +import { t } from './i18n'; + +afterEach(cleanup); + +/** The shared placeholder inside ONE element, or null. */ +const emptyIn = (el: HTMLElement): HTMLElement | null => + el.querySelector('[data-slot="empty-value"]'); + +const DESCRIPTION_HEADER = t('engine.list.col.description', 'en-US'); + +async function mount() { + const { container } = render( + + + , + ); + await waitFor(() => + expect(container.querySelectorAll('thead th').length).toBeGreaterThan(0), + ); + await waitFor(() => expect(container.querySelectorAll('tbody tr').length).toBe(2)); + + const headers = () => + Array.from(container.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim()); + + /** The cell under `header` in the row whose first cell reads `name`. */ + const cell = (name: string, header: string): HTMLElement => { + const idx = headers().indexOf(header); + expect(idx, `the ${header} column is present — headers were ${JSON.stringify(headers())}`) + .toBeGreaterThanOrEqual(0); + const tr = Array.from(container.querySelectorAll('tbody tr')).find((r) => + (r.querySelector('td')?.textContent ?? '').includes(name), + ); + expect(tr, `the row for ${name} rendered`).toBeTruthy(); + const td = (tr as HTMLElement).querySelectorAll('td')[idx]; + expect(td, `the ${name} row has a cell under ${header}`).toBeTruthy(); + return td as HTMLElement; + }; + return { cell }; +} + +describe('metadata list defaultCell uses the shared EmptyValue (objectui#8504)', () => { + it('THE DEFECT — an empty cell carries an accessible name', async () => { + const { cell } = await mount(); + const placeholder = emptyIn(cell('beta', DESCRIPTION_HEADER)); + + expect(placeholder, 'the empty cell draws the shared placeholder').not.toBeNull(); + expect(placeholder, 'and therefore has an accessible name').toHaveAttribute('aria-label'); + expect( + (placeholder as HTMLElement).getAttribute('aria-label'), + 'the name is a word, never a naked punctuation mark', + ).toBe('No value'); + expect((placeholder as HTMLElement).textContent, 'the glyph is unchanged').toBe('—'); + // CONTROL — without this, a table that renders NO values passes above. + expect( + within(cell('alpha', DESCRIPTION_HEADER)).queryByText('A real description'), + 'CONTROL: the sibling row still prints its description', + ).not.toBeNull(); + }); + + it('NON-REGRESSION — a FILLED cell renders its value and NO placeholder', async () => { + const { cell } = await mount(); + const filled = cell('alpha', DESCRIPTION_HEADER); + + expect(within(filled).queryByText('A real description'), 'the value reaches the cell') + .not.toBeNull(); + // THE DISCRIMINATING HALF: red for an EmptyValue-everywhere implementation. + expect(emptyIn(filled), 'a filled cell carries NO placeholder').toBeNull(); + }); + + it('the placeholder is inert inside the row link', async () => { + // Column 0's cell is rendered inside the row's ``. The shared + // component's `pointer-events-none` / `no-underline` / `select-none` are + // what stop a missing value from reading as clickable there; the + // hand-rolled span had none of them. + const { cell } = await mount(); + const placeholder = emptyIn(cell('beta', DESCRIPTION_HEADER)) as HTMLElement; + for (const cls of ['pointer-events-none', 'no-underline', 'select-none']) { + expect(placeholder.className, `the placeholder carries ${cls}`).toContain(cls); + } + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx index ae90afd05b..1733fc7ed7 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx @@ -25,7 +25,7 @@ import { SelectTrigger, SelectValue, } from '@object-ui/components'; -import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; +import { Empty, EmptyTitle, EmptyDescription, EmptyValue } from '@object-ui/components'; import { PageShell } from './PageShell.js'; import { MetadataTypeActions } from './MetadataTypeActions.js'; import { CreatePackageDialog } from './PackagesPage.js'; @@ -633,7 +633,7 @@ function defaultColumns(primaryKey: string): NonNullable—; + return ; } if (typeof value === 'boolean') return value ? '✓' : '✗'; if (typeof value === 'object') { diff --git a/packages/plugin-chatbot/src/AiPendingActionsInbox.tsx b/packages/plugin-chatbot/src/AiPendingActionsInbox.tsx index cc690304a5..3c13ecba58 100644 --- a/packages/plugin-chatbot/src/AiPendingActionsInbox.tsx +++ b/packages/plugin-chatbot/src/AiPendingActionsInbox.tsx @@ -56,6 +56,7 @@ import { EmptyDescription, EmptyHeader, EmptyMedia, + EmptyValue, } from '@object-ui/components'; import { RefreshCw, @@ -190,7 +191,7 @@ function safeParseJson(input: string | null | undefined): unknown { function JsonBlock({ value, max = 320 }: { value: unknown; max?: number }) { const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2); - if (text == null || text === '') return ; + if (text == null || text === '') return ; return (
                 
-
{selected.proposed_by ?? '—'}
+
{selected.proposed_by ?? }
-
{selected.decided_by ?? '—'}
+
{selected.decided_by ?? }
{selected.conversation_id ? (
diff --git a/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx b/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx new file mode 100644 index 0000000000..7b06ec0808 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx @@ -0,0 +1,210 @@ +/** + * 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. + */ + +/** + * The AI-approvals detail drawer draws the shared `EmptyValue` for its three + * missing-value placeholders (objectui#8504). + * + * ## The three carriers, and why they are one PR and not two + * + * objectui#8504 lists ONE carrier in this file — `JsonBlock`'s `` at `:193`. Its "Adjacent" + * section names two more, at `:507` and `:511`: `{selected.proposed_by ?? '—'}` + * and `{selected.decided_by ?? '—'}`, bare text fallbacks inside a plain + * `
` — a different SOURCE shape, and the card asks for a deliberate + * decision rather than a silent sweep in either direction. + * + * They are IN. The reasoning, recorded so it can be argued with: + * + * - The card's scope fence protects a population of 41 bare em-dash ternary + * fallbacks across 33 files whose MEMBERSHIP is unknown — it holds a Select + * option label, a duration fallback and codegen writing markdown cells, so + * triage has to separate real carriers from decoration before anything + * mechanical touches them. These two are not un-triaged: they were verified + * individually and written into the card as "no span, no class, no + * accessible name". + * - They are rendered into the DOM, in this drawer, next to the very cell + * `:193` covers. To a screen reader the defect is identical; only the + * source spelling differs. + * - Leaving them means this file still ships an unannounced em dash after a + * PR whose entire purpose is to retire them from it, and a second PR would + * have to reopen the same four lines. + * + * What is deliberately NOT swept in: `formatRelative`'s `if (!s) return '—'` at + * `:172`. That helper is declared `: string`; turning its fallback into a node + * changes the return type and every call site. It is a member of the fenced + * population, and it stays there. + * + * ## `JsonBlock`'s empty branch is reachable, but only just — MEASURED + * + * All three call sites are in this drawer. Two pass through `safeParseJson`, + * which returns `null` for a falsy input — and `JSON.stringify(null)` is the + * string `"null"`, not empty, so a null/absent `tool_input` renders a `
`
+ * reading `null` and never reaches the guard. The third (`error`) is behind a
+ * truthiness check. The one input that DOES reach it is a JSON-encoded empty
+ * string, `'""'`: `JSON.parse` yields `''`, `typeof` says string, and `text ===
+ * ''` fires. That is a real tool input, and it is what the case below uses.
+ *
+ * ## Which cases DISCRIMINATE — MEASURED, not predicted
+ *
+ * The caricature was RUN: all three sites rewritten to render ``
+ * unconditionally. Both `THE DEFECT` cases stay GREEN under it — "the empty
+ * cell has an accessible name" is equally true of a drawer that has stopped
+ * printing values — so each carries a control that reads a real value out of a
+ * sibling field. The two `NON-REGRESSION` cases are what refuse it.
+ *
+ * ## Visual deltas, per site
+ *
+ * `:193` keeps `text-xs` through `className` — it sits where a `text-xs 
`
+ * would be, and there is no neighbouring shared placeholder to match — so its
+ * delta is the accessible name, the three affordances, and one step of opacity
+ * (`text-muted-foreground` → `/50`). `:507` / `:511` were bare text inheriting
+ * `text-xs font-mono` from their `
`, which they still inherit; their delta + * is the same three affordances plus the muted colour. + * + * Every assertion is scoped to ONE labelled field (objectui#8495). + */ +import * as React from 'react'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import type { PendingActionRow } from '@objectstack/spec/contracts'; +import { AiPendingActionsInbox } from '../AiPendingActionsInbox'; + +const ROW_ID = 'aaaaaaaa1111'; + +function row(over: Partial = {}): PendingActionRow { + return { + id: ROW_ID, + object_name: 'task', + action_name: 'delete', + tool_name: 'action_delete_task', + tool_input: '{"id":"t1"}', + status: 'pending', + proposed_by: 'agent_1', + proposed_at: new Date().toISOString(), + ...over, + } as PendingActionRow; +} + +function stubList(items: PendingActionRow[]) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ items, total: items.length }), + }) as unknown as Response), + ); +} + +beforeEach(() => { + vi.unstubAllGlobals(); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** The shared placeholder inside ONE element, or null. */ +const emptyIn = (el: HTMLElement): HTMLElement | null => + el.querySelector('[data-slot="empty-value"]'); + +/** Render the inbox, open the row's detail drawer, and scope reads to it. */ +async function openDrawer(over: Partial) { + stubList([row(over)]); + render(); + const view = await screen.findByTestId(`ai-inbox-view-${ROW_ID}`); + fireEvent.click(view); + await waitFor(() => expect(screen.queryByText('Proposed by')).not.toBeNull()); + + /** + * The value block under a drawer field label. Each field is a `
` holding + * a `
diff --git a/packages/plugin-dashboard/src/__tests__/RecordDetailDrawer.emptyPlaceholderAffordance-8504.test.tsx b/packages/plugin-dashboard/src/__tests__/RecordDetailDrawer.emptyPlaceholderAffordance-8504.test.tsx new file mode 100644 index 0000000000..0ab8f9d73b --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/RecordDetailDrawer.emptyPlaceholderAffordance-8504.test.tsx @@ -0,0 +1,129 @@ +/** + * 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. + */ + +/** + * `RecordDetailDrawer`'s empty `
` draws the shared `EmptyValue` + * (objectui#8504). + * + * ## The defect + * + * The drill-to-record drawer spelled its own placeholder — `` — with no `data-slot`, no + * `aria-label` and none of the shared component's `select-none` / + * `no-underline` / `pointer-events-none`. A screen reader walking the + * definition list heard a label ("Stage") followed by a naked punctuation mark. + * + * Its own neighbours already had the real thing: `renderFieldValue` hands empty + * values back as `''` and the drawer's guard catches them, but a value that + * survives to a type-aware cell renderer reaches that renderer's own empty + * branch, which returns `EmptyValue`. Two visually-identical dashes in one + * drawer, only one of them announced. + * + * ## Which case DISCRIMINATES — MEASURED, not predicted + * + * The caricature was RUN: the `
` rewritten to `` + * unconditionally, filled rows included. Both cases below go red, but only + * `NON-REGRESSION` refuses it through its headline assertion. `THE DEFECT`'s + * claim — "the empty row has an accessible name" — is equally true of a drawer + * that has stopped rendering values at all, which is what its control is for. + * + * ## A deliberate visual change + * + * The retired span was `text-muted-foreground/60`; the shared component is + * `text-muted-foreground/50`. That one step is adopted on purpose — the whole + * point of a shared placeholder is that a drawer cannot show two different + * dashes — and the glyph is unchanged. + * + * Assertions are scoped to ONE row's `
` (objectui#8495). + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, within, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { RecordDetailDrawer } from '../RecordDetailDrawer'; + +const schema = { + fields: { + name: { type: 'text', label: 'Name' }, + stage: { type: 'text', label: 'Stage' }, + }, +}; + +/** The shared placeholder inside ONE element, or null. */ +const emptyIn = (el: HTMLElement): HTMLElement | null => + el.querySelector('[data-slot="empty-value"]'); + +// The drawer is a `Sheet`, so its body lands in a portal OUTSIDE `container`. +// Reading `container` here returned null for every case — a harness bug that +// looks exactly like the component rendering nothing. +afterEach(cleanup); + +function mount(record: Record) { + render( + , + ); + const body = document.body.querySelector('[data-testid="record-detail-body"]') as HTMLElement; + expect(body, 'the drawer body rendered').not.toBeNull(); + + /** The value cell of the row labelled `label` — never a body-wide lookup. */ + const valueCell = (label: string): HTMLElement => { + const dt = Array.from(body.querySelectorAll('dt')).find( + (el) => (el.textContent ?? '').trim() === label, + ); + expect(dt, `the "${label}" row is present`).toBeTruthy(); + const dd = (dt as HTMLElement).parentElement?.querySelector('dd'); + expect(dd, `the "${label}" row has a value cell`).toBeTruthy(); + return dd as HTMLElement; + }; + return { valueCell }; +} + +describe('RecordDetailDrawer empty rows use the shared EmptyValue (objectui#8504)', () => { + it('THE DEFECT — an empty row carries an accessible name', () => { + const { valueCell } = mount({ id: 'opp-1', name: 'Acme Renewal', stage: '' }); + const placeholder = emptyIn(valueCell('Stage')); + + expect(placeholder, 'the empty row draws the shared placeholder').not.toBeNull(); + expect(placeholder, 'and therefore has an accessible name').toHaveAttribute('aria-label'); + expect( + (placeholder as HTMLElement).getAttribute('aria-label'), + 'the name is a word, never a naked punctuation mark', + ).toBe('No value'); + expect((placeholder as HTMLElement).textContent, 'the glyph is unchanged').toBe('—'); + // CONTROL — without this, a drawer that renders no values at all passes above. + expect( + within(valueCell('Name')).queryByText('Acme Renewal'), + 'CONTROL: the sibling row rendered by value', + ).not.toBeNull(); + }); + + it('NON-REGRESSION — a FILLED row renders its value and NO placeholder', () => { + const { valueCell } = mount({ id: 'opp-1', name: 'Acme Renewal', stage: 'Won' }); + const filled = valueCell('Stage'); + + expect(within(filled).queryByText('Won'), 'the value reaches the row').not.toBeNull(); + // THE DISCRIMINATING HALF: red for an EmptyValue-everywhere implementation. + expect(emptyIn(filled), 'a filled row carries NO placeholder').toBeNull(); + }); + + it('a field the record omits entirely is empty too', () => { + // `renderFieldValue` returns `''` for null/undefined, so a key present in + // the schema but absent from the record takes the same branch. + const { valueCell } = mount({ id: 'opp-1', name: 'Acme Renewal', stage: undefined }); + expect(emptyIn(valueCell('Stage')), 'an absent value is empty').not.toBeNull(); + expect( + emptyIn(valueCell('Name')), + 'CONTROL: the filled sibling is still not a placeholder', + ).toBeNull(); + }); +}); diff --git a/packages/plugin-grid/src/ImportWizard.savedMappingEmptyValue-8504.test.tsx b/packages/plugin-grid/src/ImportWizard.savedMappingEmptyValue-8504.test.tsx new file mode 100644 index 0000000000..ca64a687e4 --- /dev/null +++ b/packages/plugin-grid/src/ImportWizard.savedMappingEmptyValue-8504.test.tsx @@ -0,0 +1,138 @@ +/** + * ObjectUI – Copyright (c) 2024-present ObjectStack Inc. + * Licensed under MIT. + */ + +/** + * The saved-mapping summary's transform column draws the shared `EmptyValue` + * (objectui#8504). + * + * ## The defect + * + * `SavedMappingSummary` spelled its own placeholder — `` — for an entry with no transform, + * with no `data-slot`, no `aria-label` and none of the shared component's + * `select-none` / `no-underline` / `pointer-events-none`. In a column headed + * "Transform", a screen-reader user heard a naked punctuation mark where the + * next row announced "lookup". + * + * ⚠️ This file is why "truncated output is not a reading" is a rule. A `head -4` + * of an em-dash grep over `ImportWizard.tsx` shows only `'— None —'`, `'— Map + * columns manually —'` and `'— Skip —'` — em dashes used as LABEL DECORATION + * inside i18n strings, not placeholders — and makes the file look clean. The + * real carrier was at `:1034`, well below the first screen. Those three + * decorations are deliberately untouched. + * + * ## Why this renders the component directly + * + * `SavedMappingSummary` is reached only through `StepMapping`, which requires a + * parsed spreadsheet and a Radix `Select` interaction — neither of which this + * component owns, and both of which would make the pin measure the wizard's + * routing rather than its cell. It is read through the file's existing + * `__testables` seam (21 entries before this one, same `@internal` contract). + * What that seam does NOT cover: that `StepMapping` still routes to this + * component. That line is untouched by this card. + * + * ## Which case DISCRIMINATES — MEASURED, not predicted + * + * The caricature was RUN: the transform cell rewritten to `` + * unconditionally, transformed entries included. `THE DEFECT` stays GREEN under + * it — "the untransformed cell has an accessible name" is also true of a table + * that has stopped printing transforms — so it carries a control, and + * `NON-REGRESSION` is the case that actually refuses the caricature. + * + * ## The visual delta + * + * `text-muted-foreground` (full opacity) becomes the shared + * `text-muted-foreground/50`: one step more muted, deliberately, plus the three + * affordances and the accessible name. The glyph is unchanged. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { __testables } from './ImportWizard'; +import type { SavedMapping } from './savedMapping'; + +afterEach(cleanup); + +const { SavedMappingSummary } = __testables; + +/** The shared placeholder inside ONE element, or null. */ +const emptyIn = (el: HTMLElement): HTMLElement | null => + el.querySelector('[data-slot="empty-value"]'); + +/** One entry with no transform, one with a real one. */ +const MAPPING: SavedMapping = { + name: 'invoice_import', + label: 'Invoice import', + targetObject: 'invoice', + fieldMapping: [ + { source: 'Customer', target: 'account' }, + { source: 'Currency', target: 'currency', transform: 'lookup' }, + ], +}; + +function mount(mapping: SavedMapping) { + const { container } = render(); + const summary = container.querySelector( + '[data-testid="import-saved-mapping-summary"]', + ) as HTMLElement; + expect(summary, 'the summary table rendered').not.toBeNull(); + + /** The LAST cell (Transform) of ONE row — never a table-wide lookup. */ + const transformCell = (rowIndex: number): HTMLElement => { + const tr = summary.querySelectorAll('tbody tr')[rowIndex]; + expect(tr, `row ${rowIndex} rendered`).toBeTruthy(); + const cells = tr.querySelectorAll('td'); + expect(cells.length, `row ${rowIndex} has three columns`).toBe(3); + return cells[2] as HTMLElement; + }; + return { summary, transformCell }; +} + +describe('SavedMappingSummary transform cell uses the shared EmptyValue (objectui#8504)', () => { + it('THE DEFECT — an entry with no transform carries an accessible name', () => { + const { transformCell } = mount(MAPPING); + const placeholder = emptyIn(transformCell(0)); + + expect(placeholder, 'the untransformed cell draws the shared placeholder').not.toBeNull(); + expect(placeholder, 'and therefore has an accessible name').toHaveAttribute('aria-label'); + expect( + (placeholder as HTMLElement).getAttribute('aria-label'), + 'the name is a word, never a naked punctuation mark', + ).toBe('No value'); + expect((placeholder as HTMLElement).textContent, 'the glyph is unchanged').toBe('—'); + // CONTROL — without this, a table that prints NO transforms passes above. + expect( + within(transformCell(1)).queryByText('lookup'), + 'CONTROL: the sibling row still prints its transform', + ).not.toBeNull(); + }); + + it('NON-REGRESSION — a TRANSFORMED entry renders its badge and NO placeholder', () => { + const { transformCell } = mount(MAPPING); + const filled = transformCell(1); + + expect(within(filled).queryByText('lookup'), 'the transform reaches the cell').not.toBeNull(); + // THE DISCRIMINATING HALF: red for an EmptyValue-everywhere implementation. + expect(emptyIn(filled), 'a transformed cell carries NO placeholder').toBeNull(); + }); + + it("an explicit transform of 'none' is empty too", () => { + // `summarizeSavedMapping` normalises `'none'` to `''`, so it takes the same + // branch as an absent transform — pinned so the two spellings cannot drift. + const { transformCell } = mount({ + ...MAPPING, + fieldMapping: [ + { source: 'Customer', target: 'account', transform: 'none' }, + { source: 'Currency', target: 'currency', transform: 'lookup' }, + ], + }); + expect(emptyIn(transformCell(0)), "'none' is not a transform").not.toBeNull(); + expect( + emptyIn(transformCell(1)), + 'CONTROL: the transformed sibling is still not a placeholder', + ).toBeNull(); + }); +}); diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx index a37cc7dcf5..7c810c531d 100644 --- a/packages/plugin-grid/src/ImportWizard.tsx +++ b/packages/plugin-grid/src/ImportWizard.tsx @@ -5,7 +5,7 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { - cn, Button, Badge, Progress, Input, Checkbox, Label, + cn, Button, Badge, Progress, Input, Checkbox, Label, EmptyValue, Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, @@ -269,6 +269,10 @@ export const __testables = { get buildSourceRows() { return buildSourceRows; }, get summarizeSavedMapping() { return summarizeSavedMapping; }, get savedMappingToDisplayIndexMap() { return savedMappingToDisplayIndexMap; }, + /** The read-only server-mapping summary table. Exposed so its cells can be + * read directly: reaching it through the wizard means driving a file parse + * and a Radix `Select`, neither of which this component owns. */ + get SavedMappingSummary() { return SavedMappingSummary; }, }; /** A reusable column-mapping template, persisted across sessions. Keys are @@ -1031,7 +1035,7 @@ const SavedMappingSummary: React.FC<{ mapping: SavedMapping }> = ({ mapping }) = {r.transform ? {r.transform} - : } + : } ))} From 550648328a836a88b916fe46627765f1cada3e2a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 04:10:48 +0000 Subject: [PATCH 2/5] test(app-shell): find the metadata-list row by its link href, not by column 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Column 0 is rendered through `defaultCell` itself, so the caricature ablation (EmptyValue everywhere) erased the very name the harness looked the row up by: all three cases went red on "the row for beta rendered" — the harness dying, not an assertion refusing the caricature. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- ...Page.emptyPlaceholderAffordance-8504.test.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx index 25d5c207e3..440a65fcf3 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx @@ -110,13 +110,23 @@ async function mount() { const headers = () => Array.from(container.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim()); - /** The cell under `header` in the row whose first cell reads `name`. */ + /** + * The cell under `header` in the row whose edit link points at `name`. + * + * The lookup deliberately reads the row LINK's `href`, not column 0's text. + * Column 0 is itself rendered through `defaultCell`, so an + * `EmptyValue`-everywhere implementation erases the name the row would be + * found by — measured: all three cases went red on "the row for beta + * rendered", i.e. on the harness, before any assertion about placeholders + * could run. `href` is built from `name` independently of `defaultCell`, so + * the caricature now has to be refused by the cases themselves. + */ const cell = (name: string, header: string): HTMLElement => { const idx = headers().indexOf(header); expect(idx, `the ${header} column is present — headers were ${JSON.stringify(headers())}`) .toBeGreaterThanOrEqual(0); - const tr = Array.from(container.querySelectorAll('tbody tr')).find((r) => - (r.querySelector('td')?.textContent ?? '').includes(name), + const tr = Array.from(container.querySelectorAll('tbody tr')).find( + (r) => r.querySelector(`a[href*="${name}"]`) !== null, ); expect(tr, `the row for ${name} rendered`).toBeTruthy(); const td = (tr as HTMLElement).querySelectorAll('td')[idx]; From c3afa6dda4ba78a963d577c73f449664e43a17f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 04:14:44 +0000 Subject: [PATCH 3/5] test: state the MEASURED caricature refusals, and add the cases that were missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caricature (EmptyValue drawn over every cell) was run against all six pins. Measured: every THE DEFECT case failed only on its CONTROL — its headline claim is equally true of a surface that has stopped rendering values — and each NON-REGRESSION case failed one assertion earlier, on value presence, so its own 'no placeholder' half never ran. Three files already had a case whose assertion fails BECAUSE a filled cell gained a placeholder. Two did not; they do now. Two dereferences that had been failing with a bare TypeError are guarded so their messages reach the summary, and the one case the caricature survives is relabelled a scope declaration rather than quoted as proof. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- ...e.emptyPlaceholderAffordance-8504.test.tsx | 34 +++++++++++++-- ...l.emptyPlaceholderAffordance-8504.test.tsx | 27 +++++++++--- ...e.emptyPlaceholderAffordance-8504.test.tsx | 38 +++++++++++++---- ...x.emptyPlaceholderAffordance-8504.test.tsx | 41 +++++++++++++++++-- ...r.emptyPlaceholderAffordance-8504.test.tsx | 19 +++++++-- ...izard.savedMappingEmptyValue-8504.test.tsx | 18 ++++++-- 6 files changed, 148 insertions(+), 29 deletions(-) diff --git a/apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx b/apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx index e5c3d5fbcb..90d11deebd 100644 --- a/apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx +++ b/apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx @@ -25,10 +25,21 @@ * ## Which case DISCRIMINATES — MEASURED, not predicted * * The caricature was RUN: the Object cell rewritten to `` - * unconditionally, objects included. `THE DEFECT` stays GREEN under it — "the - * objectless row has an accessible name" is also true of a table that has - * stopped printing objects — so it carries a control that reads a real object - * out of the sibling row. `NON-REGRESSION` is what refuses the caricature. + * unconditionally, objects included. Every case goes red, on a different + * assertion: + * + * - `exactly ONE of the two rows draws a placeholder` fails on "and the + * filled row does NOT" — the assertion that fails BECAUSE a filled cell + * gained a placeholder. + * - `NON-REGRESSION` fails one assertion earlier, on "the object reaches the + * cell": the caricature also stops the column printing objects, so its own + * `no placeholder` half is never reached. + * - `THE DEFECT` fails ONLY on its control. Its headline claim — "the + * objectless row has an accessible name" — is equally true of a table that + * has stopped printing objects. + * + * Reverting the fix turns `THE DEFECT` and the one-of-two case red on their + * headline assertions and leaves `NON-REGRESSION` green. * * ## The visual delta * @@ -145,4 +156,19 @@ describe('PublicFormsPage object cell uses the shared EmptyValue (objectui#8504) // THE DISCRIMINATING HALF: red for an EmptyValue-everywhere implementation. expect(emptyIn(filled), 'a cell with an object carries NO placeholder').toBeNull(); }); + + it('exactly ONE of the two rows draws a placeholder', () => { + // The assertion order matters, and it was measured. `NON-REGRESSION` above + // fails on its FIRST assertion under the caricature — the object stops + // reaching the cell — so its `no placeholder` half never runs. This case + // reaches that half: the empty row still has one, the filled row must not, + // and the second assertion is the one that fails BECAUSE a filled cell + // gained a placeholder. + return mount().then(({ cell }) => { + expect(emptyIn(cell('objectless_form', 'Object')), 'the empty row has one') + .not.toBeNull(); + expect(emptyIn(cell('task_form', 'Object')), 'and the filled row does NOT') + .toBeNull(); + }); + }); }); diff --git a/packages/app-shell/src/views/metadata-admin/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx b/packages/app-shell/src/views/metadata-admin/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx index 3c3ffa82e3..a08e4d0347 100644 --- a/packages/app-shell/src/views/metadata-admin/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/AuditPanel.emptyPlaceholderAffordance-8504.test.tsx @@ -15,11 +15,22 @@ * ## What each case can and cannot discriminate — MEASURED * * The caricature was RUN, not predicted: `AuditPanel`'s lock cell rewritten to - * `` unconditionally, locked rows included. Only - * `NON-REGRESSION` refuses it. `THE DEFECT`'s headline claim — "the empty cell - * has an accessible name" — is TRUE of a column that has stopped rendering lock - * states altogether, so it is paired with a control that reads a real value out - * of the sibling row and would itself be measuring nothing without it. + * `` unconditionally, locked rows included. All three cases go + * red, but on different assertions, and the difference is the point: + * + * - `keeps the em-dash branch for a null lockState` fails on "CONTROL: the + * locked sibling is still not a placeholder" — the ONE assertion here that + * fails BECAUSE a filled cell gained a placeholder. + * - `NON-REGRESSION` fails one assertion earlier, on "the lock state reaches + * the cell": under the caricature the column stops printing states at all, + * so its own `no placeholder` half is never reached. + * - `THE DEFECT` fails ONLY on its control. Its headline claim — "the empty + * cell has an accessible name" — is TRUE of a column that has given up on + * lock states, which is exactly why the control is not optional. + * + * Reverting the fix instead (the hand-rolled span restored) turns `THE DEFECT` + * and the null-lockState case red on their headline assertions, and leaves + * `NON-REGRESSION` green — the correct shape for a non-regression case. * * ## A deliberate visual change * @@ -138,8 +149,12 @@ describe('AuditPanel lock cell draws the shared EmptyValue (objectui#8504)', () // THE DISCRIMINATING HALF: red for an EmptyValue-everywhere implementation. expect(emptyIn(locked), 'a locked cell carries NO placeholder').toBeNull(); // The visual delta is opacity only — the glyph itself did not change. + // Guarded first: an unguarded dereference fails with a bare TypeError and + // the message below never reaches the summary (measured on the revert leg). + const unlocked = emptyIn(cell(0, LOCK_HEADER)); + expect(unlocked, 'the unlocked sibling still draws a placeholder').not.toBeNull(); expect( - (emptyIn(cell(0, LOCK_HEADER)) as HTMLElement).textContent, + (unlocked as HTMLElement).textContent, 'the unlocked cell still reads as an em dash', ).toBe(EM_DASH); }); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx index 440a65fcf3..a0473c8d03 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx @@ -20,10 +20,21 @@ * ## Which case DISCRIMINATES — MEASURED, not predicted * * The caricature was RUN: `defaultCell` rewritten to `return ` - * for every value, filled cells included. `THE DEFECT` stays GREEN under it — - * "the empty cell has an accessible name" is equally true of a table that has - * stopped printing values — so it carries a control that reads a real - * description out of the sibling row. `NON-REGRESSION` is what refuses it. + * for every value, filled cells included. + * + * - `NON-REGRESSION` refuses it, on "the value reaches the cell" — under the + * caricature the Description column stops printing descriptions. + * - `THE DEFECT` fails ONLY on its control. Its headline claim is equally + * true of a table that has stopped printing values. + * - The third case SURVIVES the caricature entirely, which is why it is + * labelled a scope declaration rather than quoted as proof. + * + * A first run of the caricature failed all three on the HARNESS instead — the + * row lookup read column 0, which `defaultCell` also renders, so the name the + * row was found by disappeared. The lookup now reads the row link's `href`. + * + * Reverting the fix turns `THE DEFECT` red on "the empty cell draws the shared + * placeholder" and leaves `NON-REGRESSION` green. * * ## The visual delta * @@ -165,15 +176,28 @@ describe('metadata list defaultCell uses the shared EmptyValue (objectui#8504)', expect(emptyIn(filled), 'a filled cell carries NO placeholder').toBeNull(); }); - it('the placeholder is inert inside the row link', async () => { + it('SCOPE DECLARATION — the placeholder is inert inside the row link', async () => { // Column 0's cell is rendered inside the row's ``. The shared // component's `pointer-events-none` / `no-underline` / `select-none` are // what stop a missing value from reading as clickable there; the // hand-rolled span had none of them. + // + // ⚠️ Labelled a SCOPE DECLARATION because it was MEASURED as the one case + // in this PR that the caricature survives: "the placeholder carries + // pointer-events-none" is true of an implementation that draws `EmptyValue` + // over every cell in the table. It goes red on the REVERT leg (there is no + // placeholder to read a class off), so it holds what the hand-rolled span + // lacked — it is not evidence that the placeholder is drawn CONDITIONALLY. + // That evidence is `NON-REGRESSION`'s. const { cell } = await mount(); - const placeholder = emptyIn(cell('beta', DESCRIPTION_HEADER)) as HTMLElement; + const placeholder = emptyIn(cell('beta', DESCRIPTION_HEADER)); + // Guarded: an unguarded dereference fails with a bare TypeError and the + // messages below never reach the summary (measured on the revert leg). + expect(placeholder, 'the empty cell drew a placeholder to read classes off') + .not.toBeNull(); for (const cls of ['pointer-events-none', 'no-underline', 'select-none']) { - expect(placeholder.className, `the placeholder carries ${cls}`).toContain(cls); + expect((placeholder as HTMLElement).className, `the placeholder carries ${cls}`) + .toContain(cls); } }); }); diff --git a/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx b/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx index 7b06ec0808..f3dcb4b423 100644 --- a/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx +++ b/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx @@ -53,10 +53,21 @@ * ## Which cases DISCRIMINATE — MEASURED, not predicted * * The caricature was RUN: all three sites rewritten to render `` - * unconditionally. Both `THE DEFECT` cases stay GREEN under it — "the empty - * cell has an accessible name" is equally true of a drawer that has stopped - * printing values — so each carries a control that reads a real value out of a - * sibling field. The two `NON-REGRESSION` cases are what refuse it. + * unconditionally. Every case goes red, on three different assertions: + * + * - The two `exactly ONE of the two …` cases fail on "and the filled … does + * NOT" — the assertions that fail BECAUSE a filled field gained a + * placeholder. + * - The two `NON-REGRESSION` cases fail one assertion earlier, on "the value + * reaches the field": the caricature also stops the drawer printing + * values, so their own `no placeholder` halves are never reached. They were + * the only refusal here until the pair above was added for exactly that + * reason. + * - Both `THE DEFECT` cases fail ONLY on their controls. Their headline + * claims are equally true of a drawer that prints nothing. + * + * Reverting the fix turns both `THE DEFECT` cases red on their headline + * assertions and leaves both `NON-REGRESSION` cases green. * * ## Visual deltas, per site * @@ -174,6 +185,20 @@ describe('AiPendingActionsInbox drawer identity fields (objectui#8504 adjacent, // THE DISCRIMINATING HALF: red for an EmptyValue-everywhere implementation. expect(emptyIn(filled), 'a known identity carries NO placeholder').toBeNull(); }); + + it('exactly ONE of the two identity fields draws a placeholder', async () => { + // Assertion order, measured: `NON-REGRESSION` above fails on its FIRST + // assertion under the caricature (the identity stops reaching the field), + // so its `no placeholder` half never runs. This case reaches it — the + // second assertion is the one that fails BECAUSE a filled field gained a + // placeholder. + const { field } = await openDrawer({ + proposed_by: null, + decided_by: 'human@objectos.ai', + } as Partial); + expect(emptyIn(field('Proposed by')), 'the unknown proposer has one').not.toBeNull(); + expect(emptyIn(field('Decided by')), 'and the known decider does NOT').toBeNull(); + }); }); describe('AiPendingActionsInbox JsonBlock (objectui#8504, :193)', () => { @@ -207,4 +232,12 @@ describe('AiPendingActionsInbox JsonBlock (objectui#8504, :193)', () => { // THE DISCRIMINATING HALF: red for an EmptyValue-everywhere implementation. expect(emptyIn(filled), 'a filled JSON block carries NO placeholder').toBeNull(); }); + + it('exactly ONE of the two JSON blocks draws a placeholder', async () => { + // The same order argument as the identity pair — this case reaches the + // `no placeholder` half against a filled sibling in the SAME drawer. + const { field } = await openDrawer({ tool_input: '""', result: '{"ok":true}' }); + expect(emptyIn(field('Tool input')), 'the empty block has one').not.toBeNull(); + expect(emptyIn(field('Result')), 'and the filled block does NOT').toBeNull(); + }); }); diff --git a/packages/plugin-dashboard/src/__tests__/RecordDetailDrawer.emptyPlaceholderAffordance-8504.test.tsx b/packages/plugin-dashboard/src/__tests__/RecordDetailDrawer.emptyPlaceholderAffordance-8504.test.tsx index 0ab8f9d73b..4054156a49 100644 --- a/packages/plugin-dashboard/src/__tests__/RecordDetailDrawer.emptyPlaceholderAffordance-8504.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/RecordDetailDrawer.emptyPlaceholderAffordance-8504.test.tsx @@ -27,10 +27,21 @@ * ## Which case DISCRIMINATES — MEASURED, not predicted * * The caricature was RUN: the `
` rewritten to `` - * unconditionally, filled rows included. Both cases below go red, but only - * `NON-REGRESSION` refuses it through its headline assertion. `THE DEFECT`'s - * claim — "the empty row has an accessible name" — is equally true of a drawer - * that has stopped rendering values at all, which is what its control is for. + * unconditionally, filled rows included. All three cases go red, on three + * different assertions: + * + * - `a field the record omits entirely` fails on "CONTROL: the filled sibling + * is still not a placeholder" — the one assertion here that fails BECAUSE a + * filled row gained a placeholder. + * - `NON-REGRESSION` fails one assertion earlier, on "the value reaches the + * row": the caricature also stops the drawer printing values, so its own + * `no placeholder` half is never reached. + * - `THE DEFECT` fails ONLY on its control. Its headline claim is equally + * true of a drawer that renders nothing, which is why the control is not + * optional. + * + * Reverting the fix turns `THE DEFECT` and the omitted-field case red on their + * headline assertions and leaves `NON-REGRESSION` green. * * ## A deliberate visual change * diff --git a/packages/plugin-grid/src/ImportWizard.savedMappingEmptyValue-8504.test.tsx b/packages/plugin-grid/src/ImportWizard.savedMappingEmptyValue-8504.test.tsx index ca64a687e4..a68f88da15 100644 --- a/packages/plugin-grid/src/ImportWizard.savedMappingEmptyValue-8504.test.tsx +++ b/packages/plugin-grid/src/ImportWizard.savedMappingEmptyValue-8504.test.tsx @@ -36,10 +36,20 @@ * ## Which case DISCRIMINATES — MEASURED, not predicted * * The caricature was RUN: the transform cell rewritten to `` - * unconditionally, transformed entries included. `THE DEFECT` stays GREEN under - * it — "the untransformed cell has an accessible name" is also true of a table - * that has stopped printing transforms — so it carries a control, and - * `NON-REGRESSION` is the case that actually refuses the caricature. + * unconditionally, transformed entries included. All three cases go red, on + * three different assertions: + * + * - `an explicit transform of 'none'` fails on "CONTROL: the transformed + * sibling is still not a placeholder" — the one assertion here that fails + * BECAUSE a filled cell gained a placeholder. + * - `NON-REGRESSION` fails one assertion earlier, on "the transform reaches + * the cell": the caricature also stops the column printing transforms, so + * its own `no placeholder` half is never reached. + * - `THE DEFECT` fails ONLY on its control — its headline claim is equally + * true of a table that prints no transforms at all. + * + * Reverting the fix turns `THE DEFECT` and the `'none'` case red on their + * headline assertions and leaves `NON-REGRESSION` green. * * ## The visual delta * From 2e2aff1adcd2b65d958f81fbe427ae05fc7e535b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 04:16:03 +0000 Subject: [PATCH 4/5] test(app-shell): give the metadata-list pin a case that refuses the caricature directly Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- ...e.emptyPlaceholderAffordance-8504.test.tsx | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx index a0473c8d03..2473988c59 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.emptyPlaceholderAffordance-8504.test.tsx @@ -22,12 +22,17 @@ * The caricature was RUN: `defaultCell` rewritten to `return ` * for every value, filled cells included. * - * - `NON-REGRESSION` refuses it, on "the value reaches the cell" — under the - * caricature the Description column stops printing descriptions. + * - `exactly ONE of the two rows draws a placeholder` fails on "and the + * filled row does NOT" — the assertion that fails BECAUSE a filled cell + * gained a placeholder. + * - `NON-REGRESSION` fails one assertion earlier, on "the value reaches the + * cell": the caricature also stops the Description column printing + * descriptions, so its own `no placeholder` half is never reached. * - `THE DEFECT` fails ONLY on its control. Its headline claim is equally * true of a table that has stopped printing values. - * - The third case SURVIVES the caricature entirely, which is why it is - * labelled a scope declaration rather than quoted as proof. + * - The `SCOPE DECLARATION` case SURVIVES the caricature entirely — the one + * survivor in this PR's 21 cases — which is why it is labelled rather than + * quoted as proof. * * A first run of the caricature failed all three on the HARNESS instead — the * row lookup read column 0, which `defaultCell` also renders, so the name the @@ -176,6 +181,17 @@ describe('metadata list defaultCell uses the shared EmptyValue (objectui#8504)', expect(emptyIn(filled), 'a filled cell carries NO placeholder').toBeNull(); }); + it('exactly ONE of the two rows draws a placeholder', async () => { + // Assertion order, measured: `NON-REGRESSION` above fails on its FIRST + // assertion under the caricature — the description stops reaching the cell + // — so its `no placeholder` half never runs. This case reaches it, and its + // second assertion is the one that fails BECAUSE a filled cell gained a + // placeholder. + const { cell } = await mount(); + expect(emptyIn(cell('beta', DESCRIPTION_HEADER)), 'the empty row has one').not.toBeNull(); + expect(emptyIn(cell('alpha', DESCRIPTION_HEADER)), 'and the filled row does NOT').toBeNull(); + }); + it('SCOPE DECLARATION — the placeholder is inert inside the row link', async () => { // Column 0's cell is rendered inside the row's ``. The shared // component's `pointer-events-none` / `no-underline` / `select-none` are From babe77f13e281daf0cb679d7089d010d7ee32e4e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 04:28:30 +0000 Subject: [PATCH 5/5] test(console): drop three zero-width spaces from the pathspec in a docblock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They were inserted to keep a glob from closing the block comment, and `no-irregular-whitespace` is an ERROR in apps/console — the only three lint errors on the branch. `check:control-bytes` is green either way: U+200B is not a control byte. The sentence names the pathspec in prose instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- ...e.emptyPlaceholderAffordance-8504.test.tsx | 5 ++-- ...x.emptyPlaceholderAffordance-8504.test.tsx | 30 +++++++++++-------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx b/apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx index 90d11deebd..38ca83dd95 100644 --- a/apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx +++ b/apps/console/src/pages/developer/PublicFormsPage.emptyPlaceholderAffordance-8504.test.tsx @@ -15,8 +15,9 @@ * ## Reachability was CHECKED before the swap, not assumed * * This is the one carrier on the card that lives in `apps/`, not `packages/` — - * an app's dependency tier, not a library's, and outside the `'packages/​*​/src/​*'` - * pathspec every census in the thread used. Measured: `@object-ui/components` + * an app's dependency tier, not a library's, and outside the packages-only + * `git grep` pathspec every census in the thread used (a glob rooted at + * `packages`, which never sees `apps`). Measured: `@object-ui/components` * is on `apps/console`'s `devDependencies` (`workspace:*`) and 29 files under * `apps/console/src` already import from it — this page among them. `EmptyValue` * joins an import list that was already there; no manifest edge was added, and diff --git a/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx b/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx index f3dcb4b423..ed48f1dac3 100644 --- a/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx +++ b/packages/plugin-chatbot/src/__tests__/AiPendingActionsInbox.emptyPlaceholderAffordance-8504.test.tsx @@ -124,6 +124,21 @@ afterEach(() => { vi.restoreAllMocks(); }); +/** + * An absent proposer next to a known decider. + * + * `undefined`, not `null`, and that is the contract talking: `PendingActionRow` + * declares `proposed_by?: string`, so the only absent value the producer can + * send is `undefined`. A first draft of this fixture used `null` and + * `type-check` refused it — the `??` guard's null arm is unreachable under the + * declared shape, and pinning it would have been pinning a value no producer + * emits. `??` catches `undefined` too, so the placeholder branch is the same. + */ +const ABSENT_PROPOSER: Partial = { + proposed_by: undefined, + decided_by: 'human@objectos.ai', +}; + /** The shared placeholder inside ONE element, or null. */ const emptyIn = (el: HTMLElement): HTMLElement | null => el.querySelector('[data-slot="empty-value"]'); @@ -151,10 +166,7 @@ async function openDrawer(over: Partial) { describe('AiPendingActionsInbox drawer identity fields (objectui#8504 adjacent, :507/:511)', () => { it('THE DEFECT — an unknown proposer carries an accessible name', async () => { - const { field } = await openDrawer({ - proposed_by: null, - decided_by: 'human@objectos.ai', - } as Partial); + const { field } = await openDrawer(ABSENT_PROPOSER); const placeholder = emptyIn(field('Proposed by')); expect(placeholder, 'the unknown proposer draws the shared placeholder').not.toBeNull(); @@ -172,10 +184,7 @@ describe('AiPendingActionsInbox drawer identity fields (objectui#8504 adjacent, }); it('NON-REGRESSION — a KNOWN identity renders its value and NO placeholder', async () => { - const { field } = await openDrawer({ - proposed_by: null, - decided_by: 'human@objectos.ai', - } as Partial); + const { field } = await openDrawer(ABSENT_PROPOSER); const filled = field('Decided by'); expect( @@ -192,10 +201,7 @@ describe('AiPendingActionsInbox drawer identity fields (objectui#8504 adjacent, // so its `no placeholder` half never runs. This case reaches it — the // second assertion is the one that fails BECAUSE a filled field gained a // placeholder. - const { field } = await openDrawer({ - proposed_by: null, - decided_by: 'human@objectos.ai', - } as Partial); + const { field } = await openDrawer(ABSENT_PROPOSER); expect(emptyIn(field('Proposed by')), 'the unknown proposer has one').not.toBeNull(); expect(emptyIn(field('Decided by')), 'and the known decider does NOT').toBeNull(); });