diff --git a/.changeset/7828-calendar-gantt-ladder-harness.md b/.changeset/7828-calendar-gantt-ladder-harness.md new file mode 100644 index 000000000..594f3d76b --- /dev/null +++ b/.changeset/7828-calendar-gantt-ladder-harness.md @@ -0,0 +1,19 @@ +--- +--- + +Test-only change; no published behaviour changes. + +objectui#7828 ports the three harness guards from objectui#7527 (`c2fc261f5`), +plus PR #7826's bound capture, from the timeline `colorField` ladder fixture to +its two siblings — `ObjectCalendar.colorFieldLadder-7243.test.tsx` and +`ObjectGantt.colorFieldLadder-7243.test.tsx`. Their readiness predicate now +identifies WHICH render wrote instead of counting arity, each helper returns the +array that predicate accepted rather than re-reading a module global, every +render is unmounted in a `finally`, and no renderer may be alive when one is +mounted. + +No runtime code is touched, and neither component's behaviour was changed or +found wanting: the two-paint window the guards exist for was measured for and is +absent on both (both gate their record query on the settled object schema), so +this is a fixture that can no longer be broken by the ordinary next edit, not a +fix for a failure. diff --git a/packages/plugin-calendar/src/ObjectCalendar.colorFieldLadder-7243.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.colorFieldLadder-7243.test.tsx index d510acf66..9cce4d7c8 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.colorFieldLadder-7243.test.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.colorFieldLadder-7243.test.tsx @@ -25,10 +25,69 @@ * calendar whose `colorField` points at a plain categorical field, which is a * behaviour change well beyond this card, and `CalendarView`'s soft-tint class * pairs are theme-aware where a derived solid hex would not be. + * + * --- + * + * objectui#7828 — HARNESS GUARDS, ported from the timeline fixture + * (objectui#7527 `c2fc261f5`) together with PR #7826's bound capture. Four + * changes, none of them about the ladder: the readiness predicate identifies + * WHICH render wrote (a title token this call owns) instead of counting + * arity; the value returned is the array that predicate accepted, not a + * second read of the module global; the render is torn down in a `finally`; + * and no renderer may be alive when one is mounted. + * + * ⚠️ WHAT WAS MEASURED, because "it stopped failing" is not a mechanism and a + * green loop is not evidence — objectui#7466 paid for that reading: on an + * idle box the broken timeline harness passed 32/32, the same 32/32 the fixed + * one gets. The lever is the METADATA fetch, not machine load, so the lever + * was swept. Instrument: the PRE-PORT harness shape (module global, + * arity-only predicate, no unmount, re-read of the global) driving two + * renders inside one `it` — the objectui#7466 exposure shape — with + * `getObjectSchema` held by 0/1/2/3/4/5/6/7/8/9/10/12/15/20/25/35/50 ms, + * three runs at each hold, all three components in the SAME vitest runs: + * + * ObjectTimeline (ungated CONTROL) 20 fail / 60 runs + * ObjectCalendar 0 fail / 60 runs + * ObjectGantt 0 fail / 60 runs + * + * The control is what makes those zeroes readable at all: the instrument DOES + * fire, in this container, on that day, in those same runs — with the card's + * exact signature: the second render reads the FIRST render's colour back out + * of the global (`second=["#abc"]` where `["#123456"]` was authored). + * + * Note WHERE it fires: every timeline failure sits at a hold of 1-9ms, and the + * coarse grid alone caught it at ONE hold out of eight. That is why the grid + * was refined instead of repeated — repetition at a hold outside the window is + * exactly the 32/32 that taught objectui#7466 nothing. + * + * The mechanism, measured the same way — writes into the module global AFTER + * the readiness predicate went green, with the render left mounted exactly as + * the pre-port harness left it: + * + * ObjectTimeline, getObjectSchema +25ms 1 paint at green, 3 LATE writes + * ObjectCalendar, +0 / +25 / +100ms 1 paint, 0 late writes + * ObjectGantt, +0 / +25 / +100ms 1 paint, 0 late writes + * + * `ObjectTimeline`'s data effect lists `objectDef`, so it paints once before + * the metadata lands and again after. `ObjectCalendar` GATES its record query + * on the SETTLED schema (objectui#6453, today the shared `useSettledSchema` + * of objectui#7225), so its first paint arrives only after the metadata read + * — the measured paint time tracks the hold, 34ms at +25 and 104ms at +100 — + * and no schema-triggered second paint follows it. + * + * ⛔ So this file does NOT claim the timeline's two-paint behaviour for + * `ObjectCalendar`: it was looked for, with a lit instrument, and it is not + * there. **No failure was reproducible on this file today.** What is ported + * is the HARNESS half, which does not depend on that behaviour — a module + * global plus an arity-only predicate cannot say WHICH component wrote, + * whatever the component does. The guards are here so that the ordinary next + * edit to a ladder fixture — a second assertion inside one of these `it` + * blocks, which is precisely the edit that made objectui#7466 — cannot + * reintroduce one. */ import React from 'react'; -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { ObjectCalendar } from './ObjectCalendar'; import { __resolveEventColorForTest as resolveEventColor } from './CalendarView'; @@ -91,7 +150,26 @@ function makeDataSource(rows: any[] = ROWS, objectSchema: any = OBJECT_SCHEMA) { } as any; } +/** Distinguishes one `colorsFor` render from the next. See `token` below. */ +let renderSeq = 0; + async function colorsFor(colorField: string, rows: any[] = ROWS, objectSchema: any = OBJECT_SCHEMA) { + // objectui#7828 (objectui#7521's guard) — a STRUCTURAL check, deliberately + // not a timing one. RTL's auto-cleanup runs in `afterEach`, never between + // two renders inside one `it`, so a render this helper failed to tear down + // is observable HERE, at a synchronous point, rather than as a race someone + // has to catch in the act. + expect(screen.queryAllByTestId('calendar-view')).toHaveLength(0); + + // A token this call OWNS. `ObjectCalendar` resolves an event's `title` from + // the configured `titleField` when the record carries it, so the token + // rides through to `lastEvents` untouched — and it is NOT the value under + // test, so the predicate below can tell "THIS render is ready" from + // "something else wrote again" without asserting the colour the caller is + // about to assert. + const token = `render-${++renderSeq}`; + const stampedRows = rows.map((row, i) => ({ ...row, subject: `${token}-${i}` })); + lastEvents = []; const schema: any = { type: 'object-calendar', @@ -103,9 +181,34 @@ async function colorsFor(colorField: string, rows: any[] = ROWS, objectSchema: a colorField, }, }; - render(); - await waitFor(() => expect(lastEvents.length).toBe(rows.length)); - return lastEvents.map((e) => e.color); + const { unmount } = render( + , + ); + try { + // Identify the AUTHOR, not just the arity. `lastEvents.length` alone + // cannot separate "the component I just mounted has painted" from "an + // earlier one painted again": both leave length === rows.length. + // + // And RETURN THE ARRAY THE PREDICATE ACCEPTED (PR #7826). `lastEvents` is + // module-level and mutable, so reading it again on the next line asks a + // SECOND question — after `act` has yielded on its way out of `waitFor`, + // a real window in which a still-live writer can answer it. Capturing + // inside the predicate makes "the value asserted" and "the value + // validated" the same object by construction. + let settled: typeof lastEvents = []; + await waitFor(() => { + const events = lastEvents; + expect(events.map((e) => e.title)).toEqual(stampedRows.map((r) => r.subject)); + settled = events; + }); + return settled.map((e) => e.color); + } finally { + // Tear THIS render down before returning, so nothing this helper mounted + // can still be writing to `lastEvents` during the NEXT call — after that + // call has reset it. Measured above: `ObjectCalendar` emits no late write + // today, which is why this is a guard and not a fix. + unmount(); + } } describe('objectui#7243 — calendar colorField ladder', () => { diff --git a/packages/plugin-gantt/src/ObjectGantt.colorFieldLadder-7243.test.tsx b/packages/plugin-gantt/src/ObjectGantt.colorFieldLadder-7243.test.tsx index 9fb635d1d..70c0acade 100644 --- a/packages/plugin-gantt/src/ObjectGantt.colorFieldLadder-7243.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.colorFieldLadder-7243.test.tsx @@ -33,10 +33,72 @@ * unmodified tree the first case reports `color: 'open'` instead of the * authored `#7c3aed`, and the palette-token case reports `'red'` instead of * that palette's hex. Both are the pre-fix values, not a crash. + * + * --- + * + * objectui#7828 — HARNESS GUARDS, ported from the timeline fixture + * (objectui#7527 `c2fc261f5`) together with PR #7826's bound capture. Four + * changes, none of them about the ladder: the readiness predicate identifies + * WHICH render wrote (a title token this call owns) instead of counting + * arity; the value returned is the array that predicate accepted, not a + * second read of the module global; every render is torn down in a `finally`; + * and no renderer may be alive when one is mounted. The two `it` blocks that + * used to mount inline now go through the same helper, because guard 4 is + * only worth anything if EVERY render in the file is accounted for. + * + * ⚠️ WHAT WAS MEASURED, because "it stopped failing" is not a mechanism and a + * green loop is not evidence — objectui#7466 paid for that reading: on an + * idle box the broken timeline harness passed 32/32, the same 32/32 the fixed + * one gets. The lever is the METADATA fetch, not machine load, so the lever + * was swept. Instrument: the PRE-PORT harness shape (module global, + * arity-only predicate, no unmount, re-read of the global) driving two + * renders inside one `it` — the objectui#7466 exposure shape — with + * `getObjectSchema` held by 0/1/2/3/4/5/6/7/8/9/10/12/15/20/25/35/50 ms, + * three runs at each hold, all three components in the SAME vitest runs: + * + * ObjectTimeline (ungated CONTROL) 20 fail / 60 runs + * ObjectGantt 0 fail / 60 runs + * ObjectCalendar 0 fail / 60 runs + * + * The control is what makes those zeroes readable at all: the instrument DOES + * fire, in this container, on that day, in those same runs — with the card's + * exact signature: the second render reads the FIRST render's colour back out + * of the global (`second=["#abc"]` where `["#123456"]` was authored). + * + * Note WHERE it fires: every timeline failure sits at a hold of 1-9ms, and the + * coarse grid alone caught it at ONE hold out of eight. That is why the grid + * was refined instead of repeated — repetition at a hold outside the window is + * exactly the 32/32 that taught objectui#7466 nothing. + * + * The mechanism, measured the same way — writes into the module global AFTER + * the readiness predicate went green, with the render left mounted exactly as + * the pre-port harness left it: + * + * ObjectTimeline, getObjectSchema +25ms 1 paint at green, 3 LATE writes + * ObjectGantt, +0 / +25 / +100ms 1 paint, 0 late writes + * ObjectCalendar, +0 / +25 / +100ms 1 paint, 0 late writes + * + * `ObjectTimeline`'s data effect lists `objectDef`, so it paints once before + * the metadata lands and again after. `ObjectGantt` GATES its record query on + * the SETTLED schema (objectui#7225 ask 2, objectui#6482's undischarged + * gating half — the effect returns early until `objectSchemaReady`), so its + * first paint arrives only after the metadata read — the measured paint time + * tracks the hold, 31ms at +25 and 114ms at +100 — and no schema-triggered + * second paint follows it. + * + * ⛔ So this file does NOT claim the timeline's two-paint behaviour for + * `ObjectGantt`: it was looked for, with a lit instrument, and it is not + * there. **No failure was reproducible on this file today.** What is ported + * is the HARNESS half, which does not depend on that behaviour — a module + * global plus an arity-only predicate cannot say WHICH component wrote, + * whatever the component does. The guards are here so that the ordinary next + * edit to a ladder fixture — a second assertion inside one of these `it` + * blocks, which is precisely the edit that made objectui#7466 — cannot + * reintroduce one. */ import React from 'react'; -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { ObjectGantt } from './ObjectGantt'; @@ -87,18 +149,46 @@ const ROWS = [ }, ]; -function makeDataSource(rows: any[] = ROWS) { +function makeDataSource(rows: any[] = ROWS, objectSchema: any = OBJECT_SCHEMA) { return { find: vi.fn(async () => ({ data: rows, total: rows.length })), findOne: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), - getObjectSchema: vi.fn(async () => OBJECT_SCHEMA), + getObjectSchema: vi.fn(async () => objectSchema), } as any; } -async function colorsFor(colorField: string, rows: any[] = ROWS) { +/** Distinguishes one render from the next. See `token` below. */ +let renderSeq = 0; + +/** + * Mounts ONE gantt, waits for THIS render to be the one that wrote, and + * returns the tasks the predicate accepted. Every render in this file goes + * through here — the colour rungs via `colorsFor` below, and the two cases + * that need a non-default `gantt` block or object schema directly. + */ +async function tasksFor( + ganttConfig: Record, + rows: any[] = ROWS, + objectSchema: any = OBJECT_SCHEMA, +) { + // objectui#7828 (objectui#7521's guard) — a STRUCTURAL check, deliberately + // not a timing one. RTL's auto-cleanup runs in `afterEach`, never between + // two renders inside one `it`, so a render this helper failed to tear down + // is observable HERE, at a synchronous point, rather than as a race someone + // has to catch in the act. + expect(screen.queryAllByTestId('gantt-view')).toHaveLength(0); + + // A token this call OWNS. `ObjectGantt` resolves a task's `title` from the + // configured `titleField` first (ADR-0079 ladder), so the token rides + // through to `lastTasks` untouched — and it is NOT the value under test, so + // the predicate below can tell "THIS render is ready" from "something else + // wrote again" without asserting the colour the caller is about to assert. + const token = `render-${++renderSeq}`; + const stampedRows = rows.map((row, i) => ({ ...row, subject: `${token}-${i}` })); + lastTasks = []; const schema: any = { type: 'object-gantt', @@ -107,12 +197,41 @@ async function colorsFor(colorField: string, rows: any[] = ROWS) { titleField: 'subject', startDateField: 'visible_from', endDateField: 'due_date', - colorField, + ...ganttConfig, }, }; - render(); - await waitFor(() => expect(lastTasks.length).toBe(rows.length)); - return lastTasks.map((t) => t.color); + const { unmount } = render( + , + ); + try { + // Identify the AUTHOR, not just the arity. `lastTasks.length` alone cannot + // separate "the component I just mounted has painted" from "an earlier one + // painted again": both leave length === rows.length. + // + // And RETURN THE ARRAY THE PREDICATE ACCEPTED (PR #7826). `lastTasks` is + // module-level and mutable, so reading it again on the next line asks a + // SECOND question — after `act` has yielded on its way out of `waitFor`, a + // real window in which a still-live writer can answer it. Capturing inside + // the predicate makes "the value asserted" and "the value validated" the + // same object by construction. + let settled: typeof lastTasks = []; + await waitFor(() => { + const tasks = lastTasks; + expect(tasks.map((t) => t.title)).toEqual(stampedRows.map((r) => r.subject)); + settled = tasks; + }); + return settled; + } finally { + // Tear THIS render down before returning, so nothing this helper mounted + // can still be writing to `lastTasks` during the NEXT call — after that + // call has reset it. Measured above: `ObjectGantt` emits no late write + // today, which is why this is a guard and not a fix. + unmount(); + } +} + +async function colorsFor(colorField: string, rows: any[] = ROWS) { + return (await tasksFor({ colorField }, rows)).map((t) => t.color); } describe('objectui#7243 — gantt colorField ladder', () => { @@ -130,34 +249,10 @@ describe('objectui#7243 — gantt colorField ladder', () => { ...OBJECT_SCHEMA, fields: { ...OBJECT_SCHEMA.fields, status: { name: 'status', type: 'text' } }, }; - lastTasks = []; - const ds = { - find: vi.fn(async () => ({ data: rows, total: rows.length })), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - getObjectSchema: vi.fn(async () => schemaless), - } as any; - render( - , - ); - await waitFor(() => expect(lastTasks.length).toBe(1)); + const tasks = await tasksFor({ colorField: 'status' }, rows, schemaless); // `in_progress` is a SEMANTIC_COLOR_MAP key -> blue -> that palette's hex. - expect(lastTasks[0].color).toBe('#3b82f6'); - expect(lastTasks[0].color).not.toBe('in_progress'); + expect(tasks[0].color).toBe('#3b82f6'); + expect(tasks[0].color).not.toBe('in_progress'); }); it('rung 3: a palette token resolves to that palette colour, not a raw CSS name', async () => { @@ -172,23 +267,7 @@ describe('objectui#7243 — gantt colorField ladder', () => { }); it('borderColorField takes rung 1 too — the alert stroke honours option colours', async () => { - lastTasks = []; - render( - , - ); - await waitFor(() => expect(lastTasks.length).toBe(1)); - expect(lastTasks[0].borderColor).toBe('#7c3aed'); + const tasks = await tasksFor({ borderColorField: 'status' }); + expect(tasks[0].borderColor).toBe('#7c3aed'); }); });