Files
TREK/client/tests/unit/studio/lineElement.test.tsx
T
Maurice cbf9325e7e fix(studio): the book tools state every field once, and the server typechecks within its memory again
- The element, spread, document, page operation and card unions carry a
  name of their own, so the declarations of every schema that holds them
  refer to them instead of spelling them out again. The shared declarations
  shrink from 9 to 5.5 MB and the server's typecheck from 4.4 to 2.9 GB, so
  the build fits the image's 4 GB again.
- edit_book_page and revise_book_design take add by what the element is,
  with an id of its own; its look follows with style by that id in the same
  call, every look field described once. A change an assistant card kept
  with its look on add still reads and runs.
- restore_book_page states what every element has once, then each kind's
  own fields and the type the five travel kinds share, without defaults; the
  page is read as a spread again before it goes back.
- The theme links are explained once in the page edit's description, and
  style points a text's face, a shape's paint and outline and a photo's crop
  look at their one full description.
- The lockup roles live in a module of their own, so the operation schemas
  no longer import the lockup engine, whose cycle left the operations unset
  in the shared tests.
- The theme switches no longer fall through a comment, the newer document
  test follows the current revision, the line inspector names its stroke
  keys, a text's anchor reads no physical side, two shapes record the
  proportions they are drawn at, and the photo look and line tests follow
  the slider and the folded ornaments.
2026-10-23 22:08:13 +00:00

205 lines
9.6 KiB
TypeScript

