mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
189 lines
7.0 KiB
TypeScript
189 lines
7.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
CURRENT_THEME_VERSION,
|
|
THEME_FONT_OPTIONS,
|
|
applyFontToConfig,
|
|
collectFontFamilies,
|
|
createCustomTheme,
|
|
} from './custom-theme';
|
|
import { THEME_PREVIEW_SPECS } from './theme-preview-specs';
|
|
|
|
describe('createCustomTheme', () => {
|
|
it('stamps version, timestamps, and carries the config through', () => {
|
|
const now = new Date('2026-06-12T10:00:00Z');
|
|
const theme = createCustomTheme({ name: 'Brand', config: { font: 'Georgia' }, now });
|
|
expect(theme.version).toBe(CURRENT_THEME_VERSION);
|
|
expect(theme.name).toBe('Brand');
|
|
expect(theme.config).toEqual({ font: 'Georgia' });
|
|
expect(theme.created).toBe(now.toISOString());
|
|
expect(theme.modified).toBe(now.toISOString());
|
|
});
|
|
});
|
|
|
|
describe('applyFontToConfig', () => {
|
|
it('sets the top-level font on an empty config', () => {
|
|
expect(applyFontToConfig({}, 'Georgia, serif')).toEqual({ font: 'Georgia, serif' });
|
|
});
|
|
|
|
it('rewrites every explicit font slot at any depth', () => {
|
|
const config = {
|
|
font: 'Helvetica',
|
|
title: { font: 'Helvetica', subtitleFont: 'Helvetica', fontSize: 16 },
|
|
axis: { labelFont: 'Helvetica', titleFont: 'Helvetica', labelFontSize: 11 },
|
|
axisX: { labelFont: 'Helvetica' },
|
|
legend: { labelFont: 'Helvetica', titleFont: 'Helvetica' },
|
|
header: { labelFont: 'Helvetica', titleFont: 'Helvetica' },
|
|
text: { font: 'Helvetica' },
|
|
};
|
|
const out = applyFontToConfig(config, 'Georgia');
|
|
expect(out).toEqual({
|
|
font: 'Georgia',
|
|
title: { font: 'Georgia', subtitleFont: 'Georgia', fontSize: 16 },
|
|
axis: { labelFont: 'Georgia', titleFont: 'Georgia', labelFontSize: 11 },
|
|
axisX: { labelFont: 'Georgia' },
|
|
legend: { labelFont: 'Georgia', titleFont: 'Georgia' },
|
|
header: { labelFont: 'Georgia', titleFont: 'Georgia' },
|
|
text: { font: 'Georgia' },
|
|
});
|
|
});
|
|
|
|
it('leaves non-font properties (including fontSize/fontWeight) untouched', () => {
|
|
const config = {
|
|
background: '#fff',
|
|
title: { fontSize: 16, fontWeight: 600 },
|
|
range: { category: ['#111', '#222'] },
|
|
};
|
|
expect(applyFontToConfig(config, 'Georgia')).toEqual({ ...config, font: 'Georgia' });
|
|
});
|
|
|
|
it('does not mutate the input', () => {
|
|
const config = { title: { font: 'Helvetica' } };
|
|
applyFontToConfig(config, 'Georgia');
|
|
expect(config).toEqual({ title: { font: 'Helvetica' } });
|
|
});
|
|
});
|
|
|
|
describe('collectFontFamilies', () => {
|
|
it('collects the top-level font and every *Font slot', () => {
|
|
const config = {
|
|
font: 'Inter',
|
|
axis: { labelFont: 'Spectral', titleFont: 'Spectral' },
|
|
legend: { labelFont: 'Caveat' },
|
|
};
|
|
expect(collectFontFamilies(config)).toEqual(new Set(['Inter', 'Spectral', 'Caveat']));
|
|
});
|
|
|
|
it('walks arrays (layered specs)', () => {
|
|
const spec = { layer: [{ mark: { font: 'A' } }, { mark: { font: 'B' } }] };
|
|
expect(collectFontFamilies(spec)).toEqual(new Set(['A', 'B']));
|
|
});
|
|
|
|
it('skips data and datasets (rows are never fonts)', () => {
|
|
const spec = {
|
|
data: { values: [{ font: 'NotAFont', x: 1 }] },
|
|
datasets: { d: [{ titleFont: 'AlsoNot' }] },
|
|
config: { font: 'Real' },
|
|
};
|
|
expect(collectFontFamilies(spec)).toEqual(new Set(['Real']));
|
|
});
|
|
|
|
it('ignores non-string font values', () => {
|
|
expect(collectFontFamilies({ font: 42, fontSize: 11 })).toEqual(new Set());
|
|
});
|
|
|
|
it('returns family stacks verbatim (the render gate loads them as-is)', () => {
|
|
expect(collectFontFamilies({ font: '"Inter", system-ui, sans-serif' })).toEqual(
|
|
new Set(['"Inter", system-ui, sans-serif']),
|
|
);
|
|
});
|
|
|
|
it('finds the font a theme applies (round-trip with applyFontToConfig)', () => {
|
|
const applied = applyFontToConfig({ axis: { labelFont: 'Old' } }, 'New');
|
|
expect(collectFontFamilies(applied)).toEqual(new Set(['New']));
|
|
});
|
|
});
|
|
|
|
describe('THEME_FONT_OPTIONS', () => {
|
|
it('offers distinct, non-empty CSS stacks', () => {
|
|
expect(THEME_FONT_OPTIONS.length).toBeGreaterThanOrEqual(4);
|
|
const values = THEME_FONT_OPTIONS.map((f) => f.value);
|
|
const labels = THEME_FONT_OPTIONS.map((f) => f.label);
|
|
expect(new Set(values).size).toBe(values.length);
|
|
expect(new Set(labels).size).toBe(labels.length);
|
|
expect(values.every((v) => v.trim().length > 0)).toBe(true);
|
|
});
|
|
|
|
it('every self-hosted family (quoted primary) carries a category fallback', () => {
|
|
// A quoted primary that hasn't loaded must degrade to a sensible system font,
|
|
// so each roster stack lists at least one fallback after the primary.
|
|
for (const { value } of THEME_FONT_OPTIONS.filter((o) => o.value.startsWith('"'))) {
|
|
expect(value).toContain(',');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('THEME_PREVIEW_SPECS', () => {
|
|
it('every gallery card is a self-contained inline-data spec', () => {
|
|
expect(THEME_PREVIEW_SPECS.length).toBeGreaterThanOrEqual(6);
|
|
for (const card of THEME_PREVIEW_SPECS) {
|
|
expect(card.id).toBeTruthy();
|
|
expect(card.caption).toBeTruthy();
|
|
const data = card.spec.data as { values?: unknown[] };
|
|
expect(Array.isArray(data.values)).toBe(true);
|
|
expect(data.values!.length).toBeGreaterThan(0);
|
|
expect(card.spec.$schema).toContain('vega-lite');
|
|
}
|
|
});
|
|
|
|
it('card ids are unique', () => {
|
|
const ids = THEME_PREVIEW_SPECS.map((c) => c.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it('exercises every color family the builder controls', () => {
|
|
type ColorDef = { type?: string; scale?: { domainMid?: unknown } };
|
|
const colorOf = (spec: (typeof THEME_PREVIEW_SPECS)[number]['spec']): ColorDef | undefined =>
|
|
(spec.encoding as { color?: ColorDef } | undefined)?.color;
|
|
|
|
const colors = THEME_PREVIEW_SPECS.map((c) => colorOf(c.spec));
|
|
// mark.color (a card with no color encoding), categorical, sequential
|
|
// (quantitative, no midpoint), and diverging (quantitative with a midpoint).
|
|
expect(colors.some((c) => c === undefined)).toBe(true);
|
|
expect(colors.some((c) => c?.type === 'nominal')).toBe(true);
|
|
expect(colors.some((c) => c?.type === 'quantitative' && c.scale?.domainMid === undefined)).toBe(
|
|
true,
|
|
);
|
|
expect(colors.some((c) => c?.scale?.domainMid !== undefined)).toBe(true);
|
|
});
|
|
|
|
it('uses bare marks so config controls are not shadowed in the preview', () => {
|
|
// A visual mark property hard-coded in a card overrides the same key in the
|
|
// injected config, making the matching Marks control a no-op in the gallery.
|
|
// Mark styling belongs in the config (a theme), never inline here.
|
|
const SHADOWING = [
|
|
'size',
|
|
'filled',
|
|
'point',
|
|
'innerRadius',
|
|
'outerRadius',
|
|
'cornerRadius',
|
|
'cornerRadiusEnd',
|
|
'opacity',
|
|
'interpolate',
|
|
'shape',
|
|
'strokeWidth',
|
|
'padAngle',
|
|
'discreteBandSize',
|
|
'continuousBandSize',
|
|
];
|
|
for (const card of THEME_PREVIEW_SPECS) {
|
|
const mark: unknown = card.spec.mark;
|
|
if (mark && typeof mark === 'object') {
|
|
for (const key of SHADOWING) {
|
|
expect(mark, `${card.id} hard-codes mark.${key}`).not.toHaveProperty(key);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|