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');
});
});