@opendata-ai/openchart-engine 1.2.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 +366 -0
- package/dist/index.js +4227 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/__test-fixtures__/specs.ts +124 -0
- package/src/__tests__/axes.test.ts +114 -0
- package/src/__tests__/compile-chart.test.ts +337 -0
- package/src/__tests__/dimensions.test.ts +151 -0
- package/src/__tests__/legend.test.ts +113 -0
- package/src/__tests__/scales.test.ts +109 -0
- package/src/annotations/__tests__/compute.test.ts +454 -0
- package/src/annotations/compute.ts +603 -0
- package/src/charts/__tests__/registry.test.ts +110 -0
- package/src/charts/bar/__tests__/compute.test.ts +294 -0
- package/src/charts/bar/__tests__/labels.test.ts +75 -0
- package/src/charts/bar/compute.ts +205 -0
- package/src/charts/bar/index.ts +33 -0
- package/src/charts/bar/labels.ts +132 -0
- package/src/charts/column/__tests__/compute.test.ts +277 -0
- package/src/charts/column/compute.ts +282 -0
- package/src/charts/column/index.ts +33 -0
- package/src/charts/column/labels.ts +108 -0
- package/src/charts/dot/__tests__/compute.test.ts +344 -0
- package/src/charts/dot/compute.ts +257 -0
- package/src/charts/dot/index.ts +46 -0
- package/src/charts/dot/labels.ts +97 -0
- package/src/charts/line/__tests__/compute.test.ts +437 -0
- package/src/charts/line/__tests__/labels.test.ts +93 -0
- package/src/charts/line/area.ts +288 -0
- package/src/charts/line/compute.ts +177 -0
- package/src/charts/line/index.ts +68 -0
- package/src/charts/line/labels.ts +144 -0
- package/src/charts/pie/__tests__/compute.test.ts +276 -0
- package/src/charts/pie/compute.ts +234 -0
- package/src/charts/pie/index.ts +49 -0
- package/src/charts/pie/labels.ts +142 -0
- package/src/charts/registry.ts +64 -0
- package/src/charts/scatter/__tests__/compute.test.ts +304 -0
- package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
- package/src/charts/scatter/compute.ts +124 -0
- package/src/charts/scatter/index.ts +41 -0
- package/src/charts/scatter/trendline.ts +100 -0
- package/src/charts/utils.ts +120 -0
- package/src/compile.ts +368 -0
- package/src/compiler/__tests__/compile.test.ts +87 -0
- package/src/compiler/__tests__/normalize.test.ts +210 -0
- package/src/compiler/__tests__/validate.test.ts +440 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/normalize.ts +269 -0
- package/src/compiler/types.ts +148 -0
- package/src/compiler/validate.ts +581 -0
- package/src/graphs/__tests__/community.test.ts +228 -0
- package/src/graphs/__tests__/compile-graph.test.ts +315 -0
- package/src/graphs/__tests__/encoding.test.ts +314 -0
- package/src/graphs/community.ts +92 -0
- package/src/graphs/compile-graph.ts +291 -0
- package/src/graphs/encoding.ts +302 -0
- package/src/graphs/types.ts +98 -0
- package/src/index.ts +74 -0
- package/src/layout/axes.ts +194 -0
- package/src/layout/dimensions.ts +199 -0
- package/src/layout/gridlines.ts +84 -0
- package/src/layout/scales.ts +426 -0
- package/src/legend/compute.ts +186 -0
- package/src/tables/__tests__/bar-column.test.ts +147 -0
- package/src/tables/__tests__/category-colors.test.ts +153 -0
- package/src/tables/__tests__/compile-table.test.ts +208 -0
- package/src/tables/__tests__/format-cells.test.ts +126 -0
- package/src/tables/__tests__/heatmap.test.ts +124 -0
- package/src/tables/__tests__/pagination.test.ts +78 -0
- package/src/tables/__tests__/search.test.ts +94 -0
- package/src/tables/__tests__/sort.test.ts +107 -0
- package/src/tables/__tests__/sparkline.test.ts +122 -0
- package/src/tables/bar-column.ts +94 -0
- package/src/tables/category-colors.ts +67 -0
- package/src/tables/compile-table.ts +420 -0
- package/src/tables/format-cells.ts +110 -0
- package/src/tables/heatmap.ts +121 -0
- package/src/tables/pagination.ts +46 -0
- package/src/tables/search.ts +66 -0
- package/src/tables/sort.ts +69 -0
- package/src/tables/sparkline.ts +113 -0
- package/src/tables/utils.ts +16 -0
- package/src/tooltips/__tests__/compute.test.ts +328 -0
- package/src/tooltips/compute.ts +231 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { validateSpec } from '../validate';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Test data
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const validLineData = [
|
|
9
|
+
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
10
|
+
{ date: '2021-01-01', value: 20, country: 'US' },
|
|
11
|
+
{ date: '2022-01-01', value: 30, country: 'UK' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const validLineSpec = {
|
|
15
|
+
type: 'line',
|
|
16
|
+
data: validLineData,
|
|
17
|
+
encoding: {
|
|
18
|
+
x: { field: 'date', type: 'temporal' },
|
|
19
|
+
y: { field: 'value', type: 'quantitative' },
|
|
20
|
+
color: { field: 'country', type: 'nominal' },
|
|
21
|
+
},
|
|
22
|
+
chrome: { title: 'GDP Growth' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const validBarSpec = {
|
|
26
|
+
type: 'bar',
|
|
27
|
+
data: [
|
|
28
|
+
{ category: 'A', count: 10 },
|
|
29
|
+
{ category: 'B', count: 20 },
|
|
30
|
+
],
|
|
31
|
+
encoding: {
|
|
32
|
+
x: { field: 'count', type: 'quantitative' },
|
|
33
|
+
y: { field: 'category', type: 'nominal' },
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const validPieSpec = {
|
|
38
|
+
type: 'pie',
|
|
39
|
+
data: [
|
|
40
|
+
{ label: 'Apples', amount: 30 },
|
|
41
|
+
{ label: 'Oranges', amount: 50 },
|
|
42
|
+
{ label: 'Bananas', amount: 20 },
|
|
43
|
+
],
|
|
44
|
+
encoding: {
|
|
45
|
+
y: { field: 'amount', type: 'quantitative' },
|
|
46
|
+
color: { field: 'label', type: 'nominal' },
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Tests
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
describe('validateSpec', () => {
|
|
55
|
+
describe('basic shape checks', () => {
|
|
56
|
+
it('rejects null with INVALID_TYPE code', () => {
|
|
57
|
+
const result = validateSpec(null);
|
|
58
|
+
expect(result.valid).toBe(false);
|
|
59
|
+
expect(result.errors[0].message).toContain('non-null object');
|
|
60
|
+
expect(result.errors[0].code).toBe('INVALID_TYPE');
|
|
61
|
+
expect(result.errors[0].suggestion).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects arrays', () => {
|
|
65
|
+
const result = validateSpec([1, 2, 3]);
|
|
66
|
+
expect(result.valid).toBe(false);
|
|
67
|
+
expect(result.errors[0].code).toBe('INVALID_TYPE');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects strings', () => {
|
|
71
|
+
const result = validateSpec('hello');
|
|
72
|
+
expect(result.valid).toBe(false);
|
|
73
|
+
expect(result.errors[0].code).toBe('INVALID_TYPE');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects objects without type with MISSING_FIELD code', () => {
|
|
77
|
+
const result = validateSpec({ data: [] });
|
|
78
|
+
expect(result.valid).toBe(false);
|
|
79
|
+
expect(result.errors[0].message).toContain('"type" field');
|
|
80
|
+
expect(result.errors[0].code).toBe('MISSING_FIELD');
|
|
81
|
+
expect(result.errors[0].suggestion).toContain('line');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects invalid type values with INVALID_VALUE code', () => {
|
|
85
|
+
const result = validateSpec({ type: 'waterfall' });
|
|
86
|
+
expect(result.valid).toBe(false);
|
|
87
|
+
expect(result.errors[0].message).toContain('"waterfall" is not a valid type');
|
|
88
|
+
expect(result.errors[0].code).toBe('INVALID_VALUE');
|
|
89
|
+
expect(result.errors[0].suggestion).toContain('line');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('chart specs', () => {
|
|
94
|
+
it('accepts a valid line spec', () => {
|
|
95
|
+
const result = validateSpec(validLineSpec);
|
|
96
|
+
expect(result.valid).toBe(true);
|
|
97
|
+
expect(result.errors).toHaveLength(0);
|
|
98
|
+
expect(result.normalized).not.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('accepts a valid bar spec', () => {
|
|
102
|
+
const result = validateSpec(validBarSpec);
|
|
103
|
+
expect(result.valid).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('accepts a valid pie spec', () => {
|
|
107
|
+
const result = validateSpec(validPieSpec);
|
|
108
|
+
expect(result.valid).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects empty data with EMPTY_DATA code and suggestion', () => {
|
|
112
|
+
const result = validateSpec({ ...validLineSpec, data: [] });
|
|
113
|
+
expect(result.valid).toBe(false);
|
|
114
|
+
expect(result.errors[0].message).toContain('non-empty array');
|
|
115
|
+
expect(result.errors[0].code).toBe('EMPTY_DATA');
|
|
116
|
+
expect(result.errors[0].suggestion).toContain('Add at least one data row');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects non-array data with INVALID_TYPE code', () => {
|
|
120
|
+
const result = validateSpec({ ...validLineSpec, data: 'not an array' });
|
|
121
|
+
expect(result.valid).toBe(false);
|
|
122
|
+
expect(result.errors[0].message).toContain('must be an array');
|
|
123
|
+
expect(result.errors[0].code).toBe('INVALID_TYPE');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('rejects missing encoding with MISSING_FIELD code and channel suggestion', () => {
|
|
127
|
+
const result = validateSpec({
|
|
128
|
+
type: 'line',
|
|
129
|
+
data: validLineData,
|
|
130
|
+
});
|
|
131
|
+
expect(result.valid).toBe(false);
|
|
132
|
+
expect(result.errors[0].message).toContain('requires an "encoding" object');
|
|
133
|
+
expect(result.errors[0].code).toBe('MISSING_FIELD');
|
|
134
|
+
// Should suggest the required channels for this chart type
|
|
135
|
+
expect(result.errors[0].suggestion).toContain('encoding');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects missing required channel with MISSING_FIELD code', () => {
|
|
139
|
+
const result = validateSpec({
|
|
140
|
+
type: 'line',
|
|
141
|
+
data: validLineData,
|
|
142
|
+
encoding: {
|
|
143
|
+
x: { field: 'date', type: 'temporal' },
|
|
144
|
+
// Missing y, which is required for line charts
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
expect(result.valid).toBe(false);
|
|
148
|
+
const yError = result.errors.find((e) => e.message.includes('encoding.y'));
|
|
149
|
+
expect(yError).toBeDefined();
|
|
150
|
+
expect(yError!.code).toBe('MISSING_FIELD');
|
|
151
|
+
expect(yError!.suggestion).toBeDefined();
|
|
152
|
+
// Suggestion should mention available data columns
|
|
153
|
+
expect(yError!.suggestion).toContain('date');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('rejects field referencing non-existent column with DATA_FIELD_MISSING code', () => {
|
|
157
|
+
const result = validateSpec({
|
|
158
|
+
type: 'line',
|
|
159
|
+
data: validLineData,
|
|
160
|
+
encoding: {
|
|
161
|
+
x: { field: 'nonexistent', type: 'temporal' },
|
|
162
|
+
y: { field: 'value', type: 'quantitative' },
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
expect(result.valid).toBe(false);
|
|
166
|
+
const fieldError = result.errors.find((e) =>
|
|
167
|
+
e.message.includes('"nonexistent" does not exist'),
|
|
168
|
+
);
|
|
169
|
+
expect(fieldError).toBeDefined();
|
|
170
|
+
expect(fieldError!.code).toBe('DATA_FIELD_MISSING');
|
|
171
|
+
expect(fieldError!.suggestion).toContain('date');
|
|
172
|
+
expect(fieldError!.suggestion).toContain('value');
|
|
173
|
+
expect(fieldError!.suggestion).toContain('country');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('rejects invalid field type with INVALID_VALUE code', () => {
|
|
177
|
+
const result = validateSpec({
|
|
178
|
+
type: 'line',
|
|
179
|
+
data: validLineData,
|
|
180
|
+
encoding: {
|
|
181
|
+
x: { field: 'date', type: 'bogus' },
|
|
182
|
+
y: { field: 'value', type: 'quantitative' },
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
expect(result.valid).toBe(false);
|
|
186
|
+
const typeError = result.errors.find((e) => e.message.includes('"bogus" is not valid'));
|
|
187
|
+
expect(typeError).toBeDefined();
|
|
188
|
+
expect(typeError!.code).toBe('INVALID_VALUE');
|
|
189
|
+
expect(typeError!.suggestion).toContain('quantitative');
|
|
190
|
+
expect(typeError!.suggestion).toContain('temporal');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('rejects disallowed type for channel with ENCODING_MISMATCH code', () => {
|
|
194
|
+
const result = validateSpec({
|
|
195
|
+
type: 'line',
|
|
196
|
+
data: validLineData,
|
|
197
|
+
encoding: {
|
|
198
|
+
x: { field: 'date', type: 'quantitative' },
|
|
199
|
+
y: { field: 'value', type: 'quantitative' },
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
expect(result.valid).toBe(false);
|
|
203
|
+
const mismatchError = result.errors.find((e) => e.message.includes('does not accept type'));
|
|
204
|
+
expect(mismatchError).toBeDefined();
|
|
205
|
+
expect(mismatchError!.code).toBe('ENCODING_MISMATCH');
|
|
206
|
+
expect(mismatchError!.suggestion).toContain('Change');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('catches temporal field with non-date values with ENCODING_MISMATCH', () => {
|
|
210
|
+
const result = validateSpec({
|
|
211
|
+
type: 'line',
|
|
212
|
+
data: [
|
|
213
|
+
{ x: 'not-a-date', y: 10 },
|
|
214
|
+
{ x: 'also-not-a-date', y: 20 },
|
|
215
|
+
],
|
|
216
|
+
encoding: {
|
|
217
|
+
x: { field: 'x', type: 'temporal' },
|
|
218
|
+
y: { field: 'y', type: 'quantitative' },
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
expect(result.valid).toBe(false);
|
|
222
|
+
const dateError = result.errors.find((e) => e.message.includes('non-date values'));
|
|
223
|
+
expect(dateError).toBeDefined();
|
|
224
|
+
expect(dateError!.code).toBe('ENCODING_MISMATCH');
|
|
225
|
+
expect(dateError!.suggestion).toContain('nominal');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('catches quantitative field with non-numeric values with ENCODING_MISMATCH', () => {
|
|
229
|
+
const result = validateSpec({
|
|
230
|
+
type: 'scatter',
|
|
231
|
+
data: [
|
|
232
|
+
{ x: 'hello', y: 10 },
|
|
233
|
+
{ x: 'world', y: 20 },
|
|
234
|
+
],
|
|
235
|
+
encoding: {
|
|
236
|
+
x: { field: 'x', type: 'quantitative' },
|
|
237
|
+
y: { field: 'y', type: 'quantitative' },
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
expect(result.valid).toBe(false);
|
|
241
|
+
const numError = result.errors.find((e) => e.message.includes('non-numeric values'));
|
|
242
|
+
expect(numError).toBeDefined();
|
|
243
|
+
expect(numError!.code).toBe('ENCODING_MISMATCH');
|
|
244
|
+
expect(numError!.suggestion).toContain('nominal');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('rejects invalid darkMode with INVALID_VALUE code', () => {
|
|
248
|
+
const result = validateSpec({
|
|
249
|
+
...validLineSpec,
|
|
250
|
+
darkMode: 'maybe',
|
|
251
|
+
});
|
|
252
|
+
expect(result.valid).toBe(false);
|
|
253
|
+
const dmError = result.errors.find((e) => e.message.includes('darkMode'));
|
|
254
|
+
expect(dmError).toBeDefined();
|
|
255
|
+
expect(dmError!.code).toBe('INVALID_VALUE');
|
|
256
|
+
expect(dmError!.suggestion).toContain('auto');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('rejects missing encoding channel field with MISSING_FIELD code', () => {
|
|
260
|
+
const result = validateSpec({
|
|
261
|
+
type: 'bar',
|
|
262
|
+
data: [{ a: 1, b: 2 }],
|
|
263
|
+
encoding: {
|
|
264
|
+
x: { type: 'quantitative' },
|
|
265
|
+
y: { field: 'a', type: 'nominal' },
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
expect(result.valid).toBe(false);
|
|
269
|
+
const fieldError = result.errors.find((e) =>
|
|
270
|
+
e.message.includes('must have a "field" string'),
|
|
271
|
+
);
|
|
272
|
+
expect(fieldError).toBeDefined();
|
|
273
|
+
expect(fieldError!.code).toBe('MISSING_FIELD');
|
|
274
|
+
expect(fieldError!.suggestion).toContain('a');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('table specs', () => {
|
|
279
|
+
it('accepts a valid table spec', () => {
|
|
280
|
+
const result = validateSpec({
|
|
281
|
+
type: 'table',
|
|
282
|
+
data: [{ name: 'Alice', age: 30 }],
|
|
283
|
+
columns: [{ key: 'name' }, { key: 'age' }],
|
|
284
|
+
});
|
|
285
|
+
expect(result.valid).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('rejects table without data', () => {
|
|
289
|
+
const result = validateSpec({
|
|
290
|
+
type: 'table',
|
|
291
|
+
columns: [{ key: 'name' }],
|
|
292
|
+
});
|
|
293
|
+
expect(result.valid).toBe(false);
|
|
294
|
+
expect(result.errors[0].code).toBe('INVALID_TYPE');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('rejects table with empty data with EMPTY_DATA code', () => {
|
|
298
|
+
const result = validateSpec({
|
|
299
|
+
type: 'table',
|
|
300
|
+
data: [],
|
|
301
|
+
columns: [{ key: 'name' }],
|
|
302
|
+
});
|
|
303
|
+
expect(result.valid).toBe(false);
|
|
304
|
+
expect(result.errors[0].code).toBe('EMPTY_DATA');
|
|
305
|
+
expect(result.errors[0].suggestion).toBeDefined();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('rejects table without columns with MISSING_FIELD code', () => {
|
|
309
|
+
const result = validateSpec({
|
|
310
|
+
type: 'table',
|
|
311
|
+
data: [{ name: 'Alice' }],
|
|
312
|
+
});
|
|
313
|
+
expect(result.valid).toBe(false);
|
|
314
|
+
expect(result.errors.some((e) => e.message.includes('"columns" array'))).toBe(true);
|
|
315
|
+
expect(result.errors[0].code).toBe('MISSING_FIELD');
|
|
316
|
+
expect(result.errors[0].suggestion).toContain('columns');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('rejects table column referencing non-existent field with DATA_FIELD_MISSING', () => {
|
|
320
|
+
const result = validateSpec({
|
|
321
|
+
type: 'table',
|
|
322
|
+
data: [{ name: 'Alice', age: 30 }],
|
|
323
|
+
columns: [{ key: 'nonexistent' }],
|
|
324
|
+
});
|
|
325
|
+
expect(result.valid).toBe(false);
|
|
326
|
+
const colError = result.errors.find((e) =>
|
|
327
|
+
e.message.includes('"nonexistent" does not exist'),
|
|
328
|
+
);
|
|
329
|
+
expect(colError).toBeDefined();
|
|
330
|
+
expect(colError!.code).toBe('DATA_FIELD_MISSING');
|
|
331
|
+
expect(colError!.suggestion).toContain('name');
|
|
332
|
+
expect(colError!.suggestion).toContain('age');
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('graph specs', () => {
|
|
337
|
+
it('accepts a valid graph spec', () => {
|
|
338
|
+
const result = validateSpec({
|
|
339
|
+
type: 'graph',
|
|
340
|
+
nodes: [{ id: 'a' }, { id: 'b' }],
|
|
341
|
+
edges: [{ source: 'a', target: 'b' }],
|
|
342
|
+
});
|
|
343
|
+
expect(result.valid).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('rejects graph without nodes with MISSING_FIELD code', () => {
|
|
347
|
+
const result = validateSpec({
|
|
348
|
+
type: 'graph',
|
|
349
|
+
edges: [],
|
|
350
|
+
});
|
|
351
|
+
expect(result.valid).toBe(false);
|
|
352
|
+
const nodeError = result.errors.find((e) => e.message.includes('"nodes"'));
|
|
353
|
+
expect(nodeError).toBeDefined();
|
|
354
|
+
expect(nodeError!.code).toBe('MISSING_FIELD');
|
|
355
|
+
expect(nodeError!.suggestion).toContain('id');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('rejects graph with empty nodes with EMPTY_DATA code', () => {
|
|
359
|
+
const result = validateSpec({
|
|
360
|
+
type: 'graph',
|
|
361
|
+
nodes: [],
|
|
362
|
+
edges: [],
|
|
363
|
+
});
|
|
364
|
+
expect(result.valid).toBe(false);
|
|
365
|
+
const emptyError = result.errors.find((e) => e.message.includes('non-empty'));
|
|
366
|
+
expect(emptyError).toBeDefined();
|
|
367
|
+
expect(emptyError!.code).toBe('EMPTY_DATA');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('rejects graph without edges with MISSING_FIELD code', () => {
|
|
371
|
+
const result = validateSpec({
|
|
372
|
+
type: 'graph',
|
|
373
|
+
nodes: [{ id: 'a' }],
|
|
374
|
+
});
|
|
375
|
+
expect(result.valid).toBe(false);
|
|
376
|
+
const edgeError = result.errors.find((e) => e.message.includes('"edges"'));
|
|
377
|
+
expect(edgeError).toBeDefined();
|
|
378
|
+
expect(edgeError!.code).toBe('MISSING_FIELD');
|
|
379
|
+
expect(edgeError!.suggestion).toContain('edges');
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe('error codes and suggestions', () => {
|
|
384
|
+
it('every error has a code', () => {
|
|
385
|
+
// Test several invalid specs and verify all errors have codes
|
|
386
|
+
const results = [
|
|
387
|
+
validateSpec(null),
|
|
388
|
+
validateSpec({ data: [] }),
|
|
389
|
+
validateSpec({ type: 'waterfall' }),
|
|
390
|
+
validateSpec({ type: 'line', data: [] }),
|
|
391
|
+
validateSpec({
|
|
392
|
+
type: 'line',
|
|
393
|
+
data: [{ x: 1 }],
|
|
394
|
+
encoding: { x: { field: 'missing', type: 'quantitative' } },
|
|
395
|
+
}),
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
for (const result of results) {
|
|
399
|
+
for (const error of result.errors) {
|
|
400
|
+
expect(error.code).toBeDefined();
|
|
401
|
+
expect(typeof error.code).toBe('string');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('every error has a suggestion', () => {
|
|
407
|
+
const results = [
|
|
408
|
+
validateSpec(null),
|
|
409
|
+
validateSpec({ data: [] }),
|
|
410
|
+
validateSpec({ type: 'waterfall' }),
|
|
411
|
+
validateSpec({ type: 'line', data: [] }),
|
|
412
|
+
];
|
|
413
|
+
|
|
414
|
+
for (const result of results) {
|
|
415
|
+
for (const error of result.errors) {
|
|
416
|
+
expect(error.suggestion).toBeDefined();
|
|
417
|
+
expect(typeof error.suggestion).toBe('string');
|
|
418
|
+
expect(error.suggestion!.length).toBeGreaterThan(0);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('DATA_FIELD_MISSING suggestion lists available fields', () => {
|
|
424
|
+
const result = validateSpec({
|
|
425
|
+
type: 'bar',
|
|
426
|
+
data: [{ alpha: 1, beta: 2, gamma: 3 }],
|
|
427
|
+
encoding: {
|
|
428
|
+
x: { field: 'nonexistent', type: 'quantitative' },
|
|
429
|
+
y: { field: 'alpha', type: 'nominal' },
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const fieldError = result.errors.find((e) => e.code === 'DATA_FIELD_MISSING');
|
|
434
|
+
expect(fieldError).toBeDefined();
|
|
435
|
+
expect(fieldError!.suggestion).toContain('alpha');
|
|
436
|
+
expect(fieldError!.suggestion).toContain('beta');
|
|
437
|
+
expect(fieldError!.suggestion).toContain('gamma');
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec compiler: validate -> normalize pipeline.
|
|
3
|
+
*
|
|
4
|
+
* This is the first stage of the engine: take raw user input (possibly JSON
|
|
5
|
+
* from an API or Claude), validate it, fill in defaults, and produce a
|
|
6
|
+
* NormalizedSpec that the rest of the engine can work with safely.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { normalizeSpec } from './normalize';
|
|
10
|
+
import type { CompileResult } from './types';
|
|
11
|
+
import { validateSpec } from './validate';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compile a raw spec through the validate -> normalize pipeline.
|
|
15
|
+
*
|
|
16
|
+
* @param spec - Raw spec input (unknown type, could be anything).
|
|
17
|
+
* @returns CompileResult with the normalized spec and any warnings.
|
|
18
|
+
* @throws Error if the spec is invalid.
|
|
19
|
+
*/
|
|
20
|
+
export function compile(spec: unknown): CompileResult {
|
|
21
|
+
const validation = validateSpec(spec);
|
|
22
|
+
|
|
23
|
+
if (!validation.valid || !validation.normalized) {
|
|
24
|
+
const errorMessages = validation.errors.map((e) => e.message).join('\n');
|
|
25
|
+
throw new Error(`Invalid spec:\n${errorMessages}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const warnings: string[] = [];
|
|
29
|
+
const normalized = normalizeSpec(validation.normalized, warnings);
|
|
30
|
+
|
|
31
|
+
return { spec: normalized, warnings };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { normalizeSpec } from './normalize';
|
|
35
|
+
export type {
|
|
36
|
+
CompileResult,
|
|
37
|
+
NormalizedChartSpec,
|
|
38
|
+
NormalizedChrome,
|
|
39
|
+
NormalizedGraphSpec,
|
|
40
|
+
NormalizedSpec,
|
|
41
|
+
NormalizedTableSpec,
|
|
42
|
+
ValidationError,
|
|
43
|
+
ValidationErrorCode,
|
|
44
|
+
ValidationResult,
|
|
45
|
+
} from './types';
|
|
46
|
+
// Re-export everything
|
|
47
|
+
export { validateSpec } from './validate';
|