Files
astrolabe/src/core/chart-builder.test.ts
T

1382 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import {
defaultFieldType,
validFieldTypes,
defaultMark,
isChannelTypeAllowed,
isColumnAllowedOnChannel,
supportsAggregate,
validAggregateOps,
supportsBin,
supportsTimeUnit,
supportsSort,
supportsStack,
sortableCategoryChannel,
builderWarnings,
defaultBuilderConfig,
isBuilderConfigValid,
isValueMapping,
channelAcceptsValue,
defaultChannelValue,
coerceChannelValue,
buildChartSpec,
buildSnippetSpecText,
generateChartName,
validFilterOps,
filterOpArity,
buildTransforms,
calculatedFieldNames,
effectiveColumns,
pruneEncodings,
rebaseBuilderConfig,
type BuilderCalculate,
type BuilderColumns,
type BuilderConfig,
type BuilderFilter,
type ChannelMapping,
} from './chart-builder';
import { VEGA_LITE_SCHEMA_URL } from './snippet';
const columns: BuilderColumns = {
columns: ['category', 'value', 'when', 'flag'],
columnTypes: [
{ name: 'category', type: 'string' },
{ name: 'value', type: 'number' },
{ name: 'when', type: 'date' },
{ name: 'flag', type: 'boolean' },
],
};
describe('defaultFieldType', () => {
it('maps inferred column types to Vega-Lite field types (spec §06)', () => {
expect(defaultFieldType('number')).toBe('quantitative');
expect(defaultFieldType('date')).toBe('temporal');
expect(defaultFieldType('string')).toBe('nominal');
expect(defaultFieldType('boolean')).toBe('nominal');
});
it('is always the head of validFieldTypes (no drift)', () => {
for (const t of ['number', 'date', 'string', 'boolean'] as const) {
expect(defaultFieldType(t)).toBe(validFieldTypes(t)[0]);
}
});
});
describe('validFieldTypes (Tier B valid-type locking)', () => {
it('never offers Quantitative for string/boolean, nor Temporal for non-date', () => {
expect(validFieldTypes('string')).not.toContain('quantitative');
expect(validFieldTypes('boolean')).not.toContain('quantitative');
expect(validFieldTypes('number')).not.toContain('temporal');
expect(validFieldTypes('string')).not.toContain('temporal');
});
it('locks date to Temporal only and offers Ordinal where order is plausible', () => {
expect(validFieldTypes('date')).toEqual(['temporal']);
expect(validFieldTypes('number')).toContain('ordinal');
expect(validFieldTypes('string')).toContain('ordinal');
});
});
describe('defaultMark (Tier B smart default)', () => {
it('picks Line for time × measure, Point for two measures, Bar for category × measure', () => {
expect(defaultMark('temporal', 'quantitative')).toBe('line');
expect(defaultMark('quantitative', 'temporal')).toBe('line');
expect(defaultMark('quantitative', 'quantitative')).toBe('point');
expect(defaultMark('nominal', 'quantitative')).toBe('bar');
expect(defaultMark('quantitative', 'nominal')).toBe('bar');
});
it('uses Point when both axes are discrete (Bar/Line/Area need a continuous axis)', () => {
expect(defaultMark('nominal', 'nominal')).toBe('point');
expect(defaultMark('nominal', 'ordinal')).toBe('point');
});
it('falls back to Bar when an axis is unmapped', () => {
expect(defaultMark('quantitative', null)).toBe('bar');
expect(defaultMark(null, null)).toBe('bar');
});
});
describe('isChannelTypeAllowed (Size discipline)', () => {
it('forbids Size for Nominal and Temporal, allows it for Quantitative/Ordinal', () => {
expect(isChannelTypeAllowed('size', 'nominal')).toBe(false);
expect(isChannelTypeAllowed('size', 'temporal')).toBe(false);
expect(isChannelTypeAllowed('size', 'quantitative')).toBe(true);
expect(isChannelTypeAllowed('size', 'ordinal')).toBe(true);
});
it('allows any type on X/Y/Color', () => {
for (const ch of ['x', 'y', 'color'] as const) {
expect(isChannelTypeAllowed(ch, 'nominal')).toBe(true);
expect(isChannelTypeAllowed(ch, 'temporal')).toBe(true);
}
});
});
describe('isColumnAllowedOnChannel (field-shelf placement by default type)', () => {
it('lets Size take only a numeric column (its default type is the magnitude)', () => {
expect(isColumnAllowedOnChannel('size', 'number')).toBe(true);
expect(isColumnAllowedOnChannel('size', 'string')).toBe(false);
expect(isColumnAllowedOnChannel('size', 'date')).toBe(false);
expect(isColumnAllowedOnChannel('size', 'boolean')).toBe(false);
});
it('lets X/Y/Color take any column type', () => {
for (const ch of ['x', 'y', 'color'] as const) {
for (const t of ['number', 'string', 'date', 'boolean'] as const) {
expect(isColumnAllowedOnChannel(ch, t)).toBe(true);
}
}
});
});
describe('builderWarnings (Tier B advisories)', () => {
it('warns when a line/area mark is missing an axis', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'line',
encodings: { x: { field: 'a', type: 'temporal' } },
});
expect(w.some((m) => /need both an X and a Y/.test(m.message))).toBe(true);
});
it('warns when two measures are drawn on a non-scatter mark, offering [Switch to Point]', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
},
};
const w = builderWarnings(config);
const hint = w.find((m) => /scatter/.test(m.message));
const fix = hint?.fixes?.find((f) => f.label === 'Switch to Point');
expect(fix).toBeDefined();
expect(fix!.apply(config).mark).toBe('point');
});
it('warns when a bar/line/area has no measure on either axis', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'bar',
encodings: { x: { field: 'a', type: 'nominal' }, y: { field: 'b', type: 'nominal' } },
});
expect(w.some((m) => /need a measure/.test(m.message))).toBe(true);
});
it('warns when an area chart is split into colour series, offering [Stack] / [Remove colour]', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'area',
encodings: {
x: { field: 't', type: 'temporal' },
y: { field: 'v', type: 'quantitative' },
color: { field: 'g', type: 'nominal' },
},
};
const w = builderWarnings(config);
const hint = w.find((m) => m.channel === 'color');
expect(hint).toBeDefined();
const labels = hint?.fixes?.map((f) => f.label) ?? [];
expect(labels).toEqual(['Stack', 'Remove colour']); // most-recommended first
// [Remove colour] clears the colour channel, so the hint re-derives away.
const cleared = hint!.fixes!.find((f) => f.label === 'Remove colour')!.apply(config);
expect(cleared.encodings.color).toBeNull();
expect(builderWarnings(cleared).some((m) => m.channel === 'color')).toBe(false);
// [Stack] turns it into a part-to-whole stack, which is no longer flagged.
const stacked = hint!.fixes!.find((f) => f.label === 'Stack')!.apply(config);
expect(stacked.stack).toBe('zero');
expect(builderWarnings(stacked).some((m) => m.channel === 'color')).toBe(false);
});
it('is silent for a clean configuration', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'line',
encodings: { x: { field: 't', type: 'temporal' }, y: { field: 'v', type: 'quantitative' } },
});
expect(w).toEqual([]);
});
describe('crowded category axis (one mark per row)', () => {
const crowded = (overrides: Partial<ChannelMapping> = {}) =>
builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative', ...overrides },
},
},
406,
);
it('warns when a raw measure draws one bar per row over a large dataset', () => {
const w = crowded();
const hint = w.find((m) => /one mark per row/.test(m.message));
expect(hint?.channel).toBe('x'); // the category axis
expect(hint?.message).toContain('406 in this dataset');
// Remedies are one-click fixes, not prose; most-recommended first.
const labels = hint?.fixes?.map((f) => f.label) ?? [];
expect(labels).toEqual(['Aggregate as Sum', 'Swap X/Y']); // aggregate before swap
});
it('[Aggregate as Sum] resolves the one-mark-per-row hint', () => {
const w = crowded();
const hint = w.find((m) => /one mark per row/.test(m.message));
const fix = hint?.fixes?.find((f) => f.label === 'Aggregate as Sum');
expect(fix).toBeDefined();
const fixed = fix!.apply({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative' },
},
});
expect(fixed.encodings.y?.aggregate).toBe('sum');
// and the hint is gone once applied
expect(builderWarnings(fixed, 406).some((m) => /one mark per row/.test(m.message))).toBe(
false,
);
});
it('is silent once the measure is aggregated (one bar per category)', () => {
const w = crowded({ aggregate: 'mean' });
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
});
it('is silent for a small dataset even with a raw measure', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative' },
},
},
12,
);
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
});
it('is silent when the row count is unknown (URL/non-tabular)', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative' },
},
});
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
});
it('uses non-bar wording (no Swap X/Y) for a line mark', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'line',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative' },
},
},
406,
);
const hint = w.find((m) => /one mark per row/.test(m.message));
expect(hint).toBeDefined();
const labels = hint?.fixes?.map((f) => f.label) ?? [];
expect(labels).not.toContain('Swap X/Y'); // a horizontal line makes no sense
expect(labels).toContain('Aggregate as Sum'); // aggregate still applies
});
});
describe('data-aware hints (from profiled column stats)', () => {
/** A BuilderColumns carrying stats for the named field. */
const withStats = (
name: string,
stats: {
distinct?: number;
distinctCapped?: boolean;
numericExtent?: { min: number; max: number } | null;
},
): BuilderColumns => ({
columns: [name],
columnTypes: [{ name, type: 'string' }],
columnStats: [
{
name,
distinct: stats.distinct ?? 1,
distinctCapped: stats.distinctCapped ?? false,
numericExtent: stats.numericExtent ?? null,
},
],
});
it('flags an aggregated category axis that still has too many distinct values', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sku', type: 'nominal' },
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' }, // aggregated, so not one-per-row
},
},
500,
withStats('sku', { distinct: 48 }),
);
const hint = w.find((m) => /distinct values/.test(m.message));
expect(hint?.channel).toBe('x');
expect(hint?.message).toMatch(/48 distinct values/);
expect(hint?.fixes?.map((f) => f.label)).toContain('Swap X/Y'); // horizontal-bar remedy
});
it('reports "more than 50" when the category cardinality hit the profiler cap', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sku', type: 'nominal' },
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
},
},
500,
withStats('sku', { distinct: 50, distinctCapped: true }),
);
expect(w.some((m) => /more than 50 distinct values/.test(m.message))).toBe(true);
});
it('is silent for a small-cardinality aggregated category axis', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sku', type: 'nominal' },
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
},
},
500,
withStats('sku', { distinct: 6 }),
);
expect(w.some((m) => /distinct values/.test(m.message))).toBe(false);
});
it('warns when a discrete colour series has too many categories', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sku', type: 'nominal' },
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
color: { field: 'tag', type: 'nominal' },
},
},
500,
{
columns: ['tag'],
columnTypes: [{ name: 'tag', type: 'string' }],
columnStats: [{ name: 'tag', distinct: 20, distinctCapped: false, numericExtent: null }],
},
);
const hint = w.find((m) => m.channel === 'color' && /legend/.test(m.message));
expect(hint?.message).toMatch(/20 categories/);
});
it('does not flag a continuous (quantitative) colour ramp for cardinality', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
color: { field: 'score', type: 'quantitative' }, // a ramp, no per-value legend
},
},
500,
{
columns: ['score'],
columnTypes: [{ name: 'score', type: 'number' }],
columnStats: [
{
name: 'score',
distinct: 50,
distinctCapped: true,
numericExtent: { min: 0, max: 9 },
},
],
},
);
expect(w.some((m) => /legend/.test(m.message))).toBe(false);
});
it('guards Size against a field whose values go negative', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
size: { field: 'delta', type: 'quantitative' },
},
},
500,
{
columns: ['delta'],
columnTypes: [{ name: 'delta', type: 'number' }],
columnStats: [
{
name: 'delta',
distinct: 30,
distinctCapped: false,
numericExtent: { min: -12, max: 40 },
},
],
},
);
const hint = w.find((m) => m.channel === 'size');
expect(hint?.message).toMatch(/can't show negative values/);
expect(hint?.message).toMatch(/down to -12/);
});
it('does not guard Size when the field is wholly non-negative', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
size: { field: 'amount', type: 'quantitative' },
},
},
500,
{
columns: ['amount'],
columnTypes: [{ name: 'amount', type: 'number' }],
columnStats: [
{
name: 'amount',
distinct: 30,
distinctCapped: false,
numericExtent: { min: 0, max: 40 },
},
],
},
);
expect(w.some((m) => m.channel === 'size')).toBe(false);
});
it('skips data-aware hints entirely when no column stats are supplied', () => {
// Old/URL datasets: same crowded config, but without stats the hints stay quiet.
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sku', type: 'nominal' },
y: { field: 'qty', type: 'quantitative', aggregate: 'sum' },
size: { field: 'qty', type: 'quantitative' },
},
},
500,
);
expect(w.some((m) => /distinct values/.test(m.message))).toBe(false);
expect(w.some((m) => m.channel === 'size')).toBe(false);
});
});
});
describe('defaultBuilderConfig', () => {
it('falls back to first-on-X, second-on-Y when the dataset is unprofiled', () => {
// `columns` carries no columnStats → no data-aware pick → positional default.
const config = defaultBuilderConfig('Sales', columns);
expect(config.mark).toBe('bar');
expect(config.datasetName).toBe('Sales');
expect(config.encodings.x).toEqual({ field: 'category', type: 'nominal' });
expect(config.encodings.y).toEqual({ field: 'value', type: 'quantitative' });
expect(config.encodings.color).toBeNull();
expect(config.encodings.size).toBeNull();
});
it('opens as a Line when the first two columns are date × number (smart mark)', () => {
const timeSeries: BuilderColumns = {
columns: ['day', 'visits'],
columnTypes: [
{ name: 'day', type: 'date' },
{ name: 'visits', type: 'number' },
],
};
const config = defaultBuilderConfig('Traffic', timeSeries);
expect(config.mark).toBe('line');
expect(config.encodings.x).toEqual({ field: 'day', type: 'temporal' });
expect(config.encodings.y).toEqual({ field: 'visits', type: 'quantitative' });
});
it('leaves Y unmapped when the dataset has a single column', () => {
const single: BuilderColumns = {
columns: ['only'],
columnTypes: [{ name: 'only', type: 'number' }],
};
const config = defaultBuilderConfig('One', single);
expect(config.encodings.x).toEqual({ field: 'only', type: 'quantitative' });
expect(config.encodings.y).toBeNull();
});
it('maps nothing when the dataset has no detected columns', () => {
const config = defaultBuilderConfig('Empty', { columns: [], columnTypes: [] });
expect(isBuilderConfigValid(config)).toBe(false);
expect(config.encodings.x).toBeNull();
});
});
describe('defaultBuilderConfig — data-aware "safest bet" (profiled datasets)', () => {
/** Stats for one column. */
const stat = (name: string, distinct: number, capped = false) => ({
name,
distinct,
distinctCapped: capped,
numericExtent: null,
});
it('opens on a low-cardinality category vs a count of records, not the first two columns', () => {
// Superstore-shaped: an id-like number first, a high-cardinality id, then tidy
// categories — the case a positional first-two-columns default would open as a
// 9994-bar degenerate chart.
const wide: BuilderColumns = {
columns: ['Row ID', 'Order ID', 'Segment', 'Sales'],
columnTypes: [
{ name: 'Row ID', type: 'number' },
{ name: 'Order ID', type: 'string' },
{ name: 'Segment', type: 'string' },
{ name: 'Sales', type: 'number' },
],
columnStats: [
stat('Row ID', 50, true),
stat('Order ID', 50, true), // high cardinality → not a category axis
stat('Segment', 3), // tidy category → the pick
stat('Sales', 50, true),
],
};
const config = defaultBuilderConfig('Superstore', wide);
expect(config.mark).toBe('bar');
expect(config.encodings.x).toEqual({ field: 'Segment', type: 'nominal' });
expect(config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
});
it('picks the lowest-cardinality readable category among several', () => {
const cols: BuilderColumns = {
columns: ['Region', 'Segment', 'City'],
columnTypes: [
{ name: 'Region', type: 'string' },
{ name: 'Segment', type: 'string' },
{ name: 'City', type: 'string' },
],
columnStats: [stat('Region', 4), stat('Segment', 3), stat('City', 50, true)],
};
const config = defaultBuilderConfig('D', cols);
expect(config.encodings.x).toEqual({ field: 'Segment', type: 'nominal' }); // 3 < 4
});
it('falls through to a time series (date vs count) when no tidy category exists', () => {
const cols: BuilderColumns = {
columns: ['Order ID', 'Order Date', 'Sales'],
columnTypes: [
{ name: 'Order ID', type: 'string' },
{ name: 'Order Date', type: 'date' },
{ name: 'Sales', type: 'number' },
],
columnStats: [
stat('Order ID', 50, true),
stat('Order Date', 50, true),
stat('Sales', 50, true),
],
};
const config = defaultBuilderConfig('D', cols);
expect(config.mark).toBe('line');
expect(config.encodings.x).toEqual({ field: 'Order Date', type: 'temporal' });
expect(config.encodings.y).toEqual({ field: 'Sales', type: 'quantitative' }); // the measure, raw
});
it('falls through to a scatter of two measures when there is no category or date', () => {
const cols: BuilderColumns = {
columns: ['Order ID', 'Sales', 'Profit'],
columnTypes: [
{ name: 'Order ID', type: 'string' },
{ name: 'Sales', type: 'number' },
{ name: 'Profit', type: 'number' },
],
columnStats: [stat('Order ID', 50, true), stat('Sales', 50, true), stat('Profit', 50, true)],
};
const config = defaultBuilderConfig('D', cols);
expect(config.mark).toBe('point');
expect(config.encodings.x).toEqual({ field: 'Sales', type: 'quantitative' });
expect(config.encodings.y).toEqual({ field: 'Profit', type: 'quantitative' });
});
});
describe('isBuilderConfigValid', () => {
const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };
it('requires at least one mapped channel', () => {
expect(isBuilderConfigValid(base)).toBe(false);
expect(isBuilderConfigValid({ ...base, encodings: { x: null, y: null } })).toBe(false);
expect(
isBuilderConfigValid({ ...base, encodings: { color: { field: 'c', type: 'nominal' } } }),
).toBe(true);
});
});
describe('buildChartSpec', () => {
it('assembles schema, named data, tooltip mark, and mapped encodings', () => {
const config = defaultBuilderConfig('Sales', columns);
const spec = buildChartSpec(config);
expect(spec).toEqual({
$schema: VEGA_LITE_SCHEMA_URL,
data: { name: 'Sales' },
mark: { type: 'bar', tooltip: true },
encoding: {
x: { field: 'category', type: 'nominal' },
y: { field: 'value', type: 'quantitative' },
},
});
});
it('omits unmapped channels and preserves canonical channel order', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'point',
encodings: {
size: { field: 's', type: 'quantitative' },
x: { field: 'a', type: 'nominal' },
color: null,
},
};
const spec = buildChartSpec(config);
expect(Object.keys(spec.encoding as object)).toEqual(['x', 'size']);
});
it('omits the encoding block entirely when nothing is mapped', () => {
const spec = buildChartSpec({ datasetName: 'D', mark: 'bar', encodings: {} });
expect(spec.encoding).toBeUndefined();
expect(spec.mark).toEqual({ type: 'bar', tooltip: true });
});
it('writes explicit width/height only when provided', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'area',
encodings: { x: { field: 'a', type: 'temporal' } },
width: 400,
height: 300,
};
const spec = buildChartSpec(config);
expect(spec.width).toBe(400);
expect(spec.height).toBe(300);
const noDims = buildChartSpec({ ...config, width: undefined, height: undefined });
expect(noDims.width).toBeUndefined();
expect(noDims.height).toBeUndefined();
});
it('carries every mark type through to the spec', () => {
for (const mark of ['bar', 'line', 'point', 'area', 'circle'] as const) {
const spec = buildChartSpec({
datasetName: 'D',
mark,
encodings: { x: { field: 'a', type: 'nominal' } },
});
expect((spec.mark as { type: string }).type).toBe(mark);
}
});
it('escapes `.`/`[`/`]` in encoded field names so they read as literal columns', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'user.age', type: 'quantitative' },
y: { field: 'cols[0]', type: 'quantitative' },
},
});
const enc = spec.encoding as Record<string, { field: string }>;
expect(enc.x.field).toBe('user\\.age');
expect(enc.y.field).toBe('cols\\[0\\]');
});
});
describe('value channels (constant colour / size — the Property model)', () => {
it('emits `{ value }` and ignores field/type/transforms when a constant is set', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'category', type: 'nominal' },
// A constant carries a (preserved-but-ignored) type and even a stale field;
// the assembler still emits only `{ value }`.
color: { value: '#c0392b', type: 'nominal', field: 'category', aggregate: 'sum' },
size: { value: 100, type: 'quantitative' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
expect(enc.color).toEqual({ value: '#c0392b' });
expect(enc.size).toEqual({ value: 100 });
});
it('counts a constant channel as mapped (saveable) and preserves channel order', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'point',
encodings: { color: { value: 'steelblue', type: 'nominal' } },
};
expect(isBuilderConfigValid(config)).toBe(true);
expect(Object.keys(buildChartSpec(config).encoding as object)).toEqual(['color']);
});
it('a constant Colour is not a series: no stacking, no area-split hint', () => {
const base: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'category', type: 'nominal' },
y: { field: 'value', type: 'quantitative' },
color: { value: '#4c78a8', type: 'nominal' },
},
};
// A field-bound Colour would enable stacking and (on area) flag the split hint;
// a constant Colour produces neither — it draws every mark one fixed colour.
expect(supportsStack(base)).toBe(false);
const areaWarnings = builderWarnings({ ...base, mark: 'area' });
expect(areaWarnings.some((w) => w.message.includes('per-series change'))).toBe(false);
// Sanity: the same config with a *field* Colour does enable stacking.
const seriesColor: BuilderConfig = {
...base,
encodings: { ...base.encodings, color: { field: 'category', type: 'nominal' } },
};
expect(supportsStack(seriesColor)).toBe(true);
});
it('describes the Property-model helpers', () => {
expect(isValueMapping({ value: 10, type: 'quantitative' })).toBe(true);
expect(isValueMapping({ field: 'value', type: 'quantitative' })).toBe(false);
expect(channelAcceptsValue('color')).toBe(true);
expect(channelAcceptsValue('size')).toBe(true);
expect(channelAcceptsValue('x')).toBe(false);
expect(channelAcceptsValue('y')).toBe(false);
expect(typeof defaultChannelValue('color')).toBe('string');
expect(defaultChannelValue('size')).toBe(100);
});
it('coerces a constant value by channel (size → number, others → string)', () => {
expect(coerceChannelValue('size', '40')).toBe(40);
expect(coerceChannelValue('size', '')).toBe(''); // blank stays raw, not NaN
expect(coerceChannelValue('size', 'big')).toBe('big'); // non-numeric stays raw
expect(coerceChannelValue('color', '#ff0000')).toBe('#ff0000');
expect(coerceChannelValue('color', 'red')).toBe('red');
});
});
describe('transforms — aggregate / bin / timeUnit', () => {
it('emits a field-less count encoding', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { type: 'quantitative', aggregate: 'count' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
expect(enc.y).toEqual({ aggregate: 'count', type: 'quantitative' });
expect(enc.y.field).toBeUndefined();
});
it('emits a non-count aggregate with its field', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
expect(enc.y).toEqual({ field: 'revenue', type: 'quantitative', aggregate: 'sum' });
});
it('emits bin on a quantitative field (histogram shape) and timeUnit on a temporal one', () => {
const hist = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'price', type: 'quantitative', bin: true },
y: { type: 'quantitative', aggregate: 'count' },
},
});
const henc = hist.encoding as Record<string, Record<string, unknown>>;
expect(henc.x).toEqual({ field: 'price', type: 'quantitative', bin: true });
const ts = buildChartSpec({
datasetName: 'D',
mark: 'line',
encodings: {
x: { field: 'day', type: 'temporal', timeUnit: 'yearmonth' },
y: { field: 'v', type: 'quantitative' },
},
});
const tenc = ts.encoding as Record<string, Record<string, unknown>>;
expect(tenc.x).toEqual({ field: 'day', type: 'temporal', timeUnit: 'yearmonth' });
});
it('exposes the transform-applicability predicates by field type', () => {
expect(supportsAggregate('quantitative')).toBe(true);
// Every type now takes at least one aggregate (`distinct` applies to anything).
expect(supportsAggregate('nominal')).toBe(true);
expect(supportsBin('quantitative')).toBe(true);
expect(supportsBin('temporal')).toBe(false);
expect(supportsTimeUnit('temporal')).toBe(true);
expect(supportsTimeUnit('quantitative')).toBe(false);
});
it('offers per-type aggregate menus: arithmetic needs numbers, min/max an ordering, distinct anything', () => {
expect(validAggregateOps('quantitative')).toEqual([
'sum',
'mean',
'median',
'min',
'max',
'distinct',
]);
expect(validAggregateOps('temporal')).toEqual(['min', 'max', 'distinct']);
expect(validAggregateOps('ordinal')).toEqual(['min', 'max', 'distinct']);
expect(validAggregateOps('nominal')).toEqual(['distinct']);
});
it('emits a distinct-count of a categorical field as a quantitative measure', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'customer', type: 'nominal', aggregate: 'distinct' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
// The carried (nominal) type is for round-tripping; the emitted type is the
// effective one — a count of unique values reads as a quantitative measure.
expect(enc.y).toEqual({ field: 'customer', type: 'quantitative', aggregate: 'distinct' });
});
it('keeps the field type on order-preserving aggregates (a temporal min is still temporal)', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'orderDate', type: 'temporal', aggregate: 'min' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
expect(enc.y).toEqual({ field: 'orderDate', type: 'temporal', aggregate: 'min' });
});
it('emits title as a bare string, the object form with a subtitle, nothing without a title', () => {
const base: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: { x: { field: 'region', type: 'nominal' } },
};
expect(buildChartSpec({ ...base, title: 'Sales by region' }).title).toBe('Sales by region');
expect(buildChartSpec({ ...base, title: 'Sales', subtitle: 'FY26' }).title).toEqual({
text: 'Sales',
subtitle: 'FY26',
});
// A subtitle alone is not emitted (VL has no standalone subtitle), nor is a
// whitespace-only title.
expect(buildChartSpec({ ...base, subtitle: 'orphan' }).title).toBeUndefined();
expect(buildChartSpec({ ...base, title: ' ' }).title).toBeUndefined();
});
it('prefers a user-written title as the generated snippet name', () => {
expect(
generateChartName({
datasetName: 'D',
mark: 'bar',
title: 'Quarterly revenue',
encodings: { x: { field: 'region', type: 'nominal' } },
}),
).toBe('Quarterly revenue');
});
it('names a distinct-count chart with a "unique" phrase', () => {
expect(
generateChartName({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'customer', type: 'nominal', aggregate: 'distinct' },
},
}),
).toBe('Bar chart of unique customer by region');
});
});
describe('sort (ranking)', () => {
const ranking: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
},
};
it('sorts the category axis by the measure axis (descending → "-y")', () => {
expect(sortableCategoryChannel(ranking)).toBe('x');
expect(supportsSort(ranking)).toBe(true);
const enc = buildChartSpec({ ...ranking, sort: 'descending' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(enc.x.sort).toBe('-y');
const asc = buildChartSpec({ ...ranking, sort: 'ascending' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(asc.x.sort).toBe('y');
});
it('does not offer sort when both axes are measures', () => {
const scatter: BuilderConfig = {
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
},
};
expect(supportsSort(scatter)).toBe(false);
expect(buildChartSpec({ ...scatter, sort: 'descending' }).encoding).toBeDefined();
const enc = buildChartSpec({ ...scatter, sort: 'descending' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(enc.x.sort).toBeUndefined();
});
});
describe('stack (part-to-whole)', () => {
const stacked: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'month', type: 'ordinal' },
y: { field: 'sales', type: 'quantitative', aggregate: 'sum' },
color: { field: 'product', type: 'nominal' },
},
};
it('stacks the quantitative axis for a bar/area + colour series', () => {
expect(supportsStack(stacked)).toBe(true);
const enc = buildChartSpec({ ...stacked, stack: 'normalize' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(enc.y.stack).toBe('normalize');
});
it('does not stack without a colour series or on a point mark', () => {
expect(supportsStack({ ...stacked, encodings: { ...stacked.encodings, color: null } })).toBe(
false,
);
expect(supportsStack({ ...stacked, mark: 'point' })).toBe(false);
});
});
describe('buildSnippetSpecText', () => {
it('produces pretty-printed JSON that parses back to the spec', () => {
const config = defaultBuilderConfig('Sales', columns);
const text = buildSnippetSpecText(config);
expect(text).toContain('\n ');
expect(JSON.parse(text)).toEqual(buildChartSpec(config));
});
});
describe('generateChartName', () => {
it('reads "<Mark> chart of <y> by <x>" when both axes are mapped', () => {
const config = defaultBuilderConfig('Sales', columns);
expect(generateChartName(config)).toBe('Bar chart of value by category');
});
it('names the single mapped field when only one channel is set', () => {
const config: BuilderConfig = {
datasetName: 'Sales',
mark: 'line',
encodings: { color: { field: 'region', type: 'nominal' } },
};
expect(generateChartName(config)).toBe('Line chart of region');
});
it('falls back to the dataset name when nothing is mapped', () => {
const config: BuilderConfig = { datasetName: 'Sales', mark: 'circle', encodings: {} };
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('escapes `.`/`[`/`]` in a predicate field so it reads as a literal column', () => {
const t = buildTransforms(
withFilters(filter({ field: 'user.age', fieldType: 'quantitative', op: 'gt', value: '10' })),
);
expect(t).toEqual([{ filter: { field: 'user\\.age', gt: 10 } }]);
});
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([]);
});
it('drops a syntactically-invalid expression filter (mid-edit preview resilience)', () => {
const t = buildTransforms(
withFilters(
filter({ mode: 'expression', expr: 'datum.value *' }), // half-typed → unparseable
filter({ id: 'f2', mode: 'expression', expr: 'datum.value > 0' }), // valid stays
),
);
expect(t).toEqual([{ filter: 'datum.value > 0' }]);
});
it('drops a calculated field whose expression does not parse', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {},
calculates: [
calc({ as: 'bad', expr: 'datum.a +' }),
calc({ id: 'c2', as: 'ok', expr: 'datum.a' }),
],
};
expect(buildTransforms(config)).toEqual([{ calculate: 'datum.a', as: 'ok' }]);
});
});
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);
});
});
describe('rebaseBuilderConfig (dataset switch)', () => {
// The new dataset shares `category` but lacks `value`/`when`/`flag`.
const newColumns: BuilderColumns = {
columns: ['category', 'profit'],
columnTypes: [
{ name: 'category', type: 'string' },
{ name: 'profit', type: 'number' },
],
};
it('keeps chart-level intent and same-name bindings, re-pointing the dataset', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'bar',
title: 'Revenue by region',
subtitle: 'FY26',
width: 400,
height: 200,
sort: 'descending',
stack: 'normalize',
encodings: { x: { field: 'category', type: 'nominal' } },
};
const out = rebaseBuilderConfig(config, 'New', newColumns);
expect(out.datasetName).toBe('New');
expect(out.mark).toBe('bar');
expect(out.title).toBe('Revenue by region');
expect(out.subtitle).toBe('FY26');
expect(out.width).toBe(400);
expect(out.height).toBe(200);
expect(out.sort).toBe('descending');
expect(out.stack).toBe('normalize');
expect(out.encodings.x).toEqual({ field: 'category', type: 'nominal' });
});
it('sheds encodings and predicate filters bound to columns the new dataset lacks', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'bar',
encodings: {
x: { field: 'category', type: 'nominal' },
y: { field: 'value', type: 'quantitative' },
},
filters: [
filter({ id: 'f1', field: 'value', fieldType: 'quantitative', op: 'gt', value: '0' }),
filter({ id: 'f2', field: 'category', fieldType: 'nominal', op: 'equal', value: 'A' }),
],
};
const out = rebaseBuilderConfig(config, 'New', newColumns);
expect(out.encodings.x).toEqual({ field: 'category', type: 'nominal' });
expect(out.encodings.y).toBeNull();
expect(out.filters?.map((f) => f.id)).toEqual(['f2']);
});
it('keeps expression filters, constants, count mappings, and calculated-field bindings', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'bar',
encodings: {
x: { field: 'ratio', type: 'quantitative' }, // a calculated field travels along
y: { type: 'quantitative', aggregate: 'count' }, // field-less count
color: { value: '#ff0000', type: 'nominal' }, // constant — no column binding
},
calculates: [calc({ as: 'ratio', expr: 'datum.profit * 2' })],
filters: [filter({ id: 'fx', mode: 'expression', expr: 'datum.value > 0' })],
};
const out = rebaseBuilderConfig(config, 'New', newColumns);
expect(out.encodings.x).toEqual({ field: 'ratio', type: 'quantitative' });
expect(out.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
expect(out.encodings.color).toEqual({ value: '#ff0000', type: 'nominal' });
expect(out.calculates).toEqual(config.calculates);
expect(out.filters).toEqual(config.filters);
});
it('a same-schema dataset keeps the whole config (only the name changes)', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'line',
encodings: {
x: { field: 'when', type: 'temporal' },
y: { field: 'value', type: 'quantitative', aggregate: 'sum' },
},
filters: [filter({ field: 'category', fieldType: 'nominal', op: 'equal', value: 'A' })],
};
const out = rebaseBuilderConfig(config, 'New', columns);
expect(out).toEqual({ ...config, datasetName: 'New' });
});
});