Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/7828-calendar-gantt-ladder-harness.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -103,9 +181,34 @@ async function colorsFor(colorField: string, rows: any[] = ROWS, objectSchema: a
colorField,
},
};
render(<ObjectCalendar schema={schema} dataSource={makeDataSource(rows, objectSchema)} />);
await waitFor(() => expect(lastEvents.length).toBe(rows.length));
return lastEvents.map((e) => e.color);
const { unmount } = render(
<ObjectCalendar schema={schema} dataSource={makeDataSource(stampedRows, objectSchema)} />,
);
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', () => {
Expand Down
185 changes: 132 additions & 53 deletions packages/plugin-gantt/src/ObjectGantt.colorFieldLadder-7243.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, any>,
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',
Expand All @@ -107,12 +197,41 @@ async function colorsFor(colorField: string, rows: any[] = ROWS) {
titleField: 'subject',
startDateField: 'visible_from',
endDateField: 'due_date',
colorField,
...ganttConfig,
},
};
render(<ObjectGantt schema={schema} dataSource={makeDataSource(rows)} />);
await waitFor(() => expect(lastTasks.length).toBe(rows.length));
return lastTasks.map((t) => t.color);
const { unmount } = render(
<ObjectGantt schema={schema} dataSource={makeDataSource(stampedRows, objectSchema)} />,
);
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', () => {
Expand All @@ -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(
<ObjectGantt
schema={{
type: 'object-gantt',
objectName: 'duly_task',
gantt: {
titleField: 'subject',
startDateField: 'visible_from',
endDateField: 'due_date',
colorField: 'status',
},
} as any}
dataSource={ds}
/>,
);
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 () => {
Expand All @@ -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(
<ObjectGantt
schema={{
type: 'object-gantt',
objectName: 'duly_task',
gantt: {
titleField: 'subject',
startDateField: 'visible_from',
endDateField: 'due_date',
borderColorField: 'status',
},
} as any}
dataSource={makeDataSource()}
/>,
);
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');
});
});
Loading