Files
astrolabe/src/app/stores/ChartBuilderStore.test.ts
T

341 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, test } from 'vitest';
import { createDataset } from '@core/dataset';
import { COUNT_FIELD, useChartBuilderStore } from './ChartBuilderStore';
import { useDatasetStore } from './DatasetStore';
import { useSnippetStore } from './SnippetStore';
const cb = () => useChartBuilderStore.getState();
const T = new Date('2026-06-01T00:00:00Z');
/** Seed a dataset directly into the store and return its id. */
function seedDataset(name: string, data: unknown): number {
const ds = createDataset({ name, data, format: 'json', source: 'inline', now: T });
useDatasetStore.getState().add(ds);
// `add` is the id authority and reassigns a collision-free id, so read it back
// rather than trusting the pre-add createDataset default.
return useDatasetStore.getState().datasets[0].id;
}
beforeEach(() => {
cb().reset();
useDatasetStore.getState().reset();
useSnippetStore.getState().reset();
});
describe('init', () => {
test('pre-populates a smart default config from the dataset columns', () => {
const id = seedDataset('Traffic', [
{ day: '2026-01-01', visits: 10 },
{ day: '2026-01-02', visits: 20 },
]);
cb().init(id);
expect(cb().datasetId).toBe(id);
expect(cb().config.datasetName).toBe('Traffic');
// date × number → a Line time series, first col on X, second on Y.
expect(cb().config.mark).toBe('line');
expect(cb().config.encodings.x).toEqual({ field: 'day', type: 'temporal' });
expect(cb().config.encodings.y).toEqual({ field: 'visits', type: 'quantitative' });
});
test('lands empty when no dataset is found', () => {
cb().init(999);
expect(cb().datasetId).toBeNull();
expect(cb().config.encodings).toEqual({});
});
});
describe('channel editing', () => {
test('mapping a column seeds a channel-appropriate type (Size stays a measure)', () => {
const id = seedDataset('Mixed', [{ name: 'A', value: 5 }]);
cb().init(id);
// A numeric column on Size → Quantitative (allowed); a category cannot be chosen
// for Size in the UI, and the store keeps the type valid for the channel.
cb().setChannelColumn('size', 'value');
expect(cb().config.encodings.size).toEqual({ field: 'value', type: 'quantitative' });
});
test('swapXY flips the two axis mappings', () => {
const id = seedDataset('XY', [{ a: 'x', b: 1 }]);
cb().init(id);
const x0 = cb().config.encodings.x;
const y0 = cb().config.encodings.y;
cb().swapXY();
expect(cb().config.encodings.x).toEqual(y0);
expect(cb().config.encodings.y).toEqual(x0);
});
test('clearing a channel to None removes it from the config', () => {
const id = seedDataset('XY', [{ a: 'x', b: 1 }]);
cb().init(id);
cb().setChannelColumn('x', null);
expect(cb().config.encodings.x).toBeNull();
});
});
describe('transforms — aggregate / bin / timeUnit / count', () => {
test('the Count-of-records option maps a field-less count measure', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().setChannelColumn('y', COUNT_FIELD);
expect(cb().config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
});
test('aggregate and bin are mutually exclusive on a channel', () => {
const id = seedDataset('S', [{ revenue: 5 }]);
cb().init(id);
cb().setChannelColumn('y', 'revenue');
cb().setChannelAggregate('y', 'sum');
expect(cb().config.encodings.y).toMatchObject({ field: 'revenue', aggregate: 'sum' });
cb().setChannelBin('y', true);
expect(cb().config.encodings.y?.aggregate).toBeUndefined();
expect(cb().config.encodings.y?.bin).toBe(true);
cb().setChannelAggregate('y', 'mean');
expect(cb().config.encodings.y?.bin).toBeUndefined();
expect(cb().config.encodings.y?.aggregate).toBe('mean');
});
test('changing field type drops transforms that no longer apply', () => {
const id = seedDataset('S', [{ price: 5 }]);
cb().init(id);
cb().setChannelColumn('x', 'price');
cb().setChannelBin('x', true);
cb().setChannelType('x', 'nominal'); // leaving quantitative
expect(cb().config.encodings.x).toEqual({ field: 'price', type: 'nominal' });
});
test('setChannelTimeUnit sets and clears granularity', () => {
const id = seedDataset('S', [{ day: '2026-01-01', v: 1 }]);
cb().init(id);
cb().setChannelTimeUnit('x', 'yearmonth');
expect(cb().config.encodings.x?.timeUnit).toBe('yearmonth');
cb().setChannelTimeUnit('x', undefined);
expect(cb().config.encodings.x?.timeUnit).toBeUndefined();
});
});
describe('sort / stack', () => {
test('setSort and setStack set and clear the chart-level fields', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().setSort('descending');
expect(cb().config.sort).toBe('descending');
cb().setSort(undefined);
expect(cb().config.sort).toBeUndefined();
cb().setStack('normalize');
expect(cb().config.stack).toBe('normalize');
});
});
describe('applyWarningFix', () => {
test('applies a hint fix to the working config (actionable hints, §06)', () => {
const id = seedDataset('Nums', [
{ a: 1, b: 2 },
{ a: 3, b: 4 },
]);
cb().init(id);
cb().setMark('bar'); // two quantitative axes on a bar → "scatter" hint with a fix
cb().applyWarningFix({ label: 'Switch to Point', apply: (c) => ({ ...c, mark: 'point' }) });
expect(cb().config.mark).toBe('point');
});
});
describe('filters (1C)', () => {
test('addFilter seeds a predicate on the first column with its derived type', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().addFilter();
const f = cb().config.filters?.[0];
expect(f).toMatchObject({
mode: 'predicate',
field: 'region',
fieldType: 'nominal',
op: 'equal',
});
});
test('setFilterField re-derives the field type and clamps an out-of-range operator', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().addFilter();
const fid = cb().config.filters![0].id;
// Point at the measure, choose an ordering op, then point back at the category:
cb().setFilterField(fid, 'revenue');
cb().updateFilter(fid, { op: 'gt', value: '3' });
expect(cb().config.filters![0]).toMatchObject({ fieldType: 'quantitative', op: 'gt' });
cb().setFilterField(fid, 'region'); // gt is invalid on a category → resets to equal
expect(cb().config.filters![0]).toMatchObject({
field: 'region',
fieldType: 'nominal',
op: 'equal',
});
});
test('removeFilter drops the row', () => {
const id = seedDataset('S', [{ region: 'N' }]);
cb().init(id);
cb().addFilter();
cb().addFilter();
const first = cb().config.filters![0].id;
cb().removeFilter(first);
expect(cb().config.filters).toHaveLength(1);
expect(cb().config.filters![0].id).not.toBe(first);
});
test('setFilterMode toggles to expression and back, seeding a field on return', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().addFilter();
const fid = cb().config.filters![0].id;
cb().setFilterMode(fid, 'expression');
expect(cb().config.filters![0].mode).toBe('expression');
cb().setFilterMode(fid, 'predicate');
expect(cb().config.filters![0]).toMatchObject({ mode: 'predicate', field: 'region' });
});
});
describe('calculated fields (1C)', () => {
test('a calculated field becomes selectable on a channel, defaulting to quantitative', () => {
const id = seedDataset('S', [{ a: 1, b: 2 }]);
cb().init(id);
cb().addCalculate();
const cid = cb().config.calculates![0].id;
cb().updateCalculate(cid, { as: 'total', expr: 'datum.a + datum.b' });
cb().setChannelColumn('y', 'total');
expect(cb().config.encodings.y).toEqual({ field: 'total', type: 'quantitative' });
});
test('removing a calculated field clears any channel that referenced it', () => {
const id = seedDataset('S', [{ a: 1, b: 2 }]);
cb().init(id);
cb().addCalculate();
const cid = cb().config.calculates![0].id;
cb().updateCalculate(cid, { as: 'total', expr: 'datum.a + datum.b' });
cb().setChannelColumn('y', 'total');
cb().removeCalculate(cid);
expect(cb().config.calculates).toHaveLength(0);
expect(cb().config.encodings.y).toBeNull(); // pruned — its field no longer exists
});
test('renaming a referenced calculated field prunes the now-dangling channel', () => {
const id = seedDataset('S', [{ a: 1 }]);
cb().init(id);
cb().addCalculate();
const cid = cb().config.calculates![0].id;
cb().updateCalculate(cid, { as: 'doubled', expr: 'datum.a * 2' });
cb().setChannelColumn('x', 'doubled');
cb().updateCalculate(cid, { as: 'tripled' }); // the channel still points at "doubled"
expect(cb().config.encodings.x).toBeNull();
});
});
describe('createSnippet', () => {
test('builds a linked snippet, activates it, and resets the builder', () => {
const id = seedDataset('Sales', [
{ region: 'N', revenue: 100 },
{ region: 'S', revenue: 80 },
]);
cb().init(id);
expect(cb().createSnippet(T)).toBe(true);
const snippets = useSnippetStore.getState().snippets;
expect(snippets).toHaveLength(1);
const made = snippets[0];
// Linked to its dataset by name (§09F), and the spec references it.
expect(made.datasetRefs).toEqual(['Sales']);
expect(made.spec).toContain('"name": "Sales"');
expect(made.meta.createdWith).toBe('chart-builder');
expect(useSnippetStore.getState().activeSnippetId).toBe(made.id);
// Builder state is reset for a fresh next open.
expect(cb().datasetId).toBeNull();
});
test('refuses to create when no channel is mapped', () => {
const id = seedDataset('Empty', [{ a: 1 }]);
cb().init(id);
cb().setChannelColumn('x', null);
cb().setChannelColumn('y', null);
expect(cb().createSnippet(T)).toBe(false);
expect(useSnippetStore.getState().snippets).toHaveLength(0);
});
});
describe('field-first assignment (assignField / focusChannel)', () => {
test('a clicked field lands on the first empty channel that accepts it', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5, qty: 2 }]);
cb().init(id); // region→X, count→Y (smart default for one category + measures)
cb().setChannelColumn('x', null);
cb().setChannelColumn('y', null);
cb().assignField('region'); // first empty channel is X
expect(cb().config.encodings.x).toEqual({ field: 'region', type: 'nominal' });
cb().assignField('sales'); // X taken → Y
expect(cb().config.encodings.y).toEqual({ field: 'sales', type: 'quantitative' });
});
test('the armed channel takes the field, then disarms', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().focusChannel('color');
expect(cb().activeChannel).toBe('color');
cb().assignField('region');
expect(cb().config.encodings.color).toEqual({ field: 'region', type: 'nominal' });
expect(cb().activeChannel).toBeNull(); // armed slot cleared after assignment
});
test('skips a channel that cannot take the column (Size needs a measure)', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().focusChannel('size');
cb().assignField('region'); // a category can't go on Size → falls through to an empty axis
expect(cb().config.encodings.size ?? null).toBeNull();
// It landed on the first axis that accepts it instead of being dropped.
const onAxis =
cb().config.encodings.x?.field === 'region' || cb().config.encodings.y?.field === 'region';
expect(onAxis).toBe(true);
});
test('Count of records assigns as a field-less count measure', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().setChannelColumn('x', null);
cb().setChannelColumn('y', null);
cb().focusChannel('y');
cb().assignField(COUNT_FIELD);
expect(cb().config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
});
});
describe('constant values (setChannelConstant — the Property model)', () => {
test('sets a fixed colour on Color, emitted as a constant', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().setChannelConstant('color', '#c0392b');
expect(cb().config.encodings.color).toMatchObject({ value: '#c0392b' });
});
test('coerces a Size constant to a number', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().setChannelConstant('size', '120');
expect(cb().config.encodings.size?.value).toBe(120);
});
test('preserves the prior field type so toggling back to a field restores it', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
cb().setChannelColumn('color', 'region'); // nominal field
cb().setChannelConstant('color', '#000000');
expect(cb().config.encodings.color).toEqual({ value: '#000000', type: 'nominal' });
});
test('refuses a constant on a positional channel (X/Y stay field-only)', () => {
const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]);
cb().init(id);
const before = cb().config.encodings.x;
cb().setChannelConstant('x', '50');
expect(cb().config.encodings.x).toBe(before); // unchanged — no-op
});
});