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 = {}) => 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; 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>; 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>; 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>; 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>; 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>; 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>; // 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>; 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 >; expect(enc.x.sort).toBe('-y'); const asc = buildChartSpec({ ...ranking, sort: 'ascending' }).encoding as Record< string, Record >; 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 >; 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 >; 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 " chart of by " 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 => ({ id: 'f1', mode: 'predicate', ...over, }); const calc = (over: Partial): 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' }); }); });