Files
TREK/client/tests/unit/studio/autoLayout.travel.test.ts
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

368 lines
14 KiB
TypeScript

import { describe, it, expect } from 'vitest'
/*
* These cases are about the layouts written as code in the shared auto layout
* (shared/src/book/auto), 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 inputs pass an
* empty template set on purpose.
*/
import type { AutoEntry, AutoInput, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
import { bookPageSetupSchema, buildBook, emptyBook } from '@trek/shared'
/**
* The auto layout, once it knows what the journey adds up to (#1973).
*
* The rule these all circle: a page is added because there is something to put
* on it. A summary spread over a journey with no coordinates, or a country page
* for a trip that never left one country, is padding — and padding in a book
* someone pays to have printed is worse than a shorter book.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({
preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
})
const entry = (over: Partial<AutoEntry> = {}): AutoEntry => ({
id: 1, title: 'A day', story: 'Something happened.', location: 'Reykjavík',
date: '2026-06-02', photos: [], ...over,
})
const stats = (over: Partial<JourneyStats> = {}): JourneyStats => ({
journeyId: 6,
distance: 1_189_000,
days: 14,
steps: 14,
photos: 57,
places: 21,
furthest: 408_000,
countries: [{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }],
points: [
{ lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS', tripId: null, photoId: null, entryId: null },
{ lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS', tripId: null, photoId: null, entryId: null },
],
trips: [],
excluded: [],
start: '2026-06-02',
end: '2026-06-15',
...over,
})
function build(over: Partial<AutoInput> = {}) {
return buildBook({
locale: 'en',
title: 'Iceland',
subtitle: null,
coverPhotoId: null,
stationsLabel: 'Stations',
dayLabel: 'DAY',
summaryLabel: 'Trip summary',
countriesLabel: 'Countries',
entries: [entry()],
page,
stats: stats(),
templates: [],
...over,
})
}
type ElementKind = BookSpread['elements'][number]['kind']
const kinds = (sp: BookSpread) => sp.elements.map(e => e.kind)
/** `Array.prototype.at` is ES2022; the client's lib target predates it. */
const last = <T,>(xs: T[]): T => xs[xs.length - 1]
const has = (doc: { spreads: BookSpread[] }, kind: ElementKind) =>
doc.spreads.some(sp => kinds(sp).includes(kind))
const find = (doc: { spreads: BookSpread[] }, kind: ElementKind) =>
doc.spreads.flatMap(sp => sp.elements).find(e => e.kind === kind)
describe('the summary spread', () => {
it('is placed, with the route and the figures', () => {
const doc = build()
expect(has(doc, 'map')).toBe(true)
expect(has(doc, 'stats')).toBe(true)
})
it('comes straight after the cover and the first page, before the entries', () => {
const doc = build()
expect(doc.spreads[0].role).toBe('cover')
expect(doc.spreads[1].role).toBe('first')
expect(kinds(doc.spreads[2])).toContain('map')
})
it('carries the journey figures rather than placeholders', () => {
const el = find(build(), 'stats')!
expect(el.kind === 'stats' && el.values.distance).toBe(1_189_000)
expect(el.kind === 'stats' && el.values.days).toBe(14)
})
it('draws the route from the journey stops', () => {
const el = find(build(), 'map')!
expect(el.kind === 'map' && el.points).toHaveLength(2)
expect(el.kind === 'map' && el.countries).toEqual(['IS'])
})
/*
* The point of the whole feature: a figure that is zero is not a fact worth
* a tile, it is a hole in the composition.
*/
it('leaves out the figures the journey does not have', () => {
const el = find(build({ stats: stats({ photos: 0, furthest: 0 }) }), 'stats')!
expect(el.kind === 'stats' && el.metrics).not.toContain('photos')
expect(el.kind === 'stats' && el.metrics).not.toContain('furthest')
expect(el.kind === 'stats' && el.metrics).toContain('distance')
})
it('has no map when the journey has no route to draw', () => {
const doc = build({ stats: stats({ points: [] }) })
expect(has(doc, 'map')).toBe(false)
// The figures are still worth a page.
expect(has(doc, 'stats')).toBe(true)
})
it('is skipped entirely when there is nothing to summarise', () => {
const doc = build({
stats: stats({
points: [], distance: 0, days: 0, steps: 0, photos: 0, places: 0, furthest: 0, countries: [],
}),
})
// Cover, first page, the one entry, last page, back cover — no summary, no country page.
expect(doc.spreads.map(sp => sp.role)).toEqual(['cover', 'first', 'inner', 'last', 'back'])
expect(has(doc, 'map')).toBe(false)
expect(has(doc, 'stats')).toBe(false)
})
it('is skipped when the figures could not be fetched at all', () => {
const doc = build({ stats: null })
expect(has(doc, 'map')).toBe(false)
expect(has(doc, 'stats')).toBe(false)
expect(has(doc, 'countries')).toBe(false)
// And the book still builds: cover, first page, the entry, last page, back cover.
expect(doc.spreads).toHaveLength(5)
})
})
describe('the country page', () => {
const twoCountries = stats({
countries: [
{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' },
{ code: 'NO', name: 'Norway', places: 3, firstVisit: '2026-06-16' },
],
})
it('is placed for a journey that crossed a border', () => {
const el = find(build({ stats: twoCountries }), 'countries')!
expect(el.kind === 'countries' && el.codes).toEqual(['IS', 'NO'])
})
/*
* One country on a page of its own says nothing the cover did not already
* say, and every page in a printed book costs money.
*/
it('is skipped for a journey that stayed in one country', () => {
expect(has(build(), 'countries')).toBe(false)
})
it('names the countries in the book language, not the API language', () => {
const el = find(build({ stats: twoCountries, locale: 'de' }), 'countries')!
expect(el.kind === 'countries' && el.names).toEqual(['Island', 'Norwegen'])
})
it('falls back to the API name when the locale has none', () => {
const el = find(build({ stats: twoCountries, locale: 'en' }), 'countries')!
expect(el.kind === 'countries' && el.names).toEqual(['Iceland', 'Norway'])
})
it('sits between the summary and the entries', () => {
const doc = build({ stats: twoCountries })
const order = doc.spreads.map(sp => kinds(sp).find(k => k === 'map' || k === 'countries') ?? sp.role)
expect(order.slice(0, 4)).toEqual(['cover', 'first', 'map', 'countries'])
})
})
describe('the back cover', () => {
/*
* It used to build its tally as `${places} Orte` — German, hardcoded, in a
* book that follows the app's language everywhere else.
*/
it('closes with a figures element rather than a built string', () => {
const back = last(build().spreads)
expect(back.role).toBe('back')
expect(kinds(back)).toContain('stats')
expect(back.elements.every(e => e.kind !== 'text' || !/Orte|Fotos/.test(e.text))).toBe(true)
})
it('sets that tally quietly, against the dark card', () => {
const back = last(build().spreads)
const el = back.elements.find(e => e.kind === 'stats')!
expect(el.kind === 'stats' && el.showIcons).toBe(false)
expect(el.opacity).toBeLessThan(1)
})
/*
* places and steps are separate figures — a journal with entries but no
* linked trip has steps and no places at all, and the tile printed "0".
*/
it('leaves out the places tally when the journey has none', () => {
const back = last(build({ stats: stats({ places: 0, steps: 4 }) }).spreads)
const el = back.elements.find(e => e.kind === 'stats')!
expect(el.kind === 'stats' && el.metrics).not.toContain('places')
})
it('still closes the book when there are no figures', () => {
const back = last(build({ stats: null }).spreads)
expect(back.role).toBe('back')
expect(kinds(back)).toContain('text')
})
})
describe('the entry spreads', () => {
it('rules the heading with a short accent line', () => {
const doc = build()
const spread = doc.spreads.find(sp => sp.entryId === 1)!
const rule = spread.elements.find(e => e.kind === 'shape' && e.frame.h < 1)
expect(rule).toBeTruthy()
expect(rule!.frame.w).toBeLessThan(20)
})
/*
* The one spread with room to spare — the left page is otherwise blank, and
* a date set as a numeral turns a gap in the book into a pause in it.
*/
it('sets the date as a numeral on an entry with no photographs', () => {
const doc = build()
const spread = doc.spreads.find(sp => sp.entryId === 1)!
const badge = spread.elements.find(e => e.kind === 'badge')
expect(badge).toBeTruthy()
expect(badge!.kind === 'badge' && badge.variant).toBe('date')
expect(badge!.kind === 'badge' && badge.text).toBe('2')
expect(badge!.kind === 'badge' && badge.sub).toBe('JUNE')
})
it('sets that numeral in the book language', () => {
const doc = build({ locale: 'de' })
const spread = doc.spreads.find(sp => sp.entryId === 1)!
const badge = spread.elements.find(e => e.kind === 'badge')!
expect(badge.kind === 'badge' && badge.sub).toBe('JUNI')
})
it('leaves out the numeral on an undated entry rather than printing a blank', () => {
const doc = build({ entries: [entry({ date: null })] })
const spread = doc.spreads.find(sp => sp.entryId === 1)!
expect(spread.elements.some(e => e.kind === 'badge')).toBe(false)
})
/*
* The marks came back on purpose. A page carrying a picture, a date numeral
* and a day chip is what a printed travel book looks like; the same page with
* only the picture is what a document looks like.
*/
it('gives a photo entry the marks that make it a printed page', () => {
const doc = build({
entries: [entry({ photos: [{ photoId: 1, width: 4000, height: 3000, caption: null }] })],
})
const spread = doc.spreads.find(sp => sp.entryId === 1)!
expect(spread.elements.some(e => e.kind === 'photo')).toBe(true)
expect(spread.elements.some(e => e.kind === 'badge')).toBe(true)
})
})
describe('the document as a whole', () => {
it('stays within the schema cap on spreads', () => {
const many = Array.from({ length: 200 }, (_, i) => entry({ id: i + 1 }))
expect(build({ entries: many }).spreads.length).toBeLessThanOrEqual(150)
})
it('gives every spread and every element an id of its own', () => {
const doc = build({ stats: stats({ countries: [
{ code: 'IS', name: 'Iceland', places: 4, firstVisit: '2026-06-02' },
{ code: 'NO', name: 'Norway', places: 2, firstVisit: '2026-06-16' },
] }) })
const ids = doc.spreads.flatMap(sp => [sp.id, ...sp.elements.map(e => e.id)])
expect(new Set(ids).size).toBe(ids.length)
})
it('opens on the cover and closes on the back cover', () => {
const doc = build()
expect(doc.spreads[0].role).toBe('cover')
expect(last(doc.spreads).role).toBe('back')
expect(doc.spreads.filter(sp => sp.role === 'cover')).toHaveLength(1)
expect(doc.spreads.filter(sp => sp.role === 'back')).toHaveLength(1)
})
})
/**
* What a journey with no book opens on (#1973).
*
* The auto layout used to run on first open, which decided what the book was
* before its author had said anything. It is now on the Auto layout menu and
* this is what takes its place: a book with nothing on its pages, and both
* covers present because the pages rail can only ever insert between them.
*/
describe('an empty book', () => {
const input: AutoInput = {
locale: 'en',
title: 'Iceland',
subtitle: null,
coverPhotoId: null,
stationsLabel: 'Stations',
dayLabel: 'DAY',
summaryLabel: 'Trip summary',
countriesLabel: 'Countries',
entries: [entry()],
page,
stats: stats(),
templates: [],
}
it('has a cover, a first page, a spread, a last page and a back cover, and nothing on any of them', () => {
const doc = emptyBook(input)
expect(doc.spreads.map(s => s.role)).toEqual(['cover', 'first', 'inner', 'last', 'back'])
expect(doc.spreads.every(s => s.elements.length === 0)).toBe(true)
expect(doc.spreads.every(s => s.background === null)).toBe(true)
})
/*
* A bound book opens onto page 1, the single right-hand page behind the
* cover (#2317). The default setup counts from 2, which was right while the
* book opened straight onto a spread; a book laid down with a first page
* counts from that page instead, whatever setup it was handed.
*/
it('numbers its first page 1, whatever the setup said', () => {
expect(input.page.pageNumbers.startAt).toBe(2)
expect(emptyBook(input).page.pageNumbers.startAt).toBe(1)
expect(buildBook(input).page.pageNumbers.startAt).toBe(1)
// Nothing else of the setup moves.
expect(emptyBook(input).page.pageNumbers.position).toBe(input.page.pageNumbers.position)
})
it('lays the first and last pages down empty in a laid-out book too', () => {
const doc = buildBook(input)
const first = doc.spreads.find(s => s.role === 'first')!
const last = doc.spreads.find(s => s.role === 'last')!
expect(doc.spreads.indexOf(first)).toBe(1)
expect(doc.spreads.indexOf(last)).toBe(doc.spreads.length - 2)
expect(first.elements).toEqual([])
expect(last.elements).toEqual([])
})
it('carries the journey title and the page setup it was given', () => {
const doc = emptyBook(input)
expect(doc.title).toBe('Iceland')
expect(doc.page.pageWidth).toBe(page.pageWidth)
})
it('gives every spread its own id', () => {
const ids = emptyBook(input).spreads.map(s => s.id)
expect(new Set(ids).size).toBe(ids.length)
})
/*
* The layout is still there, on the menu. This is the difference between
* offering it and having already applied it.
*/
it('is what the auto layout replaces when somebody asks for it', () => {
expect(buildBook(input).spreads.length).toBeGreaterThan(emptyBook(input).spreads.length)
})
})