@opendata-ai/openchart-react 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.
@@ -0,0 +1,134 @@
1
+ import type { ChartSpec } from '@opendata-ai/openchart-core';
2
+ import { cleanup, render, waitFor } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+ import { Chart } from '../Chart';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Test data
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const lineSpec: ChartSpec = {
11
+ type: 'line',
12
+ data: [
13
+ { date: '2020-01-01', value: 10, country: 'US' },
14
+ { date: '2021-01-01', value: 40, country: 'US' },
15
+ { date: '2020-01-01', value: 15, country: 'UK' },
16
+ { date: '2021-01-01', value: 35, country: 'UK' },
17
+ ],
18
+ encoding: {
19
+ x: { field: 'date', type: 'temporal' },
20
+ y: { field: 'value', type: 'quantitative' },
21
+ color: { field: 'country', type: 'nominal' },
22
+ },
23
+ chrome: {
24
+ title: 'GDP Growth',
25
+ subtitle: 'US vs UK over time',
26
+ source: 'World Bank',
27
+ },
28
+ };
29
+
30
+ const barSpec: ChartSpec = {
31
+ type: 'bar',
32
+ data: [
33
+ { name: 'A', value: 10 },
34
+ { name: 'B', value: 30 },
35
+ { name: 'C', value: 20 },
36
+ ],
37
+ encoding: {
38
+ x: { field: 'value', type: 'quantitative' },
39
+ y: { field: 'name', type: 'nominal' },
40
+ },
41
+ chrome: {
42
+ title: 'Updated Title',
43
+ },
44
+ };
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Helper: render Chart and wait for SVG to appear (useEffect is deferred)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ async function renderChart(props: React.ComponentProps<typeof Chart>) {
51
+ const result = render(<Chart {...props} />);
52
+ await waitFor(() => {
53
+ expect(result.container.querySelector('svg')).not.toBeNull();
54
+ });
55
+ return result;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Tests
60
+ // ---------------------------------------------------------------------------
61
+
62
+ afterEach(() => {
63
+ cleanup();
64
+ });
65
+
66
+ describe('<Chart />', () => {
67
+ it('renders an SVG element', async () => {
68
+ const { container } = await renderChart({ spec: lineSpec });
69
+ const svg = container.querySelector('svg');
70
+ expect(svg).not.toBeNull();
71
+ expect(svg?.getAttribute('class')).toBe('viz-chart');
72
+ });
73
+
74
+ it('renders chrome text elements', async () => {
75
+ const { container } = await renderChart({ spec: lineSpec });
76
+
77
+ const title = container.querySelector('.viz-title');
78
+ expect(title).not.toBeNull();
79
+ expect(title?.textContent).toBe('GDP Growth');
80
+
81
+ const subtitle = container.querySelector('.viz-subtitle');
82
+ expect(subtitle?.textContent).toBe('US vs UK over time');
83
+
84
+ const source = container.querySelector('.viz-source');
85
+ expect(source?.textContent).toBe('World Bank');
86
+ });
87
+
88
+ it('spec changes trigger re-render', async () => {
89
+ const { container, rerender } = await renderChart({ spec: lineSpec });
90
+
91
+ const titleBefore = container.querySelector('.viz-title');
92
+ expect(titleBefore?.textContent).toBe('GDP Growth');
93
+
94
+ rerender(<Chart spec={barSpec} />);
95
+ await waitFor(() => {
96
+ expect(container.querySelector('.viz-title')?.textContent).toBe('Updated Title');
97
+ });
98
+ });
99
+
100
+ it('unmounting cleans up chart instance', async () => {
101
+ const { container, unmount } = await renderChart({ spec: lineSpec });
102
+
103
+ const svgBefore = container.querySelector('svg');
104
+ expect(svgBefore).not.toBeNull();
105
+
106
+ unmount();
107
+
108
+ expect(container.querySelector('svg')).toBeNull();
109
+ });
110
+
111
+ it('className prop passes through to wrapper div', async () => {
112
+ const { container } = await renderChart({ spec: lineSpec, className: 'my-chart' });
113
+
114
+ const wrapper = container.firstElementChild as HTMLElement;
115
+ expect(wrapper?.className).toContain('my-chart');
116
+ });
117
+
118
+ it('renders with dark mode option', async () => {
119
+ const { container } = await renderChart({ spec: lineSpec, darkMode: 'force' });
120
+
121
+ const svg = container.querySelector('svg');
122
+ expect(svg).not.toBeNull();
123
+ });
124
+
125
+ it('style prop passes through to wrapper div', async () => {
126
+ const { container } = await renderChart({
127
+ spec: lineSpec,
128
+ style: { border: '1px solid red' },
129
+ });
130
+
131
+ const wrapper = container.firstElementChild as HTMLElement;
132
+ expect(wrapper?.style.border).toBe('1px solid red');
133
+ });
134
+ });
@@ -0,0 +1,126 @@
1
+ import type { TableSpec } from '@opendata-ai/openchart-core';
2
+ import { cleanup, render, waitFor } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+ import { DataTable } from '../DataTable';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Test data
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const tableSpec: TableSpec = {
11
+ type: 'table',
12
+ data: [
13
+ { name: 'Alice', age: 30, city: 'Portland' },
14
+ { name: 'Bob', age: 25, city: 'Seattle' },
15
+ { name: 'Charlie', age: 35, city: 'Denver' },
16
+ ],
17
+ columns: [
18
+ { key: 'name', label: 'Name' },
19
+ { key: 'age', label: 'Age' },
20
+ { key: 'city', label: 'City' },
21
+ ],
22
+ chrome: { title: 'People Table' },
23
+ };
24
+
25
+ const updatedSpec: TableSpec = {
26
+ type: 'table',
27
+ data: [
28
+ { x: 1, y: 2 },
29
+ { x: 3, y: 4 },
30
+ ],
31
+ columns: [
32
+ { key: 'x', label: 'X Value' },
33
+ { key: 'y', label: 'Y Value' },
34
+ ],
35
+ chrome: { title: 'Updated Table' },
36
+ };
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Helper: render DataTable and wait for the table to mount (useEffect is deferred)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ async function renderTable(props: React.ComponentProps<typeof DataTable>) {
43
+ const result = render(<DataTable {...props} />);
44
+ await waitFor(() => {
45
+ expect(result.container.querySelector('table')).not.toBeNull();
46
+ });
47
+ return result;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Tests
52
+ // ---------------------------------------------------------------------------
53
+
54
+ afterEach(() => {
55
+ cleanup();
56
+ });
57
+
58
+ describe('<DataTable />', () => {
59
+ it('renders a table', async () => {
60
+ const { container } = await renderTable({ spec: tableSpec });
61
+ const table = container.querySelector('table');
62
+ expect(table).not.toBeNull();
63
+ });
64
+
65
+ it('renders correct number of columns', async () => {
66
+ const { container } = await renderTable({ spec: tableSpec });
67
+ const headers = container.querySelectorAll('thead th');
68
+ expect(headers.length).toBe(3);
69
+ });
70
+
71
+ it('renders correct number of rows', async () => {
72
+ const { container } = await renderTable({ spec: tableSpec });
73
+ const rows = container.querySelectorAll('tbody tr');
74
+ expect(rows.length).toBe(3);
75
+ });
76
+
77
+ it('spec changes trigger re-render', async () => {
78
+ const { container, rerender } = await renderTable({ spec: tableSpec });
79
+
80
+ const titleBefore = container.querySelector('.viz-table-title');
81
+ expect(titleBefore?.textContent).toBe('People Table');
82
+
83
+ rerender(<DataTable spec={updatedSpec} />);
84
+ await waitFor(() => {
85
+ expect(container.querySelector('.viz-table-title')?.textContent).toBe('Updated Table');
86
+ });
87
+
88
+ const headersAfter = container.querySelectorAll('thead th');
89
+ expect(headersAfter.length).toBe(2);
90
+ });
91
+
92
+ it('unmounting cleans up', async () => {
93
+ const { container, unmount } = await renderTable({ spec: tableSpec });
94
+
95
+ const tableBefore = container.querySelector('table');
96
+ expect(tableBefore).not.toBeNull();
97
+
98
+ unmount();
99
+
100
+ expect(container.querySelector('table')).toBeNull();
101
+ });
102
+
103
+ it('className passes through', async () => {
104
+ const { container } = await renderTable({ spec: tableSpec, className: 'my-table' });
105
+
106
+ const wrapper = container.firstElementChild as HTMLElement;
107
+ expect(wrapper?.className).toContain('my-table');
108
+ });
109
+
110
+ it('renders with dark mode option', async () => {
111
+ const { container } = await renderTable({ spec: tableSpec, darkMode: 'force' });
112
+
113
+ const table = container.querySelector('table');
114
+ expect(table).not.toBeNull();
115
+ });
116
+
117
+ it('style prop passes through to wrapper div', async () => {
118
+ const { container } = await renderTable({
119
+ spec: tableSpec,
120
+ style: { border: '1px solid red' },
121
+ });
122
+
123
+ const wrapper = container.firstElementChild as HTMLElement;
124
+ expect(wrapper?.style.border).toBe('1px solid red');
125
+ });
126
+ });
@@ -0,0 +1,130 @@
1
+ import type { GraphSpec } from '@opendata-ai/openchart-core';
2
+ import { cleanup, render, waitFor } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+ import { Graph } from '../Graph';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Test data
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const basicSpec: GraphSpec = {
11
+ type: 'graph',
12
+ nodes: [
13
+ { id: 'a', label: 'Node A' },
14
+ { id: 'b', label: 'Node B' },
15
+ { id: 'c', label: 'Node C' },
16
+ ],
17
+ edges: [
18
+ { source: 'a', target: 'b' },
19
+ { source: 'b', target: 'c' },
20
+ ],
21
+ chrome: {
22
+ title: 'Test Graph',
23
+ subtitle: 'A simple test graph',
24
+ },
25
+ };
26
+
27
+ const updatedSpec: GraphSpec = {
28
+ type: 'graph',
29
+ nodes: [
30
+ { id: 'x', label: 'Node X' },
31
+ { id: 'y', label: 'Node Y' },
32
+ ],
33
+ edges: [{ source: 'x', target: 'y' }],
34
+ chrome: {
35
+ title: 'Updated Graph',
36
+ },
37
+ };
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Helper: render Graph and wait for canvas to mount (useEffect is deferred)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ async function renderGraph(props: React.ComponentProps<typeof Graph>) {
44
+ const result = render(<Graph {...props} />);
45
+ await waitFor(() => {
46
+ expect(result.container.querySelector('canvas')).not.toBeNull();
47
+ });
48
+ return result;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Tests
53
+ // ---------------------------------------------------------------------------
54
+
55
+ afterEach(() => {
56
+ cleanup();
57
+ });
58
+
59
+ describe('<Graph />', () => {
60
+ it('renders a container div', async () => {
61
+ const { container } = await renderGraph({ spec: basicSpec });
62
+ const wrapper = container.firstElementChild as HTMLElement;
63
+ expect(wrapper).not.toBeNull();
64
+ expect(wrapper.tagName.toLowerCase()).toBe('div');
65
+ });
66
+
67
+ it('mounts graph instance with canvas element', async () => {
68
+ const { container } = await renderGraph({ spec: basicSpec });
69
+ const canvas = container.querySelector('canvas');
70
+ expect(canvas).not.toBeNull();
71
+ });
72
+
73
+ it('renders chrome text elements', async () => {
74
+ const { container } = await renderGraph({ spec: basicSpec });
75
+
76
+ const title = container.querySelector('.viz-title');
77
+ expect(title).not.toBeNull();
78
+ expect(title?.textContent).toBe('Test Graph');
79
+
80
+ const subtitle = container.querySelector('.viz-subtitle');
81
+ expect(subtitle?.textContent).toBe('A simple test graph');
82
+ });
83
+
84
+ it('spec changes trigger re-render', async () => {
85
+ const { container, rerender } = await renderGraph({ spec: basicSpec });
86
+
87
+ const titleBefore = container.querySelector('.viz-title');
88
+ expect(titleBefore?.textContent).toBe('Test Graph');
89
+
90
+ rerender(<Graph spec={updatedSpec} />);
91
+ await waitFor(() => {
92
+ expect(container.querySelector('.viz-title')?.textContent).toBe('Updated Graph');
93
+ });
94
+ });
95
+
96
+ it('unmounting cleans up graph instance', async () => {
97
+ const { container, unmount } = await renderGraph({ spec: basicSpec });
98
+
99
+ const canvasBefore = container.querySelector('canvas');
100
+ expect(canvasBefore).not.toBeNull();
101
+
102
+ unmount();
103
+
104
+ expect(container.querySelector('canvas')).toBeNull();
105
+ });
106
+
107
+ it('className prop passes through to wrapper div', async () => {
108
+ const { container } = await renderGraph({ spec: basicSpec, className: 'my-graph' });
109
+
110
+ const wrapper = container.firstElementChild as HTMLElement;
111
+ expect(wrapper?.className).toContain('my-graph');
112
+ });
113
+
114
+ it('style prop passes through to wrapper div', async () => {
115
+ const { container } = await renderGraph({
116
+ spec: basicSpec,
117
+ style: { border: '1px solid red' },
118
+ });
119
+
120
+ const wrapper = container.firstElementChild as HTMLElement;
121
+ expect(wrapper?.style.border).toBe('1px solid red');
122
+ });
123
+
124
+ it('renders with dark mode option', async () => {
125
+ const { container } = await renderGraph({ spec: basicSpec, darkMode: 'force' });
126
+
127
+ const canvas = container.querySelector('canvas');
128
+ expect(canvas).not.toBeNull();
129
+ });
130
+ });
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Tests for VizThemeProvider, useVizTheme, and useVizDarkMode.
3
+ *
4
+ * Verifies that themes and dark mode preferences cascade through the
5
+ * provider hierarchy and that nested providers override parent values.
6
+ */
7
+
8
+ import type { ThemeConfig } from '@opendata-ai/openchart-core';
9
+ import { render, waitFor } from '@testing-library/react';
10
+ import { describe, expect, it } from 'vitest';
11
+ import { useVizDarkMode, useVizTheme, VizThemeProvider } from '../ThemeContext';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Test harness components
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function ThemeConsumer({ testId = 'theme-output' }: { testId?: string }) {
18
+ const theme = useVizTheme();
19
+ return <div data-testid={testId}>{theme ? JSON.stringify(theme) : 'no-theme'}</div>;
20
+ }
21
+
22
+ function DarkModeConsumer({ testId = 'darkmode-output' }: { testId?: string }) {
23
+ const darkMode = useVizDarkMode();
24
+ return <div data-testid={testId}>{darkMode ?? 'undefined'}</div>;
25
+ }
26
+
27
+ /** Render a ThemeConsumer and wait for it to appear in the DOM. */
28
+ async function renderTheme(jsx: React.ReactElement) {
29
+ const result = render(jsx);
30
+ await waitFor(() => {
31
+ expect(result.container.querySelector('[data-testid]')).not.toBeNull();
32
+ });
33
+ return result;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // useVizTheme
38
+ // ---------------------------------------------------------------------------
39
+
40
+ describe('useVizTheme', () => {
41
+ it('returns undefined when used outside of a provider', async () => {
42
+ const { container } = await renderTheme(<ThemeConsumer />);
43
+ const output = container.querySelector('[data-testid="theme-output"]');
44
+ expect(output?.textContent).toBe('no-theme');
45
+ });
46
+ });
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // VizThemeProvider
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe('VizThemeProvider', () => {
53
+ it('provides theme to child components', async () => {
54
+ const theme: ThemeConfig = {
55
+ colors: { categorical: ['#ff0000', '#00ff00', '#0000ff'] },
56
+ };
57
+
58
+ const { container } = await renderTheme(
59
+ <VizThemeProvider theme={theme}>
60
+ <ThemeConsumer />
61
+ </VizThemeProvider>,
62
+ );
63
+
64
+ const output = container.querySelector('[data-testid="theme-output"]');
65
+ const parsed = JSON.parse(output!.textContent!);
66
+ expect(parsed.colors.categorical).toEqual(['#ff0000', '#00ff00', '#0000ff']);
67
+ });
68
+
69
+ it('provides undefined theme when passed undefined', async () => {
70
+ const { container } = await renderTheme(
71
+ <VizThemeProvider theme={undefined}>
72
+ <ThemeConsumer />
73
+ </VizThemeProvider>,
74
+ );
75
+
76
+ const output = container.querySelector('[data-testid="theme-output"]');
77
+ expect(output?.textContent).toBe('no-theme');
78
+ });
79
+
80
+ it('nested providers override parent theme', async () => {
81
+ const parentTheme: ThemeConfig = {
82
+ colors: { categorical: ['#111'] },
83
+ fonts: { family: 'Arial' },
84
+ };
85
+
86
+ const childTheme: ThemeConfig = {
87
+ colors: { categorical: ['#222'] },
88
+ borderRadius: 8,
89
+ };
90
+
91
+ const { container } = await renderTheme(
92
+ <VizThemeProvider theme={parentTheme}>
93
+ <VizThemeProvider theme={childTheme}>
94
+ <ThemeConsumer />
95
+ </VizThemeProvider>
96
+ </VizThemeProvider>,
97
+ );
98
+
99
+ const output = container.querySelector('[data-testid="theme-output"]');
100
+ const parsed = JSON.parse(output!.textContent!);
101
+
102
+ // Inner provider completely replaces the context value (no merging)
103
+ expect(parsed.colors.categorical).toEqual(['#222']);
104
+ expect(parsed.borderRadius).toBe(8);
105
+ // Parent-only fields are absent since inner provider replaces the value
106
+ expect(parsed.fonts).toBeUndefined();
107
+ });
108
+
109
+ it('multiple consumers at different nesting levels receive correct themes', async () => {
110
+ const outerTheme: ThemeConfig = {
111
+ colors: { background: '#fff' },
112
+ };
113
+
114
+ const innerTheme: ThemeConfig = {
115
+ colors: { background: '#000' },
116
+ };
117
+
118
+ const result = render(
119
+ <VizThemeProvider theme={outerTheme}>
120
+ <ThemeConsumer testId="outer-theme" />
121
+ <VizThemeProvider theme={innerTheme}>
122
+ <ThemeConsumer testId="inner-theme" />
123
+ </VizThemeProvider>
124
+ </VizThemeProvider>,
125
+ );
126
+
127
+ await waitFor(() => {
128
+ expect(result.container.querySelector('[data-testid="outer-theme"]')).not.toBeNull();
129
+ expect(result.container.querySelector('[data-testid="inner-theme"]')).not.toBeNull();
130
+ });
131
+
132
+ const outerParsed = JSON.parse(
133
+ result.container.querySelector('[data-testid="outer-theme"]')!.textContent!,
134
+ );
135
+ const innerParsed = JSON.parse(
136
+ result.container.querySelector('[data-testid="inner-theme"]')!.textContent!,
137
+ );
138
+
139
+ expect(outerParsed.colors.background).toBe('#fff');
140
+ expect(innerParsed.colors.background).toBe('#000');
141
+ });
142
+
143
+ it('theme updates propagate to consumers', async () => {
144
+ const theme1: ThemeConfig = { borderRadius: 4 };
145
+ const theme2: ThemeConfig = { borderRadius: 12 };
146
+
147
+ const { container, rerender } = await renderTheme(
148
+ <VizThemeProvider theme={theme1}>
149
+ <ThemeConsumer />
150
+ </VizThemeProvider>,
151
+ );
152
+
153
+ const parsed1 = JSON.parse(
154
+ container.querySelector('[data-testid="theme-output"]')!.textContent!,
155
+ );
156
+ expect(parsed1.borderRadius).toBe(4);
157
+
158
+ rerender(
159
+ <VizThemeProvider theme={theme2}>
160
+ <ThemeConsumer />
161
+ </VizThemeProvider>,
162
+ );
163
+
164
+ await waitFor(() => {
165
+ const parsed2 = JSON.parse(
166
+ container.querySelector('[data-testid="theme-output"]')!.textContent!,
167
+ );
168
+ expect(parsed2.borderRadius).toBe(12);
169
+ });
170
+ });
171
+ });
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // useVizDarkMode
175
+ // ---------------------------------------------------------------------------
176
+
177
+ describe('useVizDarkMode', () => {
178
+ it('returns undefined when used outside of a provider', async () => {
179
+ const { container } = await renderTheme(<DarkModeConsumer />);
180
+ const output = container.querySelector('[data-testid="darkmode-output"]');
181
+ expect(output?.textContent).toBe('undefined');
182
+ });
183
+
184
+ it('returns the darkMode value from provider', async () => {
185
+ const { container } = await renderTheme(
186
+ <VizThemeProvider theme={undefined} darkMode="force">
187
+ <DarkModeConsumer />
188
+ </VizThemeProvider>,
189
+ );
190
+
191
+ const output = container.querySelector('[data-testid="darkmode-output"]');
192
+ expect(output?.textContent).toBe('force');
193
+ });
194
+
195
+ it('returns undefined when provider omits darkMode', async () => {
196
+ const { container } = await renderTheme(
197
+ <VizThemeProvider theme={undefined}>
198
+ <DarkModeConsumer />
199
+ </VizThemeProvider>,
200
+ );
201
+
202
+ const output = container.querySelector('[data-testid="darkmode-output"]');
203
+ expect(output?.textContent).toBe('undefined');
204
+ });
205
+
206
+ it('nested provider overrides parent darkMode', async () => {
207
+ const { container } = await renderTheme(
208
+ <VizThemeProvider theme={undefined} darkMode="force">
209
+ <VizThemeProvider theme={undefined} darkMode="off">
210
+ <DarkModeConsumer />
211
+ </VizThemeProvider>
212
+ </VizThemeProvider>,
213
+ );
214
+
215
+ const output = container.querySelector('[data-testid="darkmode-output"]');
216
+ expect(output?.textContent).toBe('off');
217
+ });
218
+
219
+ it('darkMode updates propagate to consumers', async () => {
220
+ const { container, rerender } = await renderTheme(
221
+ <VizThemeProvider theme={undefined} darkMode="off">
222
+ <DarkModeConsumer />
223
+ </VizThemeProvider>,
224
+ );
225
+
226
+ expect(container.querySelector('[data-testid="darkmode-output"]')?.textContent).toBe('off');
227
+
228
+ rerender(
229
+ <VizThemeProvider theme={undefined} darkMode="force">
230
+ <DarkModeConsumer />
231
+ </VizThemeProvider>,
232
+ );
233
+
234
+ await waitFor(() => {
235
+ expect(container.querySelector('[data-testid="darkmode-output"]')?.textContent).toBe('force');
236
+ });
237
+ });
238
+ });