@opendata-ai/openchart-engine 6.0.0 → 6.1.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 +155 -19
- package/dist/index.js +1513 -164
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +6 -3
- package/src/__tests__/axes.test.ts +168 -4
- package/src/__tests__/compile-chart.test.ts +23 -12
- package/src/__tests__/compile-layer.test.ts +386 -0
- package/src/__tests__/dimensions.test.ts +6 -3
- package/src/__tests__/legend.test.ts +6 -3
- package/src/__tests__/scales.test.ts +176 -2
- package/src/annotations/__tests__/compute.test.ts +8 -4
- package/src/charts/bar/__tests__/compute.test.ts +12 -6
- package/src/charts/bar/compute.ts +21 -5
- package/src/charts/column/__tests__/compute.test.ts +14 -7
- package/src/charts/column/compute.ts +21 -6
- package/src/charts/dot/__tests__/compute.test.ts +10 -5
- package/src/charts/dot/compute.ts +10 -4
- package/src/charts/line/__tests__/compute.test.ts +102 -11
- package/src/charts/line/__tests__/curves.test.ts +51 -0
- package/src/charts/line/__tests__/labels.test.ts +2 -1
- package/src/charts/line/__tests__/mark-options.test.ts +175 -0
- package/src/charts/line/area.ts +19 -8
- package/src/charts/line/compute.ts +64 -25
- package/src/charts/line/curves.ts +40 -0
- package/src/charts/pie/__tests__/compute.test.ts +10 -5
- package/src/charts/pie/compute.ts +2 -1
- package/src/charts/rule/index.ts +127 -0
- package/src/charts/scatter/__tests__/compute.test.ts +10 -5
- package/src/charts/scatter/compute.ts +15 -5
- package/src/charts/text/index.ts +92 -0
- package/src/charts/tick/index.ts +84 -0
- package/src/charts/utils.ts +1 -1
- package/src/compile.ts +175 -23
- package/src/compiler/__tests__/compile.test.ts +4 -4
- package/src/compiler/__tests__/normalize.test.ts +4 -4
- package/src/compiler/__tests__/validate.test.ts +25 -26
- package/src/compiler/index.ts +1 -1
- package/src/compiler/normalize.ts +77 -4
- package/src/compiler/types.ts +6 -2
- package/src/compiler/validate.ts +167 -35
- package/src/graphs/__tests__/compile-graph.test.ts +2 -2
- package/src/graphs/compile-graph.ts +2 -2
- package/src/index.ts +17 -1
- package/src/layout/axes.ts +122 -20
- package/src/layout/dimensions.ts +15 -9
- package/src/layout/scales.ts +320 -31
- package/src/legend/compute.ts +9 -6
- package/src/tables/__tests__/compile-table.test.ts +1 -1
- package/src/tooltips/__tests__/compute.test.ts +10 -5
- package/src/tooltips/compute.ts +32 -14
- package/src/transforms/__tests__/bin.test.ts +88 -0
- package/src/transforms/__tests__/calculate.test.ts +146 -0
- package/src/transforms/__tests__/conditional.test.ts +109 -0
- package/src/transforms/__tests__/filter.test.ts +59 -0
- package/src/transforms/__tests__/index.test.ts +93 -0
- package/src/transforms/__tests__/predicates.test.ts +176 -0
- package/src/transforms/__tests__/timeunit.test.ts +129 -0
- package/src/transforms/bin.ts +87 -0
- package/src/transforms/calculate.ts +60 -0
- package/src/transforms/conditional.ts +46 -0
- package/src/transforms/filter.ts +17 -0
- package/src/transforms/index.ts +48 -0
- package/src/transforms/predicates.ts +90 -0
- package/src/transforms/timeunit.ts +88 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { runBin } from '../bin';
|
|
3
|
+
|
|
4
|
+
describe('runBin', () => {
|
|
5
|
+
const data = [{ value: 2 }, { value: 7 }, { value: 12 }, { value: 18 }, { value: 23 }];
|
|
6
|
+
|
|
7
|
+
it('bins with default params (bin: true)', () => {
|
|
8
|
+
const result = runBin(data, { bin: true, field: 'value', as: 'binned' });
|
|
9
|
+
expect(result).toHaveLength(5);
|
|
10
|
+
// Each row should have a 'binned' field
|
|
11
|
+
for (const row of result) {
|
|
12
|
+
expect(row).toHaveProperty('binned');
|
|
13
|
+
}
|
|
14
|
+
// Original data should be preserved
|
|
15
|
+
expect(result[0].value).toBe(2);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('bins with explicit maxbins', () => {
|
|
19
|
+
const result = runBin(data, {
|
|
20
|
+
bin: { maxbins: 5 },
|
|
21
|
+
field: 'value',
|
|
22
|
+
as: 'binned',
|
|
23
|
+
});
|
|
24
|
+
// With 5 bins over range 2-23, step should be roughly 5
|
|
25
|
+
const binValues = new Set(result.map((r) => r.binned));
|
|
26
|
+
expect(binValues.size).toBeGreaterThanOrEqual(2);
|
|
27
|
+
expect(binValues.size).toBeLessThanOrEqual(6);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('bins with explicit step', () => {
|
|
31
|
+
const result = runBin(data, {
|
|
32
|
+
bin: { step: 10 },
|
|
33
|
+
field: 'value',
|
|
34
|
+
as: 'binned',
|
|
35
|
+
});
|
|
36
|
+
// Step=10 from extent [2,23]: bins at 2, 12, 22
|
|
37
|
+
const binValues = [...new Set(result.map((r) => r.binned))].sort(
|
|
38
|
+
(a, b) => (a as number) - (b as number),
|
|
39
|
+
);
|
|
40
|
+
expect(binValues.length).toBeGreaterThanOrEqual(2);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('produces [start, end] when as is a tuple', () => {
|
|
44
|
+
const result = runBin(data, {
|
|
45
|
+
bin: { step: 10 },
|
|
46
|
+
field: 'value',
|
|
47
|
+
as: ['bin_start', 'bin_end'],
|
|
48
|
+
});
|
|
49
|
+
for (const row of result) {
|
|
50
|
+
expect(row).toHaveProperty('bin_start');
|
|
51
|
+
expect(row).toHaveProperty('bin_end');
|
|
52
|
+
if (row.bin_start !== null) {
|
|
53
|
+
expect((row.bin_end as number) - (row.bin_start as number)).toBe(10);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('bins with explicit extent', () => {
|
|
59
|
+
const result = runBin(data, {
|
|
60
|
+
bin: { extent: [0, 30], step: 10 },
|
|
61
|
+
field: 'value',
|
|
62
|
+
as: 'binned',
|
|
63
|
+
});
|
|
64
|
+
// All values should fall in bins starting at 0, 10, 20
|
|
65
|
+
const binValues = new Set(result.map((r) => r.binned));
|
|
66
|
+
for (const v of binValues) {
|
|
67
|
+
expect([0, 10, 20]).toContain(v);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('handles NaN values gracefully', () => {
|
|
72
|
+
const dataWithNaN = [{ value: 5 }, { value: NaN }];
|
|
73
|
+
const result = runBin(dataWithNaN, { bin: true, field: 'value', as: 'binned' });
|
|
74
|
+
expect(result[1].binned).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('handles empty data', () => {
|
|
78
|
+
const result = runBin([], { bin: true, field: 'value', as: 'binned' });
|
|
79
|
+
expect(result).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('preserves existing fields', () => {
|
|
83
|
+
const dataWithExtra = [{ value: 5, name: 'test' }];
|
|
84
|
+
const result = runBin(dataWithExtra, { bin: true, field: 'value', as: 'binned' });
|
|
85
|
+
expect(result[0].name).toBe('test');
|
|
86
|
+
expect(result[0].value).toBe(5);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { runCalculate } from '../calculate';
|
|
3
|
+
|
|
4
|
+
describe('runCalculate', () => {
|
|
5
|
+
const data = [
|
|
6
|
+
{ x: 10, y: 3 },
|
|
7
|
+
{ x: -5, y: 2 },
|
|
8
|
+
{ x: 100, y: 0 },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe('binary operations with field2', () => {
|
|
12
|
+
it('adds two fields', () => {
|
|
13
|
+
const result = runCalculate(data, {
|
|
14
|
+
calculate: { op: '+', field: 'x', field2: 'y' },
|
|
15
|
+
as: 'sum',
|
|
16
|
+
});
|
|
17
|
+
expect(result[0].sum).toBe(13);
|
|
18
|
+
expect(result[1].sum).toBe(-3);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('subtracts two fields', () => {
|
|
22
|
+
const result = runCalculate(data, {
|
|
23
|
+
calculate: { op: '-', field: 'x', field2: 'y' },
|
|
24
|
+
as: 'diff',
|
|
25
|
+
});
|
|
26
|
+
expect(result[0].diff).toBe(7);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('multiplies two fields', () => {
|
|
30
|
+
const result = runCalculate(data, {
|
|
31
|
+
calculate: { op: '*', field: 'x', field2: 'y' },
|
|
32
|
+
as: 'product',
|
|
33
|
+
});
|
|
34
|
+
expect(result[0].product).toBe(30);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('divides two fields', () => {
|
|
38
|
+
const result = runCalculate(data, {
|
|
39
|
+
calculate: { op: '/', field: 'x', field2: 'y' },
|
|
40
|
+
as: 'ratio',
|
|
41
|
+
});
|
|
42
|
+
expect(result[0].ratio).toBeCloseTo(10 / 3);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('binary operations with value', () => {
|
|
47
|
+
it('adds a constant', () => {
|
|
48
|
+
const result = runCalculate(data, {
|
|
49
|
+
calculate: { op: '+', field: 'x', value: 5 },
|
|
50
|
+
as: 'result',
|
|
51
|
+
});
|
|
52
|
+
expect(result[0].result).toBe(15);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('multiplies by a constant', () => {
|
|
56
|
+
const result = runCalculate(data, {
|
|
57
|
+
calculate: { op: '*', field: 'x', value: 2 },
|
|
58
|
+
as: 'result',
|
|
59
|
+
});
|
|
60
|
+
expect(result[0].result).toBe(20);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('division by zero', () => {
|
|
65
|
+
it('returns NaN for division by zero', () => {
|
|
66
|
+
const result = runCalculate(data, {
|
|
67
|
+
calculate: { op: '/', field: 'x', field2: 'y' },
|
|
68
|
+
as: 'ratio',
|
|
69
|
+
});
|
|
70
|
+
// Third row: x=100, y=0
|
|
71
|
+
expect(result[2].ratio).toBeNaN();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('unary operations', () => {
|
|
76
|
+
it('abs', () => {
|
|
77
|
+
const result = runCalculate(data, {
|
|
78
|
+
calculate: { op: 'abs', field: 'x' },
|
|
79
|
+
as: 'result',
|
|
80
|
+
});
|
|
81
|
+
expect(result[0].result).toBe(10);
|
|
82
|
+
expect(result[1].result).toBe(5);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('round', () => {
|
|
86
|
+
const floatData = [{ v: 3.7 }, { v: 3.2 }];
|
|
87
|
+
const result = runCalculate(floatData, {
|
|
88
|
+
calculate: { op: 'round', field: 'v' },
|
|
89
|
+
as: 'result',
|
|
90
|
+
});
|
|
91
|
+
expect(result[0].result).toBe(4);
|
|
92
|
+
expect(result[1].result).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('floor', () => {
|
|
96
|
+
const floatData = [{ v: 3.9 }];
|
|
97
|
+
const result = runCalculate(floatData, {
|
|
98
|
+
calculate: { op: 'floor', field: 'v' },
|
|
99
|
+
as: 'result',
|
|
100
|
+
});
|
|
101
|
+
expect(result[0].result).toBe(3);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('ceil', () => {
|
|
105
|
+
const floatData = [{ v: 3.1 }];
|
|
106
|
+
const result = runCalculate(floatData, {
|
|
107
|
+
calculate: { op: 'ceil', field: 'v' },
|
|
108
|
+
as: 'result',
|
|
109
|
+
});
|
|
110
|
+
expect(result[0].result).toBe(4);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('log', () => {
|
|
114
|
+
const result = runCalculate([{ v: Math.E }], {
|
|
115
|
+
calculate: { op: 'log', field: 'v' },
|
|
116
|
+
as: 'result',
|
|
117
|
+
});
|
|
118
|
+
expect(result[0].result).toBeCloseTo(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('sqrt', () => {
|
|
122
|
+
const result = runCalculate([{ v: 16 }], {
|
|
123
|
+
calculate: { op: 'sqrt', field: 'v' },
|
|
124
|
+
as: 'result',
|
|
125
|
+
});
|
|
126
|
+
expect(result[0].result).toBe(4);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('preserves existing fields', () => {
|
|
131
|
+
const result = runCalculate(data, {
|
|
132
|
+
calculate: { op: 'abs', field: 'x' },
|
|
133
|
+
as: 'absX',
|
|
134
|
+
});
|
|
135
|
+
expect(result[0].x).toBe(10);
|
|
136
|
+
expect(result[0].y).toBe(3);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('handles empty data', () => {
|
|
140
|
+
const result = runCalculate([], {
|
|
141
|
+
calculate: { op: '+', field: 'x', value: 1 },
|
|
142
|
+
as: 'result',
|
|
143
|
+
});
|
|
144
|
+
expect(result).toHaveLength(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isConditionalValueDef, resolveConditionalValue } from '../conditional';
|
|
3
|
+
|
|
4
|
+
describe('resolveConditionalValue', () => {
|
|
5
|
+
it('returns condition value when test passes', () => {
|
|
6
|
+
const result = resolveConditionalValue(
|
|
7
|
+
{ category: 'A', value: 10 },
|
|
8
|
+
{
|
|
9
|
+
condition: { test: { field: 'category', equal: 'A' }, value: 'red' },
|
|
10
|
+
value: 'gray',
|
|
11
|
+
},
|
|
12
|
+
);
|
|
13
|
+
expect(result).toBe('red');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns default value when test fails', () => {
|
|
17
|
+
const result = resolveConditionalValue(
|
|
18
|
+
{ category: 'B', value: 10 },
|
|
19
|
+
{
|
|
20
|
+
condition: { test: { field: 'category', equal: 'A' }, value: 'red' },
|
|
21
|
+
value: 'gray',
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
expect(result).toBe('gray');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('evaluates multiple conditions in order', () => {
|
|
28
|
+
const def = {
|
|
29
|
+
condition: [
|
|
30
|
+
{ test: { field: 'value', gt: 90 }, value: 'red' },
|
|
31
|
+
{ test: { field: 'value', gt: 50 }, value: 'orange' },
|
|
32
|
+
{ test: { field: 'value', gt: 0 }, value: 'green' },
|
|
33
|
+
],
|
|
34
|
+
value: 'gray',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
expect(resolveConditionalValue({ value: 95 }, def)).toBe('red');
|
|
38
|
+
expect(resolveConditionalValue({ value: 70 }, def)).toBe('orange');
|
|
39
|
+
expect(resolveConditionalValue({ value: 25 }, def)).toBe('green');
|
|
40
|
+
expect(resolveConditionalValue({ value: -5 }, def)).toBe('gray');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('resolves field reference from condition', () => {
|
|
44
|
+
const result = resolveConditionalValue(
|
|
45
|
+
{ category: 'A', label: 'Category A' },
|
|
46
|
+
{
|
|
47
|
+
condition: {
|
|
48
|
+
test: { field: 'category', equal: 'A' },
|
|
49
|
+
field: 'label',
|
|
50
|
+
},
|
|
51
|
+
value: 'unknown',
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
expect(result).toBe('Category A');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns undefined when no condition matches and no default', () => {
|
|
58
|
+
const result = resolveConditionalValue(
|
|
59
|
+
{ v: 5 },
|
|
60
|
+
{
|
|
61
|
+
condition: { test: { field: 'v', gt: 100 }, value: 'high' },
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
expect(result).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('works with logical combinators in test', () => {
|
|
68
|
+
const result = resolveConditionalValue(
|
|
69
|
+
{ x: 5, y: 15 },
|
|
70
|
+
{
|
|
71
|
+
condition: {
|
|
72
|
+
test: {
|
|
73
|
+
and: [
|
|
74
|
+
{ field: 'x', gt: 0 },
|
|
75
|
+
{ field: 'y', gt: 10 },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
value: 'both-positive',
|
|
79
|
+
},
|
|
80
|
+
value: 'nope',
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
expect(result).toBe('both-positive');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('isConditionalValueDef', () => {
|
|
88
|
+
it('returns true for conditional value defs', () => {
|
|
89
|
+
expect(
|
|
90
|
+
isConditionalValueDef({
|
|
91
|
+
condition: { test: { field: 'x', gt: 0 }, value: 'red' },
|
|
92
|
+
value: 'blue',
|
|
93
|
+
}),
|
|
94
|
+
).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns false for regular encoding channels', () => {
|
|
98
|
+
expect(isConditionalValueDef({ field: 'x', type: 'quantitative' })).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns false for null', () => {
|
|
102
|
+
expect(isConditionalValueDef(null)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns false for primitives', () => {
|
|
106
|
+
expect(isConditionalValueDef('hello')).toBe(false);
|
|
107
|
+
expect(isConditionalValueDef(42)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { runFilter } from '../filter';
|
|
3
|
+
|
|
4
|
+
describe('runFilter', () => {
|
|
5
|
+
const data = [
|
|
6
|
+
{ name: 'Alice', age: 25, city: 'NYC' },
|
|
7
|
+
{ name: 'Bob', age: 30, city: 'LA' },
|
|
8
|
+
{ name: 'Carol', age: 35, city: 'NYC' },
|
|
9
|
+
{ name: 'Dave', age: 40, city: 'LA' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
it('filters by field equality', () => {
|
|
13
|
+
const result = runFilter(data, { field: 'city', equal: 'NYC' });
|
|
14
|
+
expect(result).toHaveLength(2);
|
|
15
|
+
expect(result.map((r) => r.name)).toEqual(['Alice', 'Carol']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('filters by numeric comparison', () => {
|
|
19
|
+
const result = runFilter(data, { field: 'age', gt: 30 });
|
|
20
|
+
expect(result).toHaveLength(2);
|
|
21
|
+
expect(result.map((r) => r.name)).toEqual(['Carol', 'Dave']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('filters with logical AND', () => {
|
|
25
|
+
const result = runFilter(data, {
|
|
26
|
+
and: [
|
|
27
|
+
{ field: 'city', equal: 'LA' },
|
|
28
|
+
{ field: 'age', gte: 35 },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
expect(result).toHaveLength(1);
|
|
32
|
+
expect(result[0].name).toBe('Dave');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('filters with logical OR', () => {
|
|
36
|
+
const result = runFilter(data, {
|
|
37
|
+
or: [
|
|
38
|
+
{ field: 'name', equal: 'Alice' },
|
|
39
|
+
{ field: 'name', equal: 'Dave' },
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
expect(result).toHaveLength(2);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns empty array when no rows match', () => {
|
|
46
|
+
const result = runFilter(data, { field: 'age', gt: 100 });
|
|
47
|
+
expect(result).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns all rows when all match', () => {
|
|
51
|
+
const result = runFilter(data, { field: 'age', gt: 0 });
|
|
52
|
+
expect(result).toHaveLength(4);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles empty data', () => {
|
|
56
|
+
const result = runFilter([], { field: 'age', gt: 0 });
|
|
57
|
+
expect(result).toHaveLength(0);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { runTransforms } from '../index';
|
|
3
|
+
|
|
4
|
+
describe('runTransforms', () => {
|
|
5
|
+
it('runs transforms in order', () => {
|
|
6
|
+
const data = [
|
|
7
|
+
{ name: 'Alice', value: 10 },
|
|
8
|
+
{ name: 'Bob', value: 20 },
|
|
9
|
+
{ name: 'Carol', value: 30 },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
// First filter, then calculate on the filtered result
|
|
13
|
+
const result = runTransforms(data, [
|
|
14
|
+
{ filter: { field: 'value', gte: 15 } },
|
|
15
|
+
{ calculate: { op: '*', field: 'value', value: 2 }, as: 'doubled' },
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
expect(result).toHaveLength(2);
|
|
19
|
+
expect(result[0].name).toBe('Bob');
|
|
20
|
+
expect(result[0].doubled).toBe(40);
|
|
21
|
+
expect(result[1].name).toBe('Carol');
|
|
22
|
+
expect(result[1].doubled).toBe(60);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('applies filter after calculate', () => {
|
|
26
|
+
const data = [{ value: 3 }, { value: 7 }, { value: 12 }];
|
|
27
|
+
|
|
28
|
+
// Calculate first, then filter on the calculated field
|
|
29
|
+
const result = runTransforms(data, [
|
|
30
|
+
{ calculate: { op: '*', field: 'value', value: 10 }, as: 'scaled' },
|
|
31
|
+
{ filter: { field: 'scaled', gt: 50 } },
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
expect(result).toHaveLength(2);
|
|
35
|
+
expect(result[0].scaled).toBe(70);
|
|
36
|
+
expect(result[1].scaled).toBe(120);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('chains bin and filter', () => {
|
|
40
|
+
const data = [{ value: 5 }, { value: 15 }, { value: 25 }, { value: 35 }];
|
|
41
|
+
|
|
42
|
+
const result = runTransforms(data, [
|
|
43
|
+
{ bin: { step: 10, extent: [0, 40] }, field: 'value', as: 'binned' },
|
|
44
|
+
{ filter: { field: 'binned', gte: 20 } },
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
expect(result).toHaveLength(2);
|
|
48
|
+
expect(result[0].value).toBe(25);
|
|
49
|
+
expect(result[1].value).toBe(35);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('chains timeUnit and filter', () => {
|
|
53
|
+
const data = [
|
|
54
|
+
{ date: new Date(2024, 0, 15) }, // January
|
|
55
|
+
{ date: new Date(2024, 5, 15) }, // June
|
|
56
|
+
{ date: new Date(2024, 11, 15) }, // December
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const result = runTransforms(data, [
|
|
60
|
+
{ timeUnit: 'month' as const, field: 'date', as: 'month' },
|
|
61
|
+
{ filter: { field: 'month', gte: 5 } }, // June onwards (0-indexed)
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
expect(result).toHaveLength(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns original data for empty transform array', () => {
|
|
68
|
+
const data = [{ x: 1 }];
|
|
69
|
+
const result = runTransforms(data, []);
|
|
70
|
+
expect(result).toEqual(data);
|
|
71
|
+
expect(result).toBe(data); // Same reference since no transforms applied
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('handles multiple filters in sequence', () => {
|
|
75
|
+
const data = [
|
|
76
|
+
{ x: 1, y: 10 },
|
|
77
|
+
{ x: 5, y: 20 },
|
|
78
|
+
{ x: 8, y: 30 },
|
|
79
|
+
{ x: 12, y: 40 },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const result = runTransforms(data, [
|
|
83
|
+
{ filter: { field: 'x', gt: 3 } },
|
|
84
|
+
{ filter: { field: 'y', lt: 35 } },
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
// x>3 keeps: {5,20}, {8,30}, {12,40}
|
|
88
|
+
// y<35 keeps: {5,20}, {8,30}
|
|
89
|
+
expect(result).toHaveLength(2);
|
|
90
|
+
expect(result[0]).toEqual({ x: 5, y: 20 });
|
|
91
|
+
expect(result[1]).toEqual({ x: 8, y: 30 });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { evaluatePredicate } from '../predicates';
|
|
3
|
+
|
|
4
|
+
describe('evaluatePredicate', () => {
|
|
5
|
+
describe('FieldPredicate: equal', () => {
|
|
6
|
+
it('matches equal string value', () => {
|
|
7
|
+
expect(evaluatePredicate({ name: 'Alice' }, { field: 'name', equal: 'Alice' })).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('rejects non-equal value', () => {
|
|
11
|
+
expect(evaluatePredicate({ name: 'Bob' }, { field: 'name', equal: 'Alice' })).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('matches equal numeric value', () => {
|
|
15
|
+
expect(evaluatePredicate({ age: 30 }, { field: 'age', equal: 30 })).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('FieldPredicate: lt/lte/gt/gte', () => {
|
|
20
|
+
it('lt: value less than threshold', () => {
|
|
21
|
+
expect(evaluatePredicate({ v: 5 }, { field: 'v', lt: 10 })).toBe(true);
|
|
22
|
+
expect(evaluatePredicate({ v: 10 }, { field: 'v', lt: 10 })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('lte: value less than or equal', () => {
|
|
26
|
+
expect(evaluatePredicate({ v: 10 }, { field: 'v', lte: 10 })).toBe(true);
|
|
27
|
+
expect(evaluatePredicate({ v: 11 }, { field: 'v', lte: 10 })).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('gt: value greater than threshold', () => {
|
|
31
|
+
expect(evaluatePredicate({ v: 15 }, { field: 'v', gt: 10 })).toBe(true);
|
|
32
|
+
expect(evaluatePredicate({ v: 10 }, { field: 'v', gt: 10 })).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('gte: value greater than or equal', () => {
|
|
36
|
+
expect(evaluatePredicate({ v: 10 }, { field: 'v', gte: 10 })).toBe(true);
|
|
37
|
+
expect(evaluatePredicate({ v: 9 }, { field: 'v', gte: 10 })).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('FieldPredicate: range', () => {
|
|
42
|
+
it('includes values within range', () => {
|
|
43
|
+
expect(evaluatePredicate({ v: 5 }, { field: 'v', range: [1, 10] })).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('includes boundary values', () => {
|
|
47
|
+
expect(evaluatePredicate({ v: 1 }, { field: 'v', range: [1, 10] })).toBe(true);
|
|
48
|
+
expect(evaluatePredicate({ v: 10 }, { field: 'v', range: [1, 10] })).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('excludes values outside range', () => {
|
|
52
|
+
expect(evaluatePredicate({ v: 0 }, { field: 'v', range: [1, 10] })).toBe(false);
|
|
53
|
+
expect(evaluatePredicate({ v: 11 }, { field: 'v', range: [1, 10] })).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('FieldPredicate: oneOf', () => {
|
|
58
|
+
it('matches values in the set', () => {
|
|
59
|
+
expect(evaluatePredicate({ c: 'red' }, { field: 'c', oneOf: ['red', 'blue'] })).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects values not in the set', () => {
|
|
63
|
+
expect(evaluatePredicate({ c: 'green' }, { field: 'c', oneOf: ['red', 'blue'] })).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('FieldPredicate: valid', () => {
|
|
68
|
+
it('valid=true passes for normal values', () => {
|
|
69
|
+
expect(evaluatePredicate({ v: 42 }, { field: 'v', valid: true })).toBe(true);
|
|
70
|
+
expect(evaluatePredicate({ v: 'hello' }, { field: 'v', valid: true })).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('valid=true rejects null/undefined/NaN', () => {
|
|
74
|
+
expect(evaluatePredicate({ v: null }, { field: 'v', valid: true })).toBe(false);
|
|
75
|
+
expect(evaluatePredicate({ v: undefined }, { field: 'v', valid: true })).toBe(false);
|
|
76
|
+
expect(evaluatePredicate({ v: NaN }, { field: 'v', valid: true })).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('valid=false passes for null/undefined/NaN', () => {
|
|
80
|
+
expect(evaluatePredicate({ v: null }, { field: 'v', valid: false })).toBe(true);
|
|
81
|
+
expect(evaluatePredicate({ v: NaN }, { field: 'v', valid: false })).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('valid=false rejects normal values', () => {
|
|
85
|
+
expect(evaluatePredicate({ v: 42 }, { field: 'v', valid: false })).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('LogicalAnd', () => {
|
|
90
|
+
it('passes when all conditions match', () => {
|
|
91
|
+
const pred = {
|
|
92
|
+
and: [
|
|
93
|
+
{ field: 'v', gt: 5 },
|
|
94
|
+
{ field: 'v', lt: 15 },
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
expect(evaluatePredicate({ v: 10 }, pred)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('fails when any condition fails', () => {
|
|
101
|
+
const pred = {
|
|
102
|
+
and: [
|
|
103
|
+
{ field: 'v', gt: 5 },
|
|
104
|
+
{ field: 'v', lt: 15 },
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
expect(evaluatePredicate({ v: 20 }, pred)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('LogicalOr', () => {
|
|
112
|
+
it('passes when any condition matches', () => {
|
|
113
|
+
const pred = {
|
|
114
|
+
or: [
|
|
115
|
+
{ field: 'c', equal: 'red' },
|
|
116
|
+
{ field: 'c', equal: 'blue' },
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
expect(evaluatePredicate({ c: 'blue' }, pred)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('fails when no condition matches', () => {
|
|
123
|
+
const pred = {
|
|
124
|
+
or: [
|
|
125
|
+
{ field: 'c', equal: 'red' },
|
|
126
|
+
{ field: 'c', equal: 'blue' },
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
expect(evaluatePredicate({ c: 'green' }, pred)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('LogicalNot', () => {
|
|
134
|
+
it('inverts a passing condition', () => {
|
|
135
|
+
expect(evaluatePredicate({ v: 5 }, { not: { field: 'v', gt: 10 } })).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('inverts a failing condition', () => {
|
|
139
|
+
expect(evaluatePredicate({ v: 15 }, { not: { field: 'v', gt: 10 } })).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('nested logical combinators', () => {
|
|
144
|
+
it('handles and inside or', () => {
|
|
145
|
+
const pred = {
|
|
146
|
+
or: [
|
|
147
|
+
{
|
|
148
|
+
and: [
|
|
149
|
+
{ field: 'x', gt: 0 },
|
|
150
|
+
{ field: 'x', lt: 10 },
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
{ field: 'x', equal: 100 },
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
expect(evaluatePredicate({ x: 5 }, pred)).toBe(true);
|
|
157
|
+
expect(evaluatePredicate({ x: 100 }, pred)).toBe(true);
|
|
158
|
+
expect(evaluatePredicate({ x: 50 }, pred)).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('handles not inside and', () => {
|
|
162
|
+
const pred = {
|
|
163
|
+
and: [{ field: 'x', gt: 0 }, { not: { field: 'x', equal: 5 } }],
|
|
164
|
+
};
|
|
165
|
+
expect(evaluatePredicate({ x: 3 }, pred)).toBe(true);
|
|
166
|
+
expect(evaluatePredicate({ x: 5 }, pred)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('edge cases', () => {
|
|
171
|
+
it('missing field returns true for no-op predicate', () => {
|
|
172
|
+
// A field predicate with no comparison operators defaults to true
|
|
173
|
+
expect(evaluatePredicate({ other: 1 }, { field: 'v' })).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|