/** * 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()); /** 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( (cur, key) => cur && typeof cur === 'object' ? (cur as Record)[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(`input[aria-label="${name}"]`)!; /** A button anywhere in the document (popovers portal to ) 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); }); });