From b461decb73ffd3a5ac4b5d4a00849159cfe0b774 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 23:36:33 +0000 Subject: [PATCH 1/2] test(gantt): anchor both contractEnvelope-6839 refusal arms on completion (objectui#8665) Both refusal arms in `ObjectGantt.contractEnvelope-6839.test.tsx` read without a completion anchor. Probed on the component with the row query and the quick-filter domain query answering at different times: - sink 2 (the option domain) asserted an ABSENCE with nothing proving the domain query had answered. With the LIVE `data` envelope and the domain answering one macrotask after the rows, the arm read `offersP3=false, offered=[p1]` while the same mount settled to `offered=[p1,p2,p3]`. The refusal assertion passed against a component that demonstrably resolved the whole domain. - sink 1 read the bars straight after a wait on `find` merely having been CALLED. Under the same profile the DOM at that moment was the `Loading Gantt chart...` placeholder and the read threw `Unable to find an element by: [data-testid="gantt-view"]`. sink 1 now waits for `gantt-view`, which renders below `ObjectGantt`'s `if (loading)` early return; `loading` is cleared in `reload`'s `finally`, in the same `isCurrent()`-guarded commit as `setData`, so its presence proves the row query settled and committed. sink 2 anchors on a CONTROL DIMENSION. The component publishes no DOM marker for the domain fetch on the refusing branch, so the fixture adds a second lookup (`owner`) resolved by the same effect: `setLookupOptions` is called once after the loop over every dimension, and no loaded task carries an `owner`, so its option can only come from the domain fetch having been read and committed. Both anchors are PRESENCE assertions, so neither arm can pass by timing out. The positive arms now also assert the drawn titles, not only a count. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- ...ObjectGantt.contractEnvelope-6839.test.tsx | 163 ++++++++++++++++-- 1 file changed, 146 insertions(+), 17 deletions(-) diff --git a/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx b/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx index 44c7ae49bd..6b2bc9382a 100644 --- a/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx @@ -37,6 +37,26 @@ * `data` and bare-array cases refuse it: they push the SAME rows through the * SAME mounts, on BOTH sinks. * + * ## The waits were repaired in objectui#8665 — what was measured + * + * Both refusal arms below used to read WITHOUT a completion anchor: sink 1 read + * the bars straight after a wait on `find` merely having been CALLED, and sink + * 2 asserted an ABSENCE with nothing proving the domain query had answered. + * Probed on this component with the two queries answering at different times: + * + * - sink 2, LIVE `data` envelope, domain answering one macrotask later: + * `offersP3=false, offered=[p1]` where the old shape read it, and + * `offered=[p1,p2,p3]` once the same mount settled. ⇒ the refusal + * assertion PASSED against a component that resolved the whole domain. + * The arm was measuring the polling tick, not the envelope. + * - sink 1, same profile: the DOM at the old wait was the `Loading Gantt + * chart…` placeholder and the read threw `Unable to find an element by: + * [data-testid="gantt-view"]` — a failure that is not about the envelope. + * + * Both now anchor on something that appears only once the relevant query has + * settled, and both anchors are PRESENCE assertions, so neither arm can pass + * by timing out. See {@link mount} and {@link domainSettled}. + * * MODULE RESOLUTION: this file imports the component by relative source path * and `@object-ui/core` is aliased by the root `vitest.config.mts` to * `packages/core/src`, so both legs resolve to SOURCE — no package `exports` @@ -51,9 +71,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; * `GanttView` is mocked to a thin shell surfacing the task count, exactly as * `ObjectGantt.quickfilter.test.tsx` and `ObjectGantt.referenceArms-6837 * .test.tsx` do — how the chart paints bars is not what this file observes. + * + * It also surfaces the drawn TITLES. A count alone is satisfied by any two + * rows, so the positive arms below assert which two arrived; the count on its + * own could not tell `TASKS` from some other pair the unwrap ladder invented. */ vi.mock('./GanttView', () => ({ - GanttView: ({ tasks }: any) =>
, + GanttView: ({ tasks }: any) => ( +
t.title).join('|')} + /> + ), })); import { ObjectGantt } from './ObjectGantt'; @@ -73,6 +103,19 @@ const PROJECTS = [ { id: 'p3', name: 'Cygnus' }, ]; +/** + * The CONTROL dimension's domain — the completion anchor sink 2 is read + * against. See {@link domainSettled}. + * + * No loaded task carries an `owner`, so the distinct-values fallback offers + * NOTHING for this dimension: an `owner` option in the dropdown can only have + * come from the domain fetch having been read and committed. + */ +const PEOPLE = [ + { id: 'u1', name: 'Ada' }, + { id: 'u2', name: 'Grace' }, +]; + /** How one case wraps its rows on the way back out of `find()`. */ type Envelope = (rows: unknown[]) => unknown; @@ -86,27 +129,54 @@ const GANTT_SCHEMA = { startDateField: 'start', endDateField: 'end', titleField: 'name', - quickFilters: [{ field: 'project', label: 'Project' }], + quickFilters: [ + { field: 'project', label: 'Project' }, + // The control dimension — resolved by the SAME `setLookupOptions` commit + // as `project`, and answered OUTSIDE the envelope under test. + { field: 'owner', label: 'Owner' }, + ], } as any; /** - * Mount over a `find()` that wraps BOTH answers in `envelope`, and wait for the - * schema-dependent commit. + * Mount over a `find()` that wraps BOTH answers in `envelope`, wait for the + * schema-dependent query, and then wait for the chart to have FINISHED + * loading. + * + * ## The two waits do different jobs — objectui#8665 + * + * The first is envelope-INDEPENDENT evidence that the schema was consumed: + * once `objectSchema` lands, the record query is re-issued carrying `$expand`, + * and `buildExpandFields` decides that from the field's `type` alone. So the + * recorded `find('task', {$expand})` proves the component consumed the schema + * for a REFUSED envelope exactly as it does for a live one. + * + * ⛔ But that call being RECORDED is not the query having ANSWERED, and this + * file used to read the bars straight off it. Probed on this component + * (objectui#8665): with the row query answering one macrotask later, the DOM at + * the moment that wait passed was the `Loading Gantt chart…` placeholder, and + * the read threw `Unable to find an element by: [data-testid="gantt-view"]` — + * the pin failing for a reason that is not about the envelope at all. * - * The settle signal is envelope-INDEPENDENT: once `objectSchema` lands, the - * record query is re-issued carrying `$expand`, and `buildExpandFields` decides - * that from the field's `type` alone. So the recorded `find('task', {$expand})` - * proves the component consumed the schema for a REFUSED envelope exactly as it - * does for a live one — and the option-fetch effect shares that commit, so a - * resolving arm has already recorded `find('projects', …)` by then. + * ⭐ So the second wait is the COMPLETION ANCHOR, and it is keyed to the + * component's own mechanism: `gantt-view` renders BELOW `ObjectGantt`'s + * `if (loading)` early return, and `loading` is cleared in `reload`'s + * `finally` — in the same commit as `setData(capped.rows)`, and only by the + * newest reload (both are guarded by `isCurrent()`). So this node's presence is + * proof that the row query settled AND committed. It is a PRESENCE assertion, + * so it cannot pass by timing out, which is the failure mode every + * absence-shaped pin has. * * ⛔ Call ONCE per case, never inside a `waitFor` predicate (objectui#7802). */ async function mount(envelope: Envelope) { const ds: any = { - find: vi.fn(async (object: string) => - object === 'projects' ? envelope(PROJECTS) : envelope(TASKS), - ), + find: vi.fn(async (object: string) => { + // The control domain is answered OUTSIDE the envelope under test, as a + // bare array, so {@link domainSettled} anchors on the same DOM whichever + // envelope this case is exercising. + if (object === 'people') return PEOPLE; + return object === 'projects' ? envelope(PROJECTS) : envelope(TASKS); + }), findOne: vi.fn(), create: vi.fn(), update: vi.fn(), @@ -118,6 +188,7 @@ async function mount(envelope: Envelope) { start: { type: 'date' }, end: { type: 'date' }, project: { type: 'lookup', reference: 'projects' }, + owner: { type: 'lookup', reference: 'people' }, }, }), }; @@ -127,18 +198,60 @@ async function mount(envelope: Envelope) { ds.find.mock.calls.some((c: any[]) => c[0] === 'task' && c[1]?.$expand?.includes('project')), ).toBe(true), ); - return { ds, view }; + await view.findByTestId('gantt-view'); + // Carried as its own observation so "the chart finished loading" and "the + // chart drew no bars" can never stand in for one another. + const drewChart = view.queryByTestId('gantt-view') !== null; + return { ds, view, drewChart }; } /** Bars the chart drew — sink 1. */ const bars = (view: any) => Number(view.getByTestId('gantt-view').getAttribute('data-count')); +/** The titles the chart drew — sink 1, as a shape rather than a count. */ +const barTitles = (view: any) => + view.getByTestId('gantt-view').getAttribute('data-titles') as string; + +/** + * ⭐ THE COMPLETION ANCHOR FOR SINK 2 (objectui#8665). + * + * Sink 2's refusal case is an ABSENCE — "`p3` is not offered" — and an absence + * is satisfied by a component that has not answered yet. Probed on this + * component: with the domain query answering one macrotask after the rows, the + * LIVE `data` envelope produced `offersP3=false, offered=[p1]` at the moment + * the old shape read it, while the very same mount settled to + * `offered=[p1,p2,p3]`. ⇒ the refusal assertion passed against a component + * that demonstrably DID resolve the domain. Nothing about the envelope was + * being measured; the polling tick was. + * + * `ObjectGantt` publishes no DOM marker for that fetch on the REFUSING branch — + * a refused domain leaves `lookupOptions[field]` an empty array, the resolver + * falls through to "distinct values present in the loaded data", and the + * dropdown looks exactly as it does before the fetch answers. So the anchor is + * a CONTROL DIMENSION instead: `owner` is a second lookup resolved by the same + * effect, whose `setLookupOptions` call sits AFTER the loop over every + * dimension — one commit for all of them. No loaded task carries an `owner`, + * so the fallback offers nothing there and `u1` can only come from the domain + * fetch having been read and committed. + * + * ⇒ waiting for `u1` proves the `projects` answer was consumed in that same + * commit, and it is a PRESENCE assertion, so this arm cannot pass by timing + * out. It also reddens under "resolve nothing, ever" — the implementation + * strictly worse than the bug, which the old absence-only shape passed. + */ +async function domainSettled(view: any): Promise { + fireEvent.click(view.getByTestId('quick-filter-trigger-owner')); + await view.findByTestId('quick-filter-option-owner-u1'); +} + /** * Is `p3` — a project NO loaded row points at — offered in the dropdown? * * This is the only observation that separates sink 2 from sink 1: the fallback * arm ("distinct values present in the loaded data") would still offer `p1` * whether or not the domain fetch was read. + * + * ⛔ Read it only AFTER {@link domainSettled}. */ async function offersUnloadedProject(view: any): Promise { fireEvent.click(view.getByTestId('quick-filter-trigger-project')); @@ -150,28 +263,39 @@ describe('ObjectGantt — the find() envelopes it reads (objectui#6839)', () => describe('sink 1 — the rows, via applyNonGridRowCeiling', () => { it("still reads the contract's `data` member", async () => { const { view } = await mount(asData); - await waitFor(() => expect(bars(view), 'the declared rows member must still draw').toBe(2)); + expect(bars(view), 'the declared rows member must still draw').toBe(2); + expect(barTitles(view), 'and it must draw THOSE two tasks').toBe('Alpha|Beta'); }); it('still reads a bare array — the live non-envelope shape fakes answer with', async () => { const { view } = await mount(asBareArray); - await waitFor(() => expect(bars(view), 'the bare-array arm must still draw').toBe(2)); + expect(bars(view), 'the bare-array arm must still draw').toBe(2); + expect(barTitles(view), 'and it must draw THOSE two tasks').toBe('Alpha|Beta'); }); it('does NOT read `records` — not a QueryResult member', async () => { // Before the fix these two tasks drew off a key `QueryResult` does not // declare, and did so AHEAD of `data`. - const { view } = await mount(asRecords); + const { view, drewChart } = await mount(asRecords); + // TWO observations, kept apart on purpose: without the first, a zero-bar + // reading is indistinguishable from a chart that had not drawn yet — + // which is what this arm did before objectui#8665. + expect( + drewChart, + 'the chart must have finished loading before it is read — an unanchored zero cannot tell a refusal from a chart still in its loading placeholder', + ).toBe(true); expect( bars(view), 'a `records` envelope must reach the chart as zero bars, not as the tasks it names', ).toBe(0); + expect(barTitles(view), 'and no task titles at all').toBe(''); }); }); describe('sink 2 — the quick-filter option domain, read directly', () => { it("still reads the contract's `data` member", async () => { const { view } = await mount(asData); + await domainSettled(view); expect( await offersUnloadedProject(view), 'the full referenced domain must still widen the dropdown past the loaded rows', @@ -180,6 +304,7 @@ describe('ObjectGantt — the find() envelopes it reads (objectui#6839)', () => it('still reads a bare array', async () => { const { view } = await mount(asBareArray); + await domainSettled(view); expect(await offersUnloadedProject(view)).toBe(true); }); @@ -188,6 +313,10 @@ describe('ObjectGantt — the find() envelopes it reads (objectui#6839)', () => // rows-only pin would have called this module green while the dropdown // silently narrowed to the values already on screen. const { view } = await mount(asRecords); + // ⭐ The absence below is only worth reading once the domain resolution + // has COMMITTED. `domainSettled` is what makes this arm fail when the + // domain is read, instead of passing because it has not answered yet. + await domainSettled(view); expect( await offersUnloadedProject(view), 'a `records` envelope must not resolve the referenced domain', From d70719a98a9ab6075606ac98fdfdb2640d4924e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 23:36:55 +0000 Subject: [PATCH 2/2] chore(changeset): declare the gantt envelope-wait repair as releasing nothing Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .changeset/gantt-envelope-wait-anchor-8665.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/gantt-envelope-wait-anchor-8665.md diff --git a/.changeset/gantt-envelope-wait-anchor-8665.md b/.changeset/gantt-envelope-wait-anchor-8665.md new file mode 100644 index 0000000000..124f64aa92 --- /dev/null +++ b/.changeset/gantt-envelope-wait-anchor-8665.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only change: the two `contractEnvelope-6839` refusal arms in `@object-ui/plugin-gantt` now anchor on a completion signal instead of reading straight after a query was issued. No published behaviour changes.