@opendata-ai/openchart-vanilla 2.0.0
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/dist/index.d.ts +327 -0
- package/dist/index.js +4745 -0
- package/dist/index.js.map +1 -0
- package/dist/simulation-worker.js +1196 -0
- package/package.json +58 -0
- package/src/__test-fixtures__/dom.ts +42 -0
- package/src/__test-fixtures__/specs.ts +187 -0
- package/src/__tests__/edit-events.test.ts +747 -0
- package/src/__tests__/events.test.ts +336 -0
- package/src/__tests__/export.test.ts +150 -0
- package/src/__tests__/mount.test.ts +219 -0
- package/src/__tests__/svg-renderer.test.ts +609 -0
- package/src/__tests__/table-mount.test.ts +484 -0
- package/src/__tests__/tooltip.test.ts +201 -0
- package/src/export.ts +105 -0
- package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
- package/src/graph/__tests__/graph-mount.test.ts +213 -0
- package/src/graph/__tests__/interaction.test.ts +205 -0
- package/src/graph/__tests__/keyboard.test.ts +653 -0
- package/src/graph/__tests__/search.test.ts +88 -0
- package/src/graph/__tests__/simulation.test.ts +233 -0
- package/src/graph/__tests__/spatial-index.test.ts +142 -0
- package/src/graph/__tests__/zoom.test.ts +195 -0
- package/src/graph/canvas-renderer.ts +660 -0
- package/src/graph/interaction.ts +359 -0
- package/src/graph/keyboard.ts +208 -0
- package/src/graph/search.ts +50 -0
- package/src/graph/simulation-worker-url.ts +30 -0
- package/src/graph/simulation-worker.ts +265 -0
- package/src/graph/simulation.ts +350 -0
- package/src/graph/spatial-index.ts +121 -0
- package/src/graph/types.ts +44 -0
- package/src/graph/worker-protocol.ts +67 -0
- package/src/graph/zoom.ts +104 -0
- package/src/graph-mount.ts +675 -0
- package/src/index.ts +56 -0
- package/src/mount.ts +1639 -0
- package/src/renderers/table-cells.ts +444 -0
- package/src/resize-observer.ts +46 -0
- package/src/svg-renderer.ts +914 -0
- package/src/table-keyboard.ts +266 -0
- package/src/table-mount.ts +532 -0
- package/src/table-renderer.ts +350 -0
- package/src/tooltip.ts +120 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG renderer integration tests.
|
|
3
|
+
*
|
|
4
|
+
* Tests the full pipeline: spec -> compileChart -> renderChartSVG -> DOM.
|
|
5
|
+
* Verifies that each chart type produces the correct SVG mark elements,
|
|
6
|
+
* and that chart furniture (chrome, axes, legend, gridlines) renders properly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ChartSpec, CompileOptions } from '@opendata-ai/openchart-engine';
|
|
10
|
+
import { compileChart } from '@opendata-ai/openchart-engine';
|
|
11
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
12
|
+
import { createContainer } from '../__test-fixtures__/dom';
|
|
13
|
+
import {
|
|
14
|
+
barSpec,
|
|
15
|
+
columnSpec,
|
|
16
|
+
lineSpec,
|
|
17
|
+
multiSeriesBarSpec,
|
|
18
|
+
pieSpec,
|
|
19
|
+
scatterSpec,
|
|
20
|
+
singleSeriesLineSpec,
|
|
21
|
+
} from '../__test-fixtures__/specs';
|
|
22
|
+
import { renderChartSVG } from '../svg-renderer';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const COMPILE_OPTS: CompileOptions = { width: 600, height: 400 };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compile a spec and render into a fresh container.
|
|
32
|
+
* Returns both the SVG element and the container for querying.
|
|
33
|
+
*/
|
|
34
|
+
function renderSpec(spec: ChartSpec, opts: CompileOptions = COMPILE_OPTS) {
|
|
35
|
+
const container = createContainer(opts.width, opts.height);
|
|
36
|
+
const layout = compileChart(spec, opts);
|
|
37
|
+
const svg = renderChartSVG(layout, container);
|
|
38
|
+
return { svg, container, layout };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Cleanup
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
document.body.innerHTML = '';
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Line chart marks
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
describe('line chart SVG rendering', () => {
|
|
54
|
+
it('renders <path> elements with valid d attribute for each series', () => {
|
|
55
|
+
const { svg } = renderSpec(lineSpec);
|
|
56
|
+
const paths = svg.querySelectorAll('.viz-mark-line path');
|
|
57
|
+
expect(paths.length).toBeGreaterThan(0);
|
|
58
|
+
|
|
59
|
+
for (const path of paths) {
|
|
60
|
+
const d = path.getAttribute('d');
|
|
61
|
+
expect(d).not.toBeNull();
|
|
62
|
+
// d attribute should start with M (moveTo) and contain curve commands
|
|
63
|
+
expect(d).toMatch(/^M/);
|
|
64
|
+
expect(d!.length).toBeGreaterThan(5);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('creates a mark group per series in multi-series line chart', () => {
|
|
69
|
+
const { svg } = renderSpec(lineSpec);
|
|
70
|
+
const lineGroups = svg.querySelectorAll('.viz-mark-line');
|
|
71
|
+
// lineSpec has 2 series (US and UK)
|
|
72
|
+
expect(lineGroups.length).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('each line mark group has a data-mark-id attribute', () => {
|
|
76
|
+
const { svg } = renderSpec(lineSpec);
|
|
77
|
+
const lineGroups = svg.querySelectorAll('.viz-mark-line');
|
|
78
|
+
for (const group of lineGroups) {
|
|
79
|
+
const markId = group.getAttribute('data-mark-id');
|
|
80
|
+
expect(markId).not.toBeNull();
|
|
81
|
+
expect(markId).toMatch(/^line-/);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('each line mark group has a data-series attribute', () => {
|
|
86
|
+
const { svg } = renderSpec(lineSpec);
|
|
87
|
+
const lineGroups = svg.querySelectorAll('.viz-mark-line');
|
|
88
|
+
const seriesNames = new Set<string>();
|
|
89
|
+
for (const group of lineGroups) {
|
|
90
|
+
const series = group.getAttribute('data-series');
|
|
91
|
+
expect(series).not.toBeNull();
|
|
92
|
+
seriesNames.add(series!);
|
|
93
|
+
}
|
|
94
|
+
expect(seriesNames.has('US')).toBe(true);
|
|
95
|
+
expect(seriesNames.has('UK')).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('paths have stroke color and non-zero stroke width', () => {
|
|
99
|
+
const { svg } = renderSpec(lineSpec);
|
|
100
|
+
const paths = svg.querySelectorAll('.viz-mark-line path');
|
|
101
|
+
for (const path of paths) {
|
|
102
|
+
const stroke = path.getAttribute('stroke');
|
|
103
|
+
expect(stroke).not.toBeNull();
|
|
104
|
+
expect(stroke).not.toBe('none');
|
|
105
|
+
const strokeWidth = Number(path.getAttribute('stroke-width'));
|
|
106
|
+
expect(strokeWidth).toBeGreaterThan(0);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Bar chart marks
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe('bar chart SVG rendering', () => {
|
|
116
|
+
it('renders <rect> elements for each data point', () => {
|
|
117
|
+
const { svg } = renderSpec(barSpec);
|
|
118
|
+
const rects = svg.querySelectorAll('.viz-mark-rect rect');
|
|
119
|
+
// barSpec has 3 data points
|
|
120
|
+
expect(rects.length).toBe(3);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('rect elements have width and height > 0', () => {
|
|
124
|
+
const { svg } = renderSpec(barSpec);
|
|
125
|
+
const rects = svg.querySelectorAll('.viz-mark-rect rect');
|
|
126
|
+
for (const rect of rects) {
|
|
127
|
+
const width = Number(rect.getAttribute('width'));
|
|
128
|
+
const height = Number(rect.getAttribute('height'));
|
|
129
|
+
expect(width).toBeGreaterThan(0);
|
|
130
|
+
expect(height).toBeGreaterThan(0);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('rect marks have data-mark-id attributes', () => {
|
|
135
|
+
const { svg } = renderSpec(barSpec);
|
|
136
|
+
const markGroups = svg.querySelectorAll('.viz-mark-rect');
|
|
137
|
+
for (const group of markGroups) {
|
|
138
|
+
const markId = group.getAttribute('data-mark-id');
|
|
139
|
+
expect(markId).not.toBeNull();
|
|
140
|
+
expect(markId).toMatch(/^rect-/);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('bar rects are oriented horizontally (width varies, y is categorical)', () => {
|
|
145
|
+
const { svg } = renderSpec(barSpec);
|
|
146
|
+
const rects = svg.querySelectorAll('.viz-mark-rect rect');
|
|
147
|
+
const widths = Array.from(rects).map((r) => Number(r.getAttribute('width')));
|
|
148
|
+
// Different data values should produce different widths
|
|
149
|
+
const uniqueWidths = new Set(widths);
|
|
150
|
+
expect(uniqueWidths.size).toBeGreaterThan(1);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Column chart marks
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe('column chart SVG rendering', () => {
|
|
159
|
+
it('renders <rect> elements oriented vertically', () => {
|
|
160
|
+
const { svg } = renderSpec(columnSpec);
|
|
161
|
+
const rects = svg.querySelectorAll('.viz-mark-rect rect');
|
|
162
|
+
expect(rects.length).toBe(3);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('column rects have varying heights (vertical orientation)', () => {
|
|
166
|
+
const { svg } = renderSpec(columnSpec);
|
|
167
|
+
const rects = svg.querySelectorAll('.viz-mark-rect rect');
|
|
168
|
+
const heights = Array.from(rects).map((r) => Number(r.getAttribute('height')));
|
|
169
|
+
// Different revenue values should produce different heights
|
|
170
|
+
const uniqueHeights = new Set(heights);
|
|
171
|
+
expect(uniqueHeights.size).toBeGreaterThan(1);
|
|
172
|
+
// All heights should be positive
|
|
173
|
+
for (const h of heights) {
|
|
174
|
+
expect(h).toBeGreaterThan(0);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('column rects have positive width', () => {
|
|
179
|
+
const { svg } = renderSpec(columnSpec);
|
|
180
|
+
const rects = svg.querySelectorAll('.viz-mark-rect rect');
|
|
181
|
+
for (const rect of rects) {
|
|
182
|
+
const width = Number(rect.getAttribute('width'));
|
|
183
|
+
expect(width).toBeGreaterThan(0);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Scatter chart marks
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
describe('scatter chart SVG rendering', () => {
|
|
193
|
+
it('renders <circle> elements for each data point', () => {
|
|
194
|
+
const { svg } = renderSpec(scatterSpec);
|
|
195
|
+
const circles = svg.querySelectorAll('.viz-mark-point');
|
|
196
|
+
// scatterSpec has 4 data points
|
|
197
|
+
expect(circles.length).toBe(4);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('circles have valid cx, cy, and r attributes', () => {
|
|
201
|
+
const { svg } = renderSpec(scatterSpec);
|
|
202
|
+
const circles = svg.querySelectorAll('.viz-mark-point');
|
|
203
|
+
for (const circle of circles) {
|
|
204
|
+
const cx = Number(circle.getAttribute('cx'));
|
|
205
|
+
const cy = Number(circle.getAttribute('cy'));
|
|
206
|
+
const r = Number(circle.getAttribute('r'));
|
|
207
|
+
expect(cx).toBeTypeOf('number');
|
|
208
|
+
expect(cy).toBeTypeOf('number');
|
|
209
|
+
expect(r).toBeGreaterThan(0);
|
|
210
|
+
// cx and cy should be within the SVG dimensions
|
|
211
|
+
expect(cx).toBeGreaterThanOrEqual(0);
|
|
212
|
+
expect(cy).toBeGreaterThanOrEqual(0);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('scatter marks have data-mark-id attributes', () => {
|
|
217
|
+
const { svg } = renderSpec(scatterSpec);
|
|
218
|
+
const circles = svg.querySelectorAll('.viz-mark-point');
|
|
219
|
+
for (const circle of circles) {
|
|
220
|
+
const markId = circle.getAttribute('data-mark-id');
|
|
221
|
+
expect(markId).not.toBeNull();
|
|
222
|
+
expect(markId).toMatch(/^point-/);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Pie chart marks
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
describe('pie chart SVG rendering', () => {
|
|
232
|
+
it('renders <path> arc segments for each slice', () => {
|
|
233
|
+
const { svg } = renderSpec(pieSpec);
|
|
234
|
+
const arcGroups = svg.querySelectorAll('.viz-mark-arc');
|
|
235
|
+
// pieSpec has 3 categories
|
|
236
|
+
expect(arcGroups.length).toBe(3);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('arc paths have valid d attribute with arc commands', () => {
|
|
240
|
+
const { svg } = renderSpec(pieSpec);
|
|
241
|
+
const paths = svg.querySelectorAll('.viz-mark-arc path');
|
|
242
|
+
for (const path of paths) {
|
|
243
|
+
const d = path.getAttribute('d');
|
|
244
|
+
expect(d).not.toBeNull();
|
|
245
|
+
// Arc paths should contain A (arc) commands
|
|
246
|
+
expect(d).toMatch(/[AaLl]/);
|
|
247
|
+
expect(d!.length).toBeGreaterThan(5);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('arc groups are translated to the pie center', () => {
|
|
252
|
+
const { svg } = renderSpec(pieSpec);
|
|
253
|
+
const arcGroups = svg.querySelectorAll('.viz-mark-arc');
|
|
254
|
+
for (const group of arcGroups) {
|
|
255
|
+
const transform = group.getAttribute('transform');
|
|
256
|
+
expect(transform).not.toBeNull();
|
|
257
|
+
expect(transform).toMatch(/translate\(\d+/);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('arc marks have fill colors', () => {
|
|
262
|
+
const { svg } = renderSpec(pieSpec);
|
|
263
|
+
const paths = svg.querySelectorAll('.viz-mark-arc path');
|
|
264
|
+
for (const path of paths) {
|
|
265
|
+
const fill = path.getAttribute('fill');
|
|
266
|
+
expect(fill).not.toBeNull();
|
|
267
|
+
expect(fill).not.toBe('none');
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Multi-series: correct grouping and distinct colors
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
describe('multi-series rendering', () => {
|
|
277
|
+
it('multi-series line chart has distinct stroke colors per series', () => {
|
|
278
|
+
const { svg } = renderSpec(lineSpec);
|
|
279
|
+
const paths = svg.querySelectorAll('.viz-mark-line path');
|
|
280
|
+
const strokes = new Set<string>();
|
|
281
|
+
for (const path of paths) {
|
|
282
|
+
const stroke = path.getAttribute('stroke');
|
|
283
|
+
if (stroke) strokes.add(stroke);
|
|
284
|
+
}
|
|
285
|
+
// US and UK should have different colors
|
|
286
|
+
expect(strokes.size).toBe(2);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('multi-series scatter chart has distinct fill colors per group', () => {
|
|
290
|
+
const { svg } = renderSpec(scatterSpec);
|
|
291
|
+
const circles = svg.querySelectorAll('.viz-mark-point');
|
|
292
|
+
const fills = new Set<string>();
|
|
293
|
+
for (const circle of circles) {
|
|
294
|
+
const fill = circle.getAttribute('fill');
|
|
295
|
+
if (fill) fills.add(fill);
|
|
296
|
+
}
|
|
297
|
+
// group A and B should have different fill colors
|
|
298
|
+
expect(fills.size).toBe(2);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('multi-series bar chart renders data-series attributes on rect marks', () => {
|
|
302
|
+
const { svg } = renderSpec(multiSeriesBarSpec);
|
|
303
|
+
const marks = svg.querySelectorAll('.viz-mark-rect[data-series]');
|
|
304
|
+
const seriesNames = new Set<string>();
|
|
305
|
+
for (const mark of marks) {
|
|
306
|
+
const s = mark.getAttribute('data-series');
|
|
307
|
+
if (s) seriesNames.add(s);
|
|
308
|
+
}
|
|
309
|
+
// Should have marks with series info
|
|
310
|
+
expect(seriesNames.size).toBeGreaterThan(0);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Chrome elements (title, subtitle, source)
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
describe('chart chrome rendering', () => {
|
|
319
|
+
it('renders title text with correct content', () => {
|
|
320
|
+
const { svg } = renderSpec(lineSpec);
|
|
321
|
+
const title = svg.querySelector('.viz-title');
|
|
322
|
+
expect(title).not.toBeNull();
|
|
323
|
+
expect(title!.textContent).toBe('GDP Growth');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('renders subtitle text with correct content', () => {
|
|
327
|
+
const { svg } = renderSpec(lineSpec);
|
|
328
|
+
const subtitle = svg.querySelector('.viz-subtitle');
|
|
329
|
+
expect(subtitle).not.toBeNull();
|
|
330
|
+
expect(subtitle!.textContent).toBe('US vs UK over time');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('renders source text with correct content', () => {
|
|
334
|
+
const { svg } = renderSpec(lineSpec);
|
|
335
|
+
const source = svg.querySelector('.viz-source');
|
|
336
|
+
expect(source).not.toBeNull();
|
|
337
|
+
expect(source!.textContent).toBe('World Bank');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('chrome elements are inside a .viz-chrome group', () => {
|
|
341
|
+
const { svg } = renderSpec(lineSpec);
|
|
342
|
+
const chromeGroup = svg.querySelector('.viz-chrome');
|
|
343
|
+
expect(chromeGroup).not.toBeNull();
|
|
344
|
+
expect(chromeGroup!.querySelector('.viz-title')).not.toBeNull();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('title has font styling applied', () => {
|
|
348
|
+
const { svg } = renderSpec(lineSpec);
|
|
349
|
+
const title = svg.querySelector('.viz-title');
|
|
350
|
+
expect(title).not.toBeNull();
|
|
351
|
+
const fontFamily = title!.getAttribute('font-family');
|
|
352
|
+
const fontSize = Number(title!.getAttribute('font-size'));
|
|
353
|
+
expect(fontFamily).not.toBeNull();
|
|
354
|
+
expect(fontSize).toBeGreaterThan(0);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('chart with no chrome specified renders no chrome text elements', () => {
|
|
358
|
+
const noChrome: ChartSpec = {
|
|
359
|
+
type: 'bar',
|
|
360
|
+
data: [{ name: 'A', value: 10 }],
|
|
361
|
+
encoding: {
|
|
362
|
+
x: { field: 'value', type: 'quantitative' },
|
|
363
|
+
y: { field: 'name', type: 'nominal' },
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
const { svg } = renderSpec(noChrome);
|
|
367
|
+
const chromeGroup = svg.querySelector('.viz-chrome');
|
|
368
|
+
expect(chromeGroup).not.toBeNull();
|
|
369
|
+
// No title/subtitle/source should be in the chrome group
|
|
370
|
+
expect(chromeGroup!.querySelector('.viz-title')).toBeNull();
|
|
371
|
+
expect(chromeGroup!.querySelector('.viz-subtitle')).toBeNull();
|
|
372
|
+
expect(chromeGroup!.querySelector('.viz-source')).toBeNull();
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Axes and tick labels
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
describe('axis rendering', () => {
|
|
381
|
+
it('renders x-axis and y-axis groups', () => {
|
|
382
|
+
const { svg } = renderSpec(lineSpec);
|
|
383
|
+
const xAxis = svg.querySelector('.viz-axis-x');
|
|
384
|
+
const yAxis = svg.querySelector('.viz-axis-y');
|
|
385
|
+
expect(xAxis).not.toBeNull();
|
|
386
|
+
expect(yAxis).not.toBeNull();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('x-axis has tick labels as text elements', () => {
|
|
390
|
+
const { svg } = renderSpec(lineSpec);
|
|
391
|
+
const xAxis = svg.querySelector('.viz-axis-x');
|
|
392
|
+
const labels = xAxis!.querySelectorAll('text');
|
|
393
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
394
|
+
// Each label should have text content
|
|
395
|
+
for (const label of labels) {
|
|
396
|
+
expect(label.textContent!.length).toBeGreaterThan(0);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('y-axis has tick labels as text elements', () => {
|
|
401
|
+
const { svg } = renderSpec(barSpec);
|
|
402
|
+
const yAxis = svg.querySelector('.viz-axis-y');
|
|
403
|
+
const labels = yAxis!.querySelectorAll('text');
|
|
404
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('x-axis has a baseline line element', () => {
|
|
408
|
+
const { svg } = renderSpec(lineSpec);
|
|
409
|
+
const xAxis = svg.querySelector('.viz-axis-x');
|
|
410
|
+
const line = xAxis!.querySelector('line');
|
|
411
|
+
// The renderer draws an axis line for x-axis
|
|
412
|
+
expect(line).not.toBeNull();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Gridlines
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
describe('gridline rendering', () => {
|
|
421
|
+
it('renders gridlines as line elements within axis groups', () => {
|
|
422
|
+
const { svg } = renderSpec(lineSpec);
|
|
423
|
+
// y-axis gridlines are horizontal lines
|
|
424
|
+
const yAxis = svg.querySelector('.viz-axis-y');
|
|
425
|
+
const gridlines = yAxis!.querySelectorAll('line');
|
|
426
|
+
expect(gridlines.length).toBeGreaterThan(0);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('gridlines have stroke-opacity for subtlety', () => {
|
|
430
|
+
const { svg } = renderSpec(lineSpec);
|
|
431
|
+
const yAxis = svg.querySelector('.viz-axis-y');
|
|
432
|
+
const gridlines = yAxis!.querySelectorAll('line');
|
|
433
|
+
for (const gl of gridlines) {
|
|
434
|
+
const opacity = gl.getAttribute('stroke-opacity');
|
|
435
|
+
if (opacity) {
|
|
436
|
+
// Gridlines should be subtle (less than 1.0 opacity)
|
|
437
|
+
expect(Number(opacity)).toBeLessThan(1);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// Legend
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
describe('legend rendering', () => {
|
|
448
|
+
it('multi-series chart renders legend entries', () => {
|
|
449
|
+
const { svg } = renderSpec(lineSpec);
|
|
450
|
+
const legend = svg.querySelector('.viz-legend');
|
|
451
|
+
expect(legend).not.toBeNull();
|
|
452
|
+
const entries = legend!.querySelectorAll('.viz-legend-entry');
|
|
453
|
+
// lineSpec has US and UK series
|
|
454
|
+
expect(entries.length).toBe(2);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('legend entries have labels with series names', () => {
|
|
458
|
+
const { svg } = renderSpec(lineSpec);
|
|
459
|
+
const entries = svg.querySelectorAll('.viz-legend-entry');
|
|
460
|
+
const labels: string[] = [];
|
|
461
|
+
for (const entry of entries) {
|
|
462
|
+
const text = entry.querySelector('text');
|
|
463
|
+
if (text?.textContent) labels.push(text.textContent);
|
|
464
|
+
}
|
|
465
|
+
expect(labels).toContain('US');
|
|
466
|
+
expect(labels).toContain('UK');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('legend entries have data-legend-label attribute', () => {
|
|
470
|
+
const { svg } = renderSpec(lineSpec);
|
|
471
|
+
const entries = svg.querySelectorAll('.viz-legend-entry');
|
|
472
|
+
for (const entry of entries) {
|
|
473
|
+
expect(entry.getAttribute('data-legend-label')).not.toBeNull();
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('legend has ARIA attributes for accessibility', () => {
|
|
478
|
+
const { svg } = renderSpec(lineSpec);
|
|
479
|
+
const legend = svg.querySelector('.viz-legend');
|
|
480
|
+
expect(legend!.getAttribute('role')).toBe('list');
|
|
481
|
+
expect(legend!.getAttribute('aria-label')).toBe('Chart legend');
|
|
482
|
+
const entries = legend!.querySelectorAll('.viz-legend-entry');
|
|
483
|
+
for (const entry of entries) {
|
|
484
|
+
expect(entry.getAttribute('role')).toBe('listitem');
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('single-series chart has no legend entries', () => {
|
|
489
|
+
const { svg } = renderSpec(singleSeriesLineSpec);
|
|
490
|
+
const entries = svg.querySelectorAll('.viz-legend-entry');
|
|
491
|
+
expect(entries.length).toBe(0);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('pie chart renders legend entries for each slice', () => {
|
|
495
|
+
const { svg } = renderSpec(pieSpec);
|
|
496
|
+
const entries = svg.querySelectorAll('.viz-legend-entry');
|
|
497
|
+
// pieSpec has 3 slices
|
|
498
|
+
expect(entries.length).toBe(3);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
// SVG root structure
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
describe('SVG root structure', () => {
|
|
507
|
+
it('SVG has correct viewBox matching dimensions', () => {
|
|
508
|
+
const { svg } = renderSpec(lineSpec);
|
|
509
|
+
const viewBox = svg.getAttribute('viewBox');
|
|
510
|
+
expect(viewBox).toBe('0 0 600 400');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('SVG has accessibility role and aria-label', () => {
|
|
514
|
+
const { svg } = renderSpec(lineSpec);
|
|
515
|
+
expect(svg.getAttribute('role')).toBe('img');
|
|
516
|
+
const ariaLabel = svg.getAttribute('aria-label');
|
|
517
|
+
expect(ariaLabel).not.toBeNull();
|
|
518
|
+
expect(ariaLabel!.length).toBeGreaterThan(0);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('SVG has viz-chart class', () => {
|
|
522
|
+
const { svg } = renderSpec(lineSpec);
|
|
523
|
+
expect(svg.getAttribute('class')).toBe('viz-chart');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('SVG has a background rect as first child', () => {
|
|
527
|
+
const { svg } = renderSpec(lineSpec);
|
|
528
|
+
const firstChild = svg.children[0];
|
|
529
|
+
expect(firstChild.tagName.toLowerCase()).toBe('rect');
|
|
530
|
+
expect(Number(firstChild.getAttribute('width'))).toBe(600);
|
|
531
|
+
expect(Number(firstChild.getAttribute('height'))).toBe(400);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('SVG has a defs element with clip path', () => {
|
|
535
|
+
const { svg } = renderSpec(lineSpec);
|
|
536
|
+
const defs = svg.querySelector('defs');
|
|
537
|
+
expect(defs).not.toBeNull();
|
|
538
|
+
const clipPath = defs!.querySelector('clipPath');
|
|
539
|
+
expect(clipPath).not.toBeNull();
|
|
540
|
+
expect(clipPath!.getAttribute('id')).toMatch(/^viz-clip-/);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('marks group is clipped via clip-path attribute', () => {
|
|
544
|
+
const { svg } = renderSpec(lineSpec);
|
|
545
|
+
const clippedGroup = svg.querySelector('[clip-path]');
|
|
546
|
+
expect(clippedGroup).not.toBeNull();
|
|
547
|
+
expect(clippedGroup!.getAttribute('clip-path')).toMatch(/url\(#viz-clip-/);
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// Inline snapshot for a critical mark element
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
describe('targeted mark snapshots', () => {
|
|
556
|
+
it('line mark group has expected structure', () => {
|
|
557
|
+
const { svg } = renderSpec(singleSeriesLineSpec);
|
|
558
|
+
const lineGroup = svg.querySelector('.viz-mark-line');
|
|
559
|
+
expect(lineGroup).not.toBeNull();
|
|
560
|
+
expect(lineGroup!.getAttribute('class')).toBe('viz-mark viz-mark-line');
|
|
561
|
+
expect(lineGroup!.getAttribute('data-mark-id')).toMatch(/^line-/);
|
|
562
|
+
|
|
563
|
+
const path = lineGroup!.querySelector('path');
|
|
564
|
+
expect(path).not.toBeNull();
|
|
565
|
+
expect(path!.getAttribute('fill')).toBe('none');
|
|
566
|
+
expect(path!.getAttribute('stroke')).not.toBeNull();
|
|
567
|
+
expect(Number(path!.getAttribute('stroke-width'))).toBeGreaterThan(0);
|
|
568
|
+
expect(path!.getAttribute('d')).toMatch(/^M/);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('rect mark group has expected structure', () => {
|
|
572
|
+
const { svg } = renderSpec(barSpec);
|
|
573
|
+
const rectGroup = svg.querySelector('.viz-mark-rect');
|
|
574
|
+
expect(rectGroup).not.toBeNull();
|
|
575
|
+
expect(rectGroup!.getAttribute('class')).toBe('viz-mark viz-mark-rect');
|
|
576
|
+
expect(rectGroup!.getAttribute('data-mark-id')).toMatch(/^rect-/);
|
|
577
|
+
|
|
578
|
+
const rect = rectGroup!.querySelector('rect');
|
|
579
|
+
expect(rect).not.toBeNull();
|
|
580
|
+
expect(Number(rect!.getAttribute('width'))).toBeGreaterThan(0);
|
|
581
|
+
expect(Number(rect!.getAttribute('height'))).toBeGreaterThan(0);
|
|
582
|
+
expect(rect!.getAttribute('fill')).not.toBeNull();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('point mark has expected attributes', () => {
|
|
586
|
+
const { svg } = renderSpec(scatterSpec);
|
|
587
|
+
const point = svg.querySelector('.viz-mark-point');
|
|
588
|
+
expect(point).not.toBeNull();
|
|
589
|
+
expect(point!.tagName.toLowerCase()).toBe('circle');
|
|
590
|
+
expect(point!.getAttribute('class')).toBe('viz-mark viz-mark-point');
|
|
591
|
+
expect(point!.getAttribute('data-mark-id')).toMatch(/^point-/);
|
|
592
|
+
expect(Number(point!.getAttribute('r'))).toBeGreaterThan(0);
|
|
593
|
+
expect(point!.getAttribute('fill')).not.toBeNull();
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('arc mark group has expected structure', () => {
|
|
597
|
+
const { svg } = renderSpec(pieSpec);
|
|
598
|
+
const arcGroup = svg.querySelector('.viz-mark-arc');
|
|
599
|
+
expect(arcGroup).not.toBeNull();
|
|
600
|
+
expect(arcGroup!.getAttribute('class')).toBe('viz-mark viz-mark-arc');
|
|
601
|
+
expect(arcGroup!.getAttribute('data-mark-id')).toMatch(/^arc-/);
|
|
602
|
+
expect(arcGroup!.getAttribute('transform')).toMatch(/translate\(/);
|
|
603
|
+
|
|
604
|
+
const path = arcGroup!.querySelector('path');
|
|
605
|
+
expect(path).not.toBeNull();
|
|
606
|
+
expect(path!.getAttribute('fill')).not.toBeNull();
|
|
607
|
+
expect(path!.getAttribute('d')).not.toBeNull();
|
|
608
|
+
});
|
|
609
|
+
});
|