Files
TREK/client/tests/unit/studio/pageSetup.test.tsx
T
Maurice 4570ace78c feat(journey): the shared book engine lays out, edits and designs whole books
Every element kind and Studio tool gets an op, the auto layout and its
templates move from the client to shared with a parity fixture, and a pure
design engine turns directives into a finished book with design findings.
2026-10-22 21:34:15 +00:00

176 lines
7.2 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import type { BookPageSetup, BookSpread } from '@trek/shared'
import { bookPageSetupSchema, normalizeBookDocument, relayoutSpread, type AutoInput } from '@trek/shared'
import { render } from '../../helpers/render'
import { SpreadView } from '../../../src/components/Studio/SpreadView'
import { PAGE_MAX_MM, PAGE_MIN_MM, PAGE_PRESETS, clampPageSize } from '../../../src/components/Studio/pagePresets'
/**
* A free trim size, page numbers, and laying out again (#1973).
*/
type PageOver = Partial<Omit<BookPageSetup, 'pageNumbers'>> & {
pageNumbers?: Partial<BookPageSetup['pageNumbers']>
}
const page = (over: PageOver = {}): BookPageSetup =>
bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, ...over })
const spread = (over: Partial<BookSpread> = {}): BookSpread => ({
id: 's1', role: 'inner', background: null, elements: [], parked: [], entryId: null, ...over,
})
describe('a custom trim size', () => {
it('is offered as a preset of its own', () => {
expect(PAGE_PRESETS.custom).toBeTruthy()
expect(PAGE_PRESETS.custom.id).toBe('custom')
})
it('is what the document contract already allowed', () => {
const parsed = page({ preset: 'custom', pageWidth: 240, pageHeight: 180 })
expect(parsed.preset).toBe('custom')
expect(parsed.pageWidth).toBe(240)
})
/*
* The floor is a passport-sized book and the ceiling is what a large press
* takes — past either, the editor is scaling to a smear rather than showing
* a page.
*/
it('holds a typed size inside what a press can print', () => {
expect(clampPageSize(10)).toBe(PAGE_MIN_MM)
expect(clampPageSize(9000)).toBe(PAGE_MAX_MM)
expect(clampPageSize(240)).toBe(240)
})
it('rounds to a tenth of a millimetre, not to whatever was typed', () => {
expect(clampPageSize(210.06)).toBe(210.1)
})
it('refuses nonsense rather than passing NaN into the document', () => {
expect(clampPageSize(Number.NaN)).toBe(PAGE_MIN_MM)
})
})
describe('page numbers', () => {
it('are off until asked for', () => {
expect(page().pageNumbers.show).toBe(false)
const { container } = render(<SpreadView spread={spread()} page={page()} folios={[2, 3]} />)
expect(container.textContent).toBe('')
})
it('number both pages of a spread, left then right', () => {
const { container } = render(
<SpreadView spread={spread()} page={page({ pageNumbers: { show: true } })} folios={[2, 3]} />,
)
expect(container.textContent).toBe('23')
})
/*
* The numbers come from the caller (foliosOf, tested with bookSheets): the
* sheet draws whatever it is handed, so a spread deep in the book reads
* what the book says it is.
*/
it('draw the numbers they are handed, wherever the spread sits', () => {
const p = page({ pageNumbers: { show: true } })
const third = render(<SpreadView spread={spread()} page={p} folios={[6, 7]} />)
expect(third.container.textContent).toBe('67')
})
/* A folio on a cover is a mistake in every book ever bound. */
it('never appear on the cover or the back cover', () => {
const p = page({ pageNumbers: { show: true } })
for (const role of ['cover', 'back'] as const) {
const { container } = render(<SpreadView spread={spread({ role })} page={p} folios={[1]} />)
expect(container.textContent, role).toBe('')
}
})
/* The single first page is a right-hand leaf and the last a left-hand one (#2317). */
it('put one number on a first or last page, on the side it is bound', () => {
const p = page({ pageNumbers: { show: true, position: 'outer' } })
const first = render(<SpreadView spread={spread({ role: 'first' })} page={p} folios={[1]} />)
const last = render(<SpreadView spread={spread({ role: 'last' })} page={p} folios={[9]} />)
expect(first.container.textContent).toBe('1')
expect(last.container.textContent).toBe('9')
const box = (c: HTMLElement) => (c.firstElementChild!.children[0]) as HTMLElement
// Outer on a right-hand page is the right edge; on a left-hand page the left.
expect(box(first.container).style.textAlign).toBe('right')
expect(box(last.container).style.textAlign).toBe('left')
})
it('draw nothing for a numbered page handed no numbers', () => {
const p = page({ pageNumbers: { show: true } })
const { container } = render(<SpreadView spread={spread()} page={p} folios={[]} />)
expect(container.textContent).toBe('')
})
it('sit against the cut edge on outer, and in the gutter on inner', () => {
const outer = render(
<SpreadView spread={spread()} page={page({ pageNumbers: { show: true, position: 'outer' } })} folios={[2, 3]} />,
)
const inner = render(
<SpreadView spread={spread()} page={page({ pageNumbers: { show: true, position: 'inner' } })} folios={[2, 3]} />,
)
const leftOf = (c: HTMLElement) =>
parseFloat(((c.firstElementChild!.children[0]) as HTMLElement).style.left)
// Outer puts the left page's number at the margin; inner pushes it in.
expect(leftOf(outer.container)).toBeLessThan(leftOf(inner.container))
})
it('survive a round trip through the document contract', () => {
const doc = normalizeBookDocument({
version: 1, title: 'T',
page: { preset: 'custom', pageWidth: 240, pageHeight: 180, pageNumbers: { show: true, startAt: 5 } },
spreads: [],
})
expect(doc.page.pageNumbers.show).toBe(true)
expect(doc.page.pageNumbers.startAt).toBe(5)
expect(doc.page.pageWidth).toBe(240)
})
it('default in for a document written before they existed', () => {
const doc = normalizeBookDocument({
version: 1, title: 'T',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210 },
spreads: [],
})
expect(doc.page.pageNumbers.show).toBe(false)
expect(doc.page.pageNumbers.position).toBe('outer')
})
})
describe('laying a spread out again', () => {
const input: AutoInput = {
locale: 'en', title: 'T', subtitle: null, coverPhotoId: null,
entries: [{ id: 7, title: 'A day', story: 'Words.', location: 'Here', date: '2026-06-02', photos: [] }],
page: page(), stats: null, stationsLabel: 'Stations', dayLabel: 'DAY', summaryLabel: 'Trip summary', countriesLabel: 'Countries',
}
it('rebuilds the spread from the entry it came from', () => {
const next = relayoutSpread(spread({ entryId: 7, elements: [] }), input)
expect(next).not.toBeNull()
expect(next!.entryId).toBe(7)
expect(next!.elements.length).toBeGreaterThan(0)
})
/*
* Keeping the id is what makes it read as the same page rearranged: the rail
* does not scroll and the selection does not jump to a different card.
*/
it('keeps the spread id, so the rail does not jump', () => {
const next = relayoutSpread(spread({ id: 'keep-me', entryId: 7 }), input)
expect(next!.id).toBe('keep-me')
})
it('refuses a spread that did not come from an entry', () => {
expect(relayoutSpread(spread({ entryId: null }), input)).toBeNull()
expect(relayoutSpread(spread({ role: 'cover', entryId: 7 }), input)).toBeNull()
expect(relayoutSpread(spread({ role: 'back', entryId: 7 }), input)).toBeNull()
})
it('refuses when the entry is gone from the journey', () => {
expect(relayoutSpread(spread({ entryId: 999 }), input)).toBeNull()
})
})