Files
astrolabe/src/core/custom-theme.test.ts
T

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);
}
}
}
});
});