@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,336 @@
|
|
|
1
|
+
import type { ChartSpec } from '@opendata-ai/openchart-engine';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createContainer, createMouseEvent } from '../__test-fixtures__/dom';
|
|
4
|
+
import { barSpec, lineSpec } from '../__test-fixtures__/specs';
|
|
5
|
+
import { createChart } from '../mount';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Test data
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const annotatedSpec: ChartSpec = {
|
|
12
|
+
...barSpec,
|
|
13
|
+
annotations: [{ type: 'refline', y: 0, label: 'Zero' }],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const textAnnotatedSpec: ChartSpec = {
|
|
17
|
+
...barSpec,
|
|
18
|
+
annotations: [{ type: 'text', x: 10, y: 'A', text: 'Peak', offset: { dx: 10, dy: -20 } }],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Tests
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
describe('chart event handlers', () => {
|
|
26
|
+
let container: HTMLDivElement;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
container = createContainer();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
document.body.innerHTML = '';
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('onMarkClick', () => {
|
|
37
|
+
it('fires with correct data when a mark element is clicked', () => {
|
|
38
|
+
const onMarkClick = vi.fn();
|
|
39
|
+
const chart = createChart(container, barSpec, { onMarkClick });
|
|
40
|
+
|
|
41
|
+
// Find a rect mark element (bar chart renders rect marks)
|
|
42
|
+
const mark = container.querySelector('[data-mark-id]');
|
|
43
|
+
expect(mark).not.toBeNull();
|
|
44
|
+
|
|
45
|
+
mark!.dispatchEvent(createMouseEvent('click', 150, 200));
|
|
46
|
+
|
|
47
|
+
expect(onMarkClick).toHaveBeenCalledTimes(1);
|
|
48
|
+
const event = onMarkClick.mock.calls[0][0];
|
|
49
|
+
expect(event.datum).toBeDefined();
|
|
50
|
+
expect(event.position).toBeDefined();
|
|
51
|
+
expect(event.position.x).toBeTypeOf('number');
|
|
52
|
+
expect(event.position.y).toBeTypeOf('number');
|
|
53
|
+
expect(event.event).toBeInstanceOf(MouseEvent);
|
|
54
|
+
|
|
55
|
+
chart.destroy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('includes series info for multi-series charts', () => {
|
|
59
|
+
const onMarkClick = vi.fn();
|
|
60
|
+
const chart = createChart(container, lineSpec, { onMarkClick });
|
|
61
|
+
|
|
62
|
+
// Line charts have marks with data-series attribute
|
|
63
|
+
const marks = container.querySelectorAll('[data-mark-id]');
|
|
64
|
+
expect(marks.length).toBeGreaterThan(0);
|
|
65
|
+
|
|
66
|
+
marks[0].dispatchEvent(createMouseEvent('click'));
|
|
67
|
+
|
|
68
|
+
expect(onMarkClick).toHaveBeenCalledTimes(1);
|
|
69
|
+
const event = onMarkClick.mock.calls[0][0];
|
|
70
|
+
// Line marks should have series info
|
|
71
|
+
expect(event.series).toBeDefined();
|
|
72
|
+
|
|
73
|
+
chart.destroy();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('onMarkHover', () => {
|
|
78
|
+
it('fires on mouseenter of a mark element', () => {
|
|
79
|
+
const onMarkHover = vi.fn();
|
|
80
|
+
const chart = createChart(container, barSpec, { onMarkHover });
|
|
81
|
+
|
|
82
|
+
const mark = container.querySelector('[data-mark-id]');
|
|
83
|
+
expect(mark).not.toBeNull();
|
|
84
|
+
|
|
85
|
+
mark!.dispatchEvent(createMouseEvent('mouseenter', 120, 180));
|
|
86
|
+
|
|
87
|
+
expect(onMarkHover).toHaveBeenCalledTimes(1);
|
|
88
|
+
const event = onMarkHover.mock.calls[0][0];
|
|
89
|
+
expect(event.datum).toBeDefined();
|
|
90
|
+
expect(event.position).toBeDefined();
|
|
91
|
+
|
|
92
|
+
chart.destroy();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('onMarkLeave', () => {
|
|
97
|
+
it('fires on mouseleave of a mark element', () => {
|
|
98
|
+
const onMarkLeave = vi.fn();
|
|
99
|
+
const chart = createChart(container, barSpec, { onMarkLeave });
|
|
100
|
+
|
|
101
|
+
const mark = container.querySelector('[data-mark-id]');
|
|
102
|
+
expect(mark).not.toBeNull();
|
|
103
|
+
|
|
104
|
+
mark!.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
|
105
|
+
|
|
106
|
+
expect(onMarkLeave).toHaveBeenCalledTimes(1);
|
|
107
|
+
// onMarkLeave receives no arguments
|
|
108
|
+
expect(onMarkLeave.mock.calls[0]).toHaveLength(0);
|
|
109
|
+
|
|
110
|
+
chart.destroy();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('onLegendToggle', () => {
|
|
115
|
+
it('fires when a legend entry is clicked', () => {
|
|
116
|
+
const onLegendToggle = vi.fn();
|
|
117
|
+
const chart = createChart(container, lineSpec, { onLegendToggle });
|
|
118
|
+
|
|
119
|
+
const legendEntry = container.querySelector('[data-legend-index]');
|
|
120
|
+
expect(legendEntry).not.toBeNull();
|
|
121
|
+
|
|
122
|
+
legendEntry.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
123
|
+
|
|
124
|
+
expect(onLegendToggle).toHaveBeenCalledTimes(1);
|
|
125
|
+
const [series, visible] = onLegendToggle.mock.calls[0];
|
|
126
|
+
expect(series).toBeTypeOf('string');
|
|
127
|
+
// First click hides the series
|
|
128
|
+
expect(visible).toBe(false);
|
|
129
|
+
|
|
130
|
+
// Click again to toggle back
|
|
131
|
+
legendEntry.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
132
|
+
expect(onLegendToggle).toHaveBeenCalledTimes(2);
|
|
133
|
+
const [, visibleAfter] = onLegendToggle.mock.calls[1];
|
|
134
|
+
expect(visibleAfter).toBe(true);
|
|
135
|
+
|
|
136
|
+
chart.destroy();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('onAnnotationClick', () => {
|
|
141
|
+
it('fires when an annotation element is clicked', () => {
|
|
142
|
+
const onAnnotationClick = vi.fn();
|
|
143
|
+
const chart = createChart(container, annotatedSpec, { onAnnotationClick });
|
|
144
|
+
|
|
145
|
+
const annotation = container.querySelector('.viz-annotation');
|
|
146
|
+
if (!annotation) {
|
|
147
|
+
// Refline at y=0 may resolve outside the chart area in test env
|
|
148
|
+
chart.destroy();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
annotation.dispatchEvent(createMouseEvent('click'));
|
|
153
|
+
|
|
154
|
+
expect(onAnnotationClick).toHaveBeenCalledTimes(1);
|
|
155
|
+
const [annotationArg, mouseEvent] = onAnnotationClick.mock.calls[0];
|
|
156
|
+
expect(annotationArg).toBeDefined();
|
|
157
|
+
expect(annotationArg.type).toBe('refline');
|
|
158
|
+
expect(mouseEvent).toBeInstanceOf(MouseEvent);
|
|
159
|
+
|
|
160
|
+
chart.destroy();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('onAnnotationEdit', () => {
|
|
165
|
+
it('sets cursor:grab on text annotations when onAnnotationEdit is provided', () => {
|
|
166
|
+
const onAnnotationEdit = vi.fn();
|
|
167
|
+
const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit });
|
|
168
|
+
|
|
169
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
170
|
+
if (!annotation) {
|
|
171
|
+
chart.destroy();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
expect(annotation.style.cursor).toBe('grab');
|
|
176
|
+
chart.destroy();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('does not set cursor:grab on non-text annotations', () => {
|
|
180
|
+
const onAnnotationEdit = vi.fn();
|
|
181
|
+
const chart = createChart(container, annotatedSpec, { onAnnotationEdit });
|
|
182
|
+
|
|
183
|
+
const annotation = container.querySelector('.viz-annotation-refline') as SVGGElement | null;
|
|
184
|
+
if (!annotation) {
|
|
185
|
+
chart.destroy();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
expect(annotation.style.cursor).not.toBe('grab');
|
|
190
|
+
chart.destroy();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('fires with original annotation and updated offset after drag', () => {
|
|
194
|
+
const onAnnotationEdit = vi.fn();
|
|
195
|
+
const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit });
|
|
196
|
+
|
|
197
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
198
|
+
if (!annotation) {
|
|
199
|
+
chart.destroy();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Simulate drag: mousedown -> mousemove -> mouseup
|
|
204
|
+
annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100));
|
|
205
|
+
document.dispatchEvent(createMouseEvent('mousemove', 150, 130));
|
|
206
|
+
document.dispatchEvent(createMouseEvent('mouseup', 150, 130));
|
|
207
|
+
|
|
208
|
+
expect(onAnnotationEdit).toHaveBeenCalledTimes(1);
|
|
209
|
+
const [annotationArg, offsetArg] = onAnnotationEdit.mock.calls[0];
|
|
210
|
+
expect(annotationArg.type).toBe('text');
|
|
211
|
+
expect(annotationArg.text).toBe('Peak');
|
|
212
|
+
// Original offset (10, -20) + drag delta (50, 30) = (60, 10)
|
|
213
|
+
expect(offsetArg.dx).toBe(60);
|
|
214
|
+
expect(offsetArg.dy).toBe(10);
|
|
215
|
+
|
|
216
|
+
chart.destroy();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('does not fire when drag movement is below threshold', () => {
|
|
220
|
+
const onAnnotationEdit = vi.fn();
|
|
221
|
+
const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit });
|
|
222
|
+
|
|
223
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
224
|
+
if (!annotation) {
|
|
225
|
+
chart.destroy();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Simulate drag with minimal movement (< 3px)
|
|
230
|
+
annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100));
|
|
231
|
+
document.dispatchEvent(createMouseEvent('mousemove', 101, 101));
|
|
232
|
+
document.dispatchEvent(createMouseEvent('mouseup', 101, 101));
|
|
233
|
+
|
|
234
|
+
expect(onAnnotationEdit).not.toHaveBeenCalled();
|
|
235
|
+
chart.destroy();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('suppresses click after drag', () => {
|
|
239
|
+
const onAnnotationClick = vi.fn();
|
|
240
|
+
const onAnnotationEdit = vi.fn();
|
|
241
|
+
const chart = createChart(container, textAnnotatedSpec, {
|
|
242
|
+
onAnnotationClick,
|
|
243
|
+
onAnnotationEdit,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
247
|
+
if (!annotation) {
|
|
248
|
+
chart.destroy();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Drag > 3px
|
|
253
|
+
annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100));
|
|
254
|
+
document.dispatchEvent(createMouseEvent('mousemove', 150, 130));
|
|
255
|
+
document.dispatchEvent(createMouseEvent('mouseup', 150, 130));
|
|
256
|
+
|
|
257
|
+
// Fire a click after the drag
|
|
258
|
+
annotation.dispatchEvent(createMouseEvent('click', 150, 130));
|
|
259
|
+
|
|
260
|
+
expect(onAnnotationEdit).toHaveBeenCalledTimes(1);
|
|
261
|
+
// Click should have been suppressed by the one-time capture handler
|
|
262
|
+
expect(onAnnotationClick).not.toHaveBeenCalled();
|
|
263
|
+
|
|
264
|
+
chart.destroy();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('applies transform during drag', () => {
|
|
268
|
+
const onAnnotationEdit = vi.fn();
|
|
269
|
+
const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit });
|
|
270
|
+
|
|
271
|
+
const annotation = container.querySelector('.viz-annotation-text') as SVGGElement | null;
|
|
272
|
+
if (!annotation) {
|
|
273
|
+
chart.destroy();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100));
|
|
278
|
+
document.dispatchEvent(createMouseEvent('mousemove', 140, 120));
|
|
279
|
+
|
|
280
|
+
// During drag, the annotation group should have a transform
|
|
281
|
+
const transform = annotation.getAttribute('transform');
|
|
282
|
+
expect(transform).toContain('translate');
|
|
283
|
+
|
|
284
|
+
// Complete the drag to clean up
|
|
285
|
+
document.dispatchEvent(createMouseEvent('mouseup', 140, 120));
|
|
286
|
+
chart.destroy();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('cleans up listeners on destroy', () => {
|
|
290
|
+
const onAnnotationEdit = vi.fn();
|
|
291
|
+
const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit });
|
|
292
|
+
|
|
293
|
+
chart.destroy();
|
|
294
|
+
|
|
295
|
+
// After destroy, dispatching events should not cause errors
|
|
296
|
+
document.dispatchEvent(createMouseEvent('mousemove', 200, 200));
|
|
297
|
+
document.dispatchEvent(createMouseEvent('mouseup', 200, 200));
|
|
298
|
+
|
|
299
|
+
expect(onAnnotationEdit).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('cleanup', () => {
|
|
304
|
+
it('removes event listeners on destroy', () => {
|
|
305
|
+
const onMarkClick = vi.fn();
|
|
306
|
+
const chart = createChart(container, barSpec, { onMarkClick });
|
|
307
|
+
|
|
308
|
+
const mark = container.querySelector('[data-mark-id]');
|
|
309
|
+
expect(mark).not.toBeNull();
|
|
310
|
+
|
|
311
|
+
chart.destroy();
|
|
312
|
+
|
|
313
|
+
// After destroy, clicking should not fire the handler
|
|
314
|
+
// (the SVG is removed, but we verify the handler reference is cleaned up)
|
|
315
|
+
expect(onMarkClick).not.toHaveBeenCalled();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('removes event listeners on update', () => {
|
|
319
|
+
const onMarkClick = vi.fn();
|
|
320
|
+
const chart = createChart(container, barSpec, { onMarkClick });
|
|
321
|
+
|
|
322
|
+
// Update triggers a re-render which should clean up old listeners
|
|
323
|
+
chart.update(lineSpec);
|
|
324
|
+
|
|
325
|
+
// Find mark in the new render
|
|
326
|
+
const mark = container.querySelector('[data-mark-id]');
|
|
327
|
+
expect(mark).not.toBeNull();
|
|
328
|
+
|
|
329
|
+
// Click the new mark - should still fire since new listeners were wired
|
|
330
|
+
mark!.dispatchEvent(createMouseEvent('click'));
|
|
331
|
+
expect(onMarkClick).toHaveBeenCalledTimes(1);
|
|
332
|
+
|
|
333
|
+
chart.destroy();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export utility tests.
|
|
3
|
+
*
|
|
4
|
+
* Tests exportSVG and exportCSV functions directly, verifying SVG string
|
|
5
|
+
* validity and CSV formatting with headers and proper escaping.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CompileOptions } from '@opendata-ai/openchart-engine';
|
|
9
|
+
import { compileChart } from '@opendata-ai/openchart-engine';
|
|
10
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
11
|
+
import { createContainer } from '../__test-fixtures__/dom';
|
|
12
|
+
import { barSpec, lineSpec } from '../__test-fixtures__/specs';
|
|
13
|
+
import { exportCSV, exportSVG } from '../export';
|
|
14
|
+
import { renderChartSVG } from '../svg-renderer';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const COMPILE_OPTS: CompileOptions = { width: 600, height: 400 };
|
|
21
|
+
|
|
22
|
+
function renderToSVG(spec = lineSpec) {
|
|
23
|
+
const container = createContainer();
|
|
24
|
+
const layout = compileChart(spec, COMPILE_OPTS);
|
|
25
|
+
return renderChartSVG(layout, container);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
document.body.innerHTML = '';
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// exportSVG
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
describe('exportSVG', () => {
|
|
37
|
+
it('returns a string starting with <svg', () => {
|
|
38
|
+
const svg = renderToSVG();
|
|
39
|
+
const result = exportSVG(svg);
|
|
40
|
+
expect(result.startsWith('<svg')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returned string contains viewBox attribute', () => {
|
|
44
|
+
const svg = renderToSVG();
|
|
45
|
+
const result = exportSVG(svg);
|
|
46
|
+
expect(result).toContain('viewBox="0 0 600 400"');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returned string contains chart chrome text content', () => {
|
|
50
|
+
const svg = renderToSVG(lineSpec);
|
|
51
|
+
const result = exportSVG(svg);
|
|
52
|
+
expect(result).toContain('GDP Growth');
|
|
53
|
+
expect(result).toContain('US vs UK over time');
|
|
54
|
+
expect(result).toContain('World Bank');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returned string contains SVG namespace', () => {
|
|
58
|
+
const svg = renderToSVG();
|
|
59
|
+
const result = exportSVG(svg);
|
|
60
|
+
expect(result).toContain('xmlns');
|
|
61
|
+
expect(result).toContain('http://www.w3.org/2000/svg');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returned string includes mark elements', () => {
|
|
65
|
+
const svg = renderToSVG(barSpec);
|
|
66
|
+
const result = exportSVG(svg);
|
|
67
|
+
// Bar chart should have rect elements
|
|
68
|
+
expect(result).toContain('<rect');
|
|
69
|
+
expect(result).toContain('viz-mark-rect');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returned string is well-formed XML (ends with closing svg tag)', () => {
|
|
73
|
+
const svg = renderToSVG();
|
|
74
|
+
const result = exportSVG(svg);
|
|
75
|
+
expect(result.endsWith('</svg>')).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// exportCSV
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
describe('exportCSV', () => {
|
|
84
|
+
it('returns empty string for empty data', () => {
|
|
85
|
+
const result = exportCSV([]);
|
|
86
|
+
expect(result).toBe('');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('first line contains column headers from data keys', () => {
|
|
90
|
+
const data = [
|
|
91
|
+
{ name: 'Alice', age: 30, city: 'Portland' },
|
|
92
|
+
{ name: 'Bob', age: 25, city: 'Seattle' },
|
|
93
|
+
];
|
|
94
|
+
const result = exportCSV(data);
|
|
95
|
+
const firstLine = result.split('\n')[0];
|
|
96
|
+
expect(firstLine).toBe('name,age,city');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('data rows follow header line', () => {
|
|
100
|
+
const data = [
|
|
101
|
+
{ name: 'Alice', age: 30 },
|
|
102
|
+
{ name: 'Bob', age: 25 },
|
|
103
|
+
];
|
|
104
|
+
const result = exportCSV(data);
|
|
105
|
+
const lines = result.split('\n');
|
|
106
|
+
expect(lines.length).toBe(3); // 1 header + 2 data rows
|
|
107
|
+
expect(lines[1]).toBe('Alice,30');
|
|
108
|
+
expect(lines[2]).toBe('Bob,25');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('escapes values containing commas', () => {
|
|
112
|
+
const data = [{ name: 'Doe, John', value: 42 }];
|
|
113
|
+
const result = exportCSV(data);
|
|
114
|
+
const lines = result.split('\n');
|
|
115
|
+
// Comma in value should be wrapped in double quotes
|
|
116
|
+
expect(lines[1]).toBe('"Doe, John",42');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('escapes values containing double quotes', () => {
|
|
120
|
+
const data = [{ name: 'Say "hi"', value: 1 }];
|
|
121
|
+
const result = exportCSV(data);
|
|
122
|
+
const lines = result.split('\n');
|
|
123
|
+
// Double quotes should be doubled and the field wrapped in quotes
|
|
124
|
+
expect(lines[1]).toBe('"Say ""hi""",1');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('escapes values containing newlines', () => {
|
|
128
|
+
const data = [{ name: 'Line1\nLine2', value: 1 }];
|
|
129
|
+
const result = exportCSV(data);
|
|
130
|
+
// The entire field should be quoted since it contains a newline
|
|
131
|
+
expect(result).toContain('"Line1\nLine2"');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('handles undefined and null values as empty strings', () => {
|
|
135
|
+
const data = [{ a: undefined, b: null, c: 'ok' }];
|
|
136
|
+
const result = exportCSV(data);
|
|
137
|
+
const lines = result.split('\n');
|
|
138
|
+
// undefined and null should render as empty
|
|
139
|
+
expect(lines[1]).toBe(',,ok');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('uses keys from first row as headers', () => {
|
|
143
|
+
const data = [
|
|
144
|
+
{ x: 1, y: 2, z: 3 },
|
|
145
|
+
{ x: 4, y: 5, z: 6 },
|
|
146
|
+
];
|
|
147
|
+
const result = exportCSV(data);
|
|
148
|
+
expect(result.split('\n')[0]).toBe('x,y,z');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { createContainer } from '../__test-fixtures__/dom';
|
|
3
|
+
import { barSpec, lineSpec } from '../__test-fixtures__/specs';
|
|
4
|
+
import { createChart } from '../mount';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Tests
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
describe('createChart', () => {
|
|
11
|
+
let container: HTMLDivElement;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
container = createContainer();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.body.innerHTML = '';
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('creates an SVG element in the container', () => {
|
|
22
|
+
const chart = createChart(container, lineSpec);
|
|
23
|
+
|
|
24
|
+
const svg = container.querySelector('svg');
|
|
25
|
+
expect(svg).not.toBeNull();
|
|
26
|
+
expect(svg?.getAttribute('class')).toBe('viz-chart');
|
|
27
|
+
|
|
28
|
+
chart.destroy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('SVG has correct viewBox dimensions', () => {
|
|
32
|
+
const chart = createChart(container, lineSpec);
|
|
33
|
+
|
|
34
|
+
const svg = container.querySelector('svg');
|
|
35
|
+
const viewBox = svg?.getAttribute('viewBox');
|
|
36
|
+
expect(viewBox).toBe('0 0 600 400');
|
|
37
|
+
|
|
38
|
+
chart.destroy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('chrome text elements are present with correct content', () => {
|
|
42
|
+
const chart = createChart(container, lineSpec);
|
|
43
|
+
|
|
44
|
+
const title = container.querySelector('.viz-title');
|
|
45
|
+
expect(title).not.toBeNull();
|
|
46
|
+
expect(title?.textContent).toBe('GDP Growth');
|
|
47
|
+
|
|
48
|
+
const subtitle = container.querySelector('.viz-subtitle');
|
|
49
|
+
expect(subtitle).not.toBeNull();
|
|
50
|
+
expect(subtitle?.textContent).toBe('US vs UK over time');
|
|
51
|
+
|
|
52
|
+
const source = container.querySelector('.viz-source');
|
|
53
|
+
expect(source).not.toBeNull();
|
|
54
|
+
expect(source?.textContent).toBe('World Bank');
|
|
55
|
+
|
|
56
|
+
chart.destroy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('update() re-renders with new data', () => {
|
|
60
|
+
const chart = createChart(container, lineSpec);
|
|
61
|
+
|
|
62
|
+
const titleBefore = container.querySelector('.viz-title');
|
|
63
|
+
expect(titleBefore?.textContent).toBe('GDP Growth');
|
|
64
|
+
|
|
65
|
+
chart.update(barSpec);
|
|
66
|
+
|
|
67
|
+
const titleAfter = container.querySelector('.viz-title');
|
|
68
|
+
expect(titleAfter?.textContent).toBe('Updated Chart');
|
|
69
|
+
|
|
70
|
+
chart.destroy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('destroy() removes all DOM elements and disconnects observer', () => {
|
|
74
|
+
const chart = createChart(container, lineSpec);
|
|
75
|
+
|
|
76
|
+
const svgBefore = container.querySelector('svg');
|
|
77
|
+
expect(svgBefore).not.toBeNull();
|
|
78
|
+
|
|
79
|
+
chart.destroy();
|
|
80
|
+
|
|
81
|
+
const svgAfter = container.querySelector('svg');
|
|
82
|
+
expect(svgAfter).toBeNull();
|
|
83
|
+
|
|
84
|
+
// Tooltip div should also be removed
|
|
85
|
+
const tooltip = container.querySelector('.viz-tooltip');
|
|
86
|
+
expect(tooltip).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('has accessible ARIA attributes on the SVG', () => {
|
|
90
|
+
const chart = createChart(container, lineSpec);
|
|
91
|
+
|
|
92
|
+
const svg = container.querySelector('svg');
|
|
93
|
+
expect(svg?.getAttribute('role')).toBe('img');
|
|
94
|
+
expect(svg?.getAttribute('aria-label')).toContain('Line chart');
|
|
95
|
+
|
|
96
|
+
chart.destroy();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('layout property returns the compiled layout', () => {
|
|
100
|
+
const chart = createChart(container, lineSpec);
|
|
101
|
+
|
|
102
|
+
expect(chart.layout).toBeDefined();
|
|
103
|
+
expect(chart.layout.dimensions.width).toBe(600);
|
|
104
|
+
expect(chart.layout.dimensions.height).toBe(400);
|
|
105
|
+
expect(chart.layout.chrome.title?.text).toBe('GDP Growth');
|
|
106
|
+
|
|
107
|
+
chart.destroy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('export("svg") returns a valid SVG string', () => {
|
|
111
|
+
const chart = createChart(container, lineSpec);
|
|
112
|
+
|
|
113
|
+
const svgString = chart.export('svg');
|
|
114
|
+
expect(svgString).toContain('<svg');
|
|
115
|
+
expect(svgString).toContain('viewBox');
|
|
116
|
+
expect(svgString).toContain('GDP Growth');
|
|
117
|
+
|
|
118
|
+
chart.destroy();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('export("csv") returns CSV data', () => {
|
|
122
|
+
const chart = createChart(container, lineSpec);
|
|
123
|
+
|
|
124
|
+
const csv = chart.export('csv');
|
|
125
|
+
expect(csv).toContain('date');
|
|
126
|
+
expect(csv).toContain('value');
|
|
127
|
+
expect(csv).toContain('country');
|
|
128
|
+
|
|
129
|
+
chart.destroy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('renders axis elements', () => {
|
|
133
|
+
const chart = createChart(container, lineSpec);
|
|
134
|
+
|
|
135
|
+
const xAxis = container.querySelector('.viz-axis-x');
|
|
136
|
+
const yAxis = container.querySelector('.viz-axis-y');
|
|
137
|
+
expect(xAxis).not.toBeNull();
|
|
138
|
+
expect(yAxis).not.toBeNull();
|
|
139
|
+
|
|
140
|
+
chart.destroy();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('renders chrome wrapper group', () => {
|
|
144
|
+
const chart = createChart(container, lineSpec);
|
|
145
|
+
|
|
146
|
+
const chromeGroup = container.querySelector('.viz-chrome');
|
|
147
|
+
expect(chromeGroup).not.toBeNull();
|
|
148
|
+
|
|
149
|
+
chart.destroy();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('passes responsive option to skip resize observer', () => {
|
|
153
|
+
// Should not throw with responsive: false
|
|
154
|
+
const chart = createChart(container, lineSpec, { responsive: false });
|
|
155
|
+
|
|
156
|
+
const svg = container.querySelector('svg');
|
|
157
|
+
expect(svg).not.toBeNull();
|
|
158
|
+
|
|
159
|
+
chart.destroy();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('renders marks with data-mark-id attributes for data points', () => {
|
|
163
|
+
const chart = createChart(container, barSpec);
|
|
164
|
+
|
|
165
|
+
const marks = container.querySelectorAll('[data-mark-id]');
|
|
166
|
+
// barSpec has 3 data points, each should produce a mark
|
|
167
|
+
expect(marks.length).toBe(3);
|
|
168
|
+
|
|
169
|
+
for (const mark of marks) {
|
|
170
|
+
const id = mark.getAttribute('data-mark-id');
|
|
171
|
+
expect(id).toMatch(/^rect-\d+$/);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
chart.destroy();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('multi-series marks have data-series attributes', () => {
|
|
178
|
+
const chart = createChart(container, lineSpec);
|
|
179
|
+
|
|
180
|
+
const marks = container.querySelectorAll('[data-series]');
|
|
181
|
+
expect(marks.length).toBeGreaterThan(0);
|
|
182
|
+
|
|
183
|
+
const seriesNames = new Set<string>();
|
|
184
|
+
for (const mark of marks) {
|
|
185
|
+
const series = mark.getAttribute('data-series');
|
|
186
|
+
expect(series).not.toBeNull();
|
|
187
|
+
seriesNames.add(series!);
|
|
188
|
+
}
|
|
189
|
+
// lineSpec has US and UK series
|
|
190
|
+
expect(seriesNames.has('US')).toBe(true);
|
|
191
|
+
expect(seriesNames.has('UK')).toBe(true);
|
|
192
|
+
|
|
193
|
+
chart.destroy();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('resize observer integration', () => {
|
|
198
|
+
let container: HTMLDivElement;
|
|
199
|
+
|
|
200
|
+
beforeEach(() => {
|
|
201
|
+
container = createContainer();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
afterEach(() => {
|
|
205
|
+
document.body.innerHTML = '';
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('resize() re-renders the chart', () => {
|
|
209
|
+
const chart = createChart(container, lineSpec);
|
|
210
|
+
|
|
211
|
+
// Should not throw
|
|
212
|
+
chart.resize();
|
|
213
|
+
|
|
214
|
+
const svg = container.querySelector('svg');
|
|
215
|
+
expect(svg).not.toBeNull();
|
|
216
|
+
|
|
217
|
+
chart.destroy();
|
|
218
|
+
});
|
|
219
|
+
});
|