mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Theme Builder: Layout/Axes/Legend/Type panels, scheme-render fix, fail-loud gallery
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user