From 2dc8ba4f8c02af99c17c9a2627b286e51bd4cf43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 01:39:14 +0000 Subject: [PATCH] test(plugin-calendar,plugin-gantt): the colorField ladder fixtures name their own writer (objectui#7828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gantt and calendar `colorField` ladder fixtures were still in the pre-objectui#7521 harness shape that produced objectui#7466's intermittent red: a module-level mutable the mock writes, an arity-only readiness predicate (`expect(lastTasks.length).toBe(rows.length)`), no unmount, and a return value re-read from the global after the predicate accepted a different one. objectui#7527 (`c2fc261f5`) hardened the timeline fixture and touched that package only; PR #7826 added the bound capture there. This ports both to the two siblings. Four changes, none of them about the ladder itself: 1. the readiness predicate identifies WHICH render wrote — a title token the call owns, riding through `titleField` — instead of counting arity, which cannot separate "the component I mounted has painted" from "an earlier one painted again"; 2. the value returned is the array that predicate accepted, not a second read of the module global taken after `act` has yielded; 3. every render is torn down in a `finally`; 4. no renderer may be alive when one is mounted. In the gantt file the two `it` blocks that mounted inline now go through the same helper, because guard 4 is only worth anything if every render in the file is accounted for. Measured rather than assumed, because a green loop is not evidence here -- objectui#7466 paid for that reading, where the broken timeline harness passed 32/32 on an idle box, the same 32/32 the fixed one gets. The lever is the metadata fetch, so the lever was swept: the pre-port harness shape driving two renders inside one `it`, with `getObjectSchema` held by 0/1/2/3/4/5/6/7/8/9/10/12/15/20/25/35/50 ms, three runs per 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 Every timeline failure sits at a hold of 1-9ms, with the card's exact signature. The two zeroes are readable only because that control fired in the same runs. So neither sibling reproduces the failure, and this does not claim the timeline's two-paint behaviour for them: both GATE their record query on the settled object schema (objectui#6453 / objectui#7225), so their first paint arrives after the metadata read -- the measured paint time tracks the hold -- and no schema-triggered second paint follows. What is ported is the harness half, which does not depend on that: a module global plus an arity-only predicate cannot say which component wrote, whatever the component does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .../7828-calendar-gantt-ladder-harness.md | 19 ++ ...ectCalendar.colorFieldLadder-7243.test.tsx | 111 ++++++++++- ...ObjectGantt.colorFieldLadder-7243.test.tsx | 185 +++++++++++++----- 3 files changed, 258 insertions(+), 57 deletions(-) create mode 100644 .changeset/7828-calendar-gantt-ladder-harness.md diff --git a/.changeset/7828-calendar-gantt-ladder-harness.md b/.changeset/7828-calendar-gantt-ladder-harness.md new file mode 100644 index 0000000000..594f3d76b9 --- /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 d510acf666..9cce4d7c8b 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 9fb635d1d6..70c0acade7 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'); }); });