Files
TREK/client/tests/unit/studio/autoLayout.stations.test.ts
T
Konstantinos Thermos 9e0fb19e79 fix(studio): keep the auto layout inside the book contract
Auto layout wrote journey data straight into the document without holding
it to the caps the contract sets, and the save path sends the document
unvalidated - so the route refused the whole body with a 400 and the
editor reported "couldn't save" for the rest of the session, because
autosave kept offering the same rejected book.

The everyday way in was the itinerary list: thirteen stops a page over
two pages is eighty elements on a spread capped at sixty, so a run of
twenty bare stops was unsavable and a longer one put two spreads over.
Sixty was below what this app's own layouts draw, which makes it a bug
with a constant in front of it rather than a limit, so the cap goes to
ninety and the page keeps the design somebody chose. It stays a
serialisation limit: the largest document the contract now admits is
under four megabytes against the book route's eight.

A long journal entry, a long place name, a journey past eighty countries
and a long title each did the same thing on their own, and a bound story
did it on open without anyone touching Auto layout. The caps become named
exports on the contract and every producer builds against them, so the
two cannot drift apart again - which is also why the stations chunk is
derived from the cap and the salvage fixture counts off it.

Fixes #2085
2026-08-28 22:01:06 +02:00

187 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from 'vitest'
/*
* These cases are about the layouts written in autoLayout.ts, which are what an
* entry falls back on when no hand-drawn template suits it. With the real
* templates in place they would be testing whichever spread somebody last drew
* in Studio — so the set is empty here on purpose.
*/
vi.mock('../../../src/components/Studio/bookTemplates.data', () => ({ SPREAD_TEMPLATES: [] }))
import { bookDocumentSchema, bookPageSetupSchema, type BookSpread } from '@trek/shared'
import { buildBook, type AutoEntry, type AutoInput } from '../../../src/components/Studio/autoLayout'
/**
* A journey with little in it (#1973).
*
* A journey built from a trip starts as one skeleton entry per stop: a date, a
* place name, nothing else. Each of those used to take a spread of its own — a
* numeral on the left, a heading on the right, and two-thirds of a metre of
* empty paper. Ten stops made ten of them, and the book was a stack of blanks.
*
* They share a page now. What follows is that, and the two things it must not
* break: the order the journey happened in, and the entries that do have
* something on them.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210 })
const bare = (id: number, over: Partial<AutoEntry> = {}): AutoEntry => ({
id, title: `Stop ${id}`, story: null, location: 'Berlin', date: '2026-07-07', photos: [], ...over,
})
const rich = (id: number): AutoEntry => ({
id, title: `Day ${id}`, story: 'Something actually happened here.',
location: 'Berlin', date: '2026-07-08', photos: [],
})
const document = (entries: AutoEntry[]) => buildBook({
locale: 'en', title: 'T', subtitle: null, coverPhotoId: null,
entries, page, stats: null, stationsLabel: 'Stations', dayLabel: 'DAY', summaryLabel: 'Trip summary', countriesLabel: 'Countries',
})
function build(entries: AutoEntry[]): BookSpread[] {
return document(entries).spreads
}
/** The inner spreads, which is where entries land. */
const inner = (spreads: BookSpread[]) => spreads.filter(s => s.role === 'inner')
describe('entries with nothing on them', () => {
it('share one page instead of taking one each', () => {
const spreads = inner(build([bare(1), bare(2), bare(3), bare(4), bare(5)]))
expect(spreads).toHaveLength(1)
})
it('all appear on it', () => {
const spreads = inner(build([bare(1), bare(2), bare(3)]))
const words = spreads[0].elements.map(e => (e as { text?: string }).text ?? '').join(' ')
expect(words).toContain('Stop 1')
expect(words).toContain('Stop 2')
expect(words).toContain('Stop 3')
})
it('carries the heading it was given, translated by the caller', () => {
const spreads = inner(build([bare(1)]))
const words = spreads[0].elements.map(e => (e as { text?: string }).text ?? '').join(' ')
expect(words).toContain('STATIONS')
})
/* Nothing to regenerate a list from, so it is not tied to a single entry. */
it('belongs to no single entry', () => {
expect(inner(build([bare(1), bare(2)]))[0].entryId).toBeNull()
})
it('runs onto a second page when there are more than fit', () => {
const many = Array.from({ length: 30 }, (_, i) => bare(i + 1))
expect(inner(build(many))).toHaveLength(2)
})
/*
* And the list it runs onto is one the server will take (#2085).
*
* This fixture already built the document that could not be saved; it only
* ever counted the spreads. The save route parses against the contract, not
* against normalizeBookDocument, so that is what a layout has to satisfy.
*/
it('produces a document the save route accepts, however long the run', () => {
const many = Array.from({ length: 200 }, (_, i) => bare(i + 1))
expect(bookDocumentSchema.safeParse(document(many)).success).toBe(true)
})
/*
* The old behaviour, restated as the thing that must not come back: one
* spread per stop, each of them nearly empty.
*/
it('does not give ten stops ten spreads', () => {
const spreads = inner(build(Array.from({ length: 10 }, (_, i) => bare(i + 1))))
expect(spreads.length).toBeLessThan(10)
})
})
describe('mixed with real entries', () => {
it('leaves an entry with a story its own spread', () => {
const spreads = inner(build([rich(1)]))
expect(spreads).toHaveLength(1)
expect(spreads[0].entryId).toBe(1)
})
/*
* The order the journey happened in is the order the book is in. Gathering
* every bare entry at the end would be tidier and wrong.
*/
it('keeps the run where it happened, between the entries around it', () => {
const spreads = inner(build([rich(1), bare(2), bare(3), rich(4)]))
expect(spreads.map(s => s.entryId)).toEqual([1, null, 4])
})
it('starts a new list after each entry that interrupts one', () => {
const spreads = inner(build([bare(1), rich(2), bare(3)]))
expect(spreads.map(s => s.entryId)).toEqual([null, 2, null])
})
/* A photograph is substance too, even with nothing written. */
it('treats an entry with a photo as one worth a spread', () => {
const withPhoto: AutoEntry = { ...bare(1), photos: [{ photoId: 5, width: 4000, height: 3000 }] }
expect(inner(build([withPhoto]))[0].entryId).toBe(1)
})
})
describe('an entry with words but no pictures', () => {
/*
* An entry with no photographs is usually one whose photographs have not been
* added yet. The page it deserves is the one it will look like once they are,
* so the layout puts the frames in and leaves them empty — the renderer draws
* them as outlines and a dropped photo fills one.
*/
it('lays out the frames the pictures will go in', () => {
const spread = inner(build([rich(1)]))[0]
const frames = spread.elements.filter(e => e.kind === 'photo')
expect(frames.length).toBeGreaterThan(0)
expect(frames.every(f => (f as { photoId: number | null }).photoId === null)).toBe(true)
})
it('gives the frame most of a page rather than a token strip', () => {
const spread = inner(build([rich(1)]))[0]
const frame = spread.elements.find(e => e.kind === 'photo')!.frame
// A page is 210 × 210. Anything much smaller is decoration, not somewhere
// a photograph goes.
expect(frame.w).toBeGreaterThan(150)
expect(frame.h).toBeGreaterThan(150)
})
/*
* The bug this replaced: everything sat in the middle of the left page and
* the right one was empty, because the layout used half a page width where
* it meant the far page. A spread has two pages and both get used.
*/
it('uses both pages, picture on the left and words on the right', () => {
const spread = inner(build([rich(1)]))[0]
const frame = spread.elements.find(e => e.kind === 'photo')!.frame
expect(frame.x).toBeLessThan(210)
const words = spread.elements.filter(e => e.kind === 'text')
expect(words.length).toBeGreaterThan(0)
// Every line of type starts on the right-hand page.
expect(words.every(w => w.frame.x >= 210)).toBe(true)
})
it('keeps everything inside the sheet', () => {
const spread = inner(build([rich(1)]))[0]
expect(spread.elements.every(e => e.frame.x + e.frame.w <= 420 && e.frame.y + e.frame.h <= 210)).toBe(true)
})
it('still writes the story', () => {
const spread = inner(build([rich(1)]))[0]
const words = spread.elements.map(e => (e as { text?: string }).text ?? '').join(' ')
expect(words).toContain('Something actually happened here.')
})
})
describe('a journey with nothing at all', () => {
it('produces a book of a cover and a back, and no blank pages between', () => {
const spreads = build([])
expect(inner(spreads)).toHaveLength(0)
expect(spreads.map(s => s.role)).toEqual(['cover', 'back'])
})
})