mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Theme Builder: structured color/type controls, scheme catalog, diverging preview
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Color panel — behavioural wiring through the live modal. The pure config
|
||||
* transforms are covered in core/theme-controls.test.ts; these confirm the
|
||||
* controls read the draft config and write back through `mutateDraftConfig`
|
||||
* (scheme pick, materialize, swatch add/remove, mark color, the JSON gate).
|
||||
* vega-embed is mocked (the gallery is integration-heavy).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { usePopoverStore } from '../stores/PopoverStore';
|
||||
import { ThemeBuilderModal } from './ThemeBuilderModal';
|
||||
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: vi.fn(() =>
|
||||
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
|
||||
),
|
||||
}));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useCustomThemeStore.getState().reset();
|
||||
usePopoverStore.getState().close(); // the open-popover registry is global; isolate tests
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const render = () => act(() => root.render(<ThemeBuilderModal />));
|
||||
|
||||
/** Open a draft seeded with `config` (block body so `act` returns void). */
|
||||
const open = (config: JsonObject) =>
|
||||
act(() => {
|
||||
useCustomThemeStore.getState().createTheme('Brand', config);
|
||||
});
|
||||
|
||||
const draftConfig = () => useCustomThemeStore.getState().draftConfig;
|
||||
const range = () => (draftConfig()?.range ?? {}) as Record<string, unknown>;
|
||||
|
||||
/** Drive an input's value through the native setter so React's value tracker
|
||||
* registers the change and fires onChange (a bare `input.value =` doesn't). */
|
||||
function setNativeValue(el: HTMLInputElement, value: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- invoked immediately via .call
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
setter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/** A button anywhere in the document (popovers portal to <body>) by exact label. */
|
||||
const button = (label: string) =>
|
||||
[...document.querySelectorAll('button')].find((b) => b.textContent === label);
|
||||
|
||||
/** The trigger of a SelectControl by its `label`-derived aria-label prefix. */
|
||||
const picker = (labelPrefix: string) =>
|
||||
[...container.querySelectorAll('button')].find((b) =>
|
||||
b.getAttribute('aria-label')?.startsWith(labelPrefix),
|
||||
);
|
||||
|
||||
describe('ColorControls', () => {
|
||||
test('materialize expands a named scheme into an editable array', () => {
|
||||
render();
|
||||
open({ range: { category: 'tableau10' } });
|
||||
|
||||
expect(range().category).toBe('tableau10');
|
||||
act(() => button('Materialize to edit')!.click());
|
||||
|
||||
expect(Array.isArray(range().category)).toBe(true);
|
||||
expect((range().category as string[]).length).toBe(10);
|
||||
});
|
||||
|
||||
test('add and remove palette colors', () => {
|
||||
render();
|
||||
open({ range: { category: ['#111111', '#222222'] } });
|
||||
|
||||
act(() => button('Add color')!.click());
|
||||
expect((range().category as string[]).length).toBe(3);
|
||||
|
||||
const remove = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Remove Palette color 1"]',
|
||||
)!;
|
||||
act(() => remove.click());
|
||||
expect(range().category).toEqual(['#222222', '#888888']);
|
||||
});
|
||||
|
||||
test('removing the last swatch deletes range.category', () => {
|
||||
render();
|
||||
open({ range: { category: ['#111111'] } });
|
||||
|
||||
const remove = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Remove Palette color 1"]',
|
||||
)!;
|
||||
act(() => remove.click());
|
||||
expect('category' in range()).toBe(false);
|
||||
});
|
||||
|
||||
test('setting a default mark color writes mark.color; clear removes it', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Default mark color"]',
|
||||
)!;
|
||||
act(() => setNativeValue(input, '#ff0000'));
|
||||
expect((draftConfig()?.mark as Record<string, unknown>).color).toBe('#ff0000');
|
||||
|
||||
act(() => button('Clear')!.click());
|
||||
expect(draftConfig()?.mark).toBeUndefined();
|
||||
});
|
||||
|
||||
test('typing in a swatch hex field updates that color', () => {
|
||||
render();
|
||||
open({ range: { category: ['#111111', '#222222'] } });
|
||||
|
||||
const hex = container.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Palette color 1 hex value"]',
|
||||
)!;
|
||||
act(() => setNativeValue(hex, '#abcdef'));
|
||||
expect((range().category as string[])[0]).toBe('#abcdef');
|
||||
});
|
||||
|
||||
test('a sequential scheme materializes to editable stops written to heatmap and ramp', () => {
|
||||
render();
|
||||
// Seed the other families as arrays so the only "Materialize" button is the
|
||||
// sequential one (categorical/diverging show "Add color"/"Add stop" instead).
|
||||
open({
|
||||
range: {
|
||||
category: ['#111111'],
|
||||
heatmap: 'viridis',
|
||||
ramp: 'viridis',
|
||||
diverging: ['#aa0000', '#0000aa'],
|
||||
},
|
||||
});
|
||||
|
||||
act(() => button('Materialize to edit')!.click());
|
||||
expect(Array.isArray(range().heatmap)).toBe(true);
|
||||
expect(range().ramp).toEqual(range().heatmap);
|
||||
|
||||
const remove = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Remove Sequential stop 1"]',
|
||||
)!;
|
||||
const before = (range().heatmap as string[]).length;
|
||||
act(() => remove.click());
|
||||
expect((range().heatmap as string[]).length).toBe(before - 1);
|
||||
expect(range().ramp).toEqual(range().heatmap);
|
||||
});
|
||||
|
||||
test('scheme dropdown options carry a color preview', () => {
|
||||
render();
|
||||
open({});
|
||||
act(() => picker('Categorical color scheme')!.click());
|
||||
|
||||
const tableau = button('Tableau 10')!;
|
||||
// The decorative swatch strip renders as spans inside the option button.
|
||||
expect(tableau.querySelectorAll('span span').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('picking a scheme writes range.category as a string', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
act(() => picker('Categorical color scheme')!.click());
|
||||
act(() => button('Category 10')!.click());
|
||||
|
||||
expect(range().category).toBe('category10');
|
||||
});
|
||||
|
||||
test('a sequential pick sets both heatmap and ramp', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
act(() => picker('Sequential color scheme')!.click());
|
||||
act(() => button('Viridis')!.click());
|
||||
|
||||
expect(range().heatmap).toBe('viridis');
|
||||
expect(range().ramp).toBe('viridis');
|
||||
});
|
||||
|
||||
test('invalid JSON disables the controls', () => {
|
||||
render();
|
||||
open({});
|
||||
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
|
||||
|
||||
expect(container.textContent).toContain('Fix the JSON below to use these controls');
|
||||
expect(button('Materialize to edit')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the Type tab exposes the font-apply control', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
const typeTab = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
|
||||
)!;
|
||||
act(() => typeTab.click());
|
||||
expect(picker('Font family')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('font options render their name in their own family', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
const typeTab = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
|
||||
)!;
|
||||
act(() => typeTab.click());
|
||||
act(() => picker('Font family')!.click());
|
||||
|
||||
const georgia = button('Georgia')!;
|
||||
expect(georgia.querySelector<HTMLElement>('span')!.style.fontFamily).toContain('Georgia');
|
||||
});
|
||||
|
||||
test('the raw JSON is collapsed by default and toggles open', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
expect(container.querySelector('#theme-config')).toBeNull();
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-controls="theme-config"]',
|
||||
)!;
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
act(() => toggle.click());
|
||||
expect(container.querySelector('#theme-config')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a parse error forces the JSON open', () => {
|
||||
render();
|
||||
open({});
|
||||
expect(container.querySelector('#theme-config')).toBeNull();
|
||||
|
||||
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
|
||||
expect(container.querySelector('#theme-config')).toBeTruthy();
|
||||
expect(
|
||||
container
|
||||
.querySelector('button[aria-controls="theme-config"]')!
|
||||
.getAttribute('aria-expanded'),
|
||||
).toBe('true');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user