diff --git a/.changeset/7166-retire-inert-fieldmeta-copies.md b/.changeset/7166-retire-inert-fieldmeta-copies.md new file mode 100644 index 000000000..ff5256490 --- /dev/null +++ b/.changeset/7166-retire-inert-fieldmeta-copies.md @@ -0,0 +1,56 @@ +--- +'@object-ui/plugin-grid': patch +--- + +`ObjectGrid` no longer copies `descriptionField`, `lookupColumns` or `lookupFilters` onto a +relational column's `fieldMeta` (objectui#7166). No behaviour change — all three still reach +the inline lookup picker, by a different seam. + +`applyRelationalMeta` writes the copy set onto the `fieldMeta` that `generateColumns` hands +to `` as the `field` prop — six JSX passes across the three column-building +paths, and nowhere else. For a relational column that resolves to `LookupCellRenderer`, +which reads exactly `reference_to`, `reference`, `display_field`, `displayField`, +`reference_field` and `options`; a `user` column resolves to `UserCellRenderer`, which +destructures `{ value }` and reads no field meta at all. Measured by receiver rather than by +count: `packages/fields/src/index.tsx`, the file holding **every** cell renderer, contains +**zero** occurrences of the three retired keys, against a control of 22 occurrences of the +`display_field` / `displayField` / `reference_to` spellings the cell does read. + +Their only readers off a field meta are `LookupField` and `UserField` — the two **editor** +widgets — and the grid's inline editor does not receive this bag. `renderCellEditor` looks +the field up in the object schema and spreads the whole def into the widget +(`{ name: ctx.column.accessorKey, ...fieldDef }`), so every key a def carries reaches +`LookupField` whether or not it is copied. The copies were dead writes: the objectui#6711 +(`reference_to_field`) and objectui#6874 (`titleFormat`) class, arriving from the opposite +direction — those keys had no *declaration*, these have no *reader on this path*. + +Two of the three, `descriptionField` and `lookupColumns`, were **added** by objectui#6875 as +its fix, classified from a read-set derivation alone. Its third key, `displayField`, is +genuinely delivered and stays copied — and it is the one that arrived with a rendering test. +⭐ The generalisable lesson, now recorded in both docblocks: **a derivation establishes that +a consumer READS a key; it does not establish that a given BAG is how the consumer gets it.** + +- New `__tests__/relationalMetaCopySet-7166.test.tsx` renders both directions. The **cell**: + four lookup columns over one referenced record, differing only in the key under test, all + resolve the same text, while the `displayField` control column resolves something else — + the control is what makes the three zeros readings rather than a fixture that never reached + the lookup path. The **editor**: each retired key still takes effect in the inline picker + with the copy set no longer carrying it — `descriptionField` drives the secondary line, + `lookupColumns` shapes the picker columns, `lookupFilters` scopes the candidates — each + against a sibling control column that declares nothing. +- The three snake_case `legacy-alias` spellings — `description_field`, `lookup_filters`, + `id_field` — have the same reader-side verdict and are **deliberately kept**. They are + recorded as legacy aliases precisely because a host `DataSource` outside these repos may + hand-feed them; that is a producer-side argument, untouched by this reader-side + measurement. Their verdict is now recorded on the table with the open producer question + stated, so the next pass inherits a measurement instead of a silence. +- ⛔ The derivation gate cannot enforce this retirement, and now says so. Its read set is a + union over three consumers, two of which are not fed this bag, so all three retired keys + remain in it and every derived assertion passes whichever verdict they carry. Their absence + is pinned by an explicit hand-written assertion plus the rendering test. Re-scoping the + derivation around the cell alone is a design change to objectui#6875's mechanism and is + filed, not made here. +- The gate is strengthened, not weakened: every `deferred` verdict is now mechanically proved + spec-declared against the installed `FieldSchema`, and the sibling pins + (`relationalMetaCopySet-6711` / `-6874`) turn the retired `lookupFilters` into a live + negative assertion on a fixture that still declares it. diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx index 75e4c3ae6..50cbe6f03 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx @@ -68,11 +68,11 @@ const MANAGER_DEF = { reference_to_field: 'MUST_NOT_BE_COPIED', }; -/** The seven keys that survive both retirements — the control. */ +/** The six keys that survive every retirement so far — the control. */ const SURVIVING_KEYS = [ 'reference_to', 'reference', 'display_field', 'id_field', 'description_field', - 'lookup_filters', 'lookupFilters', + 'lookup_filters', ] as const; const ROWS = [{ id: 'r1', name: 'Tower T1', manager: 'u1' }]; @@ -161,13 +161,18 @@ describe('objectui#6711 — ObjectGrid no longer copies `reference_to_field` ont expect(meta).not.toHaveProperty('reference_to_field'); }); - it(`still copies the seven surviving relational keys (${name})`, async () => { + it(`still copies the six surviving relational keys (${name})`, async () => { const meta = await renderAndCaptureMeta(schemaExtra); for (const key of SURVIVING_KEYS) { expect(meta).toHaveProperty(key); } expect(meta.reference_to).toBe('users'); expect(meta.display_field).toBe('name'); + // objectui#7166 retired `lookupFilters` from the copy set — its only + // reader is an editor widget, which `renderCellEditor` feeds from the + // schema def. The fixture above still declares it, so this absence is a + // reading and not a fixture that never offered the key. + expect(meta).not.toHaveProperty('lookupFilters'); }); } }); diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx index d750d0b19..b07e99333 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx @@ -79,11 +79,11 @@ const MANAGER_DEF = { reference_to_field: 'x', }; -/** The seven keys that survive both retirements — the control. */ +/** The six keys that survive every retirement so far — the control. */ const SURVIVING_KEYS = [ 'reference_to', 'reference', 'display_field', 'id_field', 'description_field', - 'lookup_filters', 'lookupFilters', + 'lookup_filters', ] as const; const ROWS = [{ id: 'r1', name: 'Tower T1', manager: 'u1' }]; @@ -172,13 +172,18 @@ describe('objectui#6874 — ObjectGrid no longer copies `titleFormat` onto field expect(meta).not.toHaveProperty('titleFormat'); }); - it(`still copies the seven surviving relational keys (${name})`, async () => { + it(`still copies the six surviving relational keys (${name})`, async () => { const meta = await renderAndCaptureMeta(schemaExtra); for (const key of SURVIVING_KEYS) { expect(meta).toHaveProperty(key); } expect(meta.reference_to).toBe('users'); expect(meta.display_field).toBe('name'); + // objectui#7166 retired `lookupFilters` from the copy set — its only + // reader is an editor widget, which `renderCellEditor` feeds from the + // schema def. The fixture above still declares it, so this absence is a + // reading and not a fixture that never offered the key. + expect(meta).not.toHaveProperty('lookupFilters'); }); } }); diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-7166.test.tsx b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-7166.test.tsx new file mode 100644 index 000000000..e68e56ff3 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-7166.test.tsx @@ -0,0 +1,322 @@ +/** + * 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. + */ + +/** + * objectui#7166 — `descriptionField`, `lookupColumns` and `lookupFilters` leave + * `ObjectGrid`'s relational copy set, and NOTHING a user can see changes. + * + * ## Why this file renders instead of asserting the copy set + * + * This card exists because a DERIVATION was mistaken for a delivery proof. + * objectui#6875 swept its three consumers, found `descriptionField` and + * `lookupColumns` read and not copied, and added them — onto a bag their only + * reader never consults. Its third key, `displayField`, was genuinely + * delivered, and it is the one that arrived with a rendering test + * (`lookupDisplayFieldSpelling-6875.test.tsx`, `ACME-42` vs `Wrong Name`). + * One-for-three, and the difference between the hit and the misses is exactly + * the evidence bar. + * + * ⭐ A derivation establishes that a consumer READS a key. It does NOT + * establish that a given BAG is how that consumer gets it. So this file proves + * the second claim the only way it can be proved — by rendering — in both + * directions: + * + * A. THE CELL. Four lookup columns over one referenced record, differing only + * in the key under test, all resolve the SAME text. The `displayField` + * column is the control: it differs, which is what makes the other three + * zeros readings rather than a fixture that never reached the lookup path. + * + * B. THE EDITOR. Each retired key STILL takes effect in the inline picker + * with the copy set no longer carrying it, because `renderCellEditor` + * looks the field up in the object schema and spreads the whole def into + * the widget. This is the half that makes the retirement + * behaviour-preserving rather than merely tidy, and each test carries a + * sibling control column that declares nothing. + * + * ## The fixture choice that makes the controls load-bearing + * + * The referenced object's schema declares ONLY `id` and `name`. That matters: + * `deriveLookupColumns` builds a picker column set from the referenced schema + * whenever a field declares no `lookup_columns`, and `effectiveDescriptionField` + * falls back to the first derived non-display column. With a two-field schema + * it derives nothing beyond the display field, so a control column shows NO + * preview line at all — and the declared column's preview is attributable to + * the declaration rather than to the heuristic. The extra values the pickers + * render (`region`, `email`) live on the RECORDS, which is all + * `recordToOption` needs. + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ObjectGrid } from '../ObjectGrid'; +import { RELATIONAL_META_KEYS } from '../relationalMetaKeys'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; + +registerAllFields(); + +const OBJECT = 'os_7166_task'; +const REF = 'os_7166_person'; + +/** Six candidates, split across two regions so a filter is observable as rows. */ +const PEOPLE = [ + { id: 'p1', name: 'Person 01', region: 'north', email: 'p1@north.example' }, + { id: 'p2', name: 'Person 02', region: 'north', email: 'p2@north.example' }, + { id: 'p3', name: 'Person 03', region: 'north', email: 'p3@north.example' }, + { id: 'p4', name: 'Person 04', region: 'south', email: 'p4@south.example' }, + { id: 'p5', name: 'Person 05', region: 'south', email: 'p5@south.example' }, + { id: 'p6', name: 'Person 06', region: 'south', email: 'p6@south.example' }, +]; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = vi.fn() as any; + if (!(Element.prototype as any).hasPointerCapture) (Element.prototype as any).hasPointerCapture = () => false; + if (!(Element.prototype as any).setPointerCapture) (Element.prototype as any).setPointerCapture = () => {}; + if (!(Element.prototype as any).releasePointerCapture) (Element.prototype as any).releasePointerCapture = () => {}; +}); + +/** + * A data source whose referenced-object query honours the `$filter` record, so + * a declared `lookupFilters` is observable as rendered rows and not only as a + * call argument. The referenced schema is deliberately two fields — see the + * docblock. + */ +function makeDataSource(fields: Record, rows: any[]) { + const refQueries: any[] = []; + return { + refQueries, + find: vi.fn(async (objectName: string, params: any) => { + if (objectName === REF) { + refQueries.push(params); + let recs = PEOPLE; + const filter = params?.$filter; + if (filter && typeof filter === 'object' && typeof filter.region === 'string') { + recs = recs.filter((p) => p.region === filter.region); + } + return { data: recs, total: recs.length, hasMore: false, pageSize: 50 }; + } + return { data: rows, total: rows.length, hasMore: false, pageSize: 50 }; + }), + findOne: vi.fn(async (objectName: string, id: string) => + objectName === REF ? (PEOPLE.find((p) => p.id === id) ?? null) : null, + ), + update: vi.fn(async (_o: string, _id: string, changes: any) => changes), + getObjectSchema: async (name: string) => { + if (name === REF) { + // No `nameField`, no `titleFormat`, no third field: nothing but the + // field def's own pointers can shape the cell or the picker. + return { name, fields: { id: { type: 'text' }, name: { type: 'text' } } }; + } + return { name, fields: { id: { type: 'text' }, title: { type: 'text', label: 'Title' }, ...fields } }; + }, + } as any; +} + +function renderGrid(ds: any, rows: any[], columns: any[]) { + const schema: any = { + type: 'object-grid', + objectName: OBJECT, + editable: true, + singleClickEdit: true, + data: rows, + pagination: { pageSize: 50 }, + columns, + }; + return render( + + + + + , + ); +} + +/** The n-th DATA cell of the single row (`td[0]` is the row-number column). */ +function cellAt(container: HTMLElement, index: number): HTMLElement { + const row = container.querySelector('tbody tr') as HTMLElement; + const tds = Array.from(row.querySelectorAll('td')) as HTMLElement[]; + return tds[index + 1]; +} + +/** Single-click into a cell and hand back the widget's own trigger button. */ +async function openEditor(cell: HTMLElement): Promise { + fireEvent.click(cell); + return await waitFor(() => { + const btn = cell.querySelector('button'); + expect(btn).toBeTruthy(); + return btn as HTMLButtonElement; + }); +} + +describe('objectui#7166 — the three retired keys were never delivered by this bag', () => { + it('none of the three is on the copy set any more (the premise the rest of this file measures)', () => { + // Control: the copy set is populated and still holds the key objectui#6875 + // genuinely delivered, so "does not contain" below is a reading. + expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(5); + expect(RELATIONAL_META_KEYS).toContain('displayField'); + for (const key of ['descriptionField', 'lookupColumns', 'lookupFilters']) { + expect(RELATIONAL_META_KEYS).not.toContain(key); + } + // The snake_case legacy aliases are a DIFFERENT population and stay copied: + // their retention rests on an open producer-side question, which this + // card's reader-side measurement does not touch. + for (const key of ['description_field', 'lookup_filters', 'id_field']) { + expect(RELATIONAL_META_KEYS).toContain(key); + } + }); + + it('A. THE CELL — declaring any of the three changes nothing; `displayField` (CONTROL) changes everything', async () => { + const rows = [{ id: 't1', title: 'Task one', plain: 'p1', desc: 'p1', cols: 'p1', filt: 'p1', disp: 'p1' }]; + const ds = makeDataSource( + { + plain: { type: 'lookup', label: 'Plain', reference: REF }, + desc: { type: 'lookup', label: 'Desc', reference: REF, descriptionField: 'email' }, + cols: { type: 'lookup', label: 'Cols', reference: REF, lookupColumns: ['name', 'region'] }, + // Points at a region this record is NOT in — if the key reached the + // cell at all, this is where it would show. + filt: { type: 'lookup', label: 'Filt', reference: REF, lookupFilters: [{ field: 'region', operator: 'eq', value: 'south' }] }, + disp: { type: 'lookup', label: 'Disp', reference: REF, displayField: 'email' }, + }, + rows, + ); + renderGrid(ds, rows, [ + { field: 'title', label: 'Title', editable: false }, + { field: 'plain', label: 'Plain', type: 'lookup' }, + { field: 'desc', label: 'Desc', type: 'lookup' }, + { field: 'cols', label: 'Cols', type: 'lookup' }, + { field: 'filt', label: 'Filt', type: 'lookup' }, + { field: 'disp', label: 'Disp', type: 'lookup' }, + ]); + await waitFor(() => expect(screen.getByText('Task one')).toBeInTheDocument()); + + // CONTROL first — the cell path is live, reads the field def, and CAN + // render something different. Without this, the four-way match below would + // also be satisfied by a fixture that never reached the lookup renderer. + await waitFor( + () => expect(screen.getByText('p1@north.example')).toBeInTheDocument(), + { timeout: 4000 }, + ); + + // The four columns that do NOT declare `displayField` all resolve the same + // text: the three retired keys made no difference to any of them. + await waitFor( + () => expect(screen.getAllByText('Person 01').length).toBe(4), + { timeout: 4000 }, + ); + }); + + it('B1. THE EDITOR — `descriptionField` still drives the picker’s secondary line', async () => { + const rows = [{ id: 't1', title: 'Task one', owner_desc: null, owner_plain: null }]; + const ds = makeDataSource( + { + owner_desc: { type: 'lookup', label: 'Owner (desc)', reference: REF, descriptionField: 'email' }, + owner_plain: { type: 'lookup', label: 'Owner (plain)', reference: REF }, + }, + rows, + ); + const { container } = renderGrid(ds, rows, [ + { field: 'title', label: 'Title', editable: false }, + { field: 'owner_desc', label: 'Owner (desc)', type: 'lookup' }, + { field: 'owner_plain', label: 'Owner (plain)', type: 'lookup' }, + ]); + await waitFor(() => expect(screen.getByText('Task one')).toBeInTheDocument()); + + // Declared: the author's `email` is previewed under each option, with the + // key off the copy set — it arrived through the editor's schema spread. + const declared = cellAt(container, 1); + fireEvent.click(await openEditor(declared)); + await waitFor(() => expect(screen.getByText('Person 01')).toBeInTheDocument()); + await waitFor(() => { + expect(document.querySelectorAll('[data-lookup-preview="email"]').length).toBeGreaterThan(0); + }); + expect(screen.getByText('p1@north.example')).toBeInTheDocument(); + fireEvent.keyDown(document.body, { key: 'Escape' }); + await waitFor(() => expect(document.querySelectorAll('[data-lookup-preview="email"]').length).toBe(0)); + + // CONTROL — same reference, same records, nothing declared: the two-field + // referenced schema derives no extra column, so there is no preview line. + const control = cellAt(container, 2); + fireEvent.click(await openEditor(control)); + await waitFor(() => expect(screen.getByText('Person 01')).toBeInTheDocument()); + expect(document.querySelectorAll('[data-lookup-preview]').length).toBe(0); + }); + + it('B2. THE EDITOR — `lookupColumns` still shapes the picker’s columns', async () => { + const rows = [{ id: 't1', title: 'Task one', owner_cols: null, owner_plain: null }]; + const ds = makeDataSource( + { + owner_cols: { type: 'lookup', label: 'Owner (cols)', reference: REF, lookupColumns: ['name', 'region'] }, + owner_plain: { type: 'lookup', label: 'Owner (plain)', reference: REF }, + }, + rows, + ); + const { container } = renderGrid(ds, rows, [ + { field: 'title', label: 'Title', editable: false }, + { field: 'owner_cols', label: 'Owner (cols)', type: 'lookup' }, + { field: 'owner_plain', label: 'Owner (plain)', type: 'lookup' }, + ]); + await waitFor(() => expect(screen.getByText('Task one')).toBeInTheDocument()); + + // Declared: `region` is a picker column, so it previews under each option. + const declared = cellAt(container, 1); + fireEvent.click(await openEditor(declared)); + await waitFor(() => expect(screen.getByText('Person 01')).toBeInTheDocument()); + await waitFor(() => { + expect(document.querySelectorAll('[data-lookup-preview="region"]').length).toBeGreaterThan(0); + }); + fireEvent.keyDown(document.body, { key: 'Escape' }); + await waitFor(() => expect(document.querySelectorAll('[data-lookup-preview="region"]').length).toBe(0)); + + // CONTROL — nothing declared, nothing derived, no preview. + const control = cellAt(container, 2); + fireEvent.click(await openEditor(control)); + await waitFor(() => expect(screen.getByText('Person 01')).toBeInTheDocument()); + expect(document.querySelectorAll('[data-lookup-preview]').length).toBe(0); + }); + + it('B3. THE EDITOR — `lookupFilters` still scopes the picker’s candidates', async () => { + const rows = [{ id: 't1', title: 'Task one', owner_filtered: null, owner_plain: null }]; + const ds = makeDataSource( + { + owner_filtered: { + type: 'lookup', + label: 'Owner (filtered)', + reference: REF, + lookupFilters: [{ field: 'region', operator: 'eq', value: 'south' }], + }, + owner_plain: { type: 'lookup', label: 'Owner (plain)', reference: REF }, + }, + rows, + ); + const { container } = renderGrid(ds, rows, [ + { field: 'title', label: 'Title', editable: false }, + { field: 'owner_filtered', label: 'Owner (filtered)', type: 'lookup' }, + { field: 'owner_plain', label: 'Owner (plain)', type: 'lookup' }, + ]); + await waitFor(() => expect(screen.getByText('Task one')).toBeInTheDocument()); + + // Declared: only the `south` half is offered — the author's hard scope + // reached the picker with the key off the copy set. + const declared = cellAt(container, 1); + fireEvent.click(await openEditor(declared)); + await waitFor(() => expect(screen.getByText('Person 04')).toBeInTheDocument()); + expect(screen.queryByText('Person 01')).not.toBeInTheDocument(); + expect(ds.refQueries.some((q: any) => q?.$filter?.region === 'south')).toBe(true); + fireEvent.keyDown(document.body, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByText('Person 04')).not.toBeInTheDocument()); + + // CONTROL — the same six records, unscoped: the north half is offered too, + // which is what makes the absence above a filter and not an empty fixture. + const control = cellAt(container, 2); + fireEvent.click(await openEditor(control)); + await waitFor(() => expect(screen.getByText('Person 01')).toBeInTheDocument()); + expect(screen.getByText('Person 04')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts index 27867154b..d0b067ef9 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts @@ -50,6 +50,24 @@ * `LookupField`'s own source. Its extracted set being a subset is a RESULT * here, not an assumption. * + * ## ⛔ THE LIMIT THAT MATTERS MOST — this gate cannot judge a COPY + * + * The read set below is a UNION over three consumers, and only the FIRST is fed + * the copied bag. So a key's presence in it means "some consumer reads this + * key" and never "this bag is how that consumer gets it". A copy-set entry + * asserts the second. objectui#6875 read the first as the second and shipped + * two keys onto a bag their only reader never consults; objectui#7166 measured + * that and retired three (`descriptionField`, `lookupColumns`, `lookupFilters`). + * + * ⚠️ Every one of them is STILL in the extracted read set, so every derived + * assertion here passes whichever verdict they carry — this gate would not go + * red if they were put back. Their absence is pinned by a hand-written + * assertion below and, behaviourally, by + * `relationalMetaCopySet-7166.test.tsx`. Re-scoping the derivation around the + * cell alone (editor widgets classified separately) is what would make this + * mechanical; that is a design change to objectui#6875's mechanism, filed + * rather than made. + * * ## ⛔ The extractor is bounded, and says so * * It reads member accesses off named receivers. A key that reaches a consumer @@ -211,14 +229,15 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest } }); - it('the copy set is exactly the copied verdicts, and includes the three keys objectui#6875 measured missing', () => { + it('the copy set is exactly the copied verdicts, and still carries the one key objectui#6875 delivered', () => { const expected = Object.entries(RELATIONAL_META_READ_SET) .filter(([, e]) => e.verdict === 'spec' || e.verdict === 'adapter-stamped' || e.verdict === 'legacy-alias') .map(([k]) => k); expect([...RELATIONAL_META_KEYS].sort()).toEqual(expected.sort()); - for (const key of ['displayField', 'descriptionField', 'lookupColumns']) { - expect(RELATIONAL_META_KEYS).toContain(key); - } + // objectui#6875 shipped three keys; ONE of them is genuinely delivered on + // this bag, and it is the one that arrived with a rendering test. + // `lookupDisplayFieldSpelling-6875.test.tsx` renders the difference it makes. + expect(RELATIONAL_META_KEYS).toContain('displayField'); // The two named keys that are NOT reachable stay out — copying them would // write a member no producer can fill (objectui#6711's reasoning). for (const key of ['reference_field', 'lookup_columns']) { @@ -226,6 +245,48 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest } }); + it('proves each `deferred` verdict is spec-declared — the class is "reaches the editor anyway", not "unproducible"', () => { + const deferred = Object.entries(RELATIONAL_META_READ_SET) + .filter(([, e]) => e.verdict === 'deferred') + .map(([k]) => k); + // Control: the bucket is populated, so the loop below is a reading. + expect(deferred.length).toBeGreaterThan(0); + for (const key of deferred) { + expect( + specProps.has(key), + `${key} is classified deferred but FieldSchema does not declare it — a key no producer ` + + 'can emit belongs under `no-producer`, whose verdict carries the opposite proof.', + ).toBe(true); + } + }); + + it('⛔ objectui#7166 — the three retired keys stay OUT of the copy set, and the derivation cannot enforce that', () => { + const retired = ['descriptionField', 'lookupColumns', 'lookupFilters']; + // Control: the copy set is populated, so "not contained" is a reading. + expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(5); + expect(RELATIONAL_META_KEYS).toContain('displayField'); + for (const key of retired) { + expect( + RELATIONAL_META_KEYS, + `${key} is back on the copy set. objectui#7166 measured it having NO reader on this bag: ` + + 'its only reader is an editor widget, which `renderCellEditor` feeds from the schema ' + + 'def. Put it back and you write a member nothing on the cell path reads.', + ).not.toContain(key); + } + + // ⭐ THE POINT OF THIS ASSERTION, and why it is hand-written rather than + // derived. All three are STILL in the extracted read set — the editor + // widgets do read them — so every derived assertion in this file passes + // whichever verdict they carry. Read-set membership means "a consumer reads + // this key"; it never meant "this bag is how that consumer gets it", and + // only the second claim justifies a copy. Mistaking the first for the + // second is what put two of these three here (objectui#6875). + const readSet = [...extractReadSet().all]; + for (const key of retired) { + expect(readSet, `${key} left the read set — then this pin is stale, not load-bearing`).toContain(key); + } + }); + it('every entry carries a note — a verdict with no reason is not a decision', () => { for (const [key, e] of Object.entries(RELATIONAL_META_READ_SET)) { expect(e.note.length, `${key} has no note`).toBeGreaterThan(20); diff --git a/packages/plugin-grid/src/relationalMetaKeys.ts b/packages/plugin-grid/src/relationalMetaKeys.ts index 4469a85af..000988fad 100644 --- a/packages/plugin-grid/src/relationalMetaKeys.ts +++ b/packages/plugin-grid/src/relationalMetaKeys.ts @@ -68,6 +68,55 @@ * with this table unchanged, in * `__tests__/lookupPickerKeys-7154.test.tsx`. * + * ## ⭐ THREE KEYS LEFT THIS COPY SET BY THAT RULE — objectui#7166 + * + * objectui#7154 stated the rule for keys that had never been copied. Applied + * BACKWARDS, to keys already on the list, the same rule retires three of them: + * `descriptionField`, `lookupColumns` and `lookupFilters`. Each is read off a + * field meta only by `LookupField` (and `lookupFilters` also by `UserField`) — + * the two EDITOR widgets — so on the cell path each write landed on a bag whose + * consumer never looks at it. Re-measured on `47035ce79`, and stated as + * receivers rather than as a count: `packages/fields/src/index.tsx`, the file + * that holds EVERY `CellRenderer`, contains zero occurrences of any of the + * three, against a control of 22 occurrences of the `display_field` / + * `displayField` / `reference_to` spellings the cell does read. + * + * ⇒ The retirement is behaviour-preserving, and that is the half worth pinning: + * all three still take effect in the inline picker with them OFF this table, + * because `renderCellEditor` hands the widget the whole schema def. + * `__tests__/relationalMetaCopySet-7166.test.tsx` renders both halves. + * + * ## ⚠️ THE TWO POPULATIONS — why three keys went and three stayed + * + * Six copied keys have no reader on this bag; only three left. The other three + * — `description_field`, `lookup_filters`, `id_field` — are the snake_case + * `legacy-alias` spellings, recorded that way PRECISELY because a host + * `DataSource` outside these two repos may hand-feed them. That is a + * PRODUCER-side argument, and every measurement above is READER-side. Retiring + * them on this evidence would answer a question nobody asked. + * + * Their reader-side verdict is now on the table anyway, so the next pass + * inherits a measurement instead of a silence. ⛔ The question still OPEN — and + * which no evidence gathered in these two repos can close — is the producer + * one: does any host outside them put these spellings on a field def? Closing + * it needs a producer survey, not another sweep of this repo. + * + * ## ⛔ THE DERIVATION GATE CANNOT SEE THIS CLASS — its green is not cover + * + * The gate re-extracts a read set that is the UNION over three consumers, two + * of which are not fed this bag. Membership there means "some consumer reads + * this key" — never "this bag is how that consumer gets it". Those are + * different claims, and the second is the one a copy-set entry asserts. All + * three retired keys REMAIN in the extracted read set, because the editor + * widgets still read them, so the derivation stays green whichever verdict they + * carry: it would not go red if someone put them back tomorrow. + * + * ⇒ What guards this retirement is the rendering test plus the explicit + * non-membership pin in the gate — not the derivation. Re-scoping the + * derivation around the cell alone, with the editor widgets classified + * separately, would close that gap; it is a design change to objectui#6875's + * mechanism and is filed rather than made here. + * * ## ⭐ Why a key can be READ and still not be worth copying * * `@objectstack/spec` 17.2.0's `FieldSchema` is a **strict** object of 71 @@ -154,7 +203,11 @@ export type RelationalMetaVerdict = | 'no-producer' /** Producible and read, but written onto the meta by another block already. */ | 'handled-elsewhere' - /** Producible and read, but outside this helper's contract — see `note`. */ + /** + * Producible and read, but read ONLY by an editor widget — which + * `ObjectGrid.renderCellEditor` feeds from the schema def, not from this bag. + * Copying such a key onto `fieldMeta` reaches nothing. See `note` per key. + */ | 'deferred'; /** Verdicts whose keys ARE copied. Everything else is deliberately skipped. */ @@ -187,19 +240,23 @@ export const RELATIONAL_META_READ_SET: Readonly