mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import { BOOK_PATTERNS, type BookPaint } from '@trek/shared';
|
|
import { useState } from 'react';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { StudioPaintPicker } from '../../../src/components/Studio/StudioPaintPicker';
|
|
import { AngleDial } from '../../../src/components/Studio/paint/AngleDial';
|
|
import { GradientStopBar } from '../../../src/components/Studio/paint/GradientStopBar';
|
|
import { RadialPad } from '../../../src/components/Studio/paint/RadialPad';
|
|
import type { PaintChannel } from '../../../src/components/Studio/paint/usePaintChannel';
|
|
import { fireEvent, render } from '../../helpers/render';
|
|
|
|
/** The value, or a failed test saying it was missing. */
|
|
function need<T>(value: T | null | undefined): T {
|
|
if (value == null) throw new Error('expected a value');
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* The paint picker, driven the way a person drives it: tabs, swatches, the
|
|
* stop bar, the dial. What is asserted is the paint it writes and whether a
|
|
* write is a live step inside a drag or the one that ends it.
|
|
*/
|
|
|
|
const t = (k: string, p?: Record<string, string | number>) => (p ? `${k}:${JSON.stringify(p)}` : k);
|
|
|
|
/** The picker over its own state, as the inspector holds it through the store. */
|
|
function Harness({ start, channel }: { start: BookPaint | null; channel: PaintChannel }) {
|
|
const [value, setValue] = useState<BookPaint | null>(start);
|
|
const write: PaintChannel = {
|
|
live: (p) => {
|
|
channel.live(p);
|
|
setValue(p);
|
|
},
|
|
commit: (p) => {
|
|
channel.commit(p);
|
|
setValue(p);
|
|
},
|
|
};
|
|
return <StudioPaintPicker value={value} channel={write} t={t} noneLabel="none" />;
|
|
}
|
|
|
|
const spy = () => ({ live: vi.fn(), commit: vi.fn() });
|
|
const last = (fn: ReturnType<typeof vi.fn>) => fn.mock.calls[fn.mock.calls.length - 1]?.[0] as BookPaint | null;
|
|
|
|
/** A rect for the bar, the dial or the pad: jsdom lays nothing out. */
|
|
function sized(el: Element, box = { left: 0, top: 0, width: 200, height: 24 }) {
|
|
el.getBoundingClientRect = () =>
|
|
({
|
|
...box,
|
|
right: box.left + box.width,
|
|
bottom: box.top + box.height,
|
|
x: box.left,
|
|
y: box.top,
|
|
toJSON: () => ({}),
|
|
}) as DOMRect;
|
|
}
|
|
|
|
const tab = (c: HTMLElement, label: string) =>
|
|
fireEvent.click(need([...c.querySelectorAll('.st-paint-tab')].find((b) => b.textContent === label)));
|
|
|
|
describe('the kind tabs', () => {
|
|
it('marks the kind the paint is, and offers no fill under its own name', () => {
|
|
const { container } = render(<Harness start={null} channel={spy()} />);
|
|
const tabs = [...container.querySelectorAll('.st-paint-tab')];
|
|
expect(tabs.map((b) => b.textContent)).toEqual([
|
|
'none',
|
|
'journey.studio.paint.solid',
|
|
'journey.studio.paint.gradient',
|
|
'journey.studio.paint.pattern',
|
|
]);
|
|
expect(tabs[0].getAttribute('aria-selected')).toBe('true');
|
|
});
|
|
|
|
it('keeps the colour across kinds, and brings a visited kind back as it was left', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={{ type: 'solid', color: '#1e3a8a' }} channel={channel} />);
|
|
|
|
tab(container, 'journey.studio.paint.pattern');
|
|
expect(last(channel.commit)).toMatchObject({ type: 'pattern', fg: '#1e3a8a' });
|
|
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.pattern.polka"]')));
|
|
expect(last(channel.commit)).toMatchObject({ type: 'pattern', pattern: 'polka' });
|
|
|
|
tab(container, 'journey.studio.paint.gradient');
|
|
expect(last(channel.commit)).toMatchObject({ type: 'linear', stops: [{ color: '#1e3a8a' }, {}] });
|
|
|
|
tab(container, 'journey.studio.paint.pattern');
|
|
expect(last(channel.commit)).toMatchObject({ type: 'pattern', pattern: 'polka' });
|
|
|
|
tab(container, 'none');
|
|
expect(last(channel.commit)).toBeNull();
|
|
expect(channel.live).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('one colour', () => {
|
|
it('writes the swatch picked, and its opacity live while dragged and once when let go', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={{ type: 'solid', color: '#111111' }} channel={channel} />);
|
|
fireEvent.click(need(container.querySelector('[aria-label="#c2410c"]')));
|
|
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c' });
|
|
|
|
const range = container.querySelector('.st-alpha input') as HTMLInputElement;
|
|
fireEvent.change(range, { target: { value: '40' } });
|
|
expect(last(channel.live)).toEqual({ type: 'solid', color: '#c2410c', alpha: 0.4 });
|
|
fireEvent.pointerUp(range);
|
|
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c', alpha: 0.4 });
|
|
|
|
fireEvent.change(range, { target: { value: '100' } });
|
|
fireEvent.pointerUp(range);
|
|
expect(last(channel.commit)).toEqual({ type: 'solid', color: '#c2410c' });
|
|
});
|
|
});
|
|
|
|
describe('a gradient', () => {
|
|
const start: BookPaint = {
|
|
type: 'linear',
|
|
angle: 90,
|
|
stops: [
|
|
{ at: 0, color: '#000000' },
|
|
{ at: 1, color: '#ffffff' },
|
|
],
|
|
};
|
|
|
|
it('adds a stop where the bar is clicked, recolours the chosen stop and takes it away', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={start} channel={channel} />);
|
|
const bar = need(container.querySelector('.st-stopbar'));
|
|
sized(bar);
|
|
fireEvent.pointerDown(bar, { button: 0, clientX: 50 });
|
|
const added = last(channel.commit);
|
|
expect(added?.type === 'linear' && added.stops).toHaveLength(3);
|
|
expect(added?.type === 'linear' && added.stops[2]).toEqual({ at: 0.25, color: '#404040' });
|
|
expect(container.querySelectorAll('.st-stop')[2].getAttribute('aria-pressed')).toBe('true');
|
|
|
|
const stopPanel = need(container.querySelector('.st-paint-stop'));
|
|
fireEvent.click(need(stopPanel.querySelector('[aria-label="#0f766e"]')));
|
|
expect(last(channel.commit)).toMatchObject({ stops: [{}, {}, { at: 0.25, color: '#0f766e' }] });
|
|
|
|
fireEvent.click(need(stopPanel.querySelector('[aria-label="journey.studio.paint.removeStop"]')));
|
|
const removed = last(channel.commit);
|
|
expect(removed?.type === 'linear' && removed.stops).toHaveLength(2);
|
|
// Two stops left: the bin is off.
|
|
expect(
|
|
(container.querySelector('.st-paint-stop [aria-label="journey.studio.paint.removeStop"]') as HTMLButtonElement)
|
|
.disabled
|
|
).toBe(true);
|
|
});
|
|
|
|
it('reverses, turns radial and back, and sets the angle from the number field', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={start} channel={channel} />);
|
|
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.paint.reverse"]')));
|
|
expect(last(channel.commit)).toMatchObject({ stops: [{ at: 1 }, { at: 0 }] });
|
|
|
|
const radio = (label: string) =>
|
|
need([...container.querySelectorAll('.st-seg-item')].find((b) => b.textContent === label));
|
|
fireEvent.click(radio('journey.studio.paint.radial'));
|
|
expect(last(channel.commit)).toMatchObject({ type: 'radial', cx: 0.5, cy: 0.5, r: 1 });
|
|
expect(container.querySelector('.st-radial-pad')).not.toBeNull();
|
|
fireEvent.click(radio('journey.studio.paint.linear'));
|
|
expect(last(channel.commit)).toMatchObject({ type: 'linear', angle: 180 });
|
|
|
|
const field = container.querySelector('.st-num input') as HTMLInputElement;
|
|
fireEvent.change(field, { target: { value: '400' } });
|
|
fireEvent.blur(field);
|
|
expect(last(channel.commit)).toMatchObject({ angle: 359 });
|
|
});
|
|
});
|
|
|
|
describe('a pattern', () => {
|
|
const start: BookPaint = { type: 'pattern', pattern: 'dots', fg: '#111111', bg: '#ffffff', scale: 1, angle: 0 };
|
|
|
|
it('draws a tile for each repeat, turns stripes to the diagonal, and lets the ground go', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={start} channel={channel} />);
|
|
expect(container.querySelectorAll('.st-pattern-tile')).toHaveLength(BOOK_PATTERNS.length);
|
|
expect(need(container.querySelector('.st-pattern-tile[aria-checked="true"]')).getAttribute('aria-label')).toBe(
|
|
'journey.studio.pattern.dots'
|
|
);
|
|
|
|
fireEvent.click(need(container.querySelector('[aria-label="journey.studio.pattern.stripes"]')));
|
|
expect(last(channel.commit)).toMatchObject({ pattern: 'stripes', angle: 45 });
|
|
|
|
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
|
expect(last(channel.commit)).toMatchObject({ bg: null });
|
|
expect(container.querySelectorAll('.st-pattern-tile.is-clear')).toHaveLength(BOOK_PATTERNS.length);
|
|
fireEvent.click(need(container.querySelector('[role="switch"]')));
|
|
expect(last(channel.commit)).toMatchObject({ bg: '#ffffff' });
|
|
});
|
|
|
|
it('sizes the repeat on an even ratio scale, live and then once', () => {
|
|
const channel = spy();
|
|
const { container } = render(<Harness start={start} channel={channel} />);
|
|
const range = container.querySelector('.st-paint-slider input') as HTMLInputElement;
|
|
// Scale 1 sits a little under halfway along 0.25 to 4.
|
|
expect(Number(range.value)).toBe(50);
|
|
fireEvent.change(range, { target: { value: '100' } });
|
|
expect(last(channel.live)).toMatchObject({ scale: 4 });
|
|
fireEvent.pointerUp(range);
|
|
expect(last(channel.commit)).toMatchObject({ scale: 4 });
|
|
expect(need(container.querySelector('.st-paint-value')).textContent).toBe('400%');
|
|
});
|
|
});
|
|
|
|
describe('the dial', () => {
|
|
it('turns with the pointer, snapping to the compass, and is one commit when let go', () => {
|
|
const onChange = vi.fn();
|
|
const { container } = render(<AngleDial value={0} onChange={onChange} label="Angle" />);
|
|
const dial = need(container.querySelector('.st-dial'));
|
|
sized(dial, { left: 0, top: 0, width: 40, height: 40 });
|
|
fireEvent.pointerDown(dial, { button: 0, clientX: 40, clientY: 20 });
|
|
expect(onChange).toHaveBeenLastCalledWith(90, false);
|
|
fireEvent.pointerMove(dial, { clientX: 20, clientY: 40 });
|
|
expect(onChange).toHaveBeenLastCalledWith(180, false);
|
|
fireEvent.pointerUp(dial);
|
|
expect(onChange).toHaveBeenLastCalledWith(180, true);
|
|
fireEvent.pointerMove(dial, { clientX: 0, clientY: 20 });
|
|
expect(onChange).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('takes the arrow keys, Shift for fifteen at a time', () => {
|
|
const onChange = vi.fn();
|
|
const { container } = render(<AngleDial value={350} onChange={onChange} label="Angle" />);
|
|
const dial = need(container.querySelector('.st-dial'));
|
|
expect(dial.getAttribute('aria-valuenow')).toBe('350');
|
|
fireEvent.keyDown(dial, { key: 'ArrowRight', shiftKey: true });
|
|
expect(onChange).toHaveBeenLastCalledWith(5, false);
|
|
fireEvent.keyUp(dial, { key: 'ArrowRight' });
|
|
expect(onChange).toHaveBeenLastCalledWith(5, true);
|
|
fireEvent.keyDown(dial, { key: 'a' });
|
|
expect(onChange).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('the stop bar', () => {
|
|
const paint: BookPaint & { type: 'linear' } = {
|
|
type: 'linear',
|
|
angle: 0,
|
|
stops: [
|
|
{ at: 0, color: '#000000' },
|
|
{ at: 0.5, color: '#888888' },
|
|
{ at: 1, color: '#ffffff' },
|
|
],
|
|
};
|
|
const labels = { bar: 'bar', stop: (pos: number) => `stop ${pos}` };
|
|
|
|
it('drags a stop along the bar live, and pulls it off to remove it', () => {
|
|
const onChange = vi.fn();
|
|
const onSelect = vi.fn();
|
|
const { container } = render(
|
|
<GradientStopBar paint={paint} selected={0} onSelect={onSelect} onChange={onChange} labels={labels} />
|
|
);
|
|
sized(need(container.querySelector('.st-stopbar')));
|
|
const stop = container.querySelectorAll('.st-stop')[1];
|
|
fireEvent.pointerDown(stop, { button: 0, clientX: 100 });
|
|
expect(onSelect).toHaveBeenCalledWith(1);
|
|
fireEvent.pointerMove(stop, { clientX: 150, clientY: 10 });
|
|
expect(onChange).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ stops: [paint.stops[0], { at: 0.75, color: '#888888' }, paint.stops[2]] }),
|
|
false
|
|
);
|
|
fireEvent.pointerMove(stop, { clientX: 150, clientY: 90 });
|
|
expect(stop.className).toContain('is-leaving');
|
|
fireEvent.pointerUp(stop);
|
|
const removed = onChange.mock.calls[onChange.mock.calls.length - 1];
|
|
expect(removed[1]).toBe(true);
|
|
expect(removed[0].stops).toHaveLength(2);
|
|
});
|
|
|
|
it('moves the stop with the arrow keys and removes it with Delete', () => {
|
|
const onChange = vi.fn();
|
|
const { container } = render(
|
|
<GradientStopBar paint={paint} selected={1} onSelect={() => {}} onChange={onChange} labels={labels} />
|
|
);
|
|
const stop = container.querySelectorAll('.st-stop')[1];
|
|
expect(stop.getAttribute('aria-label')).toBe('stop 50');
|
|
fireEvent.keyDown(stop, { key: 'ArrowLeft', shiftKey: true });
|
|
expect(onChange.mock.calls[0][0].stops[1].at).toBe(0.4);
|
|
fireEvent.keyDown(stop, { key: 'Delete' });
|
|
expect(onChange.mock.calls[1][0].stops).toHaveLength(2);
|
|
fireEvent.keyDown(stop, { key: 'x' });
|
|
expect(onChange).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does not add a stop past six, or when the click lands on a stop', () => {
|
|
const onChange = vi.fn();
|
|
const six = { ...paint, stops: [0, 0.2, 0.4, 0.6, 0.8, 1].map((at) => ({ at, color: '#000000' })) };
|
|
const { container } = render(
|
|
<GradientStopBar paint={six} selected={0} onSelect={() => {}} onChange={onChange} labels={labels} />
|
|
);
|
|
const bar = need(container.querySelector('.st-stopbar'));
|
|
sized(bar);
|
|
fireEvent.pointerDown(bar, { button: 0, clientX: 30 });
|
|
fireEvent.pointerDown(need(container.querySelector('.st-stop')), { button: 0, clientX: 0 });
|
|
fireEvent.pointerUp(need(container.querySelector('.st-stop')));
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('the radial centre', () => {
|
|
it('moves the centre with the pointer and the keys', () => {
|
|
const onChange = vi.fn();
|
|
const radial = {
|
|
type: 'radial' as const,
|
|
cx: 0.5,
|
|
cy: 0.5,
|
|
r: 1,
|
|
stops: [
|
|
{ at: 0, color: '#ffffff' },
|
|
{ at: 1, color: '#000000' },
|
|
],
|
|
};
|
|
const { container } = render(<RadialPad paint={radial} onChange={onChange} label="Centre" />);
|
|
const pad = need(container.querySelector('.st-radial-pad'));
|
|
sized(pad, { left: 0, top: 0, width: 100, height: 50 });
|
|
fireEvent.pointerDown(pad, { button: 0, clientX: 25, clientY: 10 });
|
|
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 0.25, cy: 0.2 }), false);
|
|
fireEvent.pointerMove(pad, { clientX: 200, clientY: -5 });
|
|
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 1, cy: 0 }), false);
|
|
fireEvent.pointerUp(pad);
|
|
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 1, cy: 0 }), true);
|
|
fireEvent.keyDown(pad, { key: 'ArrowDown' });
|
|
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({ cx: 0.5, cy: 0.52 }), true);
|
|
});
|
|
});
|