diff --git a/.changeset/8266-dashboard-count-aggregate-series-key.md b/.changeset/8266-dashboard-count-aggregate-series-key.md new file mode 100644 index 0000000000..c4fc28fefb --- /dev/null +++ b/.changeset/8266-dashboard-count-aggregate-series-key.md @@ -0,0 +1,39 @@ +--- +'@object-ui/core': patch +'@object-ui/plugin-dashboard': patch +'@object-ui/plugin-charts': patch +--- + +Fix a dashboard chart widget with a FIELDLESS `count` aggregate plotting nothing +(objectui#8266). + +A widget bound to an object with `aggregate: { function: 'count', groupBy: 'status' }` +and no `field` — the normal way to author "how many records per status" — rendered an +empty chart. No error, no empty state: a plot frame with the category ticks drawn and +not one mark in it, which reads exactly like "this object has no rows yet". + +**Cause.** The two dashboard relays (`DashboardGridLayout`, `DashboardRenderer`) each +built the series binding as `aggregate?.field || (options.yField || 'value')`, which for +a fieldless count resolves to `'value'`. The rows an object-bound fieldless count +returns are keyed `'count'` — the alias the engine projects `COUNT(*)` under, pinned +since framework#3701. A `dataKey` naming a column no row carries plots nothing, and +neither of the renderer's two guards fires on it: the rows DO carry the category key, +and the series array is not empty. + +**Fix.** `chartMeasureKey` is a new `@object-ui/core` export delegating to +`chartAggregateValueKey` in `@objectstack/spec/ui` — the contract's own derivation of +"the value column an object-bound aggregate produces". Both relays now consult it, and +the row-projection side (`aggregateValueKey` in `@object-ui/plugin-charts`) is routed +through the same function, so the two halves of the question cannot drift again. + +**What moves on screen.** A chart that was blank now draws. Charts that already drew are +unaffected: a field-bearing aggregate resolves to its raw field under both the old and +the new reading, and a chart with no `aggregate` at all keeps the author's `yField`. +One authored key changes meaning: a `yField` written on an object-bound chart that +ALSO declares an aggregate no longer wins over the aggregate's own column — it named a +record column that a grouped aggregate never returns, so it plotted nothing before. + +**Not fixed here, and out of scope.** The same widget with no `options.xField` is +refused by the category-axis guard naming `name`, a key the author never wrote (they +wrote `aggregate.groupBy`). That is the category half of the same relay gap and is +filed separately. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 88bf1dcd69..dc1dcfaabf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -72,6 +72,10 @@ export * from './utils/dashboard-filters.js'; export * from './utils/merge-filters.js'; export * from './utils/compare-to.js'; export * from './utils/chart-series.js'; +// "Which result column carries the measure?" — one answer for the row +// projection and the series binding alike, delegated to the spec's own +// derivation so the two cannot drift (objectui#8266). +export * from './utils/chart-measure-key.js'; // The AUTHORED half of a dataset-bound chart (objectui#4229's data/presentation // split), shared by the dashboard widget and the report's embedded chart so the // same spec keys are lowered identically on both (objectui#4877). diff --git a/packages/core/src/utils/chart-measure-key.test.ts b/packages/core/src/utils/chart-measure-key.test.ts new file mode 100644 index 0000000000..f7eadff023 --- /dev/null +++ b/packages/core/src/utils/chart-measure-key.test.ts @@ -0,0 +1,61 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8266 — the ONE answer to "which result column carries the measure?". + * + * The regression this exists for is the FIELDLESS COUNT arm: it is the only + * shape where the contract's answer and a `aggregate?.field || yField` read + * disagree, and it is the normal way to author "how many records per status". + * Every other arm agrees under both readings, which is precisely why the + * disagreement survived in the tree — see the two dashboard relays, pinned in + * `plugin-dashboard/src/__tests__/DashboardChart.countSeriesKey-8266.test.tsx`. + * + * The fallback arms are the other half: a floor that fires too eagerly would + * take the caller's `yField` back over an aggregate the contract CAN answer for, + * re-creating the bug through the fix. + */ +import { describe, it, expect } from 'vitest'; +import { chartMeasureKey } from './chart-measure-key'; + +describe('chartMeasureKey — the contract answers', () => { + it('is the raw field for a field-bearing aggregate, never the caller floor', () => { + expect(chartMeasureKey({ field: 'amount', function: 'sum' }, 'value')).toBe('amount'); + }); + + it('is the literal "count" for a fieldless count — NOT the caller floor', () => { + // THE REGRESSION. `aggregate?.field || yField` answered 'value' here, and + // the rows carry 'count', so the chart plotted nothing and said nothing. + expect(chartMeasureKey({ function: 'count', groupBy: 'status' }, 'value')).toBe('count'); + }); + + it('still prefers an explicit field even for count', () => { + expect(chartMeasureKey({ field: 'amount', function: 'count' }, 'value')).toBe('amount'); + }); + + it('ignores a caller floor the author chose, when the aggregate answers', () => { + // An authored `yField` names a column an object-bound aggregate does not + // project. Honouring it would plot nothing, exactly as 'value' did. + expect(chartMeasureKey({ function: 'count', groupBy: 'status' }, 'total')).toBe('count'); + }); +}); + +describe('chartMeasureKey — the caller floor, only where the contract is silent', () => { + it('falls back when there is no aggregate at all', () => { + // A provider whose rows are raw records: the author's yField IS the key. + expect(chartMeasureKey(undefined, 'value')).toBe('value'); + expect(chartMeasureKey(undefined, 'amount')).toBe('amount'); + }); + + it('falls back for an aggregate shape ChartAggregateSchema rejects', () => { + // "only 'count' may omit field" — a fieldless sum is not a declaration the + // spec admits, so the contract has no column to name. + expect(chartMeasureKey({ function: 'sum' }, 'value')).toBe('value'); + expect(chartMeasureKey({}, 'value')).toBe('value'); + }); +}); diff --git a/packages/core/src/utils/chart-measure-key.ts b/packages/core/src/utils/chart-measure-key.ts new file mode 100644 index 0000000000..8733345f13 --- /dev/null +++ b/packages/core/src/utils/chart-measure-key.ts @@ -0,0 +1,73 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * chart-measure-key — the ONE answer to "which result column carries the + * measure?" for an object-bound chart (objectui#8266). + * + * ## The disagreement this replaces + * + * The question was answered independently in three places, and for a FIELDLESS + * `count` — the normal way to author "how many records per status", and the + * most common dashboard chart there is — two of them answered differently: + * + * - the ROW PROJECTION (`aggregateValueKey` / `aggregateRecords` / + * `runAggregate`'s alias, all in `plugin-charts/src/ObjectChart.tsx`) keys + * the value column `'count'`, the alias the engine projects `COUNT(*)` + * under; + * - the SERIES BINDING, spelled twice in `plugin-dashboard` + * (`DashboardGridLayout.tsx` and `DashboardRenderer.tsx`), read + * `aggregate.field || (options.yField || 'value')` and so bound `'value'`. + * + * A `dataKey` naming a column no row carries plots nothing, and nothing says + * so: measured on `origin/main` `0fa7a9c83`, rows `[{status:'open',count:2}, + * {status:'paid',count:5}]` with `series:[{dataKey:'value'}]` rendered a + * `.recharts-surface` with the category ticks "open"/"paid", **0 bars, 0 + * rectangles**, no refusal and no empty state — the same rows under + * `dataKey:'count'` drew 2 rectangles. An empty chart reads as "no data yet", + * which is exactly what an author with a genuinely empty object also sees. + * + * ## Why the answer is delegated rather than restated + * + * `chartAggregateValueKey` in `@objectstack/spec/ui` is the CONTRACT's own + * derivation — "the VALUE column an object-bound aggregate produces … what a + * chart's series / y-axis binding must name". Producers and checkers already + * read it. Restating the rule here would make this file a fourth opinion of the + * same question, which is the shape (objectui#5042 / #7544 / #8193 / #8168) + * this module exists to end. So the rule lives upstream in the spec and this is + * the seam objectui-side callers share, exactly as `humanizeLabel` (objectui#5444) + * and `buildChartSeries` (ADR-0021) already are. + * + * ⛔ The inverse fix — making the row projection key a fieldless count + * `'value'` — is not available: it contradicts a pin the tree already carries + * (`ObjectChart.aggregateResultColumns.test.ts`) and renames the column every + * other consumer reads, including the engine's own `COUNT(*)` alias. + */ + +import { chartAggregateValueKey, type ChartAggregateLike } from '@objectstack/spec/ui'; + +export type { ChartAggregateLike }; + +/** + * The result column a chart's series must bind to. + * + * `fallback` is the caller's own floor, used ONLY when the contract has no + * answer — a chart that declares no `aggregate` at all (its rows are raw + * records or authored literals, where the author's `yField` is the right key), + * or an aggregate shape `ChartAggregateSchema` already rejects. It is never a + * second opinion about an aggregate the contract CAN answer for. + * + * @param aggregate the chart's inline aggregate, or `undefined` + * @param fallback the key to bind when the contract has no answer + */ +export function chartMeasureKey( + aggregate: ChartAggregateLike | undefined, + fallback: string, +): string { + return chartAggregateValueKey(aggregate) ?? fallback; +} diff --git a/packages/plugin-charts/src/ObjectChart.aggregateResultColumns.test.ts b/packages/plugin-charts/src/ObjectChart.aggregateResultColumns.test.ts index 374075e92d..9f0536ad6b 100644 --- a/packages/plugin-charts/src/ObjectChart.aggregateResultColumns.test.ts +++ b/packages/plugin-charts/src/ObjectChart.aggregateResultColumns.test.ts @@ -37,6 +37,29 @@ describe('aggregateValueKey', () => { it('prefers an explicit field even for count', () => { expect(aggregateValueKey({ field: 'total', function: 'count' })).toBe('total'); }); + + /** + * objectui#8266 routed this function through `chartMeasureKey` + * (`@object-ui/core`), which delegates to the contract's own + * `chartAggregateValueKey`, so the SERIES binding the dashboard relays + * compose cannot drift from the column this projects. The delegation is + * behaviour-preserving, and these two arms are the only shapes where that + * claim is not already covered above: the contract answers `undefined` for + * them (`ChartAggregateSchema` refuses a fieldless non-count outright), so + * everything after the delegation is this renderer's own floor. + * + * They are unreachable from validated metadata and reachable from + * unvalidated. The floor exists so such a row keys a COLUMN rather than the + * literal string "undefined", which is the regression the file below pins. + */ + it('floors a fieldless non-count on the function name, as before the delegation', () => { + expect(aggregateValueKey({ function: 'sum' })).toBe('sum'); + expect(aggregateValueKey({ function: 'avg' })).toBe('avg'); + }); + + it('floors an empty aggregate on "count", as before the delegation', () => { + expect(aggregateValueKey({})).toBe('count'); + }); }); describe('aggregateRecords — result columns', () => { diff --git a/packages/plugin-charts/src/ObjectChart.countSeriesKeyRender-8266.test.tsx b/packages/plugin-charts/src/ObjectChart.countSeriesKeyRender-8266.test.tsx new file mode 100644 index 0000000000..d65c0c7127 --- /dev/null +++ b/packages/plugin-charts/src/ObjectChart.countSeriesKeyRender-8266.test.tsx @@ -0,0 +1,138 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8266, the RENDER half — what a `dataKey` naming a column the rows do + * not carry actually draws. + * + * `plugin-dashboard/src/__tests__/DashboardChart.countSeriesKey-8266.test.tsx` + * pins which column the two dashboard relays name. That is necessary and not + * sufficient: a seam assertion cannot tell a honoured binding from an ignored + * one, and the whole claim of that card is that the mismatch is SILENT. So this + * file renders the real chain — `ChartRenderer` → `normalizeChartSchema` → + * `AdvancedChartImpl` — over the rows a fieldless count actually returns, once + * under each key, and reads the DOM. + * + * It lives here because `recharts` resolves inside `plugin-charts` alone, so + * this is the only package that can mock `ResponsiveContainer` to a measured + * box and count marks at all (the same reason + * `plugin-dashboard/src/__tests__/DatasetWidget.chartConfig.dom.test.tsx` + * states for its own split). + * + * ## Measured on `origin/main` `0fa7a9c83`, before any fix + * + * dataKey 'value' (what the relays composed): surface drawn, x ticks + * ["open","paid"], 0 `.recharts-bar`, 0 `.recharts-rectangle`, NO + * `[data-chart-error]`, NO `chart-empty-state`. + * dataKey 'count' (the column the rows carry): 1 `.recharts-bar`, + * 2 `.recharts-rectangle`, y ticks 0..8. + * + * Same rows, same harness — which is what makes the zero a statement about the + * binding rather than about the harness. The zero arm is kept as a pin + * deliberately: it is the reason the seam file's assertions are worth making, + * and if a future guard DOES start refusing this shape loudly, this is the test + * that must be re-decided rather than a silent behaviour change nobody notices. + * + * ⚠️ Mark counts are harness-bound (`ResponsiveContainer` is fixed at 480x320 + * here); they are re-derived in this file and never carried in from another. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; + +// Recharts measures via ResizeObserver, which reports 0x0 under the headless +// DOM, so nothing paints. Fix its size — the shim every render test in this +// package uses. +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: any) => + React.cloneElement(children, { width: 480, height: 320 }), + }; +}); + +import { ChartRenderer } from './ChartRenderer'; +import { aggregateRecords } from './ObjectChart'; + +afterEach(cleanup); + +/** + * Not transcribed — produced by the very builder the row projection uses, so a + * change to the projected column name lands in this fixture instead of leaving + * the pin asserting against a shape the product stopped emitting. + */ +const COUNT_ROWS = aggregateRecords( + [ + { status: 'open' }, { status: 'open' }, + { status: 'paid' }, { status: 'paid' }, { status: 'paid' }, { status: 'paid' }, { status: 'paid' }, + ], + { function: 'count', groupBy: 'status' }, +); + +const drawWith = async (dataKey: string) => { + const { container } = render( + , + ); + // `AdvancedChartImpl` is lazy — wait for the real outcome, not the skeleton. + // Either terminal state satisfies this, so a refusal is never mistaken for a + // timeout and a timeout is never read as "it drew nothing". + await waitFor(() => { + if (!container.querySelector('.recharts-surface') && !container.querySelector('[data-chart-error]')) { + throw new Error('neither a plot nor a refusal'); + } + }, { timeout: 5000 }); + return { + marks: container.querySelectorAll('.recharts-rectangle').length, + series: container.querySelectorAll('.recharts-bar').length, + refusal: container.querySelector('[data-chart-error]')?.getAttribute('data-chart-error') ?? null, + emptyState: !!screen.queryByTestId('chart-empty-state'), + ticks: Array.from(container.querySelectorAll('.recharts-cartesian-axis-tick-value')).map((n) => n.textContent), + }; +}; + +describe('a fieldless count projects its value under "count" (objectui#8266)', () => { + it('is the shape the row builder emits — the premise the rest of the file rests on', () => { + expect(COUNT_ROWS).toEqual([ + { status: 'open', count: 2 }, + { status: 'paid', count: 5 }, + ]); + }); + + it('draws its marks when the series names that column', async () => { + const drawn = await drawWith('count'); + expect(drawn.refusal).toBeNull(); + expect(drawn.series).toBe(1); + expect(drawn.marks).toBe(2); + // The values really reached an axis, so "it drew" is not just a container. + expect(drawn.ticks).toEqual(expect.arrayContaining(['open', 'paid', '0', '2', '4'])); + }); + + it('draws NOTHING, silently, when the series names "value" instead', async () => { + const drawn = await drawWith('value'); + // The failure this card is about: a plot frame with the categories on it… + expect(drawn.ticks).toEqual(['open', 'paid']); + // …and not one mark in it. + expect(drawn.marks).toBe(0); + expect(drawn.series).toBe(0); + // …and nothing anywhere says so. Both guards this renderer carries decline: + // `hasNoCategoryKey` is satisfied (the rows DO have `status`) and + // `hasNoPlottableSeries` keys on `series: []`, which this is not. + expect(drawn.refusal).toBeNull(); + expect(drawn.emptyState).toBe(false); + }); +}); diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index 611bd1823b..cb9934d724 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useContext, useCallback, useMemo } from 're import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation, useFilterScope, ElementDataSourceGate, type ElementDataSourceMapping } from '@object-ui/react'; import { ChartRenderer } from './ChartRenderer'; import { normalizeChartSchema } from './normalizeChartSchema'; -import { ComponentRegistry, humanizeLabel, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveFilterPlaceholders, resolveContextTokens, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, deriveDimensionLabelMaps, dimensionOptionTranslator, loadDimensionFieldMeta, relabelDimensions, localizeFieldOptions, elementDataSourceBlock, type DimensionFieldMeta, type CompareToConfig, type DrillEvent, type ChartResultField, type ChartSegmentClickEvent } from '@object-ui/core'; +import { ComponentRegistry, chartMeasureKey, humanizeLabel, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveFilterPlaceholders, resolveContextTokens, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, deriveDimensionLabelMaps, dimensionOptionTranslator, loadDimensionFieldMeta, relabelDimensions, localizeFieldOptions, elementDataSourceBlock, type DimensionFieldMeta, type CompareToConfig, type DrillEvent, type ChartResultField, type ChartSegmentClickEvent } from '@object-ui/core'; import { Sheet, SheetContent, SheetHeader, SheetTitle, Dialog, DialogContent, DialogHeader, DialogTitle, RefreshIndicator, Button, ChartSkeleton, DataEmptyState } from '@object-ui/components'; import { AlertCircle, ArrowUpRight, Inbox } from 'lucide-react'; import { builtinAggregateLabels, useSafeFieldLabel, useSafeTranslate } from '@object-ui/i18n'; @@ -35,7 +35,17 @@ export { humanizeLabel }; * agrees on one key instead of each re-deriving it. */ export function aggregateValueKey(aggregate: { field?: string; function?: string }): string { - return aggregate.field || aggregate.function || 'count'; + // The contract's own derivation answers every aggregate `ChartAggregateSchema` + // ADMITS; the tail is this renderer's floor for shapes it REJECTS (a + // non-count aggregate that names no field, or an empty bag), which reach this + // function only from unvalidated metadata and must still key a column rather + // than the string "undefined". Byte-identical to the three-rung `field || + // function || 'count'` it replaces — the two extra rungs are unreachable for + // any aggregate the spec answers for. Delegated rather than restated so this + // cannot drift from the SERIES binding the dashboard relays compose, which is + // exactly how a fieldless count came to project `'count'` and be plotted as + // `'value'` (objectui#8266). + return chartMeasureKey(aggregate, aggregate.function || 'count'); } /** diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 1db59997dc..70a5a2cc69 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -6,6 +6,7 @@ import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react'; import { SchemaRenderer, useHasDndProvider, useDnd } from '@object-ui/react'; import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { BaseSchema, DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types'; +import { chartMeasureKey } from '@object-ui/core'; import { isObjectProvider, deriveStaticTableColumns } from './utils'; import { classifyWidgetType } from './widgetDispatch'; import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget'; @@ -236,7 +237,14 @@ export const DashboardGridLayout: React.FC = ({ function: providerAgg.function, groupBy: providerAgg.groupBy, } : undefined; - const effectiveYField = effectiveAggregate?.field || yField; + // Which column carries the measure is the CONTRACT's question, not this + // relay's: a fieldless `count` projects its value under the literal + // `'count'` (the engine's `COUNT(*)` alias), so `aggregate?.field || + // yField` bound `'value'` against rows that carry `'count'` and the + // chart plotted nothing, silently (objectui#8266). `yField` survives as + // the floor for a provider with NO aggregate, whose rows are raw + // records — there the author's `yField` really is the key. + const effectiveYField = chartMeasureKey(effectiveAggregate, yField); return { type: 'object-chart', chartType: dispatch.chartType, diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index af6ee14b43..f955fcd598 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -16,6 +16,7 @@ import { buildWidgetScopedFilter, mergeFilters, toDomProps, + chartMeasureKey, } from '@object-ui/core'; import { cn, Card, CardHeader, CardTitle, CardContent, Button, getLazyIcon } from '@object-ui/components'; import { forwardRef, useState, useEffect, useCallback, useMemo, useRef, Fragment } from 'react'; @@ -615,7 +616,13 @@ const DashboardRendererInner = forwardRef { + composed.push(props.schema ?? props); + return null; +}; +for (const type of ['object-chart', 'chart'] as const) { + ComponentRegistry.register(type, recorder as any, { + namespace: 'test', + label: 'recorder', + category: 'plugin', + } as any); +} + +afterEach(cleanup); + +const dataSource = { aggregate: async () => [], find: async () => [] }; + +/** Render one widget through a relay and return the node it composed. */ +const composeVia = async (surface: 'grid' | 'renderer', widget: Record) => { + composed.length = 0; + render( + + {surface === 'grid' ? ( + + ) : ( + + )} + , + ); + await waitFor(() => expect(composed.length).toBeGreaterThan(0)); + const node = composed[composed.length - 1]; + cleanup(); + return node; +}; + +const objectWidget = (aggregate: unknown, options: Record = { xField: 'status' }) => ({ + id: 'w1', + type: 'bar', + title: 'By status', + options, + data: { provider: 'object', object: 'crm_case', ...(aggregate ? { aggregate } : {}) }, +}); + +const SURFACES = ['grid', 'renderer'] as const; + +describe.each(SURFACES)('%s relay — object-bound aggregate series key (objectui#8266)', (surface) => { + it('binds a FIELDLESS count to the column the rows carry, not to the yField floor', async () => { + const node = await composeVia(surface, objectWidget({ function: 'count', groupBy: 'status' })); + // The regression: 'value' here, against rows keyed 'count'. + expect(node.series[0].dataKey).toBe('count'); + }); + + it('ignores an authored yField that an object-bound aggregate cannot project', async () => { + // `yField` names a column of the RECORDS, and a grouped aggregate does not + // return records. Honouring it plots nothing for exactly the same reason + // 'value' did, so the aggregate's own column wins. + const node = await composeVia( + surface, + objectWidget({ function: 'count', groupBy: 'status' }, { xField: 'status', yField: 'amount' }), + ); + expect(node.series[0].dataKey).toBe('count'); + }); + + it('still binds a field-bearing aggregate to its raw field', async () => { + const node = await composeVia(surface, objectWidget({ function: 'sum', field: 'amount', groupBy: 'status' })); + expect(node.series[0].dataKey).toBe('amount'); + }); + + it('still binds a count that DOES name a field to that field', async () => { + const node = await composeVia(surface, objectWidget({ function: 'count', field: 'amount', groupBy: 'status' })); + expect(node.series[0].dataKey).toBe('amount'); + }); + + it('keeps the yField floor for an object provider with NO aggregate', async () => { + // Rows are raw records here, so the author's yField really is the key — + // this is the arm a floor that fired too eagerly would have broken. + expect((await composeVia(surface, objectWidget(null))).series[0].dataKey).toBe('value'); + expect( + (await composeVia(surface, objectWidget(null, { xField: 'status', yField: 'amount' }))).series[0].dataKey, + ).toBe('amount'); + }); +}); + +describe.each(SURFACES)('%s relay — the authored-rows branch is NOT the same decision', (surface) => { + const literalWidget = (options: Record) => ({ + id: 'w2', + type: 'bar', + title: 'Literal', + options, + data: [ + { name: 'open', value: 2, hours: 7 }, + { name: 'paid', value: 5, hours: 9 }, + ], + }); + + it('binds the authored yField, because there is no aggregate to consult', async () => { + expect((await composeVia(surface, literalWidget({}))).series[0].dataKey).toBe('value'); + expect((await composeVia(surface, literalWidget({ yField: 'hours' }))).series[0].dataKey).toBe('hours'); + }); + + it('composes the literal `chart` node, not `object-chart`', async () => { + // The branch discriminator itself: these rows never reach the object path, + // which is why consulting an aggregate there would be meaningless. + const node = await composeVia(surface, literalWidget({ yField: 'hours' })); + expect(node.type).toBe('chart'); + expect(node.aggregate).toBeUndefined(); + expect(Array.isArray(node.data)).toBe(true); + }); +});