diff --git a/.changeset/9729-mirror-only-published-keys-measured.md b/.changeset/9729-mirror-only-published-keys-measured.md new file mode 100644 index 0000000000..f6bd28400f --- /dev/null +++ b/.changeset/9729-mirror-only-published-keys-measured.md @@ -0,0 +1,14 @@ +--- +--- + +Measure the two LOCAL mirrored-but-undeclared keys the `MirroredUndeclared` +ledger carries — `views.zod.ts#DetailViewFieldSchema`'s `dueLike` and +`objectql.zod.ts#ObjectGridSchema`'s `operators` — per key, for objectui#9729's +at-tier contract review. Test only; no package is released by this change, and +neither published accept set moves: the remedy for each key (declare it on the +twin, or narrow the mirror) is the decision this measurement exists to inform. + +Also corrects the ledger entry for `dueLike`, which justified itself with a +package-wide name grep. `field-types.ts` does declare that name — on +`DateFieldMetadata` and `DateTimeFieldMetadata`, not on this pair's twin. The +measurement was right and its stated reason was not: a name is not a key. diff --git a/packages/plugin-detail/src/__tests__/DetailSection.dueLikeReachesTheCell-9729.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.dueLikeReachesTheCell-9729.test.tsx new file mode 100644 index 0000000000..934df4d085 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/DetailSection.dueLikeReachesTheCell-9729.test.tsx @@ -0,0 +1,78 @@ +/** + * 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. + */ + +/** + * An authored `dueLike` on a DETAIL-VIEW FIELD reaches the cell (objectui#9729). + * + * ## Why this reading exists + * + * `views.zod.ts#DetailViewFieldSchema` states `dueLike` and the `DetailViewField` + * twin does not — one of the two LOCAL entries in the `MirroredUndeclared` + * ledger. The contract review that decides which published face moves needs one + * fact the ledger cannot carry: whether anything READS the key on THIS pair's + * authored face, because a key nothing reads is a different decision from one a + * renderer consumes. + * + * ⛔ A source grep cannot answer that. `SchemaRenderer` hands a node's leftover + * keys to the component as props, and this path adds a second hop of its own: + * `DetailSection` spreads the authored field into the enriched bag + * (`enrichDetailField` opens with a spread of the view field) and hands that bag + * to the resolved cell renderer. So the question is asked here the only way it + * can be answered — by rendering, and by watching the drawn text change. + * + * ## The reading, and its control + * + * `end_date` deliberately does NOT match the due/deadline field-NAME convention + * that `resolveDueLike` falls back to, so the authored key is the only thing + * that can turn the affordance on. The control leg renders BY VALUE — it asserts + * the neutral relative wording is present, not merely that "Overdue" is absent, + * which would also be true of a document that drew nothing. + * + * ⛔ This file takes no position on the remedy. It measures a read; whether the + * key should be declared on the twin or removed from the mirror is objectui#9729. + */ + +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import * as React from 'react'; +import { DetailSection } from '../DetailSection'; +import type { DetailViewSection } from '@object-ui/types'; + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); +afterEach(cleanup); + +const daysAgo = (n: number): string => { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString(); +}; + +const sectionOf = (fields: unknown[]): DetailViewSection => + ({ title: 'S', fields }) as unknown as DetailViewSection; + +/** A past date on a field whose NAME carries no due/deadline convention. */ +const BASE = { name: 'end_date', label: 'End', type: 'date', format: 'relative' }; +const DATA = { end_date: daysAgo(3) }; + +describe('an authored `dueLike` on a DetailViewField', () => { + it('reaches the date cell and changes the drawn wording', () => { + render(); + expect(document.body.innerHTML).toContain('Overdue'); + }); + + it('CONTROL — the same field without the key draws the neutral wording', () => { + render(); + const html = document.body.innerHTML; + // Rendered BY VALUE first: an absent affordance means nothing on a document + // that drew nothing at all. + expect(html).toContain('days ago'); + expect(html).not.toContain('Overdue'); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.operatorsInert-9729.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.operatorsInert-9729.test.tsx new file mode 100644 index 0000000000..124d6088cb --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.operatorsInert-9729.test.tsx @@ -0,0 +1,98 @@ +/** + * 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. + */ + +/** + * An authored `operators` on an `object-grid` changes nothing drawn (objectui#9729). + * + * ## Why this reading exists + * + * `objectql.zod.ts#ObjectGridSchema` states `operators` and the `ObjectGridSchema` + * twin does not — the second LOCAL entry in the `MirroredUndeclared` ledger, and + * the one whose mirror line carries its own provenance: a comment saying the key + * was missing from an earlier TypeScript scan. The contract review needs to know + * whether a renderer consumes it. + * + * ⛔ A source grep cannot answer that, because this renderer receives a node's + * leftover keys as props: `ObjectGridRenderer` spreads everything it does not + * itself consume onto `ObjectGrid`. So the question is asked with a RULER — the + * same document drawn twice, compared as bytes — rather than by reading code. + * + * ## The ruler, and why the control comes first + * + * A byte comparison that reports "no difference" is worthless unless the + * instrument can report one. The first case pins that two identical documents + * draw identical bytes (the ruler is stable — no timestamps, no random ids), the + * second is a LIT CONTROL on a key the renderer demonstrably DOES read, and only + * then is the measurement taken. It is taken twice: once on a plain grid and + * once with the filter surface on, since `operators` is a filtering word and a + * grid without a filter affordance would be the wrong corpus to ask. + * + * ⚠️ The claim is bounded by the ruler: this is what the RENDERED output does + * with the key, on these documents. ⛔ It is not a claim that no code anywhere + * could ever read it. + * + * ⛔ This file takes no position on the remedy — objectui#9729 is the card. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import React from 'react'; +import { ObjectGridRenderer } from '../index'; + +afterEach(cleanup); + +const BASE: Record = { + type: 'object-grid', + objectName: 'probe', + columns: ['name'], + staticData: [ + { id: '1', name: 'Alpha' }, + { id: '2', name: 'Beta' }, + ], +}; + +/** Draw one document and return its markup, once the rows are on screen. */ +async function draw(schema: Record): Promise { + const { container } = render(); + await screen.findByText('Alpha', {}, { timeout: 5000 }); + return container.innerHTML; +} + +describe('the ruler', () => { + it('two identical documents draw identical bytes', async () => { + const a = await draw({ ...BASE }); + cleanup(); + const b = await draw({ ...BASE }); + expect(b).toBe(a); + }); + + it('LIT CONTROL — a key the renderer DOES read moves the bytes', async () => { + const a = await draw({ ...BASE }); + cleanup(); + const b = await draw({ ...BASE, label: 'GRID-CAPTION-9729' }); + expect(b).not.toBe(a); + expect(b).toContain('GRID-CAPTION-9729'); + }); +}); + +describe('an authored `operators`', () => { + it('changes nothing the grid draws', async () => { + const a = await draw({ ...BASE }); + cleanup(); + const b = await draw({ ...BASE, operators: { name: ['equals', 'contains'] } }); + expect(b).toBe(a); + }); + + it('changes nothing with the filter surface on either', async () => { + const FILTERS = { ...BASE, showFilters: true, searchableFields: ['name'] }; + const a = await draw({ ...FILTERS }); + cleanup(); + const b = await draw({ ...FILTERS, operators: { name: ['equals', 'contains'] } }); + expect(b).toBe(a); + }); +}); diff --git a/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts b/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts new file mode 100644 index 0000000000..d0f606de8e --- /dev/null +++ b/packages/types/src/__tests__/mirror-only-published-keys-9729.test.ts @@ -0,0 +1,182 @@ +/** + * 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 two LOCAL mirrored-but-undeclared keys, measured per key (objectui#9729). + * + * ## What this file is, and ⛔ what it is not + * + * `MirroredUndeclared` in `zod-mirror-parity.test.ts` measures the DIRECTION — + * a key the zod mirror states and the TypeScript twin does not — and reconciles + * it per pair. It deliberately says nothing about what each key DOES, because + * the remedy is a contract decision: declaring the key on the twin ENLARGES the + * published TypeScript accept set, narrowing the mirror SHRINKS the published + * validator's, and both move a face `@object-ui/types` already ships. + * + * ⛔ This file does not pick either direction, and it is ⛔ not a repair. It is + * the per-key CONSEQUENCE the ledger's one-line entries cannot carry, recorded + * so the at-tier contract review can read it instead of re-deriving it. Every + * assertion below is a statement about `origin/main` as it stands; whichever + * direction the review rules, the entries here move with it. + * + * ## The two keys do NOT behave the same way, and the difference is the ruling + * + * Both twins are hand-written interfaces, but only one of them can REFUSE: + * + * - `DetailViewField` carries no index signature, so TypeScript rejects an + * authored `dueLike` outright (`TS2353`). The two published faces therefore + * actively CONTRADICT each other: the validator judges the key and keeps + * it, the compiler refuses the same document. + * - `ObjectGridSchema` extends `BaseSchema`, whose `[key: string]: any` index + * signature absorbs any unstated key. TypeScript neither declares nor + * refuses `operators`; it types it `any`. The faces do not contradict — one + * is simply silent, which is a weaker defect and a different decision. + * + * ## ⚠️ The third key the ledger groups with these two is NOT this defect + * + * `complex.zod.ts#DashboardConfigSchema`'s `aria` is a `z.never()` retirement + * tombstone: it REFUSES the key by name and prints a remedy. Its consequence is + * the OPPOSITE of the two above — they admit silently, it rejects loudly — so a + * sweep that treats the three alike would ask a maintainer to repair something + * already doing its job. Pinned here as the CONTRAST, by re-deriving its shape + * rather than by citing the reading. + * + * ## Every zero below carries a lit control on the same instrument + * + * A green `safeParse` proves nothing on its own: both mirrors accept unknown + * keys (one strips them, the other keeps them unexamined). What distinguishes a + * MIRRORED key from an unknown one is that the mirror JUDGES it — so each key's + * pin is paired with an unrecognised-key control parsed through the same schema. + */ + +import { describe, it, expect } from 'vitest'; +import { DetailViewFieldSchema } from '../zod/views.zod.js'; +import { ObjectGridSchema } from '../zod/objectql.zod.js'; +import { DashboardConfigSchema } from '../zod/complex.zod.js'; +import type { DetailViewField } from '../views.js'; +import type { ObjectGridSchema as ObjectGridSchemaType } from '../objectql.js'; + +/** A control key no surface in this package declares. */ +const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares9729'; + +describe('views.zod.ts#DetailViewFieldSchema — `dueLike`', () => { + it('the mirror JUDGES the key, while an unknown key is stripped unexamined', () => { + const judged = DetailViewFieldSchema.safeParse({ name: 'end_date', dueLike: true }); + expect(judged.success).toBe(true); + // KEPT, not stripped — the mirror states it. + expect(judged.success && judged.data).toMatchObject({ dueLike: true }); + + // LIT CONTROL, same schema, same document shape: an unstated key survives + // the parse and is GONE from the output. So "green" is not the evidence — + // the key surviving into `data` is. + const control = DetailViewFieldSchema.safeParse({ name: 'end_date', [UNKNOWN_KEY]: true }); + expect(control.success).toBe(true); + expect(control.success && Object.keys(control.data)).not.toContain(UNKNOWN_KEY); + }); + + it('the mirror refuses a wrong-typed `dueLike` BY NAME', () => { + const r = DetailViewFieldSchema.safeParse({ name: 'end_date', dueLike: 'yes' }); + expect(r.success).toBe(false); + expect(!r.success && r.error.issues.map((i) => i.path.join('.'))).toContain('dueLike'); + }); +}); + +describe('objectql.zod.ts#ObjectGridSchema — `operators`', () => { + it('the mirror JUDGES the key, while an unknown key is kept unexamined', () => { + const judged = ObjectGridSchema.safeParse({ + type: 'object-grid', + objectName: 'probe', + operators: { name: ['equals'] }, + }); + expect(judged.success).toBe(true); + + // LIT CONTROL: this mirror's base passes unknown keys THROUGH, so an + // unstated key is kept as-is and is never type-checked. The distinguishing + // evidence for a mirrored key is therefore the refusal below, not the pass. + const control = ObjectGridSchema.safeParse({ + type: 'object-grid', + objectName: 'probe', + [UNKNOWN_KEY]: 42, + }); + expect(control.success).toBe(true); + }); + + it('the mirror refuses a wrong-typed `operators` BY NAME, and ignores the control', () => { + const judged = ObjectGridSchema.safeParse({ + type: 'object-grid', + objectName: 'probe', + operators: 42, + }); + expect(judged.success).toBe(false); + expect(!judged.success && judged.error.issues.map((i) => i.path.join('.'))).toContain('operators'); + + const control = ObjectGridSchema.safeParse({ + type: 'object-grid', + objectName: 'probe', + [UNKNOWN_KEY]: 42, + }); + expect(control.success).toBe(true); + }); +}); + +describe('complex.zod.ts#DashboardConfigSchema — `aria` is the CONTRAST, not the defect', () => { + it('REFUSES the key by name and names a remedy — the opposite consequence', () => { + const r = DashboardConfigSchema.safeParse({ aria: { label: 'x' } }); + expect(r.success).toBe(false); + const issue = !r.success ? r.error.issues.find((i) => i.path.join('.') === 'aria') : undefined; + expect(issue).toBeDefined(); + // The remedy travels with the refusal. Asserted as the leading token rather + // than the whole sentence: the wording is the contract here, the prose that + // follows it is not. + expect(String(issue?.message)).toContain('RETIRED'); + + // LIT CONTROL on the same schema: an unrecognised key is NOT refused, so + // the refusal above is BY NAME and not a strictness the whole object has. + const control = DashboardConfigSchema.safeParse({ [UNKNOWN_KEY]: true }); + expect(control.success).toBe(true); + }); + + it('a document that omits the key stays green — a tombstone gates nothing else', () => { + expect(DashboardConfigSchema.safeParse({ showHeader: true }).success).toBe(true); + }); +}); + +/* ── The TypeScript side, where the two keys stop behaving alike ───────────── */ + +/** + * `DetailViewField` REFUSES `dueLike`. The `@ts-expect-error` below IS the + * assertion: it fails the package's `tsc -p tsconfig.test.json` leg in both + * directions — if the key stops being refused (someone declares it on the twin, + * which is one of the two remedies under review) the directive becomes unused + * and TypeScript reports it. + */ +// @ts-expect-error objectui#9729 — `dueLike` is not a member of `DetailViewField`. +export const authoredDueLike: DetailViewField = { name: 'end_date', dueLike: true }; + +/** LIT CONTROL: a key the twin DOES declare is accepted on the same literal. */ +export const authoredCurrency: DetailViewField = { name: 'end_date', currency: 'USD' }; + +/** + * `ObjectGridSchema` does NOT refuse `operators` — and the absence of a + * `@ts-expect-error` here is the measurement, not an omission: adding one + * reddens the same type-check leg as unused, which is how this claim fails if + * the twin ever loses `BaseSchema`'s index signature. + */ +export const authoredOperators: ObjectGridSchemaType = { + type: 'object-grid', + objectName: 'probe', + operators: { name: ['equals'] }, +}; + +describe('the TypeScript twins', () => { + it('are compiled by this package’s type-check leg, which is where the two directives above are read', () => { + // The runtime here only keeps the three bindings alive; the assertions that + // matter are the directives, and `tsc -p tsconfig.test.json` is the reader. + expect([authoredDueLike, authoredCurrency, authoredOperators]).toHaveLength(3); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index ee6b2bf2c1..9f8d5ea259 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -3649,15 +3649,38 @@ interface MirroredUndeclared { * was missing from an earlier TypeScript scan — so the gap was NOTICED at authoring * time, written down in a comment, and then measured by nothing for as long as the * fourth direction did not exist. That is this card's thesis in one key. + * + * ⚠️ The twin is NOT silent about the neighbouring name: `ObjectGridSchema` + * declares `operations` (the CRUD-affordance toggles the block's authoring + * vocabulary and `@objectstack/spec`'s `object-grid` surface both spell that + * way), and it is `BaseSchema`'s index signature — not a declaration — that + * lets an authored `operators` type-check. Per-key consequences, including + * which face judges the key and which merely absorbs it, are measured by + * `mirror-only-published-keys-9729.test.ts` for objectui#9729's contract + * review. ⛔ Neither remedy is taken there and none is implied here. */ "objectql.zod.ts#ObjectGridSchema": | "operators"; /** - * LOCAL, one key. `dueLike` exists on the zod face only: `git grep` finds it in the - * mirror and nowhere in this package's TypeScript declarations. An author writing - * it gets a green `safeParse` from a published validator for a key the published - * types never invited. + * LOCAL, one key. An author writing `dueLike` on a detail-view field gets a green + * `safeParse` from a published validator for a key the published `DetailViewField` + * never invited — and, because that twin carries no index signature, the SAME + * document is refused by the compiler (`TS2353`). The two published faces + * contradict each other rather than one of them being merely quiet. + * + * ⛔ This entry once justified itself with "a `git grep` finds it in the mirror and + * nowhere in this package's TypeScript declarations", which is FALSE as written and + * is corrected here rather than repeated: `field-types.ts` declares `dueLike` on + * `DateFieldMetadata` and on `DateTimeFieldMetadata`, both in this package. ⭐ A + * NAME is not a KEY — a key is (interface, name) — and a package-wide grep for the + * name answers a question this ledger is not asking. What the operator measures, + * and what stays true, is that the key is absent from THIS PAIR'S twin. + * + * The per-key consequences — that the mirror judges the key rather than merely + * passing it, and that objectui's own detail-view path READS it at runtime — are + * measured by `mirror-only-published-keys-9729.test.ts` and by the two probes + * objectui#9729 landed beside the renderers that read it. ⛔ No remedy is implied. */ "views.zod.ts#DetailViewFieldSchema": | "dueLike";