mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
1382 lines
49 KiB
TypeScript
1382 lines
49 KiB
TypeScript
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' });
|
||
});
|
||
});
|