mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
import {
|
|
DatasetNotFoundError,
|
|
escapeVegaField,
|
|
prepareSpecForRender,
|
|
type ResolvableDataset,
|
|
} from './rendering';
|
|
|
|
describe('prepareSpecForRender', () => {
|
|
test('returns a deep copy, never the same reference', () => {
|
|
const spec = { mark: 'bar', encoding: { x: { field: 'a' } } };
|
|
const out = prepareSpecForRender(spec);
|
|
expect(out).not.toBe(spec);
|
|
expect(out.encoding).not.toBe(spec.encoding);
|
|
expect(out).toEqual(spec);
|
|
});
|
|
|
|
test('never mutates the input spec (copy-not-mutate invariant)', () => {
|
|
const spec = {
|
|
data: { values: [{ a: 1 }] },
|
|
mark: 'bar',
|
|
width: 200,
|
|
height: 100,
|
|
encoding: { x: { field: 'a', type: 'quantitative' } },
|
|
};
|
|
const before = structuredClone(spec);
|
|
// A mode that both sets and removes sizing — the most invasive transform.
|
|
prepareSpecForRender(spec, { fitMode: 'width' });
|
|
expect(spec).toEqual(before);
|
|
});
|
|
|
|
test('Original (default) leaves sizing untouched', () => {
|
|
const spec = { $schema: 'x', mark: 'line', width: 200, height: 100 };
|
|
expect(prepareSpecForRender(spec)).toEqual(spec);
|
|
expect(prepareSpecForRender(spec, { fitMode: 'default' })).toEqual(spec);
|
|
});
|
|
});
|
|
|
|
describe('prepareSpecForRender — fit modes (spec §04 Rendering Contract step 2)', () => {
|
|
const base = { mark: 'bar', width: 200, height: 100 };
|
|
|
|
test('Width: width→container, height removed', () => {
|
|
const out = prepareSpecForRender(base, { fitMode: 'width' }) as Record<string, unknown>;
|
|
expect(out.width).toBe('container');
|
|
expect('height' in out).toBe(false);
|
|
});
|
|
|
|
test('Height: height→container, width removed', () => {
|
|
const out = prepareSpecForRender(base, { fitMode: 'height' }) as Record<string, unknown>;
|
|
expect(out.height).toBe('container');
|
|
expect('width' in out).toBe(false);
|
|
});
|
|
|
|
test('Full: both dimensions→container', () => {
|
|
const out = prepareSpecForRender(base, { fitMode: 'full' }) as Record<string, unknown>;
|
|
expect(out.width).toBe('container');
|
|
expect(out.height).toBe('container');
|
|
});
|
|
|
|
test('adds container sizing even when the spec declares no width/height', () => {
|
|
const out = prepareSpecForRender({ mark: 'point' }, { fitMode: 'full' }) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
expect(out).toEqual({ mark: 'point', width: 'container', height: 'container' });
|
|
});
|
|
|
|
test('recurses into layered sub-specs', () => {
|
|
const spec = {
|
|
layer: [
|
|
{ mark: 'bar', width: 50, height: 50 },
|
|
{ mark: 'line', height: 50 },
|
|
],
|
|
};
|
|
const out = prepareSpecForRender(spec, { fitMode: 'full' }) as unknown as {
|
|
width: string;
|
|
height: string;
|
|
layer: Array<Record<string, unknown>>;
|
|
};
|
|
expect(out.width).toBe('container');
|
|
expect(out.height).toBe('container');
|
|
expect(out.layer[0]).toMatchObject({ width: 'container', height: 'container' });
|
|
expect(out.layer[1]).toMatchObject({ width: 'container', height: 'container' });
|
|
});
|
|
|
|
test('recurses into concat arrays and a child spec (facet/repeat)', () => {
|
|
const spec = {
|
|
facet: { field: 'g', type: 'nominal' },
|
|
spec: {
|
|
hconcat: [{ mark: 'bar', height: 80 }, { mark: 'point' }],
|
|
},
|
|
};
|
|
const out = prepareSpecForRender(spec, { fitMode: 'width' }) as unknown as {
|
|
spec: { hconcat: Array<Record<string, unknown>> };
|
|
};
|
|
for (const child of out.spec.hconcat) {
|
|
expect(child.width).toBe('container');
|
|
expect('height' in child).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('prepareSpecForRender — dataset resolution (spec §04 Rendering Contract step 1)', () => {
|
|
const datasets: ResolvableDataset[] = [
|
|
{ name: 'JsonDs', data: [{ a: 1 }], format: 'json', source: 'inline' },
|
|
{ name: 'CsvDs', data: 'a,b\n1,2', format: 'csv', source: 'inline' },
|
|
{ name: 'TsvDs', data: 'a\tb\n1\t2', format: 'tsv', source: 'inline' },
|
|
{
|
|
name: 'TopoDs',
|
|
data: { type: 'Topology', objects: {} },
|
|
format: 'topojson',
|
|
source: 'inline',
|
|
},
|
|
// A fetched URL snapshot carries its payload in `data` (like inline); an
|
|
// unfetched reference has `data: null` and only its `url`.
|
|
{
|
|
name: 'UrlFetchedDs',
|
|
data: 'a,b\n1,2',
|
|
url: 'https://x/y.csv',
|
|
format: 'csv',
|
|
source: 'url',
|
|
},
|
|
{ name: 'UrlUnfetchedDs', data: null, url: 'https://x/y.csv', format: 'csv', source: 'url' },
|
|
];
|
|
|
|
test('inline JSON → values inlined', () => {
|
|
const out = prepareSpecForRender({ data: { name: 'JsonDs' }, mark: 'bar' }, { datasets });
|
|
expect(out.data).toEqual({ values: [{ a: 1 }] });
|
|
});
|
|
|
|
test('inline CSV → raw text inlined, tagged csv', () => {
|
|
const out = prepareSpecForRender({ data: { name: 'CsvDs' } }, { datasets });
|
|
expect(out.data).toEqual({ values: 'a,b\n1,2', format: { type: 'csv' } });
|
|
});
|
|
|
|
test('inline TSV → raw text inlined, tagged tsv', () => {
|
|
const out = prepareSpecForRender({ data: { name: 'TsvDs' } }, { datasets });
|
|
expect(out.data).toEqual({ values: 'a\tb\n1\t2', format: { type: 'tsv' } });
|
|
});
|
|
|
|
test('inline TopoJSON → value inlined, tagged topojson (preserving feature)', () => {
|
|
const out = prepareSpecForRender(
|
|
{ data: { name: 'TopoDs', format: { feature: 'counties' } } },
|
|
{ datasets },
|
|
);
|
|
expect(out.data).toEqual({
|
|
values: { type: 'Topology', objects: {} },
|
|
format: { feature: 'counties', type: 'topojson' },
|
|
});
|
|
});
|
|
|
|
test('fetched URL snapshot → its payload inlined, tagged with the format (like inline)', () => {
|
|
const out = prepareSpecForRender({ data: { name: 'UrlFetchedDs' } }, { datasets });
|
|
expect(out.data).toEqual({ values: 'a,b\n1,2', format: { type: 'csv' } });
|
|
});
|
|
|
|
test('unfetched URL reference → live url fallback tagged with the format', () => {
|
|
const out = prepareSpecForRender({ data: { name: 'UrlUnfetchedDs' } }, { datasets });
|
|
expect(out.data).toEqual({ url: 'https://x/y.csv', format: { type: 'csv' } });
|
|
});
|
|
|
|
test('resolves references in nested layer / concat / child sub-specs', () => {
|
|
const spec = {
|
|
layer: [{ data: { name: 'JsonDs' } }],
|
|
spec: { hconcat: [{ data: { name: 'CsvDs' } }] },
|
|
};
|
|
const out = prepareSpecForRender(spec, { datasets }) as unknown as {
|
|
layer: Array<{ data: unknown }>;
|
|
spec: { hconcat: Array<{ data: unknown }> };
|
|
};
|
|
expect(out.layer[0].data).toEqual({ values: [{ a: 1 }] });
|
|
expect(out.spec.hconcat[0].data).toEqual({ values: 'a,b\n1,2', format: { type: 'csv' } });
|
|
});
|
|
|
|
test('matches dataset names case-insensitively', () => {
|
|
const out = prepareSpecForRender({ data: { name: 'jsonds' } }, { datasets });
|
|
expect(out.data).toEqual({ values: [{ a: 1 }] });
|
|
});
|
|
|
|
test('throws DatasetNotFoundError naming the missing dataset', () => {
|
|
expect(() => prepareSpecForRender({ data: { name: 'Missing' } }, { datasets })).toThrow(
|
|
DatasetNotFoundError,
|
|
);
|
|
expect(() => prepareSpecForRender({ data: { name: 'Missing' } }, { datasets })).toThrow(
|
|
/Missing/,
|
|
);
|
|
});
|
|
|
|
test('a library reference with no datasets provided is still not-found', () => {
|
|
expect(() => prepareSpecForRender({ data: { name: 'Anything' } })).toThrow(
|
|
DatasetNotFoundError,
|
|
);
|
|
});
|
|
|
|
test('a spec with no named refs passes through untouched even with no datasets', () => {
|
|
const spec = { data: { values: [{ a: 1 }] }, mark: 'bar' };
|
|
expect(prepareSpecForRender(spec)).toEqual(spec);
|
|
});
|
|
|
|
test('a self-defined top-level datasets name is left untouched and does not throw', () => {
|
|
const spec = { datasets: { local: [{ a: 1 }] }, data: { name: 'local' }, mark: 'bar' };
|
|
const out = prepareSpecForRender(spec, { datasets });
|
|
expect(out.data).toEqual({ name: 'local' });
|
|
});
|
|
|
|
test('native Vega-Lite data with a name label is left untouched (not resolved, never throws)', () => {
|
|
// A name on inline/url data, or a generator, is native Vega-Lite — even when
|
|
// the name is absent from the library. Resolution must not touch or reject it.
|
|
const inline = { data: { name: 'pts', values: [{ a: 1 }] }, mark: 'point' };
|
|
expect(prepareSpecForRender(inline, { datasets }).data).toEqual({
|
|
name: 'pts',
|
|
values: [{ a: 1 }],
|
|
});
|
|
const url = { data: { name: 'remote', url: 'https://x/y.csv' } };
|
|
expect(prepareSpecForRender(url, { datasets }).data).toEqual({
|
|
name: 'remote',
|
|
url: 'https://x/y.csv',
|
|
});
|
|
const gen = { data: { name: 'seq', sequence: { start: 0, stop: 5 } } };
|
|
expect(prepareSpecForRender(gen, { datasets }).data).toEqual({
|
|
name: 'seq',
|
|
sequence: { start: 0, stop: 5 },
|
|
});
|
|
});
|
|
|
|
test('resolution runs before fit-mode: a ref + fit mode produce both transforms', () => {
|
|
const spec = { data: { name: 'JsonDs' }, mark: 'bar' };
|
|
const out = prepareSpecForRender(spec, { datasets, fitMode: 'full' }) as unknown as {
|
|
data: unknown;
|
|
width: string;
|
|
height: string;
|
|
};
|
|
expect(out.data).toEqual({ values: [{ a: 1 }] });
|
|
expect(out.width).toBe('container');
|
|
expect(out.height).toBe('container');
|
|
});
|
|
|
|
test('copy-not-mutate: the input spec is untouched during resolution', () => {
|
|
const spec = { data: { name: 'JsonDs' }, mark: 'bar' };
|
|
const before = structuredClone(spec);
|
|
prepareSpecForRender(spec, { datasets });
|
|
expect(spec).toEqual(before);
|
|
});
|
|
|
|
test('preserves pre-existing reference keys other than name', () => {
|
|
const out = prepareSpecForRender({ data: { name: 'JsonDs', foo: 1 } }, { datasets }) as {
|
|
data: Record<string, unknown>;
|
|
};
|
|
expect(out.data).toEqual({ values: [{ a: 1 }], foo: 1 });
|
|
});
|
|
|
|
test('a "data" field buried in inline rows is not resolved and does not throw', () => {
|
|
// The inline rows carry a column named `data` whose value looks like a
|
|
// reference object. It is payload; resolution must not descend into it.
|
|
const spec = { data: { values: [{ data: { name: 'Missing' } }] }, mark: 'bar' };
|
|
const out = prepareSpecForRender(spec, { datasets }) as { data: { values: unknown[] } };
|
|
expect(out.data.values).toEqual([{ data: { name: 'Missing' } }]);
|
|
});
|
|
|
|
test('resolves a reference inside a lookup transform (from.data)', () => {
|
|
const spec = {
|
|
data: { name: 'JsonDs' },
|
|
transform: [{ lookup: 'id', from: { data: { name: 'CsvDs' }, key: 'id', fields: ['x'] } }],
|
|
};
|
|
const out = prepareSpecForRender(spec, { datasets }) as unknown as {
|
|
transform: Array<{ from: { data: unknown } }>;
|
|
};
|
|
expect(out.transform[0].from.data).toEqual({ values: 'a,b\n1,2', format: { type: 'csv' } });
|
|
});
|
|
});
|
|
|
|
describe('escapeVegaField', () => {
|
|
test('escapes dots and brackets that VL treats as accessors', () => {
|
|
expect(escapeVegaField('user.age')).toBe('user\\.age');
|
|
expect(escapeVegaField('a[0]')).toBe('a\\[0\\]');
|
|
});
|
|
|
|
test('leaves plain field names untouched', () => {
|
|
expect(escapeVegaField('category')).toBe('category');
|
|
});
|
|
});
|