import {
bookLineElementSchema,
bookLinePoints,
bookPageSetupSchema,
bookPhotoElementSchema,
bookShapeElementSchema,
type BookDocument,
type BookElement,
type BookLineElement,
type BookSpread,
} from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { LineHandles } from '../../../src/components/Studio/LineHandles';
import { DIVIDER_PRESETS, LINE_PRESETS } from '../../../src/components/Studio/linePresets';
import { constrainPoint } from '../../../src/components/Studio/lineSnap';
import { SpreadView } from '../../../src/components/Studio/SpreadView';
import { EdgeSection } from '../../../src/components/Studio/StudioEdgeControls';
import { StudioInspector } from '../../../src/components/Studio/StudioInspector';
import { StudioLineShelf } from '../../../src/components/Studio/StudioLineShelf';
import { useStudioStore } from '../../../src/store/studioStore';
import { fireEvent, render } from '../../helpers/render';
/**
* The drawn line and the cut edge in the Studio: drawn on the page, placed
* from the shelf, styled in the inspector and shaped by its handles.
*/
const page = bookPageSetupSchema.parse({});
const t = (k: string) => k;
const line = (over: Partial<BookLineElement> = {}) =>
bookLineElementSchema.parse({ id: 'l1', kind: 'line', frame: { x: 230, y: 100, w: 100, h: 4 }, ...over });
function spread(elements: BookElement[]): BookSpread {
return { id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null };
}
function load(elements: BookElement[], selection = elements.slice(0, 1).map((e) => e.id)) {
useStudioStore.getState().load({ version: 1, title: 'T', page, spreads: [spread(elements)] } as BookDocument);
useStudioStore.getState().select(selection);
}
const els = () => useStudioStore.getState().doc?.spreads[0].elements ?? [];
const first = () => els()[0] as BookLineElement;
beforeEach(() => load([]));
describe('on the page', () => {
it('FE-STUDIO-LINEEL-001: draws a line as a stroked body with its heads filled', () => {
const { container } = render(
<SpreadView spread={spread([line({ endCap: 'arrow', dash: 'dashed' })])} page={page} />
);
const paths = [...container.querySelectorAll('svg path')];
expect(paths).toHaveLength(2);
expect(paths[0].getAttribute('stroke')).toBe('#141414');
expect(paths[0].getAttribute('stroke-dasharray')).toBe('1.5 1');
expect(paths[1].getAttribute('fill')).toBe('#141414');
});
it('FE-STUDIO-LINEEL-002: cuts a rectangle with an edge as a path, and clips a photo to its edge', () => {
const frame = { x: 10, y: 10, w: 60, h: 40 };
const rect = bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame, edge: 'zigzag', edgeSize: 3 });
const plain = bookShapeElementSchema.parse({ id: 's2', kind: 'shape', frame });
const photo = bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame, photoId: 4, edge: 'perforated' });
const { container } = render(<SpreadView spread={spread([rect, plain, photo])} page={page} />);
const cut = container.querySelectorAll('svg path')[0];
expect(cut.getAttribute('d')?.split('L').length).toBeGreaterThan(40);
// The clip's id is the photo's with a suffix of its own, so a spread drawn twice keeps them apart.
expect(container.querySelector('clipPath[id^="c-p1-"] path')?.getAttribute('d')).toMatch(/^M0 0L/);
});
});
describe('the shelf', () => {
it('FE-STUDIO-LINEEL-003: offers every line and divider, each labelled, and places it on the right-hand page', () => {
const added: BookElement[] = [];
const { getByLabelText, container } = render(
<StudioLineShelf page={page} single={false} onAdd={(el) => added.push(el)} t={t} />
);
expect(container.querySelectorAll('.st-shape-btn')).toHaveLength(LINE_PRESETS.length + DIVIDER_PRESETS.length);
fireEvent.click(getByLabelText('journey.studio.linePreset.route'));
fireEvent.click(getByLabelText('journey.studio.linePreset.divider-sun'));
const [route, divider] = added as BookLineElement[];
expect(route).toMatchObject({ kind: 'line', dash: 'dotted', curve: 'smooth', startCap: 'dot', endCap: 'arrow' });
expect(route.points).toHaveLength(3);
expect(bookLinePoints(route)[0][0]).toBeCloseTo(262.5, 1);
expect(divider).toMatchObject({ ornament: 'sun', strokeWidth: 0.3 });
});
it('FE-STUDIO-LINEEL-004: places a line on a single page from its own left edge', () => {
const added: BookElement[] = [];
const { getByLabelText } = render(<StudioLineShelf page={page} single onAdd={(el) => added.push(el)} t={t} />);
fireEvent.click(getByLabelText('journey.studio.linePreset.straight'));
expect(bookLinePoints(added[0] as BookLineElement)[0][0]).toBeCloseTo(52.5, 1);
});
});
describe('the inspector', () => {
const draw = () =>
render(
<StudioInspector
spreadIndex={0}
page={page}
stats={null}
path={[]}
source={{ entries: [], photos: [], photoEntries: {} }}
setPageNumbers={() => {}}
t={t}
locale="en"
/>
);
it('FE-STUDIO-LINEEL-005: styles a line, its ends and its ornament, refitting the frame for a head', () => {
load([line()]);
const { getByText, getByTitle } = draw();
fireEvent.click(getByText('journey.studio.strokeDashed'));
expect(first().dash).toBe('dashed');
fireEvent.click(getByText('journey.studio.lineCourse.smooth'));
fireEvent.click(getByText('journey.studio.lineBody.wave'));
expect(first()).toMatchObject({ curve: 'smooth', squiggle: 'wave' });
const before = first().frame.h;
fireEvent.click(document.querySelectorAll('[title="journey.studio.cap.arrow"]')[1] as HTMLElement);
expect(first().endCap).toBe('arrow');
expect(first().frame.h).toBeGreaterThan(before);
fireEvent.click(getByText('journey.studio.swapEnds'));
expect(first()).toMatchObject({ startCap: 'arrow', endCap: 'none' });
// The ornaments stay folded away until a line has one.
const ornaments = [...document.querySelectorAll('.st-section-head')].find(
(b) => b.textContent === 'journey.studio.ornamentTitle'
) as HTMLElement;
fireEvent.click(ornaments);
fireEvent.click(getByTitle('journey.studio.ornament.heart'));
expect(first().ornament).toBe('heart');
});
it('FE-STUDIO-LINEEL-006: cuts an edge on a shape from its tiles and sets how deep it bites', () => {
const frame = { x: 10, y: 10, w: 60, h: 40 };
load([bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame })]);
const { getByLabelText, container } = draw();
const head = [...container.querySelectorAll('.st-section-head')].find(
(b) => b.textContent === 'journey.studio.edgeStyle'
) as HTMLElement;
fireEvent.click(head);
fireEvent.click(getByLabelText('journey.studio.edge.perforated'));
expect(els()[0]).toMatchObject({ edge: 'perforated' });
});
});
describe('the edge controls', () => {
it('FE-STUDIO-LINEEL-007: stay away from a shape that takes no edge, and say so where one is set anyway', () => {
const set: unknown[] = [];
const none = render(
<EdgeSection edge={undefined} edgeSize={undefined} outline="heart" onSet={(p) => set.push(p)} t={t} />
);
expect(none.container.innerHTML).toBe('');
const stray = render(<EdgeSection edge="torn" edgeSize={2} outline="heart" onSet={(p) => set.push(p)} t={t} />);
expect(stray.getByText('journey.studio.edgeUnavailable')).toBeTruthy();
fireEvent.click(stray.getByLabelText('journey.studio.edge.straight'));
expect(set).toEqual([{ edge: 'straight' }]);
const photo = render(<EdgeSection edge="scallop" edgeSize={4} outline={null} onSet={(p) => set.push(p)} t={t} />);
const depth = photo.container.querySelector('.st-num input') as HTMLInputElement;
expect(depth.value).toBe('4');
});
});
describe('the handles', () => {
it('FE-STUDIO-LINEEL-008: holds a dragged end level or plumb near it, and to steps with shift', () => {
const level = constrainPoint([100, 1], [0, 0], false);
expect(level[1]).toBe(0);
expect(level[0]).toBeCloseTo(100, 1);
expect(constrainPoint([100, 30], [0, 0], false)).toEqual([100, 30]);
const stepped = constrainPoint([100, 30], [0, 0], true);
expect((Math.atan2(stepped[1], stepped[0]) * 180) / Math.PI).toBeCloseTo(15, 5);
expect(constrainPoint([5, 5], undefined, true)).toEqual([5, 5]);
});
it('FE-STUDIO-LINEEL-009: drags an end on the spread, adds a point from the middle and removes it again', () => {
load([line()]);
const draw = () => render(<LineHandles el={first()} spreadIndex={0} scaled={2} t={t} />);
const { container, unmount } = draw();
const ends = container.querySelectorAll('[title="journey.studio.addPoint"]');
expect(ends).toHaveLength(1);
const handles = container.querySelectorAll('.st-handle');
const end = handles[handles.length - 1] as HTMLElement;
fireEvent.pointerDown(end, { clientX: 0, clientY: 0, pointerId: 1 });
fireEvent.pointerMove(end, { clientX: 20, clientY: 40, pointerId: 1 });
fireEvent.pointerUp(end, { pointerId: 1 });
const moved = bookLinePoints(first());
expect(moved[1][0]).toBeCloseTo(340, 1);
expect(moved[1][1]).toBeCloseTo(122, 1);
unmount();
const again = draw();
const mid = again.container.querySelector('[title="journey.studio.addPoint"]') as HTMLElement;
fireEvent.pointerDown(mid, { clientX: 0, clientY: 0, pointerId: 2 });
fireEvent.pointerUp(mid, { pointerId: 2 });
expect(first().points).toHaveLength(3);
again.unmount();
const third = draw();
fireEvent.doubleClick(third.container.querySelector('[title="journey.studio.removePoint"]') as HTMLElement);
expect(first().points).toHaveLength(2);
});
});