Files
TREK/client/tests/unit/studio/paintBackground.test.tsx
T
Maurice 670c841866 feat(studio): paint shapes and pages with gradients, alpha and twelve patterns
- A shape and a page take a paint: one colour with its own alpha, a linear or
  radial gradient of two to six stops, or one of twelve patterns (checks,
  gingham, stripes, pinstripes, dots, polka dots, zigzag, waves, grid, scales,
  sunburst, confetti). fill, gradient and background keep the colour that
  stands for the paint, so an older reader still draws something close, and a
  plain colour or the old cover fade is stored in the old fields alone.
- Gradients render as SVG in user space with ids per mounted copy, so the two
  halves of a spread in page mode never share a defs id; patterns are drawn
  out as geometry clipped to the outline and capped at 2000 primitives.
- The new paint picker has none, solid, gradient and pattern tabs, a stop bar
  to click, drag and pull stops off, an angle dial, a radial centre pad and
  pattern tiles drawn by the page's own generator.
- Every swatch row gains the book's own colours, colours read from the
  page's photos, recent colours and, where it applies, an opacity slider.
- With nothing selected the inspector sets the page background, gives it to
  every page of its kind, and lays a photo under the whole page.
- The assistant's background operation takes a paint as well as a colour,
  style and add set a shape's paint, and describe_book_page reads both.
- BOOK_SCHEMA_REV goes to 2.
2026-10-23 20:10:36 +02:00

249 lines
9.5 KiB
TypeScript

