/** * 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(''), inspectData: () => null, }), ), })); (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` (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; /** 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 ) 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( '[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( '[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( 'input[aria-label="Default mark color"]', )!; act(() => setNativeValue(input, '#ff0000')); expect((draftConfig()?.mark as Record).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( '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( '[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('picking a scheme writes range.category as a Vega scheme object', () => { render(); open({}); act(() => picker('Categorical color scheme')!.click()); act(() => button('Category 10')!.click()); // The `{ scheme }` object — a bare scheme-name string is rejected at render. expect(range().category).toEqual({ scheme: 'category10' }); }); test('a sequential pick sets both heatmap and ramp to the scheme object', () => { render(); open({}); act(() => picker('Sequential color scheme')!.click()); act(() => button('Viridis')!.click()); expect(range().heatmap).toEqual({ scheme: 'viridis' }); expect(range().ramp).toEqual({ scheme: '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('the raw JSON is collapsed by default and toggles open', () => { render(); open({}); expect(container.querySelector('#theme-config')).toBeNull(); const toggle = container.querySelector( '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'); }); });