Theme Builder: Layout/Axes/Legend/Type panels, scheme-render fix, fail-loud gallery

This commit is contained in:
2026-06-14 13:08:29 +03:00
parent b03c3b08ab
commit 91b8b1e7fe
19 changed files with 1381 additions and 135 deletions
@@ -0,0 +1,173 @@
/**
* Layout / Axes & grid / Legend / Type panels — behavioural wiring through the
* live modal. The pure config transforms (path get/set, coercion) are covered in
* core/theme-controls.test.ts; these confirm each panel reads the draft config
* and writes the right path back through `mutateDraftConfig`, including the
* minimal-diff delete (clearing a value removes the key). 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`, then switch to a structured-control tab. */
const open = (config: JsonObject, tabLabel: string) => {
act(() => {
useCustomThemeStore.getState().createTheme('Brand', config);
});
const tab = [...container.querySelectorAll('button')].find(
(b) => b.getAttribute('role') === 'tab' && b.textContent === tabLabel,
)!;
act(() => tab.click());
};
const config = () => useCustomThemeStore.getState().draftConfig as JsonObject;
const at = (...path: string[]): unknown =>
path.reduce<unknown>(
(cur, key) =>
cur && typeof cur === 'object' ? (cur as Record<string, unknown>)[key] : undefined,
config(),
);
/** Drive an input's value through the native setter so React's tracker fires onChange. */
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 }));
}
const numberInput = (name: string) =>
container.querySelector<HTMLInputElement>(`input[aria-label="${name}"]`)!;
/** 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);
/** Open the SelectControl whose accessible name starts with `prefix`, then pick `option`. */
const pick = (prefix: string, option: string) => {
const trigger = [...container.querySelectorAll('button')].find((b) =>
b.getAttribute('aria-label')?.startsWith(prefix),
)!;
act(() => trigger.click());
act(() => button(option)!.click());
};
describe('LayoutControls', () => {
test('background mode "Transparent" writes background: transparent', () => {
render();
open({}, 'Layout');
pick('Background', 'Transparent');
expect(config().background).toBe('transparent');
});
test('corner radius writes view.cornerRadius and clearing prunes the view object', () => {
render();
open({}, 'Layout');
act(() => setNativeValue(numberInput('Corner radius'), '8'));
expect(at('view', 'cornerRadius')).toBe(8);
act(() => setNativeValue(numberInput('Corner radius'), ''));
expect(config().view).toBeUndefined();
});
test('object padding shows the JSON hint instead of a number control', () => {
render();
open({ padding: { left: 5, top: 5 } }, 'Layout');
expect(container.textContent).toContain('edit it in the JSON below');
expect(container.querySelector('input[aria-label="Padding"]')).toBeNull();
});
});
describe('AxesControls', () => {
test('grid lines "Hidden" writes axis.grid false', () => {
render();
open({}, 'Axes & grid');
pick('Grid lines', 'Hidden');
expect(at('axis', 'grid')).toBe(false);
});
test('grid style "Dotted" writes a dash array; "Theme default" removes it', () => {
render();
open({}, 'Axes & grid');
pick('Grid style', 'Dotted');
expect(at('axis', 'gridDash')).toEqual([2, 2]);
pick('Grid style', 'Theme default');
expect(at('axis', 'gridDash')).toBeUndefined();
});
test('a negative label angle is accepted', () => {
render();
open({}, 'Axes & grid');
act(() => setNativeValue(numberInput('Label angle'), '-45'));
expect(at('axis', 'labelAngle')).toBe(-45);
});
});
describe('LegendControls', () => {
test('position writes legend.orient', () => {
render();
open({}, 'Legend');
pick('Position', 'Bottom');
expect(at('legend', 'orient')).toBe('bottom');
});
test('symbol size writes legend.symbolSize', () => {
render();
open({}, 'Legend');
act(() => setNativeValue(numberInput('Symbol size'), '120'));
expect(at('legend', 'symbolSize')).toBe(120);
});
});
describe('TypeControls', () => {
test('title weight "Bold" writes title.fontWeight 700', () => {
render();
open({}, 'Type');
pick('Title weight', 'Bold');
expect(at('title', 'fontWeight')).toBe(700);
});
test('axis label size writes axis.labelFontSize', () => {
render();
open({}, 'Type');
act(() => setNativeValue(numberInput('Axis label size'), '9'));
expect(at('axis', 'labelFontSize')).toBe(9);
});
});