import { act as hookAct, renderHook } from '@testing-library/react';
import type { BookDocument, BookElement, BookSpread } from '@trek/shared';
import { MAX_SPREAD_ELEMENTS, bookPageSetupSchema, bookShapeElementSchema, withSpreadPaint } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
backgroundCandidates,
backgroundPhotoOf,
backgroundToAll,
bleedFrame,
withBackgroundPhoto,
withoutBackgroundPhoto,
} from '../../../src/components/Studio/paint/backgroundPhoto';
import { usePaintChannel } from '../../../src/components/Studio/paint/usePaintChannel';
import { StudioBackgroundSection } from '../../../src/components/Studio/StudioBackgroundSection';
import type { JourneySource } from '../../../src/components/Studio/StudioSidebar';
import { useStudioStore } from '../../../src/store/studioStore';
import { act, 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 page background: its paint, the same background on every page, and a
* photograph under the whole page; and the channel that makes a drag one undo.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 });
const frame = { x: 20, y: 20, w: 40, h: 40 };
const shape = (id: string): BookElement => bookShapeElementSchema.parse({ id, kind: 'shape', frame }) as BookElement;
const spread = (id: string, over: Partial<BookSpread> = {}): BookSpread => ({
id,
role: 'inner',
background: null,
elements: [],
parked: [],
entryId: null,
...over,
});
const book = (spreads: BookSpread[]): BookDocument => ({ version: 1, title: 'T', page, spreads }) as BookDocument;
const stripes = {
type: 'pattern' as const,
pattern: 'stripes' as const,
fg: '#112233',
bg: '#ffeedd',
scale: 1,
angle: 45,
};
const source: JourneySource = {
entries: [
{
id: 4,
title: 'Porto',
story: null,
location: null,
date: null,
lat: null,
lng: null,
mood: null,
weather: null,
pros: [],
cons: [],
photoIds: [21, 22],
},
],
photos: [
{ photoId: 21, entryIds: [4] },
{ photoId: 30, entryIds: [] },
],
photoEntries: {},
};
describe('a background photo', () => {
it('covers the whole sheet, bleed included, on a spread and on a single page', () => {
expect(bleedFrame(spread('a'), page)).toEqual({ x: -3, y: -3, w: 426, h: 216 });
expect(bleedFrame(spread('c', { role: 'cover' }), page)).toEqual({ x: -3, y: -3, w: 216, h: 216 });
});
it('goes in at the back, locked, and replaces the one there was', () => {
let n = 0;
const newId = () => `bg${++n}`;
const once = need(withBackgroundPhoto(spread('a', { elements: [shape('s1')] }), page, 12, newId));
expect(once.elements.map((e) => e.id)).toEqual(['bg1', 's1']);
expect(once.elements[0]).toMatchObject({
kind: 'photo',
photoId: 12,
locked: true,
fit: 'cover',
frame: { x: -3, w: 426 },
});
expect(backgroundPhotoOf(once, page)?.photoId).toBe(12);
const twice = need(withBackgroundPhoto(once, page, 13, newId));
expect(twice.elements.map((e) => e.id)).toEqual(['bg1', 's1']);
expect(backgroundPhotoOf(twice, page)?.photoId).toBe(13);
expect(withoutBackgroundPhoto(twice, page).elements.map((e) => e.id)).toEqual(['s1']);
const plain = spread('b', { elements: [shape('s1')] });
expect(withoutBackgroundPhoto(plain, page)).toBe(plain);
});
it('is not a photo that only happens to be at the back: unlocked, turned or smaller does not count', () => {
const placed = need(withBackgroundPhoto(spread('a'), page, 5, () => 'p'));
const back = placed.elements[0] as Extract<BookElement, { kind: 'photo' }>;
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, locked: false }] }, page)).toBeNull();
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, rotation: 3 }] }, page)).toBeNull();
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, frame }] }, page)).toBeNull();
expect(backgroundPhotoOf({ ...placed, elements: [{ ...back, photoId: null }] }, page)).toBeNull();
expect(backgroundPhotoOf(undefined, page)).toBeNull();
});
it('is refused on a full page', () => {
const full = spread('a', { elements: Array.from({ length: MAX_SPREAD_ELEMENTS }, (_, i) => shape(`s${i}`)) });
expect(withBackgroundPhoto(full, page, 1, () => 'x')).toBeNull();
});
it('is offered from the page first, then its entry, then the gallery, each once', () => {
expect(backgroundCandidates([3, 4], [4, 5], [5, 6, 7], 4)).toEqual([3, 4, 5, 6]);
});
});
describe('the same background on every page', () => {
it('copies the paint and its colour to the pages of its kind, the covers to the covers', () => {
const from = withSpreadPaint(spread('a'), stripes);
const doc = book([
spread('cv', { role: 'cover', background: '#202020' }),
from,
spread('b', { background: '#000000' }),
spread('l', { role: 'last' }),
]);
const out = backgroundToAll(doc, 1);
expect(out.spreads[2]).toMatchObject({ backgroundPaint: stripes, background: '#ffeedd' });
expect(out.spreads[3]).toMatchObject({ backgroundPaint: stripes });
expect(out.spreads[0]).toBe(doc.spreads[0]);
expect(backgroundToAll(doc, 9)).toBe(doc);
const covers = backgroundToAll(doc, 0);
expect(covers.spreads[2]).toBe(doc.spreads[2]);
const paper = backgroundToAll(book([spread('a'), spread('b', { background: '#000000' })]), 0);
expect(paper.spreads[1].background).toBeNull();
});
});
describe('the background section', () => {
beforeEach(() => {
useStudioStore.getState().load(book([spread('a', { entryId: 4, elements: [shape('s1')] }), spread('b')]));
});
const draw = () => render(<StudioBackgroundSection spreadIndex={0} page={page} source={source} t={(k) => k} />);
const spreads = () => need(useStudioStore.getState().doc).spreads;
it('paints the page, gives every page the same, and says so for a moment', () => {
vi.useFakeTimers();
try {
const { container } = draw();
fireEvent.click(
need(
[...container.querySelectorAll('.st-paint-tab')].find((b) => b.textContent === 'journey.studio.paint.solid')
)
);
expect(spreads()[0].background).toBe('#111111');
const apply = need(container.querySelector('.st-paint-apply'));
fireEvent.click(apply);
expect(spreads()[1].background).toBe('#111111');
expect(apply.textContent).toBe('journey.studio.paint.appliedAll');
act(() => {
vi.advanceTimersByTime(2000);
});
expect(apply.textContent).toBe('journey.studio.paint.applyAll');
} finally {
vi.useRealTimers();
}
});
it('offers the entry and the gallery photos, lays one under the page, and takes it away again', () => {
const { container } = draw();
const thumbs = container.querySelectorAll('.st-bgphoto');
expect(thumbs).toHaveLength(3);
fireEvent.click(thumbs[1]);
expect(backgroundPhotoOf(spreads()[0], page)?.photoId).toBe(22);
expect(need(container.querySelector('.st-bgphoto[aria-pressed="true"] img')).getAttribute('src')).toContain('22');
fireEvent.click(need(container.querySelector('.st-paint-apply.is-danger')));
expect(backgroundPhotoOf(spreads()[0], page)).toBeNull();
fireEvent.click(thumbs[0]);
fireEvent.click(need(container.querySelector('.st-bgphoto[aria-pressed="true"]')));
expect(spreads()[0].elements.map((e) => e.id)).toEqual(['s1']);
});
it('says how to get a photo when the journey has none', () => {
const { container } = render(
<StudioBackgroundSection
spreadIndex={1}
page={page}
source={{ entries: [], photos: [], photoEntries: {} }}
t={(k) => k}
/>
);
expect(container.querySelector('.st-bgphoto')).toBeNull();
expect(container.textContent).toContain('journey.studio.paint.noPhotos');
});
it('draws nothing for a page that is not there', () => {
const { container } = render(<StudioBackgroundSection spreadIndex={9} page={page} source={source} t={(k) => k} />);
expect(container.innerHTML).toBe('');
});
});
describe('the paint channel', () => {
beforeEach(() => {
useStudioStore.getState().load(book([spread('a')]));
});
it('makes a drag one undo step, and a click one of its own', () => {
const { result } = renderHook(() =>
usePaintChannel((paint) => (doc) => ({
...doc,
spreads: doc.spreads.map((sp) => withSpreadPaint(sp, paint)),
}))
);
const store = useStudioStore.getState;
hookAct(() => {
result.current.live({ type: 'solid', color: '#111111', alpha: 0.2 });
result.current.live({ type: 'solid', color: '#111111', alpha: 0.5 });
result.current.commit({ type: 'solid', color: '#111111', alpha: 0.6 });
});
expect(need(store().doc).spreads[0].backgroundPaint).toEqual({ type: 'solid', color: '#111111', alpha: 0.6 });
expect(store().past).toHaveLength(1);
hookAct(() => {
result.current.commit({ type: 'solid', color: '#222222' });
});
expect(need(store().doc).spreads[0].background).toBe('#222222');
expect(store().past).toHaveLength(2);
hookAct(() => {
store().undo();
});
hookAct(() => {
store().undo();
});
expect(need(store().doc).spreads[0].backgroundPaint).toBeUndefined();
expect(need(store().doc).spreads[0].background).toBeNull();
});
});