mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
/**
|
|
* 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(''),
|
|
inspectData: () => null,
|
|
onDataChange: () => () => {},
|
|
}),
|
|
),
|
|
}));
|
|
|
|
(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);
|
|
});
|
|
|
|
test('ticks "Hidden" writes axis.ticks false', () => {
|
|
render();
|
|
open({}, 'Axes & grid');
|
|
pick('Ticks', 'Hidden');
|
|
expect(at('axis', 'ticks')).toBe(false);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test('direction "Horizontal" writes legend.direction', () => {
|
|
render();
|
|
open({}, 'Legend');
|
|
pick('Direction', 'Horizontal');
|
|
expect(at('legend', 'direction')).toBe('horizontal');
|
|
});
|
|
});
|
|
|
|
describe('accordion panels', () => {
|
|
// Every panel groups its controls into the single-expand accordion. Verify the
|
|
// structure on the Color panel: each section is a collapsible header, the first
|
|
// is open, the rest collapsed.
|
|
const headers = () =>
|
|
[...container.querySelectorAll('button')].filter((b) =>
|
|
b.hasAttribute('data-accordion-header'),
|
|
);
|
|
|
|
test('a converted panel renders collapsible sections, first open', () => {
|
|
render();
|
|
open({}, 'Color');
|
|
const hs = headers();
|
|
expect(hs).toHaveLength(4); // categorical / mark color / sequential / diverging
|
|
expect(hs[0].textContent).toContain('Categorical palette');
|
|
expect(hs[3].textContent).toContain('Diverging gradient');
|
|
expect(hs[0].getAttribute('aria-expanded')).toBe('true');
|
|
expect(hs[1].getAttribute('aria-expanded')).toBe('false');
|
|
});
|
|
|
|
test('a section header shows a count badge of the properties set within it', () => {
|
|
render();
|
|
// Two axis-grid keys set → the Grid section badge reads 2.
|
|
open({ axis: { grid: false, gridColor: '#fff' } }, 'Axes & grid');
|
|
const grid = headers().find((h) => h.textContent?.includes('Grid'))!;
|
|
expect(grid.querySelector('[aria-label="2 set"]')?.textContent).toBe('2');
|
|
});
|
|
});
|
|
|
|
describe('MarksControls', () => {
|
|
// The per-type accordion sections start collapsed except the first ("All
|
|
// marks"); open a section by clicking its header before reaching its controls.
|
|
const openSection = (title: string) => {
|
|
const header = [...container.querySelectorAll('button')].find(
|
|
(b) => b.hasAttribute('data-accordion-header') && b.textContent?.includes(title),
|
|
)!;
|
|
act(() => header.click());
|
|
};
|
|
|
|
test('bar corner radius writes the bar-only path, not the generic mark', () => {
|
|
render();
|
|
open({}, 'Marks');
|
|
openSection('Bars');
|
|
act(() => setNativeValue(numberInput('Bar corner radius'), '4'));
|
|
expect(at('bar', 'cornerRadiusEnd')).toBe(4);
|
|
expect(at('mark', 'cornerRadius')).toBeUndefined();
|
|
});
|
|
|
|
test('clearing a bar control prunes the emptied bar object (minimal diff)', () => {
|
|
render();
|
|
open({ bar: { cornerRadiusEnd: 4 } }, 'Marks');
|
|
openSection('Bars');
|
|
act(() => setNativeValue(numberInput('Bar corner radius'), ''));
|
|
expect(config().bar).toBeUndefined();
|
|
});
|
|
|
|
test('line curve writes line.interpolate', () => {
|
|
render();
|
|
open({}, 'Marks');
|
|
openSection('Lines & areas');
|
|
pick('Line curve', 'Monotone');
|
|
expect(at('line', 'interpolate')).toBe('monotone');
|
|
});
|
|
|
|
test('arc donut hole writes arc.innerRadius', () => {
|
|
render();
|
|
open({}, 'Marks');
|
|
openSection('Arc');
|
|
act(() => setNativeValue(numberInput('Donut hole radius'), '40'));
|
|
expect(at('arc', 'innerRadius')).toBe(40);
|
|
});
|
|
|
|
test('tooltips tri-state "Off" writes mark.tooltip false', () => {
|
|
render();
|
|
open({}, 'Marks');
|
|
pick('Tooltips', 'Off'); // "All marks" section is open by default
|
|
expect(at('mark', 'tooltip')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('TypeControls', () => {
|
|
test('axis label size writes axis.labelFontSize', () => {
|
|
render();
|
|
open({}, 'Type');
|
|
act(() => setNativeValue(numberInput('Axis label size'), '9'));
|
|
expect(at('axis', 'labelFontSize')).toBe(9);
|
|
});
|
|
|
|
test('axis title weight "Bold" (shared WeightRow) writes 700', () => {
|
|
render();
|
|
open({}, 'Type');
|
|
pick('Axis title weight', 'Bold');
|
|
expect(at('axis', 'titleFontWeight')).toBe(700);
|
|
});
|
|
});
|
|
|
|
describe('TitleControls', () => {
|
|
test('alignment "Left" writes title.anchor start', () => {
|
|
render();
|
|
open({}, 'Title');
|
|
pick('Alignment', 'Left');
|
|
expect(at('title', 'anchor')).toBe('start');
|
|
});
|
|
|
|
test('title weight "Bold" writes title.fontWeight 700 (relocated from Type)', () => {
|
|
render();
|
|
open({}, 'Title');
|
|
pick('Title weight', 'Bold');
|
|
expect(at('title', 'fontWeight')).toBe(700);
|
|
});
|
|
|
|
test('subtitle size writes title.subtitleFontSize', () => {
|
|
render();
|
|
open({}, 'Title');
|
|
act(() => setNativeValue(numberInput('Subtitle size'), '11'));
|
|
expect(at('title', 'subtitleFontSize')).toBe(11);
|
|
});
|
|
});
|
|
|
|
describe('FormatControls', () => {
|
|
test('a number preset chip fills numberFormat; clearing the field removes it', () => {
|
|
render();
|
|
open({}, 'Formats');
|
|
act(() => button('$1,234.00')!.click());
|
|
expect(config().numberFormat).toBe('$,.2f');
|
|
|
|
act(() => setNativeValue(container.querySelector('input[aria-label="Number format"]')!, ''));
|
|
expect(config().numberFormat).toBeUndefined();
|
|
});
|
|
|
|
test('date format typed free-text writes timeFormat', () => {
|
|
render();
|
|
open({}, 'Formats');
|
|
act(() => setNativeValue(container.querySelector('input[aria-label="Date format"]')!, '%Y'));
|
|
expect(config().timeFormat).toBe('%Y');
|
|
});
|
|
});
|
|
|
|
describe('HeaderControls', () => {
|
|
test('facet title size writes header.titleFontSize', () => {
|
|
render();
|
|
open({}, 'Headers');
|
|
act(() => setNativeValue(numberInput('Header title size'), '13'));
|
|
expect(at('header', 'titleFontSize')).toBe(13);
|
|
});
|
|
});
|