Files
TREK/client/tests/unit/studio/autoLayout.travel.test.ts
Maurice 87c73e50fb feat(studio): give a book its first and its last page (#2317)
A new book was a cover, a spread and a back cover, so it opened straight
onto pages 2 and 3. A bound book does not: the inside of the cover is
blank, and the first thing facing the reader is a single right-hand page,
page 1. The same at the end, where a single left-hand page sits before the
back cover. Both are laid down now, empty, in a new book and in a laid-out
one, and they are fixed the way the covers are: an inner spread only ever
goes between them.

The page numbers follow. They were counted from a spread's position in the
array, which assumed the spread after the cover was the first, and three
places did that sum on their own. One function now counts along the book,
one page for a single leaf and two for a spread, so the sheet on screen and
the label in the print view and the folio on the page all say the same
number. A book with a first page starts at 1; a book from before the first
page existed has none and keeps the numbers it always had.
2026-09-11 11:01:15 +02:00

367 lines
14 KiB
TypeScript

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 type { BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
import { buildBook, emptyBook, type AutoEntry, type AutoInput } from '../../../src/components/Studio/autoLayout'
import { bookPageSetupSchema } 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(),
...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(),
}
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)
})
})