mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
416 lines
18 KiB
TypeScript
416 lines
18 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
|
|
import { render } from '../../helpers/render'
|
|
import { SpreadView } from '../../../src/components/Studio/SpreadView'
|
|
import { bookPageSetupSchema } from '@trek/shared'
|
|
|
|
/**
|
|
* The page renderer, with the elements added in #1973.
|
|
*
|
|
* `SpreadView` is the *only* renderer — headless Chromium runs this same
|
|
* component tree to make the PDF — so what these check is not "does the editor
|
|
* look right" but "would this print". The two questions that matter are whether
|
|
* an element draws at all, and whether it draws in millimetres.
|
|
*/
|
|
|
|
const page: BookPageSetup = bookPageSetupSchema.parse({
|
|
preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
|
|
})
|
|
|
|
const frame = { x: 10, y: 20, w: 80, h: 60 }
|
|
const common = { frame, rotation: 0, opacity: 1, locked: false }
|
|
const typeset = { font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, stale: false }
|
|
|
|
function spread(elements: BookElement[]): BookSpread {
|
|
return { id: 's1', role: 'inner', background: null, elements, parked: [], entryId: null }
|
|
}
|
|
|
|
function draw(elements: BookElement[], props: { print?: boolean } = {}) {
|
|
return render(<SpreadView spread={spread(elements)} page={page} {...props} />)
|
|
}
|
|
|
|
/**
|
|
* The first element on the sheet.
|
|
*
|
|
* `SpreadView` renders the sheet as one absolutely-positioned div and the
|
|
* elements inside it, so a plain `div > div` selector finds the sheet — which
|
|
* is a div inside Testing Library's own container. This walks in one more.
|
|
*/
|
|
function firstElement(container: HTMLElement): HTMLElement | null {
|
|
const sheet = container.firstElementChild
|
|
return (sheet?.firstElementChild as HTMLElement) ?? null
|
|
}
|
|
|
|
const photo = (over: Record<string, unknown> = {}): BookElement => ({
|
|
...common, id: 'p1', kind: 'photo', photoId: 42, fit: 'cover',
|
|
focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', mask: null, frameStyle: 'none',
|
|
...over,
|
|
} as BookElement)
|
|
|
|
const shape = (over: Record<string, unknown> = {}): BookElement => ({
|
|
...common, id: 's1', kind: 'shape', shape: 'rect', fill: '#112233', gradient: 'none',
|
|
stroke: null, strokeWidth: 0, strokeStyle: 'solid', radius: 0,
|
|
...over,
|
|
} as BookElement)
|
|
|
|
describe('shapes', () => {
|
|
it('draws a rectangle as a box, which is what CSS is good at', () => {
|
|
const { container } = draw([shape()])
|
|
expect(container.querySelector('svg')).toBeNull()
|
|
expect(firstElement(container)!.style.width).toBe('80mm')
|
|
})
|
|
|
|
it('draws anything else as an SVG path', () => {
|
|
const { container } = draw([shape({ shape: 'heart' })])
|
|
const svg = container.querySelector('svg')
|
|
expect(svg).not.toBeNull()
|
|
expect(svg!.querySelector('path')?.getAttribute('d')).toMatch(/^M/)
|
|
})
|
|
|
|
/*
|
|
* The viewBox is in millimetres and the element is that many millimetres
|
|
* wide, so one user unit is one millimetre. That is what keeps a stroke the
|
|
* same width on every side of a non-square frame — the whole reason the path
|
|
* is scaled rather than the coordinate system.
|
|
*/
|
|
it('gives the SVG a viewBox in millimetres, matching its own size', () => {
|
|
const { container } = draw([shape({ shape: 'star-5' })])
|
|
const svg = container.querySelector('svg')!
|
|
expect(svg.getAttribute('viewBox')).toBe('0 0 80 60')
|
|
expect(svg.getAttribute('width')).toBe('80mm')
|
|
expect(svg.getAttribute('height')).toBe('60mm')
|
|
})
|
|
|
|
it('scales the path to the frame, so the drawing fills the box it was snapped to', () => {
|
|
const { container } = draw([shape({ shape: 'diamond' })])
|
|
const d = container.querySelector('path')!.getAttribute('d')!
|
|
const nums = (d.match(/-?\d*\.?\d+/g) ?? []).map(Number)
|
|
const xs = nums.filter((_, i) => i % 2 === 0)
|
|
const ys = nums.filter((_, i) => i % 2 === 1)
|
|
expect(Math.max(...xs)).toBeCloseTo(80, 1)
|
|
expect(Math.max(...ys)).toBeCloseTo(60, 1)
|
|
})
|
|
|
|
it('insets the path by half the stroke, keeping the outline inside the frame', () => {
|
|
const { container } = draw([shape({ shape: 'pentagon', stroke: '#000000', strokeWidth: 2 })])
|
|
const path = container.querySelector('path')!
|
|
expect(path.getAttribute('transform')).toBe('translate(1 1)')
|
|
expect(path.getAttribute('stroke-width')).toBe('2')
|
|
})
|
|
|
|
it('turns a dashed stroke into a dash pattern proportional to its width', () => {
|
|
const { container } = draw([shape({ shape: 'hexagon', stroke: '#000000', strokeWidth: 1, strokeStyle: 'dashed' })])
|
|
expect(container.querySelector('path')!.getAttribute('stroke-dasharray')).toBe('3 2')
|
|
})
|
|
|
|
it('gives a gear the even-odd rule so its middle is a hole', () => {
|
|
const { container } = draw([shape({ shape: 'gear', fill: '#000000' })])
|
|
expect(container.querySelector('path')!.getAttribute('fill-rule')).toBe('evenodd')
|
|
})
|
|
|
|
it('leaves a heart alone, so its subpaths do not knock holes in each other', () => {
|
|
const { container } = draw([shape({ shape: 'heart', fill: '#000000' })])
|
|
expect(container.querySelector('path')!.getAttribute('fill-rule')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('photo frames', () => {
|
|
it('draws a plain photo without a clip path', () => {
|
|
const { container } = draw([photo()])
|
|
expect(container.querySelector('clipPath')).toBeNull()
|
|
expect(container.querySelector('img')).not.toBeNull()
|
|
})
|
|
|
|
it('cuts a masked photo with an objectBoundingBox clip, so it follows a resize', () => {
|
|
const { container } = draw([photo({ mask: 'heart' })])
|
|
const clip = container.querySelector('clipPath')!
|
|
expect(clip.getAttribute('clipPathUnits')).toBe('objectBoundingBox')
|
|
// 0..1 coordinates, not 0..100 — that is what objectBoundingBox means.
|
|
const nums = (clip.querySelector('path')!.getAttribute('d')!.match(/\d*\.?\d+/g) ?? []).map(Number)
|
|
expect(Math.max(...nums)).toBeLessThanOrEqual(1.06)
|
|
})
|
|
|
|
it('treats a rect mask as no mask at all', () => {
|
|
const { container } = draw([photo({ mask: 'rect' })])
|
|
expect(container.querySelector('clipPath')).toBeNull()
|
|
})
|
|
|
|
it('gives a Polaroid a white body and a chin thicker than its sides', () => {
|
|
const { container } = draw([photo({ frameStyle: 'polaroid' })])
|
|
const outer = firstElement(container)!
|
|
expect(outer.style.background).toContain('rgb(255, 255, 255)')
|
|
const inner = outer.querySelector('div') as HTMLElement
|
|
// 5.5% of the short side at the sides, 17% at the bottom.
|
|
expect(inner.style.top).toBe('3.3mm')
|
|
expect(inner.style.bottom).toBe('10.2mm')
|
|
})
|
|
|
|
it('gives film a dark body and sprocket holes', () => {
|
|
const { container } = draw([photo({ frameStyle: 'film' })])
|
|
const outer = firstElement(container)!
|
|
expect(outer.style.background).toContain('rgb(20, 20, 20)')
|
|
// The picture, plus the two sprocket strips.
|
|
expect(outer.querySelectorAll('div').length).toBeGreaterThan(2)
|
|
})
|
|
|
|
/*
|
|
* Two strips, each centred on its corner. Both halves matter: the panel
|
|
* preview draws two as well, and a preview that disagrees with the result
|
|
* makes the second strip look like a bug.
|
|
*/
|
|
it('tapes both top corners, each strip centred on its corner', () => {
|
|
const { container } = draw([photo({ frameStyle: 'tape' })])
|
|
const strips = [...firstElement(container)!.querySelectorAll('div')]
|
|
.filter(d => (d as HTMLElement).style.transform.includes('rotate'))
|
|
expect(strips).toHaveLength(2)
|
|
|
|
const [left, right] = strips as HTMLElement[]
|
|
// 26% of the short side wide, 7.5% tall; half of each is the offset.
|
|
expect(left.style.left).toBe('-7.8mm')
|
|
expect(left.style.top).toBe('-2.25mm')
|
|
expect(right.style.right).toBe('-7.8mm')
|
|
// Mirrored, so the pair reads as symmetric rather than as two of the same.
|
|
expect(left.style.transform).toBe('rotate(-45deg)')
|
|
expect(right.style.transform).toBe('rotate(45deg)')
|
|
})
|
|
|
|
it('applies a filter to the image rather than to the frame around it', () => {
|
|
const { container } = draw([photo({ filter: 'bw' })])
|
|
expect((container.querySelector('img') as HTMLElement).style.filter).toContain('grayscale')
|
|
})
|
|
|
|
/*
|
|
* An empty frame is an instruction to the person laying out the book. Printed,
|
|
* it would be a hatched rectangle on a page somebody paid to have bound.
|
|
*/
|
|
it('shows an empty frame while editing and prints nothing at all', () => {
|
|
const { container: editing } = draw([photo({ photoId: null })], {})
|
|
expect(firstElement(editing)).not.toBeNull()
|
|
|
|
const { container: printing } = draw([photo({ photoId: null })], { print: true })
|
|
expect(printing.querySelector('img')).toBeNull()
|
|
expect(firstElement(printing)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('travel elements', () => {
|
|
const map = (over: Record<string, unknown> = {}): BookElement => ({
|
|
...common, ...typeset, id: 'm1', kind: 'map', style: 'minimal',
|
|
showLand: true, showRoute: true, showPins: true, showLabels: false,
|
|
countries: ['IS'],
|
|
points: [
|
|
{ lat: 64.14, lng: -21.94, label: 'Reykjavík' },
|
|
{ lat: 65.68, lng: -18.12, label: 'Akureyri' },
|
|
],
|
|
...over,
|
|
} as BookElement)
|
|
|
|
it('draws the route as a polyline and its stops as pins', () => {
|
|
const { container } = draw([map()])
|
|
expect(container.querySelector('polyline')).not.toBeNull()
|
|
expect(container.querySelectorAll('circle')).toHaveLength(2)
|
|
})
|
|
|
|
it('draws the country outlines under the route', () => {
|
|
const { container } = draw([map()])
|
|
// One path for Iceland; the route itself is a polyline, not a path.
|
|
expect(container.querySelectorAll('path').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('leaves out the layers that are switched off', () => {
|
|
const { container } = draw([map({ showRoute: false, showPins: false, showLand: false })])
|
|
expect(container.querySelector('polyline')).toBeNull()
|
|
expect(container.querySelectorAll('circle')).toHaveLength(0)
|
|
})
|
|
|
|
it('labels the stops only when asked', () => {
|
|
expect(draw([map()]).container.querySelectorAll('text')).toHaveLength(0)
|
|
const { container } = draw([map({ showLabels: true })])
|
|
expect(container.textContent).toContain('Reykjavík')
|
|
})
|
|
|
|
it('survives a map with no stops and no countries rather than dividing by zero', () => {
|
|
const { container } = draw([map({ points: [], countries: [] })])
|
|
const svg = container.querySelector('svg')!
|
|
expect(svg.getAttribute('viewBox')).toBe('0 0 80 60')
|
|
expect(svg.innerHTML).not.toContain('NaN')
|
|
})
|
|
|
|
it('survives a single stop, where the bounds have no extent', () => {
|
|
const { container } = draw([map({ points: [{ lat: 64.14, lng: -21.94, label: 'one' }] })])
|
|
expect(container.querySelector('svg')!.innerHTML).not.toContain('NaN')
|
|
})
|
|
|
|
it('draws the figures with the values the document holds', () => {
|
|
const stats = {
|
|
...common, ...typeset, id: 'st1', kind: 'stats',
|
|
metrics: ['distance', 'days'], layout: 'grid', showIcons: true, units: 'metric',
|
|
values: { distance: 1_189_000, days: 14 },
|
|
} as unknown as BookElement
|
|
const { container } = draw([stats])
|
|
expect(container.textContent).toContain('1,189')
|
|
expect(container.textContent).toContain('14')
|
|
})
|
|
|
|
it('converts distance to miles without touching the stored metres', () => {
|
|
const stats = {
|
|
...common, ...typeset, id: 'st1', kind: 'stats',
|
|
metrics: ['distance'], layout: 'row', showIcons: false, units: 'imperial',
|
|
values: { distance: 1_609_344 },
|
|
} as unknown as BookElement
|
|
expect(draw([stats]).container.textContent).toContain('1,000')
|
|
})
|
|
|
|
it('names the countries and draws their silhouettes', () => {
|
|
const countries = {
|
|
...common, ...typeset, id: 'c1', kind: 'countries',
|
|
codes: ['IS', 'NO'], names: ['Iceland', 'Norway'],
|
|
layout: 'list', showOutline: true, showFlag: false, showName: true, align: 'center',
|
|
} as unknown as BookElement
|
|
const { container } = draw([countries])
|
|
expect(container.textContent).toContain('Iceland')
|
|
expect(container.textContent).toContain('Norway')
|
|
expect(container.querySelectorAll('svg').length).toBe(2)
|
|
})
|
|
|
|
/*
|
|
* The bundled boundaries do not carry every dependency and territory as its
|
|
* own feature, so some codes have no silhouette. The name still has to print
|
|
* — a country page that silently drops a country would be worse than one
|
|
* without a drawing next to it.
|
|
*/
|
|
it('still names a country whose silhouette is not bundled', () => {
|
|
const countries = {
|
|
...common, ...typeset, id: 'c1', kind: 'countries',
|
|
codes: ['FO'], names: ['Faroe Islands'],
|
|
layout: 'list', showOutline: true, showFlag: false, showName: true, align: 'center',
|
|
} as unknown as BookElement
|
|
const { container } = draw([countries])
|
|
expect(container.textContent).toContain('Faroe Islands')
|
|
expect(container.querySelectorAll('svg')).toHaveLength(0)
|
|
})
|
|
|
|
it('leaves out the outlines when they are switched off', () => {
|
|
const countries = {
|
|
...common, ...typeset, id: 'c1', kind: 'countries',
|
|
codes: ['IS'], names: ['Iceland'],
|
|
layout: 'list', showOutline: false, showFlag: false, showName: true, align: 'center',
|
|
} as unknown as BookElement
|
|
expect(draw([countries]).container.querySelectorAll('svg')).toHaveLength(0)
|
|
})
|
|
|
|
it('draws a badge with its figure and its caption', () => {
|
|
const badge = {
|
|
...common, ...typeset, id: 'b1', kind: 'badge',
|
|
variant: 'date', text: '13', sub: 'APRIL', code: null, style: 'stacked',
|
|
} as unknown as BookElement
|
|
const { container } = draw([badge])
|
|
expect(container.textContent).toContain('13')
|
|
expect(container.textContent).toContain('APRIL')
|
|
})
|
|
|
|
/*
|
|
* Drawn, not typed. A regional-indicator pair renders as the two letters on
|
|
* Windows, which is where most readers are — see the note in flags.ts.
|
|
*/
|
|
/*
|
|
* ── What colour a mark's words are ────────────────────────────────────
|
|
*
|
|
* A chip is a filled capsule, so its words answer to the fill rather than to
|
|
* the page. Ink on ink is the failure this prevents, and it is not a subtle
|
|
* one: a black day counter on a black chip is an empty capsule.
|
|
*/
|
|
it('sets a chip on a dark fill in paper, not in ink', () => {
|
|
const badge = {
|
|
...common, ...typeset, id: 'b1', kind: 'badge',
|
|
variant: 'day', text: 'DAY 1', sub: '', code: null, style: 'chip',
|
|
accent: '#111111', color: '#111111', autoColor: true,
|
|
} as unknown as BookElement
|
|
const { container } = draw([badge])
|
|
const html = container.innerHTML
|
|
expect(html).toContain('rgb(255, 255, 255)')
|
|
})
|
|
|
|
it('sets a chip on a pale fill in ink', () => {
|
|
const badge = {
|
|
...common, ...typeset, id: 'b1', kind: 'badge',
|
|
variant: 'day', text: 'DAY 1', sub: '', code: null, style: 'chip',
|
|
accent: '#f4efe6', color: '#ffffff', autoColor: true,
|
|
} as unknown as BookElement
|
|
expect(draw([badge]).container.innerHTML).toContain('rgb(28, 27, 25)')
|
|
})
|
|
|
|
/* Automatic is a good default and a bad cage: a chosen colour wins outright. */
|
|
it('uses the chosen colour once automatic is off, fill or no fill', () => {
|
|
const badge = {
|
|
...common, ...typeset, id: 'b1', kind: 'badge',
|
|
variant: 'day', text: 'DAY 1', sub: '', code: null, style: 'chip',
|
|
accent: '#111111', color: '#c81e4a', autoColor: false,
|
|
} as unknown as BookElement
|
|
// The badge's own words, not the page behind it — the paper is white here,
|
|
// so asking the whole tree would pass whatever the mark did.
|
|
const words = draw([badge]).getByText('DAY 1')
|
|
expect(words.style.color).toBe('rgb(200, 30, 74)')
|
|
})
|
|
|
|
it('draws a flag badge as geometry rather than as an emoji', () => {
|
|
const badge = {
|
|
...common, ...typeset, id: 'b1', kind: 'badge',
|
|
variant: 'flag', text: '', sub: 'Iceland', code: 'IS', style: 'plain',
|
|
} as unknown as BookElement
|
|
const { container } = draw([badge])
|
|
expect(container.textContent).not.toContain('\u{1F1EE}\u{1F1F8}')
|
|
// Iceland is a Nordic cross: a field, an upright, a crossbar and two inner
|
|
// bars, plus the hairline that keeps a white edge off a white page.
|
|
expect(container.querySelectorAll('svg rect')).toHaveLength(6)
|
|
expect(container.querySelector('svg')!.getAttribute('viewBox')).toBe('0 0 30 20')
|
|
})
|
|
|
|
it('falls back to the outline for a country whose flag it cannot draw exactly', () => {
|
|
const badge = {
|
|
...common, ...typeset, id: 'b1', kind: 'badge',
|
|
variant: 'flag', text: '', sub: 'United Kingdom', code: 'GB', style: 'plain',
|
|
} as unknown as BookElement
|
|
const { container } = draw([badge])
|
|
// The silhouette, not a construction: one path, no bands.
|
|
expect(container.querySelectorAll('svg rect')).toHaveLength(0)
|
|
expect(container.querySelectorAll('svg path')).toHaveLength(1)
|
|
})
|
|
|
|
it('positions every travel element in millimetres, like everything else on the page', () => {
|
|
const badge = {
|
|
...common, ...typeset, id: 'b1', kind: 'badge',
|
|
variant: 'day', text: 'DAY 1', sub: '', code: null, style: 'chip',
|
|
} as unknown as BookElement
|
|
const el = firstElement(draw([badge]).container)!
|
|
expect(el.style.left).toBe('10mm')
|
|
expect(el.style.top).toBe('20mm')
|
|
expect(el.style.width).toBe('80mm')
|
|
})
|
|
})
|
|
|
|
describe('text typefaces (#2183)', () => {
|
|
const text = (font: string): BookElement => ({
|
|
...common, id: 't1', kind: 'text', text: 'A heading', font,
|
|
size: 11, weight: 400, italic: false, align: 'left',
|
|
leading: 1.45, tracking: 0, color: '#1a1a1a',
|
|
} as unknown as BookElement)
|
|
|
|
it('sets every picker font on the element, not just the three the old map knew', () => {
|
|
// The regression: a second stack map in bookRender.ts covered sans/serif/
|
|
// display only, so playfair and friends silently inherited Poppins.
|
|
expect(firstElement(draw([text('playfair')]).container)!.style.fontFamily).toContain('Playfair Display')
|
|
expect(firstElement(draw([text('inter')]).container)!.style.fontFamily).toContain('Inter')
|
|
expect(firstElement(draw([text('garamond')]).container)!.style.fontFamily).toContain('EB Garamond')
|
|
expect(firstElement(draw([text('bebas')]).container)!.style.fontFamily).toContain('Bebas Neue')
|
|
})
|
|
|
|
it('serif means Lora, matching the picker, not the bare system Georgia', () => {
|
|
expect(firstElement(draw([text('serif')]).container)!.style.fontFamily).toContain('Lora')
|
|
})
|
|
|
|
it('an unknown family still falls back to Poppins instead of the machine default', () => {
|
|
expect(firstElement(draw([text('comic-sans')]).container)!.style.fontFamily).toContain('Poppins')
|
|
})
|
|
})
|