Chart builder: filter/calculate transforms, data preview, and inline expression validation

This commit is contained in:
2026-06-11 13:01:23 +03:00
parent 1791ee9f8d
commit 05df0cd7d3
13 changed files with 1919 additions and 27 deletions
+234
View File
@@ -16,8 +16,16 @@ import {
buildChartSpec,
buildSnippetSpecText,
generateChartName,
validFilterOps,
filterOpArity,
buildTransforms,
calculatedFieldNames,
effectiveColumns,
pruneEncodings,
type BuilderCalculate,
type BuilderColumns,
type BuilderConfig,
type BuilderFilter,
type ChannelMapping,
} from './chart-builder';
import { VEGA_LITE_SCHEMA_URL } from './snippet';
@@ -831,3 +839,229 @@ describe('generateChartName', () => {
expect(generateChartName(config)).toBe('Circle chart of Sales');
});
});
// --- Data transforms: filters + calculated fields (spec §06 → Data) -------------
const filter = (over: Partial<BuilderFilter>): BuilderFilter => ({
id: 'f1',
mode: 'predicate',
...over,
});
const calc = (over: Partial<BuilderCalculate>): BuilderCalculate => ({
id: 'c1',
expr: '',
as: '',
...over,
});
const withFilters = (...filters: BuilderFilter[]): BuilderConfig => ({
datasetName: 'D',
mark: 'bar',
encodings: {},
filters,
});
describe('validFilterOps (guarded operators per field type)', () => {
it('offers ordering + range for measures and temporal fields', () => {
const ordered = ['equal', 'notEqual', 'lt', 'lte', 'gt', 'gte', 'range'];
expect(validFilterOps('quantitative')).toEqual(ordered);
expect(validFilterOps('temporal')).toEqual(ordered);
});
it('offers only equality + membership for categories (no ordering)', () => {
expect(validFilterOps('nominal')).toEqual(['equal', 'notEqual', 'oneOf']);
expect(validFilterOps('ordinal')).toEqual(['equal', 'notEqual', 'oneOf']);
});
});
describe('filterOpArity', () => {
it('classifies single / range / list operators', () => {
expect(filterOpArity('equal')).toBe('single');
expect(filterOpArity('gte')).toBe('single');
expect(filterOpArity('range')).toBe('range');
expect(filterOpArity('oneOf')).toBe('list');
});
});
describe('buildTransforms (predicate coercion + shape)', () => {
it('coerces a quantitative predicate value to a number', () => {
const t = buildTransforms(
withFilters(filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '10' })),
);
expect(t).toEqual([{ filter: { field: 'value', gt: 10 } }]);
});
it('keeps a categorical value a string', () => {
const t = buildTransforms(
withFilters(filter({ field: 'category', fieldType: 'nominal', op: 'equal', value: 'East' })),
);
expect(t).toEqual([{ filter: { field: 'category', equal: 'East' } }]);
});
it('expresses notEqual as a {not} wrapper (no bare inequality predicate)', () => {
const t = buildTransforms(
withFilters(
filter({ field: 'category', fieldType: 'nominal', op: 'notEqual', value: 'East' }),
),
);
expect(t).toEqual([{ filter: { not: { field: 'category', equal: 'East' } } }]);
});
it('builds a two-bound range with coerced numbers', () => {
const t = buildTransforms(
withFilters(
filter({
field: 'value',
fieldType: 'quantitative',
op: 'range',
value: '0',
value2: '100',
}),
),
);
expect(t).toEqual([{ filter: { field: 'value', range: [0, 100] } }]);
});
it('splits and trims a oneOf membership list', () => {
const t = buildTransforms(
withFilters(
filter({
field: 'category',
fieldType: 'nominal',
op: 'oneOf',
value: 'East, West ,North',
}),
),
);
expect(t).toEqual([{ filter: { field: 'category', oneOf: ['East', 'West', 'North'] } }]);
});
it('passes an expression-mode filter through verbatim', () => {
const t = buildTransforms(withFilters(filter({ mode: 'expression', expr: 'datum.value > 0' })));
expect(t).toEqual([{ filter: 'datum.value > 0' }]);
});
it('skips incomplete entries (blank value, blank range bound, blank expression)', () => {
const t = buildTransforms(
withFilters(
filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '' }),
filter({ id: 'f2', field: 'value', fieldType: 'quantitative', op: 'range', value: '1' }),
filter({ id: 'f3', mode: 'expression', expr: ' ' }),
),
);
expect(t).toEqual([]);
});
it('emits calculated fields before filters, in list order', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {},
calculates: [calc({ as: 'total', expr: 'datum.a + datum.b' })],
filters: [filter({ field: 'total', fieldType: 'quantitative', op: 'gt', value: '5' })],
};
expect(buildTransforms(config)).toEqual([
{ calculate: 'datum.a + datum.b', as: 'total' },
{ filter: { field: 'total', gt: 5 } },
]);
});
it('skips an unnamed or empty calculate', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {},
calculates: [calc({ as: '', expr: 'datum.a' }), calc({ id: 'c2', as: 'x', expr: '' })],
};
expect(buildTransforms(config)).toEqual([]);
});
});
describe('buildChartSpec — transform integration', () => {
it('places transform between data and mark, only when something resolves', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: { x: { field: 'category', type: 'nominal' } },
filters: [filter({ field: 'value', fieldType: 'quantitative', op: 'gte', value: '0' })],
});
expect(spec.transform).toEqual([{ filter: { field: 'value', gte: 0 } }]);
expect(Object.keys(spec)).toEqual(['$schema', 'data', 'transform', 'mark', 'encoding']);
});
it('omits the transform key when no filter/calculate resolves', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: { x: { field: 'category', type: 'nominal' } },
filters: [filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '' })],
});
expect(spec).not.toHaveProperty('transform');
});
});
describe('calculatedFieldNames', () => {
it('returns named (as) fields in order, trimmed, dropping empties', () => {
expect(
calculatedFieldNames([
calc({ as: ' total ', expr: '1' }),
calc({ id: 'c2', as: '', expr: '2' }),
]),
).toEqual(['total']);
});
it('handles an absent list', () => {
expect(calculatedFieldNames(undefined)).toEqual([]);
});
});
describe('effectiveColumns', () => {
it('appends calculated fields as numeric columns the channels can use', () => {
const eff = effectiveColumns(columns, [calc({ as: 'ratio', expr: 'datum.value / 2' })]);
expect(eff.columns).toContain('ratio');
expect(eff.columnTypes).toContainEqual({ name: 'ratio', type: 'number' });
});
it('does not shadow a real column with a same-named calculate', () => {
const eff = effectiveColumns(columns, [calc({ as: 'value', expr: '1' })]);
expect(eff.columns.filter((c) => c === 'value')).toHaveLength(1);
});
it('returns the base columns unchanged (same ref) when there are no calculates', () => {
expect(effectiveColumns(columns, undefined)).toBe(columns);
expect(effectiveColumns(columns, [])).toBe(columns);
});
});
describe('pruneEncodings', () => {
it('clears a channel mapped to a now-missing field', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: { y: { field: 'gone', type: 'quantitative' } },
calculates: [],
};
expect(pruneEncodings(config, columns).encodings.y).toBeNull();
});
it('keeps channels on real or still-present calculated fields (same ref)', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'category', type: 'nominal' },
y: { field: 'ratio', type: 'quantitative' },
},
calculates: [calc({ as: 'ratio', expr: '1' })],
};
expect(pruneEncodings(config, columns)).toBe(config);
});
it('leaves a field-less count mapping alone', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: { y: { type: 'quantitative', aggregate: 'count' } },
};
expect(pruneEncodings(config, columns)).toBe(config);
});
});