@astryxdesign/charts 0.5.2-canary.e4f8e4e → 0.5.2-canary.ea1b9b5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,8 +25,8 @@ It ships to npm **only under the `@canary` dist-tag** — there is never a stabl
25
25
  ## Status
26
26
 
27
27
  Under active development. The config-model chart and its marks/chrome are in place;
28
- API and visuals are still being refined. See the repo-root `CHARTV2_*` docs for the
29
- plan, design research, and verification checklist.
28
+ API and visuals are still being refined. See [`docs/`](./docs/) for the plan, design research, readiness audit, and
29
+ verification checklist.
30
30
 
31
31
  ## Usage
32
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/charts",
3
- "version": "0.5.2-canary.e4f8e4e",
3
+ "version": "0.5.2-canary.ea1b9b5",
4
4
  "description": "Astryx charts — a config-model data visualization library (d3-based). Published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@stylexjs/stylex": ">=0.10.0",
48
- "@astryxdesign/core": "0.5.2-canary.e4f8e4e",
48
+ "@astryxdesign/core": "0.5.2-canary.ea1b9b5",
49
49
  "react": ">=19.0.0",
50
50
  "react-dom": ">=19.0.0"
51
51
  },
@@ -55,7 +55,7 @@
55
55
  "d3-shape": "^3.2.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@astryxdesign/cli": "0.5.2-canary.e4f8e4e",
58
+ "@astryxdesign/cli": "0.5.2-canary.ea1b9b5",
59
59
  "@types/d3-scale": "^4.0.9",
60
60
  "@types/d3-shape": "^3.1.8"
61
61
  },
@@ -2,15 +2,18 @@
2
2
 
3
3
  /**
4
4
  * @file Chart.test.tsx
5
- * @input Uses vitest, @testing-library/react, Chart root + bar mark
6
- * @output Unit tests for Chart accessibility (WCAG 1.1.1)
5
+ * @input Uses vitest, @testing-library/react, Chart root + bar/line marks
6
+ * @output Unit tests for Chart accessibility (WCAG 1.1.1) and the chart
7
+ * root's functional contract — measurement gate, per-series render
8
+ * delegation, event layer geometry, palette assignment, and the
9
+ * declarative legend/tooltip slots (issue #4295 viz coverage)
7
10
  * @position Testing; validates Chart.tsx accessible name + data-table fallback
8
11
  */
9
12
 
10
13
  import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
11
- import {render, screen, act} from '@testing-library/react';
14
+ import {render, screen, act, within} from '@testing-library/react';
12
15
  import {Chart} from './Chart';
13
- import {bar} from './marks';
16
+ import {bar, line} from './marks';
14
17
 
15
18
  const data = [
16
19
  {month: 'Jan', revenue: 100, profit: 20},
@@ -80,6 +83,25 @@ describe('Chart accessible name', () => {
80
83
  screen.getByRole('img', {name: 'Quarterly revenue'}),
81
84
  ).toBeInTheDocument();
82
85
  });
86
+
87
+ it('describes the svg from the subtitle and mirrors the title in an svg <title>', () => {
88
+ const {container} = render(
89
+ <Chart
90
+ data={data}
91
+ xKey="month"
92
+ series={[bar('revenue')]}
93
+ title="Quarterly revenue"
94
+ subtitle="Revenue per month"
95
+ />,
96
+ );
97
+ reportWidth(600);
98
+
99
+ const svg = container.querySelector('svg')!;
100
+ expect(svg.querySelector('title')!.textContent).toBe('Quarterly revenue');
101
+ const desc = svg.querySelector('desc')!;
102
+ expect(desc.textContent).toBe('Revenue per month');
103
+ expect(svg.getAttribute('aria-describedby')).toBe(desc.id);
104
+ });
83
105
  });
84
106
 
85
107
  describe('Chart data table fallback', () => {
@@ -127,3 +149,113 @@ describe('Chart data table fallback', () => {
127
149
  expect(screen.queryByRole('table')).not.toBeInTheDocument();
128
150
  });
129
151
  });
