@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.
Files changed (44) hide show
  1. package/dist/index.d.ts +327 -0
  2. package/dist/index.js +4745 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/simulation-worker.js +1196 -0
  5. package/package.json +58 -0
  6. package/src/__test-fixtures__/dom.ts +42 -0
  7. package/src/__test-fixtures__/specs.ts +187 -0
  8. package/src/__tests__/edit-events.test.ts +747 -0
  9. package/src/__tests__/events.test.ts +336 -0
  10. package/src/__tests__/export.test.ts +150 -0
  11. package/src/__tests__/mount.test.ts +219 -0
  12. package/src/__tests__/svg-renderer.test.ts +609 -0
  13. package/src/__tests__/table-mount.test.ts +484 -0
  14. package/src/__tests__/tooltip.test.ts +201 -0
  15. package/src/export.ts +105 -0
  16. package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
  17. package/src/graph/__tests__/graph-mount.test.ts +213 -0
  18. package/src/graph/__tests__/interaction.test.ts +205 -0
  19. package/src/graph/__tests__/keyboard.test.ts +653 -0
  20. package/src/graph/__tests__/search.test.ts +88 -0
  21. package/src/graph/__tests__/simulation.test.ts +233 -0
  22. package/src/graph/__tests__/spatial-index.test.ts +142 -0
  23. package/src/graph/__tests__/zoom.test.ts +195 -0
  24. package/src/graph/canvas-renderer.ts +660 -0
  25. package/src/graph/interaction.ts +359 -0
  26. package/src/graph/keyboard.ts +208 -0
  27. package/src/graph/search.ts +50 -0
  28. package/src/graph/simulation-worker-url.ts +30 -0
  29. package/src/graph/simulation-worker.ts +265 -0
  30. package/src/graph/simulation.ts +350 -0
  31. package/src/graph/spatial-index.ts +121 -0
  32. package/src/graph/types.ts +44 -0
  33. package/src/graph/worker-protocol.ts +67 -0
  34. package/src/graph/zoom.ts +104 -0
  35. package/src/graph-mount.ts +675 -0
  36. package/src/index.ts +56 -0
  37. package/src/mount.ts +1639 -0
  38. package/src/renderers/table-cells.ts +444 -0
  39. package/src/resize-observer.ts +46 -0
  40. package/src/svg-renderer.ts +914 -0
  41. package/src/table-keyboard.ts +266 -0
  42. package/src/table-mount.ts +532 -0
  43. package/src/table-renderer.ts +350 -0
  44. 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
+ });