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