mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: filter/calculate transforms, data preview, and inline expression validation
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user