Files
TREK/client/tests/unit/studio/paintBackground.test.tsx
T
Maurice 84e18e4954 fix(studio): paste style, the page background, layouts and fill page keep the engine's rules
Paste style wrote the copied fields straight onto the element, although a
pasted look is meant to be the style operation the assistant sends. It now
runs the engine's own restyle (bookElementStyled): a colour or face pasted by
hand takes the element off the theme, so the next theme applied no longer
brings the old colour back; a plain fill replaces the paint of the shape it
lands on instead of staying hidden under it; and a line takes another line's
look without its points, which are its course, not its look.

A background picked by hand in the inspector kept the page's link to the
theme, so the next theme applied set the page back to the theme's colour. The
picker and the background operation now paint through one helper that takes
the link away (withSpreadPaintByHand). Giving one page's background to every
page carries that page's link along, or takes the others' away when it was
painted by hand.

A built-in layout, a book's own layout and laying a page out again from its
entry ignored locks in the Studio: a locked shape was dropped and a locked
photo poured into a slot unlocked, while the assistant's layout operation left
both where they were. All four now lay a page out around its locked elements
through one function (layoutAroundLocks), which the engine's layout operation
uses too.

Fill page and fill spread kept the photo in its group, so the background
picture was selected and dragged with its caption and listed under the group
at the front. It leaves its group as the engine's fill does, and a group left
with one member goes.
2026-10-24 04:06:27 +00:00

276 lines
11 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();
});
it("gives every page the source page's link to the theme, or takes theirs away when it was painted by hand", () => {
const linked = book([
spread('a', { background: '#f4efe6', backgroundToken: 'paper' }),
spread('b', { background: '#000000', backgroundToken: 'accent' }),
spread('c', { background: '#000000' }),
]);
const following = backgroundToAll(linked, 0);
expect(following.spreads[1]).toMatchObject({ background: '#f4efe6', backgroundToken: 'paper' });
expect(following.spreads[2]).toMatchObject({ background: '#f4efe6', backgroundToken: 'paper' });
const byHand = backgroundToAll(book([spread('a', { background: '#dbeafe' }), ...linked.spreads.slice(1)]), 0);
expect(byHand.spreads[1]).toMatchObject({ background: '#dbeafe' });
expect(byHand.spreads[1].backgroundToken).toBeUndefined();
expect(byHand.spreads[2].backgroundToken).toBeUndefined();
});
});
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('takes the page off the theme colour it followed once a colour is picked by hand', () => {
// A white paper is no colour of its own: the page follows the theme's paper with no background.
useStudioStore.getState().load(book([spread('a', { backgroundToken: 'paper' })]));
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');
expect(spreads()[0].backgroundToken).toBeUndefined();
});
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();
});
});