mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add Chart Builder: no-JSON Vega-Lite composer from a dataset (M4)
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
defaultFieldType,
|
||||
validFieldTypes,
|
||||
defaultMark,
|
||||
isChannelTypeAllowed,
|
||||
builderWarnings,
|
||||
defaultBuilderConfig,
|
||||
isBuilderConfigValid,
|
||||
buildChartSpec,
|
||||
buildSnippetSpecText,
|
||||
generateChartName,
|
||||
type BuilderColumns,
|
||||
type BuilderConfig,
|
||||
} 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('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', () => {
|
||||
const w = builderWarnings({
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'a', type: 'quantitative' },
|
||||
y: { field: 'b', type: 'quantitative' },
|
||||
},
|
||||
});
|
||||
expect(w.some((m) => /scatter/.test(m.message))).toBe(true);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const w = builderWarnings({
|
||||
datasetName: 'D',
|
||||
mark: 'area',
|
||||
encodings: {
|
||||
x: { field: 't', type: 'temporal' },
|
||||
y: { field: 'v', type: 'quantitative' },
|
||||
color: { field: 'g', type: 'nominal' },
|
||||
},
|
||||
});
|
||||
expect(w.some((m) => m.channel === 'color')).toBe(true);
|
||||
});
|
||||
|
||||
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('defaultBuilderConfig', () => {
|
||||
it('puts the first column on X and the second on Y, each with derived type', () => {
|
||||
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('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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user