341 lines
13 KiB
TypeScript
341 lines
13 KiB
TypeScript
import { describe, test, expect, vi } from 'vitest';
|
|||
|
|
import React from 'react';
|
||
|
|
import { createRoot, Root } from 'react-dom/client';
|
||
|
|
import { act } from 'react-dom/test-utils';
|
||
|
|
import {
|
||
|
|
NumericUnitInput,
|
||
|
|
parseCssLength,
|
||
|
|
SizeControl,
|
||
|
|
AspectRatioControl,
|
||
|
|
parseAspectRatioInput,
|
||
|
|
FocalPointGrid,
|
||
|
|
FOCAL_POINTS,
|
||
|
|
SpacingControl,
|
||
|
|
BorderControl,
|
||
|
|
buildBorderShorthand,
|
||
|
|
AnimationControl,
|
||
|
|
ANIMATIONS,
|
||
|
|
VisibilityControl,
|
||
|
|
} from './shared';
|
||
|
|
|
||
|
|
/* Same DOM-harness pattern as AssetPicker.test.tsx / ImageBlock.render.test.tsx
|
||
|
|
(no @testing-library/react in this repo). */
|
||
|
|
let container: HTMLDivElement;
|
||
|
|
let root: Root;
|
||
|
|
|
||
|
|
function render(ui: React.ReactElement) {
|
||
|
|
container = document.createElement('div');
|
||
|
|
document.body.appendChild(container);
|
||
|
|
act(() => {
|
||
|
|
root = createRoot(container);
|
||
|
|
root.render(ui);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function unmount() {
|
||
|
|
act(() => { root.unmount(); });
|
||
|
|
container.remove();
|
||
|
|
}
|
||
|
|
|
||
|
|
function click(el: Element | null) {
|
||
|
|
if (!el) throw new Error('element not found');
|
||
|
|
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||
|
|
}
|
||
|
|
|
||
|
|
function setValue(input: HTMLInputElement | HTMLSelectElement, value: string) {
|
||
|
|
const proto = input instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
|
||
|
|
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
|
||
|
|
setter.call(input, value);
|
||
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
|
|
}
|
||
|
|
|
||
|
|
function blur(el: Element) {
|
||
|
|
act(() => { el.dispatchEvent(new FocusEvent('blur', { bubbles: true })); el.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); });
|
||
|
|
}
|
||
|
|
|
||
|
|
function q<T extends Element = Element>(testId: string): T | null {
|
||
|
|
return container.querySelector(`[data-testid="${testId}"]`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------- NumericUnitInput / parseCssLength ---------- */
|
||
|
|
describe('parseCssLength', () => {
|
||
|
|
test('parses "16px" into number 16 + unit px', () => {
|
||
|
|
expect(parseCssLength('16px')).toEqual({ num: '16', unit: 'px' });
|
||
|
|
});
|
||
|
|
test('parses "50%" into number 50 + unit %', () => {
|
||
|
|
expect(parseCssLength('50%', ['px', '%'])).toEqual({ num: '50', unit: '%' });
|
||
|
|
});
|
||
|
|
test('treats "auto" and "" as empty', () => {
|
||
|
|
expect(parseCssLength('auto')).toEqual({ num: '', unit: 'px' });
|
||
|
|
expect(parseCssLength('')).toEqual({ num: '', unit: 'px' });
|
||
|
|
expect(parseCssLength(undefined)).toEqual({ num: '', unit: 'px' });
|
||
|
|
});
|
||
|
|
test('falls back to the first configured unit for an unrecognized unit', () => {
|
||
|
|
expect(parseCssLength('16vh', ['px', '%'])).toEqual({ num: '16', unit: 'px' });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('NumericUnitInput', () => {
|
||
|
|
test('renders the parsed number and unit from a "16px" value', () => {
|
||
|
|
render(<NumericUnitInput value="16px" onChange={vi.fn()} />);
|
||
|
|
expect(q<HTMLInputElement>('numeric-unit-number')!.value).toBe('16');
|
||
|
|
expect(q<HTMLSelectElement>('numeric-unit-unit')!.value).toBe('px');
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('changing the number emits the recombined string', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<NumericUnitInput value="16px" onChange={onChange} />);
|
||
|
|
setValue(q<HTMLInputElement>('numeric-unit-number')!, '32');
|
||
|
|
expect(onChange).toHaveBeenCalledWith('32px');
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('changing the unit re-emits with the current number', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<NumericUnitInput value="16px" onChange={onChange} />);
|
||
|
|
setValue(q<HTMLSelectElement>('numeric-unit-unit')!, '%');
|
||
|
|
expect(onChange).toHaveBeenCalledWith('16%');
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('clearing the number emits empty string, not "0px"', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<NumericUnitInput value="16px" onChange={onChange} />);
|
||
|
|
setValue(q<HTMLInputElement>('numeric-unit-number')!, '');
|
||
|
|
expect(onChange).toHaveBeenCalledWith('');
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('omits the unit <select> when only one unit is configured', () => {
|
||
|
|
render(<NumericUnitInput value="1px" onChange={vi.fn()} units={['px']} />);
|
||
|
|
expect(q('numeric-unit-unit')).toBeNull();
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------- SizeControl ---------- */
|
||
|
|
describe('SizeControl', () => {
|
||
|
|
test('clicking a preset button emits its value', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<SizeControl label="Width" value="" onChange={onChange} />);
|
||
|
|
const buttons = container.querySelectorAll('[data-testid="size-control"] .preset-btn');
|
||
|
|
const hundred = Array.from(buttons).find((b) => b.textContent === '100%')!;
|
||
|
|
click(hundred);
|
||
|
|
expect(onChange).toHaveBeenCalledWith('100%');
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('custom numeric input emits a non-preset value', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<SizeControl label="Width" value="" onChange={onChange} />);
|
||
|
|
setValue(q<HTMLInputElement>('size-control-custom-number')!, '400');
|
||
|
|
expect(onChange).toHaveBeenCalledWith('400px');
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------- AspectRatioControl / parseAspectRatioInput ---------- */
|
||
|
|
describe('parseAspectRatioInput', () => {
|
||
|
|
test('parses "16:9" into "16 / 9"', () => {
|
||
|
|
expect(parseAspectRatioInput('16:9')).toBe('16 / 9');
|
||
|
|
});
|
||
|
|
test('accepts "16/9" and "16x9" separators too', () => {
|
||
|
|
expect(parseAspectRatioInput('16/9')).toBe('16 / 9');
|
||
|
|
expect(parseAspectRatioInput('16x9')).toBe('16 / 9');
|
||
|
|
});
|
||
|
|
test('returns empty string for unparseable input', () => {
|
||
|
|
expect(parseAspectRatioInput('not-a-ratio')).toBe('');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('AspectRatioControl', () => {
|
||
|
|
test('clicking the 16:9 preset emits "16 / 9"', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<AspectRatioControl value="" onChange={onChange} />);
|
||
|
|
const buttons = container.querySelectorAll('[data-testid="aspect-ratio-control"] .preset-btn');
|
||
|
|
const btn = Array.from(buttons).find((b) => b.textContent === '16:9')!;
|
||
|
|
click(btn);
|
||
|
|
expect(onChange).toHaveBeenCalledWith('16 / 9');
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('clicking Original emits empty string', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<AspectRatioControl value="1 / 1" onChange={onChange} />);
|
||
|
|
const buttons = container.querySelectorAll('[data-testid="aspect-ratio-control"] .preset-btn');
|
||
|
|
const btn = Array.from(buttons).find((b) => b.textContent === 'Original')!;
|
||
|
|
click(btn);
|
||
|
|
expect(onChange).toHaveBeenCalledWith('');
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('typing a custom "16:9" and blurring emits "16 / 9"', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<AspectRatioControl value="" onChange={onChange} />);
|
||
|
|
const input = q<HTMLInputElement>('aspect-ratio-custom-input')!;
|
||
|
|
setValue(input, '16:9');
|
||
|
|
blur(input);
|
||
|
|
expect(onChange).toHaveBeenCalledWith('16 / 9');
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------- FocalPointGrid ---------- */
|
||
|
|
describe('FocalPointGrid', () => {
|
||
|
|
test('has 9 cells matching FOCAL_POINTS', () => {
|
||
|
|
render(<FocalPointGrid value="center" onChange={vi.fn()} />);
|
||
|
|
const cells = container.querySelectorAll('[data-testid="focal-point-grid"] button');
|
||
|
|
expect(cells.length).toBe(9);
|
||
|
|
expect(FOCAL_POINTS.length).toBe(9);
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('clicking a cell emits its object-position value', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<FocalPointGrid value="center" onChange={onChange} />);
|
||
|
|
click(q('focal-point-left-top'));
|
||
|
|
expect(onChange).toHaveBeenCalledWith('left top');
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('the active cell is visually highlighted (distinct background)', () => {
|
||
|
|
render(<FocalPointGrid value="right bottom" onChange={vi.fn()} />);
|
||
|
|
const active = q<HTMLButtonElement>('focal-point-right-bottom')!;
|
||
|
|
const inactive = q<HTMLButtonElement>('focal-point-center')!;
|
||
|
|
expect(active.style.background).not.toBe(inactive.style.background);
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('defaults the highlight to center when value is empty', () => {
|
||
|
|
render(<FocalPointGrid value="" onChange={vi.fn()} />);
|
||
|
|
const center = q<HTMLButtonElement>('focal-point-center')!;
|
||
|
|
const other = q<HTMLButtonElement>('focal-point-left-top')!;
|
||
|
|
expect(center.style.background).not.toBe(other.style.background);
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------- SpacingControl ---------- */
|
||
|
|
describe('SpacingControl', () => {
|
||
|
|
test('linked mode: editing the shared field emits onChange for all 4 sides with the same value', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<SpacingControl label="Margin" value={{}} onChange={onChange} />);
|
||
|
|
setValue(q<HTMLInputElement>('spacing-linked-number')!, '16');
|
||
|
|
expect(onChange).toHaveBeenCalledWith('top', '16px');
|
||
|
|
expect(onChange).toHaveBeenCalledWith('right', '16px');
|
||
|
|
expect(onChange).toHaveBeenCalledWith('bottom', '16px');
|
||
|
|
expect(onChange).toHaveBeenCalledWith('left', '16px');
|
||
|
|
expect(onChange).toHaveBeenCalledTimes(4);
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a preset fills all sides with the preset value', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<SpacingControl label="Padding" value={{}} onChange={onChange} />);
|
||
|
|
const buttons = container.querySelectorAll('[data-testid="spacing-control"] .preset-btn');
|
||
|
|
const medium = Array.from(buttons).find((b) => b.textContent === 'M')!;
|
||
|
|
click(medium);
|
||
|
|
expect(onChange).toHaveBeenCalledWith('top', '16px');
|
||
|
|
expect(onChange).toHaveBeenCalledWith('left', '16px');
|
||
|
|
expect(onChange).toHaveBeenCalledTimes(4);
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('unlinking lets each side emit independently', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<SpacingControl label="Margin" value={{ top: '4px', right: '4px', bottom: '4px', left: '4px' }} onChange={onChange} />);
|
||
|
|
click(q('spacing-link-toggle'));
|
||
|
|
setValue(q<HTMLInputElement>('spacing-top-number')!, '20');
|
||
|
|
expect(onChange).toHaveBeenCalledWith('top', '20px');
|
||
|
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------- BorderControl ---------- */
|
||
|
|
describe('BorderControl', () => {
|
||
|
|
test('changing width/style/color merges into the full BorderValue object', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
const value = { width: '1px', style: 'solid', color: '#000000' };
|
||
|
|
render(<BorderControl value={value} onChange={onChange} />);
|
||
|
|
setValue(q<HTMLSelectElement>('border-style-select')!, 'dashed');
|
||
|
|
expect(onChange).toHaveBeenCalledWith({ width: '1px', style: 'dashed', color: '#000000' });
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('buildBorderShorthand builds a CSS border shorthand', () => {
|
||
|
|
expect(buildBorderShorthand({ width: '2px', style: 'solid', color: '#3b82f6' })).toBe('2px solid #3b82f6');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('buildBorderShorthand returns "none" when style is none or width is empty', () => {
|
||
|
|
expect(buildBorderShorthand({ width: '2px', style: 'none', color: '#000' })).toBe('none');
|
||
|
|
expect(buildBorderShorthand({ width: '', style: 'solid', color: '#000' })).toBe('none');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------- AnimationControl ---------- */
|
||
|
|
describe('AnimationControl', () => {
|
||
|
|
test('ANIMATIONS matches the exact data-animation values html-export.ts supports', () => {
|
||
|
|
expect(ANIMATIONS.map((a) => a.value)).toEqual([
|
||
|
|
'none', 'fade-in', 'slide-up', 'slide-left', 'slide-right', 'zoom-in', 'bounce',
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('selecting an animation emits { animation, animationDelay }', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<AnimationControl value={{ animation: 'none' }} onChange={onChange} />);
|
||
|
|
const buttons = container.querySelectorAll('[data-testid="animation-control"] .preset-btn');
|
||
|
|
const fadeIn = Array.from(buttons).find((b) => b.textContent === 'Fade In')!;
|
||
|
|
click(fadeIn);
|
||
|
|
expect(onChange).toHaveBeenCalledWith({ animation: 'fade-in', animationDelay: '0' });
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('the delay input is hidden when animation is "none" and shown otherwise', () => {
|
||
|
|
render(<AnimationControl value={{ animation: 'none' }} onChange={vi.fn()} />);
|
||
|
|
expect(q('animation-delay-input')).toBeNull();
|
||
|
|
unmount();
|
||
|
|
|
||
|
|
render(<AnimationControl value={{ animation: 'bounce', animationDelay: '0.5' }} onChange={vi.fn()} />);
|
||
|
|
expect(q<HTMLInputElement>('animation-delay-input')!.value).toBe('0.5');
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('changing the delay emits the current animation with the new delay', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<AnimationControl value={{ animation: 'zoom-in', animationDelay: '0' }} onChange={onChange} />);
|
||
|
|
setValue(q<HTMLInputElement>('animation-delay-input')!, '0.3');
|
||
|
|
expect(onChange).toHaveBeenCalledWith({ animation: 'zoom-in', animationDelay: '0.3' });
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ---------- VisibilityControl ---------- */
|
||
|
|
describe('VisibilityControl', () => {
|
||
|
|
test('emits the exact hideOnDesktop/hideOnTablet/hideOnMobile prop names', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<VisibilityControl value={{}} onChange={onChange} />);
|
||
|
|
click(q('visibility-hideOnDesktop'));
|
||
|
|
expect(onChange).toHaveBeenCalledWith({ hideOnDesktop: true });
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('toggling reflects existing true values back to false', () => {
|
||
|
|
const onChange = vi.fn();
|
||
|
|
render(<VisibilityControl value={{ hideOnMobile: true }} onChange={onChange} />);
|
||
|
|
click(q('visibility-hideOnMobile'));
|
||
|
|
expect(onChange).toHaveBeenCalledWith({ hideOnMobile: false });
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('checkboxes reflect the initial value', () => {
|
||
|
|
render(<VisibilityControl value={{ hideOnTablet: true }} onChange={vi.fn()} />);
|
||
|
|
expect(q<HTMLInputElement>('visibility-hideOnDesktop')!.checked).toBe(false);
|
||
|
|
expect(q<HTMLInputElement>('visibility-hideOnTablet')!.checked).toBe(true);
|
||
|
|
expect(q<HTMLInputElement>('visibility-hideOnMobile')!.checked).toBe(false);
|
||
|
|
unmount();
|
||
|
|
});
|
||
|
|
});
|