152
+
153
+ describe('Chart measurement gate', () => {
154
+ it('renders only an empty height-reserving placeholder until the container reports a width', () => {
155
+ const {container} = render(
156
+ <Chart data={data} xKey="month" series={[bar('revenue')]} height={300} />,
157
+ );
158
+
159
+ expect(container.querySelector('svg')).toBeNull();
160
+ const placeholder = container.firstElementChild as HTMLElement;
161
+ expect(placeholder.tagName).toBe('DIV');
162
+ expect(placeholder.style.height).toBe('300px');
163
+ expect(placeholder.childElementCount).toBe(0);
164
+ });
165
+ });
166
+
167
+ describe('Chart render delegation', () => {
168
+ it('renders one clipped group per series', () => {
169
+ const {container} = render(
170
+ <Chart
171
+ data={data}
172
+ xKey="month"
173
+ series={[bar('revenue'), line('profit')]}
174
+ />,
175
+ );
176
+ reportWidth(600);
177
+
178
+ const plot = container.querySelector('g[clip-path]');
179
+ expect(plot).not.toBeNull();
180
+ const seriesGroups = Array.from(plot!.children).filter(
181
+ el => el.tagName === 'g',
182
+ );
183
+ expect(seriesGroups).toHaveLength(2);
184
+ // Each series' render() output lands in its own group: bar paths in the
185
+ // first, the line's stroke path in the second.
186
+ expect(seriesGroups[0].querySelectorAll('path').length).toBeGreaterThan(0);
187
+ expect(seriesGroups[1].querySelectorAll('path').length).toBeGreaterThan(0);
188
+ });
189
+
190
+ it('sizes the transparent event-capture rect to the plot area inside the default margins', () => {
191
+ const {container} = render(
192
+ <Chart data={data} xKey="month" series={[bar('revenue')]} height={300} />,
193
+ );
194
+ reportWidth(600);
195
+
196
+ // 600 - 48 (left) - 24 (right) = 528; 300 - 24 (top) - 32 (bottom) = 244.
197
+ const eventRect = container.querySelector(
198
+ 'svg > g > rect[fill="transparent"]',
199
+ );
200
+ expect(eventRect).not.toBeNull();
201
+ expect(eventRect).toHaveAttribute('width', '528');
202
+ expect(eventRect).toHaveAttribute('height', '244');
203
+ });
204
+
205
+ it('assigns distinct palette colors to series that do not declare one', () => {
206
+ const {container} = render(
207
+ <Chart
208
+ data={data}
209
+ xKey="month"
210
+ series={[line('revenue'), line('profit')]}
211
+ />,
212
+ );
213
+ reportWidth(600);
214
+
215
+ const strokes = Array.from(
216
+ container.querySelectorAll('g[clip-path] path[stroke]'),
217
+ ).map(p => p.getAttribute('stroke'));
218
+ expect(strokes).toHaveLength(2);
219
+ expect(strokes[0]).toBeTruthy();
220
+ expect(strokes[1]).toBeTruthy();
221
+ expect(strokes[0]).not.toBe(strokes[1]);
222
+ });
223
+ });
224
+
225
+ describe('Chart legend slot', () => {
226
+ it('derives legend items from series labels when legend is enabled', () => {
227
+ render(
228
+ <Chart
229
+ data={data}
230
+ xKey="month"
231
+ series={[bar('revenue', {label: 'Revenue'}), line('profit')]}
232
+ legend
233
+ />,
234
+ );
235
+ reportWidth(600);
236
+
237
+ const list = screen.getByRole('list', {name: 'Chart legend'});
238
+ const rows = within(list).getAllByRole('listitem');
239
+ expect(rows).toHaveLength(2);
240
+ expect(within(rows[0]).getByText('Revenue')).toBeInTheDocument();
241
+ // Label falls back to the dataKey when not provided.
242
+ expect(within(rows[1]).getByText('profit')).toBeInTheDocument();
243
+ });
244
+ });
245
+
246
+ describe('Chart tooltip slot', () => {
247
+ it('mounts the grouped tooltip portal only when tooltip is enabled', () => {
248
+ const {unmount} = render(
249
+ <Chart data={data} xKey="month" series={[bar('revenue')]} />,
250
+ );
251
+ reportWidth(600);
252
+ expect(document.querySelector('[role="tooltip"]')).toBeNull();
253
+ unmount();
254
+
255
+ render(
256
+ <Chart data={data} xKey="month" series={[bar('revenue')]} tooltip />,
257
+ );
258
+ reportWidth(600);
259
+ expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
260
+ });
261
+ });
@@ -0,0 +1,131 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ChartAxis.test.tsx
5
+ * @input Uses vitest, @testing-library/react, Chart, ChartAxis, bar mark
6
+ * @output Functional tests for axis rendering — group semantics, band/linear
7
+ * tick labels, default edge-line rules, custom formatting, truncation,
8
+ * and label capping
9
+ * @position Colocated test for ChartAxis.tsx (issue #4295 viz coverage)
10
+ */
11
+
12
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
+ import {render, screen, act, within} from '@testing-library/react';
14
+ import {Chart} from './Chart';
15
+ import {ChartAxis} from './ChartAxis';
16
+ import {bar} from './marks/bar';
17
+
18
+ const DATA = [
19
+ {month: 'Jan', sales: 4},
20
+ {month: 'Feb', sales: 10},
21
+ {month: 'Mar', sales: 2},
22
+ ];
23
+
24
+ // Capture the ResizeObserver callback so tests can drive the reported width.
25
+ let resizeCallback: ResizeObserverCallback | undefined;
26
+
27
+ beforeEach(() => {
28
+ resizeCallback = undefined;
29
+ vi.stubGlobal(
30
+ 'ResizeObserver',
31
+ class {
32
+ constructor(cb: ResizeObserverCallback) {
33
+ resizeCallback = cb;
34
+ }
35
+ observe() {}
36
+ unobserve() {}
37
+ disconnect() {}
38
+ },
39
+ );
40
+ });
41
+
42
+ afterEach(() => {
43
+ vi.unstubAllGlobals();
44
+ });
45
+
46
+ function reportWidth(width: number) {
47
+ act(() => {
48
+ resizeCallback?.(
49
+ [{contentRect: {width}} as unknown as ResizeObserverEntry],
50
+ {} as ResizeObserver,
51
+ );
52
+ });
53
+ }
54
+
55
+ function renderChart(axes: React.ReactNode, data = DATA) {
56
+ const utils = render(
57
+ <Chart data={data} xKey="month" series={[bar('sales')]} axes={axes} />,
58
+ );
59
+ reportWidth(600);
60
+ return utils;
61
+ }
62
+
63
+ describe('ChartAxis bottom (band scale)', () => {
64
+ it('renders a labeled group with one tick label per category', () => {
65
+ renderChart(<ChartAxis position="bottom" />);
66
+ const axis = screen.getByRole('group', {name: 'bottom axis'});
67
+ expect(within(axis).getByText('Jan')).toBeInTheDocument();
68
+ expect(within(axis).getByText('Feb')).toBeInTheDocument();
69
+ expect(within(axis).getByText('Mar')).toBeInTheDocument();
70
+ });
71
+
72
+ it('is translated to the bottom edge of the plot and draws its edge line by default', () => {
73
+ renderChart(<ChartAxis position="bottom" />);
74
+ const axis = screen.getByRole('group', {name: 'bottom axis'});
75
+ // Inner height for the default 300px chart: 300 - 24 - 32 = 244.
76
+ expect(axis).toHaveAttribute('transform', 'translate(0,244)');
77
+ const edge = axis.querySelector('line')!;
78
+ expect(edge).not.toBeNull();
79
+ expect(edge).toHaveAttribute('x2', '528');
80
+ });
81
+
82
+ it('caps labels to maxTicks by evenly skipping categories', () => {
83
+ renderChart(<ChartAxis position="bottom" maxTicks={2} />);
84
+ const axis = screen.getByRole('group', {name: 'bottom axis'});
85
+ // 3 categories capped at 2 → step 2 → indices 0 and 2 survive.
86
+ expect(within(axis).getByText('Jan')).toBeInTheDocument();
87
+ expect(within(axis).getByText('Mar')).toBeInTheDocument();
88
+ expect(within(axis).queryByText('Feb')).not.toBeInTheDocument();
89
+ });
90
+
91
+ it('truncates long category labels with an ellipsis', () => {
92
+ renderChart(<ChartAxis position="bottom" truncate={4} />, [
93
+ {month: 'January', sales: 4},
94
+ {month: 'Feb', sales: 10},
95
+ ]);
96
+ const axis = screen.getByRole('group', {name: 'bottom axis'});
97
+ expect(within(axis).getByText('Janu…')).toBeInTheDocument();
98
+ // Short labels are left alone.
99
+ expect(within(axis).getByText('Feb')).toBeInTheDocument();
100
+ });
101
+ });
102
+
103
+ describe('ChartAxis left (linear scale)', () => {
104
+ it('renders d3-formatted value ticks without an edge line by default', () => {
105
+ renderChart(<ChartAxis position="left" />);
106
+ const axis = screen.getByRole('group', {name: 'left axis'});
107
+ // Bar series pins a zero baseline → domain [0, 10] → ticks 0,2,...,10.
108
+ expect(within(axis).getByText('0')).toBeInTheDocument();
109
+ expect(within(axis).getByText('4')).toBeInTheDocument();
110
+ expect(within(axis).getByText('10')).toBeInTheDocument();
111
+ // Documented default: only the bottom axis draws its edge line.
112
+ expect(axis.querySelector('line')).toBeNull();
113
+ });
114
+
115
+ it('applies a custom tick formatter', () => {
116
+ renderChart(
117
+ <ChartAxis position="left" tickFormat={v => `$${String(v)}`} />,
118
+ );
119
+ const axis = screen.getByRole('group', {name: 'left axis'});
120
+ expect(within(axis).getByText('$0')).toBeInTheDocument();
121
+ expect(within(axis).getByText('$10')).toBeInTheDocument();
122
+ });
123
+
124
+ it('showTicks draws a perpendicular mark per tick and forces the axis line on', () => {
125
+ renderChart(<ChartAxis position="left" showTicks />);
126
+ const axis = screen.getByRole('group', {name: 'left axis'});
127
+ const lines = axis.querySelectorAll('line');
128
+ // 6 ticks (0..10 by 2) + the forced edge line.
129
+ expect(lines).toHaveLength(7);
130
+ });
131
+ });
@@ -0,0 +1,121 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ChartGrid.test.tsx
5
+ * @input Uses vitest, @testing-library/react, Chart, ChartGrid, bar mark
6
+ * @output Functional tests for grid lines — horizontal default, the skipped
7
+ * y=0 line, and vertical lines at band centers
8
+ * @position Colocated test for ChartGrid.tsx (issue #4295 viz coverage)
9
+ */
10
+
11
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
12
+ import {render, act} from '@testing-library/react';
13
+ import {Chart} from './Chart';
14
+ import {ChartGrid} from './ChartGrid';
15
+ import {bar} from './marks/bar';
16
+
17
+ const DATA = [
18
+ {month: 'Jan', sales: 4},
19
+ {month: 'Feb', sales: 10},
20
+ {month: 'Mar', sales: 2},
21
+ ];
22
+
23
+ // Capture the ResizeObserver callback so tests can drive the reported width.
24
+ let resizeCallback: ResizeObserverCallback | undefined;
25
+
26
+ beforeEach(() => {
27
+ resizeCallback = undefined;
28
+ vi.stubGlobal(
29
+ 'ResizeObserver',
30
+ class {
31
+ constructor(cb: ResizeObserverCallback) {
32
+ resizeCallback = cb;
33
+ }
34
+ observe() {}
35
+ unobserve() {}
36
+ disconnect() {}
37
+ },
38
+ );
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.unstubAllGlobals();
43
+ });
44
+
45
+ function reportWidth(width: number) {
46
+ act(() => {
47
+ resizeCallback?.(
48
+ [{contentRect: {width}} as unknown as ResizeObserverEntry],
49
+ {} as ResizeObserver,
50
+ );
51
+ });
52
+ }
53
+
54
+ function renderGrid(grid: React.ReactNode) {
55
+ const utils = render(
56
+ <Chart data={DATA} xKey="month" series={[bar('sales')]} grid={grid} />,
57
+ );
58
+ reportWidth(600);
59
+ // Marks render paths and the event layer is a rect, so every <line> in the
60
+ // svg belongs to the grid (no axes are mounted in these tests).
61
+ const lines = Array.from(utils.container.querySelectorAll('svg line'));
62
+ return {...utils, lines};
63
+ }
64
+
65
+ describe('ChartGrid', () => {
66
+ it('draws horizontal lines across the plot for every y tick except the y=0 baseline', () => {
67
+ const {lines} = renderGrid(<ChartGrid />);
68
+ // Domain [0, 10] → ticks 0,2,4,6,8,10; the 0 line is skipped → 5 lines.
69
+ expect(lines).toHaveLength(5);
70
+ for (const l of lines) {
71
+ expect(l.getAttribute('x1')).toBe('0');
72
+ expect(l.getAttribute('x2')).toBe('528'); // full plot width
73
+ expect(l.getAttribute('y1')).toBe(l.getAttribute('y2')); // horizontal
74
+ // y=0 maps to the plot bottom (244) — never double-drawn over the axis.
75
+ expect(l.getAttribute('y1')).not.toBe('244');
76
+ }
77
+ });
78
+
79
+ it('draws a vertical line at each band center when vertical is enabled', () => {
80
+ const {lines} = renderGrid(<ChartGrid horizontal={false} vertical />);
81
+ expect(lines).toHaveLength(3); // one per category
82
+ for (const l of lines) {
83
+ expect(l.getAttribute('x1')).toBe(l.getAttribute('x2')); // vertical
84
+ expect(l.getAttribute('y1')).toBe('0');
85
+ expect(l.getAttribute('y2')).toBe('244'); // full plot height
86
+ }
87
+ // Band centers are strictly increasing across categories.
88
+ const xs = lines.map(l => Number(l.getAttribute('x1')));
89
+ expect(xs[0]).toBeLessThan(xs[1]);
90
+ expect(xs[1]).toBeLessThan(xs[2]);
91
+ });
92
+
93
+ it('combines horizontal and vertical lines when both are enabled', () => {
94
+ const {lines} = renderGrid(<ChartGrid horizontal vertical />);
95
+ expect(lines).toHaveLength(8); // 5 horizontal + 3 vertical
96
+ });
97
+
98
+ it('places vertical lines at d3 tick values on a linear x scale', () => {
99
+ const numeric = [
100
+ {month: 0, sales: 1},
101
+ {month: 5, sales: 7},
102
+ {month: 10, sales: 3},
103
+ ];
104
+ const utils = render(
105
+ <Chart
106
+ data={numeric}
107
+ xKey="month"
108
+ series={[bar('sales')]}
109
+ grid={<ChartGrid horizontal={false} vertical />}
110
+ />,
111
+ );
112
+ reportWidth(600);
113
+ const lines = Array.from(utils.container.querySelectorAll('svg line'));
114
+ // Linear domain [0, 10] → ticks 0,2,4,6,8,10 → 6 evenly spaced lines.
115
+ expect(lines).toHaveLength(6);
116
+ const xs = lines.map(l => Number(l.getAttribute('x1')));
117
+ expect(xs[0]).toBe(0);
118
+ expect(xs[5]).toBe(528);
119
+ expect(xs[1] - xs[0]).toBeCloseTo(528 / 5);
120
+ });
121
+ });
@@ -0,0 +1,54 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ChartLegend.test.tsx
5
+ * @input Uses vitest, @testing-library/react, ChartLegend
6
+ * @output Functional tests for the standalone chart legend
7
+ * @position Colocated test for ChartLegend.tsx (issue #4295 viz coverage)
8
+ */
9
+
10
+ import {describe, it, expect} from 'vitest';
11
+ import {render, screen, within} from '@testing-library/react';
12
+ import {ChartLegend} from './ChartLegend';
13
+
14
+ const items = [
15
+ {label: 'Revenue', color: '#ff0000', type: 'bar'},
16
+ {label: 'Profit', color: '#00ff00', type: 'line'},
17
+ ];
18
+
19
+ describe('ChartLegend', () => {
20
+ it('exposes list semantics with one item per legend entry', () => {
21
+ render(<ChartLegend items={items} />);
22
+ const list = screen.getByRole('list', {name: 'Chart legend'});
23
+ const rows = within(list).getAllByRole('listitem');
24
+ expect(rows).toHaveLength(2);
25
+ expect(within(rows[0]).getByText('Revenue')).toBeInTheDocument();
26
+ expect(within(rows[1]).getByText('Profit')).toBeInTheDocument();
27
+ });
28
+
29
+ it('renders a decorative swatch in each entry with the item color', () => {
30
+ render(<ChartLegend items={items} />);
31
+ const rows = screen.getAllByRole('listitem');
32
+ const firstSwatch = rows[0].querySelector('[aria-hidden="true"]');
33
+ const secondSwatch = rows[1].querySelector('[aria-hidden="true"]');
34
+ expect(firstSwatch).not.toBeNull();
35
+ expect(firstSwatch!.getAttribute('style')).toContain('#ff0000');
36
+ expect(secondSwatch!.getAttribute('style')).toContain('#00ff00');
37
+ });
38
+
39
+ it('renders nothing when there are no items', () => {
40
+ const {container} = render(<ChartLegend items={[]} />);
41
+ expect(container.firstChild).toBeNull();
42
+ });
43
+
44
+ it('renders nothing when items are omitted entirely', () => {
45
+ const {container} = render(<ChartLegend />);
46
+ expect(container.firstChild).toBeNull();
47
+ });
48
+
49
+ it('keeps list semantics in the vertical (start/end) orientation', () => {
50
+ render(<ChartLegend items={items} position="end" />);
51
+ const list = screen.getByRole('list', {name: 'Chart legend'});
52
+ expect(within(list).getAllByRole('listitem')).toHaveLength(2);
53
+ });
54
+ });
@@ -0,0 +1,50 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ChartSwatch.test.tsx
5
+ * @input Uses vitest, @testing-library/react, ChartSwatch
6
+ * @output Functional tests for the ChartSwatch color primitive
7
+ * @position Colocated test for ChartSwatch.tsx (issue #4295 viz coverage)
8
+ */
9
+
10
+ import {describe, it, expect} from 'vitest';
11
+ import {render} from '@testing-library/react';
12
+ import {ChartSwatch, swatchVariantForType} from './ChartSwatch';
13
+
14
+ describe('ChartSwatch', () => {
15
+ it('renders a decorative element hidden from the accessibility tree', () => {
16
+ const {container} = render(<ChartSwatch color="#ff0000" />);
17
+ const swatch = container.firstElementChild;
18
+ expect(swatch).not.toBeNull();
19
+ expect(swatch).toHaveAttribute('aria-hidden', 'true');
20
+ });
21
+
22
+ it('applies the given color through a dynamic style', () => {
23
+ const {container} = render(<ChartSwatch color="#ff0000" />);
24
+ const swatch = container.firstElementChild as HTMLElement;
25
+ expect(swatch.getAttribute('style')).toContain('#ff0000');
26
+ });
27
+
28
+ it('styles the square and line variants differently', () => {
29
+ const {container: square} = render(<ChartSwatch color="#000" />);
30
+ const {container: line} = render(
31
+ <ChartSwatch color="#000" variant="line" />,
32
+ );
33
+ expect(square.firstElementChild!.className).not.toBe(
34
+ line.firstElementChild!.className,
35
+ );
36
+ });
37
+ });
38
+
39
+ describe('swatchVariantForType', () => {
40
+ it('maps bar series to the square chip', () => {
41
+ expect(swatchVariantForType('bar')).toBe('square');
42
+ });
43
+
44
+ it('maps every non-bar series (and unknown types) to the line stroke', () => {
45
+ expect(swatchVariantForType('line')).toBe('line');
46
+ expect(swatchVariantForType('dot')).toBe('line');
47
+ expect(swatchVariantForType('area')).toBe('line');
48
+ expect(swatchVariantForType(undefined)).toBe('line');
49
+ });
50
+ });
@@ -0,0 +1,282 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file ChartTooltip.test.tsx
5
+ * @input Uses vitest, @testing-library/react, ChartTooltip, ChartProvider,
6
+ * d3-scale
7
+ * @output Functional tests for the grouped tooltip — portal lifecycle, pointer
8
+ * subscription, card content/visibility, hover dots, crosshair vs band
9
+ * highlight, and the custom render contract
10
+ * @position Colocated test for ChartTooltip.tsx (issue #4295 viz coverage).
11
+ * Renders under a hand-built ChartProvider so the pointer stream can
12
+ * be driven directly (jsdom has no SVG geometry for the real one).
13
+ */
14
+
15
+ import {describe, it, expect, vi} from 'vitest';
16
+ import {render, act} from '@testing-library/react';
17
+ import {scaleLinear, scaleBand} from 'd3-scale';
18
+ import {ChartTooltip} from './ChartTooltip';
19
+ import {ChartProvider} from './ChartContext';
20
+ import type {
21
+ ChartContext,
22
+ ChartPointerEvent,
23
+ ResolvedPoint,
24
+ SeriesDef,
25
+ } from './types';
26
+
27
+ const DATA = [
28
+ {month: 'Jan', sales: 3, profit: 1},
29
+ {month: 'Feb', sales: 7, profit: 2},
30
+ {month: 'Mar', sales: 5, profit: 4},
31
+ ];
32
+
33
+ const SALES_POINTS: ResolvedPoint[] = [
34
+ {px: 50, py: 120, py0: 200, dataIndex: 0},
35
+ {px: 250, py: 40, py0: 200, dataIndex: 1},
36
+ {px: 450, py: 80, py0: 200, dataIndex: 2},
37
+ ];
38
+
39
+ /** Minimal post-layout series def, annotated the way computeLayout would. */
40
+ function makeSeries(overrides: Partial<SeriesDef> = {}): SeriesDef {
41
+ return {
42
+ type: 'line',
43
+ key: 'sales',
44
+ dataKeys: ['sales'],
45
+ label: 'Sales',
46
+ color: '#ff0000',
47
+ layout: {},
48
+ resolve: () => [],
49
+ render: () => null,
50
+ _uid: '0:sales',
51
+ ...overrides,
52
+ } as SeriesDef;
53
+ }
54
+
55
+ interface Harness {
56
+ ctx: ChartContext;
57
+ handlers: Set<(e: ChartPointerEvent) => void>;
58
+ dispatch: (e: ChartPointerEvent) => void;
59
+ }
60
+
61
+ function makeHarness(overrides: Partial<ChartContext> = {}): Harness {
62
+ const handlers = new Set<(e: ChartPointerEvent) => void>();
63
+ const ctx: ChartContext = {
64
+ width: 500,
65
+ height: 200,
66
+ margin: {top: 24, right: 24, bottom: 32, left: 48},
67
+ data: DATA,
68
+ xKey: 'month',
69
+ xScale: scaleBand<string>()
70
+ .domain(['Jan', 'Feb', 'Mar'])
71
+ .range([0, 500])
72
+ .padding(0.2),
73
+ yScale: scaleLinear().domain([0, 10]).range([200, 0]),
74
+ resolved: new Map([['0:sales', SALES_POINTS]]),
75
+ onPointer: handler => {
76
+ handlers.add(handler);
77
+ return () => {
78
+ handlers.delete(handler);
79
+ };
80
+ },
81
+ svgRef: {current: null},
82
+ ...overrides,
83
+ };
84
+ const dispatch = (e: ChartPointerEvent) => {
85
+ act(() => {
86
+ for (const h of handlers) {
87
+ h(e);
88
+ }
89
+ });
90
+ };
91
+ return {ctx, handlers, dispatch};
92
+ }
93
+
94
+ function renderTooltip(
95
+ harness: Harness,
96
+ props: React.ComponentProps<typeof ChartTooltip> = {},
97
+ ) {
98
+ return render(
99
+ <ChartProvider value={harness.ctx}>
100
+ <svg>
101
+ <g>
102
+ <ChartTooltip series={[makeSeries()]} {...props} />
103
+ </g>
104
+ </svg>
105
+ </ChartProvider>,
106
+ );
107
+ }
108
+
109
+ const hoverAt = (index: number): ChartPointerEvent => ({
110
+ x: SALES_POINTS[index].px,
111
+ y: 100,
112
+ nearest: {...SALES_POINTS[index], seriesKey: 'sales'},
113
+ active: false,
114
+ });
115
+
116
+ const pointerLeave: ChartPointerEvent = {
117
+ x: -1,
118
+ y: -1,
119
+ nearest: null,
120
+ active: false,
121
+ };
122
+
123
+ const card = () => document.querySelector('[role="tooltip"]') as HTMLElement;
124
+
125
+ describe('ChartTooltip card', () => {
126
+ it('portals an initially empty tooltip card into document.body', () => {
127
+ const harness = makeHarness();
128
+ const {container} = renderTooltip(harness);
129
+ expect(card()).not.toBeNull();
130
+ expect(card().parentElement).toBe(document.body);
131
+ // The card lives outside the chart subtree.
132
+ expect(container.querySelector('[role="tooltip"]')).toBeNull();
133
+ expect(card().textContent).toBe('');
134
+ });
135
+
136
+ it('shows the hovered x value and the bare value for a single series', () => {
137
+ const harness = makeHarness();
138
+ renderTooltip(harness);
139
+ harness.dispatch(hoverAt(1));
140
+ expect(card().textContent).toContain('Feb');
141
+ expect(card().textContent).toContain('7');
142
+ });
143
+
144
+ it('lists a labeled row per series for multi-series charts', () => {
145
+ const profitPoints = SALES_POINTS.map(p => ({...p, py: p.py + 30}));
146
+ const harness = makeHarness({
147
+ resolved: new Map([
148
+ ['0:sales', SALES_POINTS],
149
+ ['1:profit', profitPoints],
150
+ ]),
151
+ });
152
+ const series = [
153
+ makeSeries(),
154
+ makeSeries({
155
+ key: 'profit',
156
+ dataKeys: ['profit'],
157
+ label: 'Profit',
158
+ _uid: '1:profit',
159
+ }),
160
+ ];
161
+ renderTooltip(harness, {series});
162
+ harness.dispatch(hoverAt(1));
163
+ expect(card().textContent).toContain('Sales');
164
+ expect(card().textContent).toContain('7');
165
+ expect(card().textContent).toContain('Profit');
166
+ expect(card().textContent).toContain('2');
167
+ });
168
+
169
+ it('reveals the card on hover and hides it again when the pointer leaves', () => {
170
+ const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
171
+ const harness = makeHarness({svgRef: {current: svgEl}});
172
+ renderTooltip(harness);
173
+
174
+ harness.dispatch(hoverAt(0));
175
+ expect(card().style.display).toBe('block');
176
+
177
+ harness.dispatch(pointerLeave);
178
+ expect(card().style.display).toBe('none');
179
+ });
180
+
181
+ it('pins the card beside the hovered point for placement="right"', () => {
182
+ const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
183
+ const harness = makeHarness({svgRef: {current: svgEl}});
184
+ renderTooltip(harness, {placement: 'right'});
185
+ harness.dispatch(hoverAt(1));
186
+ // jsdom rects are zero, so left = svgLeft(0) + margin.left(48) + px(250)
187
+ // + the 8px gap.
188
+ expect(card().style.display).toBe('block');
189
+ expect(card().style.left).toBe('306px');
190
+ });
191
+
192
+ it('hides the card when the hovered index no longer exists in data', () => {
193
+ const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
194
+ const harness = makeHarness({svgRef: {current: svgEl}});
195
+ renderTooltip(harness);
196
+ harness.dispatch({
197
+ x: 0,
198
+ y: 0,
199
+ nearest: {px: 50, py: 120, py0: 200, dataIndex: 99, seriesKey: 'sales'},
200
+ active: false,
201
+ });
202
+ expect(card().textContent).toBe('');
203
+ expect(card().style.display).toBe('none');
204
+ });
205
+ });
206
+
207
+ describe('ChartTooltip hover indicators', () => {
208
+ it('marks the hovered point of each series with a dot in the series color', () => {
209
+ const harness = makeHarness();
210
+ const {container} = renderTooltip(harness);
211
+ harness.dispatch(hoverAt(1));
212
+ const dot = container.querySelector('circle')!;
213
+ expect(dot).not.toBeNull();
214
+ expect(dot).toHaveAttribute('cx', '250');
215
+ expect(dot).toHaveAttribute('cy', '40');
216
+ expect(dot).toHaveAttribute('stroke', '#ff0000');
217
+ });
218
+
219
+ it('skips the hover dot when the series value is missing at that index', () => {
220
+ const gappy = SALES_POINTS.map((p, i) => (i === 1 ? {...p, py: NaN} : p));
221
+ const harness = makeHarness({resolved: new Map([['0:sales', gappy]])});
222
+ const {container} = renderTooltip(harness);
223
+ harness.dispatch(hoverAt(1));
224
+ expect(container.querySelector('circle')).toBeNull();
225
+ });
226
+
227
+ it('draws a vertical crosshair through the hovered x for line charts', () => {
228
+ const harness = makeHarness();
229
+ const {container} = renderTooltip(harness);
230
+ harness.dispatch(hoverAt(1));
231
+ const crosshair = container.querySelector('line')!;
232
+ expect(crosshair).not.toBeNull();
233
+ expect(crosshair.getAttribute('x1')).toBe(crosshair.getAttribute('x2'));
234
+ expect(crosshair).toHaveAttribute('y1', '0');
235
+ expect(crosshair).toHaveAttribute('y2', '200');
236
+ });
237
+
238
+ it('draws a soft band-highlight rect instead of a crosshair when the chart has bars', () => {
239
+ const harness = makeHarness();
240
+ const {container} = renderTooltip(harness, {
241
+ series: [makeSeries({type: 'bar'})],
242
+ });
243
+ harness.dispatch(hoverAt(1));
244
+ expect(container.querySelector('line')).toBeNull();
245
+ const highlight = container.querySelector('rect')!;
246
+ expect(highlight).not.toBeNull();
247
+ expect(Number(highlight.getAttribute('width'))).toBeGreaterThan(0);
248
+ });
249
+ });
250
+
251
+ describe('ChartTooltip custom render', () => {
252
+ it('replaces the card body and receives the x value plus one row per series', () => {
253
+ const renderFn = vi.fn(() => <span>custom body</span>);
254
+ const harness = makeHarness();
255
+ renderTooltip(harness, {render: renderFn});
256
+ harness.dispatch(hoverAt(1));
257
+ expect(card().textContent).toBe('custom body');
258
+ expect(renderFn).toHaveBeenCalledWith('Feb', [
259
+ expect.objectContaining({key: 'sales', label: 'Sales', value: 7}),
260
+ ]);
261
+ });
262
+
263
+ it('hides the card when the custom render opts out by returning null', () => {
264
+ const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
265
+ const harness = makeHarness({svgRef: {current: svgEl}});
266
+ renderTooltip(harness, {render: () => null});
267
+ harness.dispatch(hoverAt(1));
268
+ expect(card().textContent).toBe('');
269
+ expect(card().style.display).toBe('none');
270
+ });
271
+ });
272
+
273
+ describe('ChartTooltip lifecycle', () => {
274
+ it('removes the portal and unsubscribes from the pointer stream on unmount', () => {
275
+ const harness = makeHarness();
276
+ const {unmount} = renderTooltip(harness);
277
+ expect(harness.handlers.size).toBe(1);
278
+ unmount();
279
+ expect(card()).toBeNull();
280
+ expect(harness.handlers.size).toBe(0);
281
+ });
282
+ });
@@ -0,0 +1,443 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file marks/marks.test.tsx
5
+ * @input Uses vitest, @testing-library/react, Chart, all mark factories
6
+ * @output Functional tests for the mark renderers — SVG shape output per mark
7
+ * type, missing-value gap handling, and GL marks degrading safely when
8
+ * WebGL is unavailable (jsdom has no canvas contexts)
9
+ * @position Colocated test for the marks/ directory (issue #4295 viz coverage)
10
+ */
11
+
12
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
+ import {render, act, screen} from '@testing-library/react';
14
+ import {Chart} from '../Chart';
15
+ import {bar} from './bar';
16
+ import {line} from './line';
17
+ import {dot} from './dot';
18
+ import {area} from './area';
19
+ import {band} from './band';
20
+ import {candlestick} from './candlestick';
21
+ import {errorBar} from './errorBar';
22
+ import {referenceLine} from './referenceLine';
23
+ import {dotGL} from './dotGL';
24
+ import {dotGLInteractive} from './dotGLInteractive';
25
+ import {heatmapGL} from './heatmapGL';
26
+ import {streamGL, type StreamGLHandle} from './streamGL';
27
+ import type {SeriesDef} from '../types';
28
+
29
+ const DATA = [
30
+ {m: 'A', v: 4},
31
+ {m: 'B', v: 7},
32
+ {m: 'C', v: 2},
33
+ ];
34
+
35
+ // One missing value in the middle — marks must gap, not draw at the baseline.
36
+ const GAPPY = [
37
+ {m: 'A', v: 4},
38
+ {m: 'B', v: null},
39
+ {m: 'C', v: 2},
40
+ ];
41
+
42
+ const BOUNDS = [
43
+ {m: 'A', hi: 5, lo: 1},
44
+ {m: 'B', hi: 8, lo: 4},
45
+ {m: 'C', hi: 6, lo: 2},
46
+ ];
47
+
48
+ // Capture the ResizeObserver callback so tests can drive the reported width.
49
+ let resizeCallback: ResizeObserverCallback | undefined;
50
+
51
+ beforeEach(() => {
52
+ resizeCallback = undefined;
53
+ vi.stubGlobal(
54
+ 'ResizeObserver',
55
+ class {
56
+ constructor(cb: ResizeObserverCallback) {
57
+ resizeCallback = cb;
58
+ }
59
+ observe() {}
60
+ unobserve() {}
61
+ disconnect() {}
62
+ },
63
+ );
64
+ });
65
+
66
+ afterEach(() => {
67
+ vi.unstubAllGlobals();
68
+ });
69
+
70
+ function reportWidth(width: number) {
71
+ act(() => {
72
+ resizeCallback?.(
73
+ [{contentRect: {width}} as unknown as ResizeObserverEntry],
74
+ {} as ResizeObserver,
75
+ );
76
+ });
77
+ }
78
+
79
+ /** Render a chart, report a width, and return the clipped plot group. */
80
+ function renderMarks(
81
+ series: SeriesDef[],
82
+ data: Record<string, unknown>[] = DATA,
83
+ xKey = 'm',
84
+ extraProps: Partial<React.ComponentProps<typeof Chart>> = {},
85
+ ) {
86
+ const utils = render(
87
+ <Chart data={data} xKey={xKey} series={series} {...extraProps} />,
88
+ );
89
+ reportWidth(600);
90
+ const plot = utils.container.querySelector('g[clip-path]')!;
91
+ return {...utils, plot};
92
+ }
93
+
94
+ describe('bar', () => {
95
+ it('renders one top-rounded path per datum in the configured color', () => {
96
+ const {plot} = renderMarks([bar('v', {color: '#123456'})]);
97
+ const paths = plot.querySelectorAll('path');
98
+ expect(paths).toHaveLength(3);
99
+ for (const p of paths) {
100
+ expect(p).toHaveAttribute('fill', '#123456');
101
+ expect(p.getAttribute('d')).toMatch(/^M/);
102
+ // Positive bars round the top corners (default radius 4).
103
+ expect(p.getAttribute('d')).toContain('Q');
104
+ }
105
+ });
106
+
107
+ it('skips bars whose value is missing instead of drawing them at the baseline', () => {
108
+ const {plot} = renderMarks([bar('v')], GAPPY);
109
+ expect(plot.querySelectorAll('path')).toHaveLength(2);
110
+ });
111
+
112
+ it('stacked bars round only the topmost layer', () => {
113
+ const stacked = [
114
+ {m: 'A', a: 2, b: 3},
115
+ {m: 'B', a: 4, b: 1},
116
+ ];
117
+ const {plot} = renderMarks(
118
+ [bar('a', {stack: 'total'}), bar('b', {stack: 'total'})],
119
+ stacked,
120
+ );
121
+ const groups = Array.from(plot.children).filter(el => el.tagName === 'g');
122
+ const [lowerPaths, upperPaths] = groups.map(g =>
123
+ Array.from(g.querySelectorAll('path')),
124
+ );
125
+ expect(lowerPaths).toHaveLength(2);
126
+ expect(upperPaths).toHaveLength(2);
127
+ // The lower layer is drawn square; only the stack top gets rounded corners.
128
+ for (const p of lowerPaths) {
129
+ expect(p.getAttribute('d')).not.toContain('Q');
130
+ }
131
+ for (const p of upperPaths) {
132
+ expect(p.getAttribute('d')).toContain('Q');
133
+ }
134
+ });
135
+
136
+ it('grouped bars render side by side within the band', () => {
137
+ const grouped = [
138
+ {m: 'A', a: 2, b: 3},
139
+ {m: 'B', a: 4, b: 1},
140
+ ];
141
+ const {plot} = renderMarks(
142
+ [bar('a', {group: 'g'}), bar('b', {group: 'g'})],
143
+ grouped,
144
+ );
145
+ const groups = Array.from(plot.children).filter(el => el.tagName === 'g');
146
+ const firstX = (g: Element) =>
147
+ Number(
148
+ g
149
+ .querySelector('path')!
150
+ .getAttribute('d')!
151
+ .match(/^M([\d.]+)/)![1],
152
+ );
153
+ // For the same category, the second group's sub-bar starts further right.
154
+ expect(firstX(groups[1])).toBeGreaterThan(firstX(groups[0]));
155
+ });
156
+ });
157
+
158
+ describe('line', () => {
159
+ it('renders a single stroke path with the configured color and width', () => {
160
+ const {plot} = renderMarks([line('v', {color: '#ff0000', strokeWidth: 3})]);
161
+ const paths = plot.querySelectorAll('path');
162
+ expect(paths).toHaveLength(1);
163
+ expect(paths[0]).toHaveAttribute('stroke', '#ff0000');
164
+ expect(paths[0]).toHaveAttribute('stroke-width', '3');
165
+ expect(paths[0]).toHaveAttribute('fill', 'none');
166
+ expect(paths[0].getAttribute('d')).toMatch(/^M/);
167
+ });
168
+
169
+ it('draws per-point dots only where the value is finite', () => {
170
+ const {plot} = renderMarks([line('v', {dots: true})], GAPPY);
171
+ expect(plot.querySelectorAll('circle')).toHaveLength(2);
172
+ });
173
+ });
174
+
175
+ describe('dot', () => {
176
+ it('renders one circle per datum with the configured radius', () => {
177
+ const {plot} = renderMarks([dot('v', {radius: 5})]);
178
+ const circles = plot.querySelectorAll('circle');
179
+ expect(circles).toHaveLength(3);
180
+ for (const c of circles) {
181
+ expect(c).toHaveAttribute('r', '5');
182
+ expect(c).toHaveAttribute('opacity', '0.8'); // default
183
+ }
184
+ });
185
+
186
+ it('skips points whose value is missing', () => {
187
+ const {plot} = renderMarks([dot('v')], GAPPY);
188
+ expect(plot.querySelectorAll('circle')).toHaveLength(2);
189
+ });
190
+
191
+ it('dodge spreads coincident points horizontally, centered on the shared x', () => {
192
+ // Two rows in the same category land on the same band center.
193
+ const coincident = [
194
+ {m: 'A', v: 4},
195
+ {m: 'A', v: 6},
196
+ {m: 'B', v: 2},
197
+ ];
198
+ const {plot} = renderMarks(
199
+ [dot('v', {dodge: true, radius: 4})],
200
+ coincident,
201
+ );
202
+ const circles = Array.from(plot.querySelectorAll('circle'));
203
+ expect(circles).toHaveLength(3);
204
+ const [a1, a2] = circles.slice(0, 2).map(c => Number(c.getAttribute('cx')));
205
+ // Spacing is radius * 2 + 1 = 9px, split symmetrically around the center.
206
+ expect(a2 - a1).toBeCloseTo(9);
207
+ const bandCenter = (a1 + a2) / 2;
208
+ expect(Number(circles[0].getAttribute('cx'))).toBeCloseTo(bandCenter - 4.5);
209
+ });
210
+ });
211
+
212
+ describe('area', () => {
213
+ it('renders a translucent fill path plus a stroke path by default', () => {
214
+ const {plot} = renderMarks([area('v', {color: '#00aa00'})]);
215
+ const paths = plot.querySelectorAll('path');
216
+ expect(paths).toHaveLength(2);
217
+ expect(paths[0]).toHaveAttribute('fill', '#00aa00');
218
+ expect(paths[0]).toHaveAttribute('fill-opacity', '0.3');
219
+ expect(paths[1]).toHaveAttribute('fill', 'none');
220
+ expect(paths[1]).toHaveAttribute('stroke', '#00aa00');
221
+ });
222
+
223
+ it('omits the stroke path when stroke is disabled', () => {
224
+ const {plot} = renderMarks([area('v', {stroke: false})]);
225
+ expect(plot.querySelectorAll('path')).toHaveLength(1);
226
+ });
227
+
228
+ it('gradient mode defines a per-series gradient and fills with it', () => {
229
+ const {plot} = renderMarks([area('v', {gradient: true})]);
230
+ const gradient = plot.querySelector('linearGradient')!;
231
+ expect(gradient).not.toBeNull();
232
+ expect(gradient.querySelectorAll('stop')).toHaveLength(2);
233
+ const fillPath = plot.querySelector('path')!;
234
+ expect(fillPath.getAttribute('fill')).toBe(`url(#${gradient.id})`);
235
+ });
236
+ });
237
+
238
+ describe('band', () => {
239
+ it('fills the region between the upper and lower bounds', () => {
240
+ const {plot} = renderMarks([band({upper: 'hi', lower: 'lo'})], BOUNDS);
241
+ const paths = plot.querySelectorAll('path');
242
+ expect(paths).toHaveLength(1);
243
+ expect(paths[0]).toHaveAttribute('fill-opacity', '0.15'); // default
244
+ expect(paths[0].getAttribute('d')).toMatch(/^M/);
245
+ });
246
+
247
+ it('renders nothing when no point has both bounds', () => {
248
+ const missing = [
249
+ {m: 'A', lo: 1},
250
+ {m: 'B', lo: 4},
251
+ ];
252
+ const {plot} = renderMarks([band({upper: 'hi', lower: 'lo'})], missing);
253
+ expect(plot.querySelectorAll('path')).toHaveLength(0);
254
+ });
255
+ });
256
+
257
+ describe('candlestick', () => {
258
+ const OHLC = [
259
+ {m: 'A', o: 2, h: 6, l: 1, c: 5}, // up: close >= open
260
+ {m: 'B', o: 6, h: 7, l: 2, c: 3}, // down
261
+ {m: 'C', o: 3, h: 5, l: 2, c: 4}, // up
262
+ ];
263
+ const series = () =>
264
+ candlestick({open: 'o', high: 'h', low: 'l', close: 'c'});
265
+
266
+ it('renders a wick line and a body rect per candle, colored by direction', () => {
267
+ const {plot} = renderMarks([series()], OHLC);
268
+ const rects = plot.querySelectorAll('rect');
269
+ const wicks = plot.querySelectorAll('line');
270
+ expect(rects).toHaveLength(3);
271
+ expect(wicks).toHaveLength(3);
272
+ expect(rects[0]).toHaveAttribute('fill', 'var(--color-success)');
273
+ expect(rects[1]).toHaveAttribute('fill', 'var(--color-error)');
274
+ expect(rects[2]).toHaveAttribute('fill', 'var(--color-success)');
275
+ });
276
+
277
+ it('skips candles missing any OHLC field', () => {
278
+ const gappy = [OHLC[0], {m: 'B', o: 6, l: 2, c: 3}, OHLC[2]];
279
+ const {plot} = renderMarks([series()], gappy);
280
+ expect(plot.querySelectorAll('rect')).toHaveLength(2);
281
+ expect(plot.querySelectorAll('line')).toHaveLength(2);
282
+ });
283
+ });
284
+
285
+ describe('errorBar', () => {
286
+ it('renders a stem plus two whisker caps per point', () => {
287
+ const {plot} = renderMarks([errorBar({high: 'hi', low: 'lo'})], BOUNDS);
288
+ const lines = plot.querySelectorAll('line');
289
+ expect(lines).toHaveLength(9); // 3 points x (stem + upper cap + lower cap)
290
+ for (const l of lines) {
291
+ expect(l).toHaveAttribute('stroke', 'var(--color-text-primary)');
292
+ }
293
+ });
294
+
295
+ it('skips whiskers missing either bound', () => {
296
+ const gappy = [BOUNDS[0], {m: 'B', hi: 8}, BOUNDS[2]];
297
+ const {plot} = renderMarks([errorBar({high: 'hi', low: 'lo'})], gappy);
298
+ expect(plot.querySelectorAll('line')).toHaveLength(6);
299
+ });
300
+ });
301
+
302
+ describe('referenceLine', () => {
303
+ it('draws a dashed horizontal line across the plot with a label badge', () => {
304
+ const {plot} = renderMarks([
305
+ line('v'),
306
+ referenceLine({y: 5, label: 'Target'}),
307
+ ]);
308
+ const dashed = plot.querySelector('line[stroke-dasharray="6 3"]')!;
309
+ expect(dashed).not.toBeNull();
310
+ expect(dashed).toHaveAttribute('x1', '0');
311
+ expect(dashed).toHaveAttribute('x2', '528'); // full plot width
312
+ expect(dashed.getAttribute('y1')).toBe(dashed.getAttribute('y2'));
313
+ expect(screen.getByText('Target')).toBeInTheDocument();
314
+ expect(plot.querySelector('rect[rx="3"]')).not.toBeNull(); // badge chip
315
+ });
316
+
317
+ it('shades a band between y and y2 bounded by two dashed lines', () => {
318
+ const {plot} = renderMarks([line('v'), referenceLine({y: 2, y2: 6})]);
319
+ expect(plot.querySelectorAll('line[stroke-dasharray="6 3"]')).toHaveLength(
320
+ 2,
321
+ );
322
+ const bandRect = plot.querySelector('rect[opacity="0.1"]')!;
323
+ expect(bandRect).not.toBeNull();
324
+ expect(bandRect).toHaveAttribute('width', '528');
325
+ });
326
+
327
+ it('draws a vertical dashed line at a numeric x on a linear scale', () => {
328
+ const numeric = [
329
+ {x: 0, v: 1},
330
+ {x: 5, v: 7},
331
+ {x: 10, v: 3},
332
+ ];
333
+ const {plot} = renderMarks(
334
+ [line('v'), referenceLine({x: 5})],
335
+ numeric,
336
+ 'x',
337
+ );
338
+ const dashed = plot.querySelector('line[stroke-dasharray="6 3"]')!;
339
+ expect(dashed).not.toBeNull();
340
+ expect(dashed.getAttribute('x1')).toBe(dashed.getAttribute('x2'));
341
+ expect(dashed).toHaveAttribute('y1', '0');
342
+ expect(dashed).toHaveAttribute('y2', '244'); // full plot height
343
+ });
344
+ });
345
+
346
+ describe('GL marks without WebGL', () => {
347
+ let getContextSpy: ReturnType<typeof vi.fn>;
348
+ const canvasProto = HTMLCanvasElement.prototype;
349
+ let originalGetContext: typeof canvasProto.getContext;
350
+ // jsdom has no SVG geometry API; give markers a null CTM so
351
+ // mountCanvasOverSVG takes its documented (0,0) fallback.
352
+ const svgProto = SVGElement.prototype as unknown as {
353
+ getScreenCTM?: () => unknown;
354
+ };
355
+ let hadGetScreenCTM: boolean;
356
+
357
+ beforeEach(() => {
358
+ originalGetContext = canvasProto.getContext;
359
+ getContextSpy = vi.fn(() => null);
360
+ canvasProto.getContext =
361
+ getContextSpy as unknown as typeof canvasProto.getContext;
362
+ hadGetScreenCTM = 'getScreenCTM' in svgProto;
363
+ if (!hadGetScreenCTM) {
364
+ svgProto.getScreenCTM = () => null;
365
+ }
366
+ });
367
+
368
+ afterEach(() => {
369
+ canvasProto.getContext = originalGetContext;
370
+ if (!hadGetScreenCTM) {
371
+ delete svgProto.getScreenCTM;
372
+ }
373
+ });
374
+
375
+ it('dotGL mounts an absolutely-positioned canvas overlay and draws nothing', () => {
376
+ const {container, unmount} = renderMarks([dotGL('v', {color: '#4f46e5'})]);
377
+ const canvas = container.querySelector('canvas')!;
378
+ expect(canvas).not.toBeNull();
379
+ expect(canvas.style.position).toBe('absolute');
380
+ expect(getContextSpy).toHaveBeenCalledWith(
381
+ 'webgl',
382
+ expect.objectContaining({alpha: true}),
383
+ );
384
+ // Every context request failed → no GL drawing could have happened.
385
+ expect(getContextSpy.mock.results.every(r => r.value === null)).toBe(true);
386
+ unmount();
387
+ });
388
+
389
+ it('heatmapGL mounts its cell canvas when both axes are categorical', () => {
390
+ const heat = [
391
+ {hour: '0', day: 'Mon', count: 1},
392
+ {hour: '1', day: 'Mon', count: 5},
393
+ {hour: '0', day: 'Tue', count: 3},
394
+ {hour: '1', day: 'Tue', count: 2},
395
+ ];
396
+ const {container, unmount} = renderMarks(
397
+ [
398
+ heatmapGL({
399
+ xKey: 'hour',
400
+ yKey: 'day',
401
+ valueKey: 'count',
402
+ colorRange: ['#000000', '#ffffff'],
403
+ }),
404
+ ],
405
+ heat,
406
+ 'hour',
407
+ );
408
+ expect(container.querySelector('canvas')).not.toBeNull();
409
+ expect(getContextSpy.mock.results.every(r => r.value === null)).toBe(true);
410
+ unmount();
411
+ });
412
+
413
+ it('streamGL exposes an imperative push/clear handle even without WebGL', () => {
414
+ const handleRef: {current: StreamGLHandle | null} = {current: null};
415
+ const {container, unmount} = renderMarks(
416
+ [streamGL({color: '#10b981', handleRef})],
417
+ [],
418
+ 'x',
419
+ {xDomain: [0, 10]},
420
+ );
421
+ expect(container.querySelector('canvas')).not.toBeNull();
422
+ expect(handleRef.current).not.toBeNull();
423
+ // Pushing and clearing must not crash when the GL context is unavailable.
424
+ act(() => {
425
+ handleRef.current!.push(1, 2);
426
+ handleRef.current!.push(2, 4);
427
+ handleRef.current!.clear();
428
+ });
429
+ unmount();
430
+ });
431
+
432
+ it('dotGLInteractive renders its two canvases and hover hit area without crashing', () => {
433
+ const {plot, unmount} = renderMarks([
434
+ dotGLInteractive('v', {color: '#111111'}),
435
+ ]);
436
+ const foreignObjects = plot.querySelectorAll('foreignObject');
437
+ expect(foreignObjects).toHaveLength(2); // visible + offscreen pick canvas
438
+ expect(foreignObjects[0].querySelector('canvas')).not.toBeNull();
439
+ expect(foreignObjects[1].querySelector('canvas')).not.toBeNull();
440
+ expect(plot.querySelector('rect[fill="transparent"]')).not.toBeNull();
441
+ unmount();
442
+ });
443
+ });
@@ -0,0 +1,51 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useChartColors.test.ts
5
+ * @input Uses vitest, @testing-library/react renderHook, useChartColors
6
+ * @output Functional tests for the theme-aware chart color palette hook
7
+ * @position Colocated test for useChartColors.ts (issue #4295 viz coverage)
8
+ */
9
+
10
+ import {describe, it, expect} from 'vitest';
11
+ import {renderHook} from '@testing-library/react';
12
+ import {useChartColors} from './useChartColors';
13
+
14
+ describe('useChartColors', () => {
15
+ it('returns the requested number of categorical colors', () => {
16
+ const {result} = renderHook(() => useChartColors());
17
+ const colors = result.current.categorical(4);
18
+ expect(colors).toHaveLength(4);
19
+ for (const c of colors) {
20
+ expect(typeof c).toBe('string');
21
+ expect(c.length).toBeGreaterThan(0);
22
+ }
23
+ });
24
+
25
+ it('assigns distinct colors within the first palette cycle', () => {
26
+ const {result} = renderHook(() => useChartColors());
27
+ const colors = result.current.categorical(4);
28
+ expect(new Set(colors).size).toBe(4);
29
+ });
30
+
31
+ it('wraps around the palette when more series than slots are requested', () => {
32
+ const {result} = renderHook(() => useChartColors());
33
+ const colors = result.current.categorical(12);
34
+ expect(colors).toHaveLength(12);
35
+ // 10 categorical tokens — slot 10 and 11 reuse slot 0 and 1.
36
+ expect(colors[10]).toBe(colors[0]);
37
+ expect(colors[11]).toBe(colors[1]);
38
+ });
39
+
40
+ it('returns an empty palette for a non-positive count', () => {
41
+ const {result} = renderHook(() => useChartColors());
42
+ expect(result.current.categorical(0)).toEqual([]);
43
+ });
44
+
45
+ it('returns a referentially stable API across rerenders', () => {
46
+ const {result, rerender} = renderHook(() => useChartColors());
47
+ const first = result.current;
48
+ rerender();
49
+ expect(result.current).toBe(first);
50
+ });
51
+ });