Files
TREK/client/tests/unit/studio/inspector.test.tsx
T

714 lines
28 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { BookDocument, BookElement, BookPageNumbers, BookPageSetup } from '@trek/shared'
import {
bookIconElementSchema, bookPageSetupSchema, bookPhotoElementSchema,
bookShapeElementSchema, bookTextElementSchema,
} from '@trek/shared'
import { fireEvent, render } from '../../helpers/render'
import { StudioInspector } from '../../../src/components/Studio/StudioInspector'
import { useStudioStore } from '../../../src/store/studioStore'
import { BOOK_FONTS } from '../../../src/components/Studio/bookFonts'
import type { JourneySource } from '../../../src/components/Studio/StudioSidebar'
/**
* The inspector, control by control (#1973).
*
* The panel was rebuilt: same job, new shape. Duplicate, lock and delete moved
* out of a section and into the head, half the groups now start folded, every
* boolean became the app's switch, and the number fields stopped writing on
* every keystroke. Nothing in the suite rendered this panel, so any one of
* those controls could have come out of the rebuild writing nothing at all and
* the only thing that would have noticed was somebody laying out a book.
*
* So the assertions here are about the document rather than about the markup:
* what is protected is that the control still writes the field it used to
* write. Where a control has a rule of its own, that rule is pinned too: a
* shape cannot be made invisible, choosing a coordinate notation is not an
* override, an emptied number field leaves the value alone.
*/
const FRAME = { x: 20, y: 30, w: 60, h: 40 }
const text = (over: Record<string, unknown> = {}): BookElement =>
bookTextElementSchema.parse({ id: 't1', kind: 'text', frame: FRAME, text: 'Harbour', ...over }) as BookElement
const photo = (over: Record<string, unknown> = {}): BookElement =>
bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame: FRAME, ...over }) as BookElement
const shape = (over: Record<string, unknown> = {}): BookElement =>
bookShapeElementSchema.parse({ id: 's1', kind: 'shape', frame: FRAME, ...over }) as BookElement
const icon = (over: Record<string, unknown> = {}): BookElement =>
bookIconElementSchema.parse({ id: 'i1', kind: 'icon', frame: FRAME, name: 'Plane', ...over }) as BookElement
const setup = (folios: Partial<BookPageNumbers> = {}): BookPageSetup => bookPageSetupSchema.parse({
preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5, pageNumbers: folios,
})
/** One entry with a point on it, so a coordinate mark has something to re-read. */
const source: JourneySource = {
entries: [{
id: 7, title: 'Bergen', story: null, location: 'Bergen', date: '2026-05-01',
lat: 60.3913, lng: 5.3221, mood: null, weather: null, pros: [], cons: [], photoIds: [],
}],
photos: [],
photoEntries: {},
}
function load(elements: BookElement[], selection = elements.slice(0, 1).map(e => e.id)) {
useStudioStore.getState().load({
version: 1, title: 'T', page: setup(), spreads: [
{ id: 'sp1', role: 'inner', background: null, elements, parked: [], entryId: null },
],
} as BookDocument)
useStudioStore.getState().select(selection)
}
function draw(over: { page?: BookPageSetup; setPageNumbers?: (p: Partial<BookPageNumbers>) => void } = {}) {
return render(
<StudioInspector
spreadIndex={0}
page={over.page ?? setup()}
stats={null}
path={[]}
source={source}
setPageNumbers={over.setPageNumbers ?? (() => {})}
t={(k: string) => k}
locale="en"
/>,
)
}
/** What the store holds now, which is the thing every assertion here is about. */
const el = (i = 0) => useStudioStore.getState().doc!.spreads[0].elements[i]
const elements = () => useStudioStore.getState().doc!.spreads[0].elements
// ── Reaching the controls ──────────────────────────────────────────────────
function head(c: HTMLElement, label: string): HTMLElement {
const found = [...c.querySelectorAll('.st-section-head')]
.find(b => b.querySelector('.st-section-label')?.textContent === label)
return found as HTMLElement
}
/** Half the panel starts folded, so a control inside one has to be uncovered. */
function open(c: HTMLElement, label: string) {
const button = head(c, label)
if (button.getAttribute('aria-expanded') === 'false') fireEvent.click(button)
}
function num(c: HTMLElement, label: string): HTMLInputElement {
const span = [...c.querySelectorAll('.st-num-label')].find(s => s.textContent === label)
return span!.closest('.st-num')!.querySelector('input') as HTMLInputElement
}
/** Every number field the panel is currently showing, by label. */
const numLabels = (c: HTMLElement) => [...c.querySelectorAll('.st-num-label')].map(s => s.textContent)
/** Type and leave, which is the only thing that commits a number now. */
function typeNum(c: HTMLElement, label: string, value: string) {
const input = num(c, label)
fireEvent.change(input, { target: { value } })
fireEvent.blur(input)
}
function line(c: HTMLElement, label: string): HTMLElement {
const found = [...c.querySelectorAll('.st-line')]
.find(l => l.querySelector('.st-line-label')?.textContent === label)
return found as HTMLElement
}
/** The swatch row on one named line, so fill and stroke stay told apart. */
function pickSwatch(c: HTMLElement, lineLabel: string, hex: string) {
fireEvent.click(line(c, lineLabel).querySelector(`[aria-label="${hex}"]`)!)
}
/**
* A group by its heading. A group's first control answers the group's own
* question and carries no label of its own (the text's colour under Colour, a
* shape's paint under Fill), so it is found by the group it sits in.
*/
function section(c: HTMLElement, label: string): HTMLElement {
const found = [...c.querySelectorAll<HTMLElement>('.st-section')]
.find(s => s.querySelector('.st-section-label')?.textContent === label)
if (!found) throw new Error(`no section headed ${label}`)
return found
}
/** The value, or a failed test saying it was missing. */
function must<T>(value: T | null | undefined): T {
if (value == null) throw new Error('expected a value')
return value
}
/** A swatch in the first swatch row of a group. */
function pickSwatchIn(c: HTMLElement, sectionLabel: string, hex: string) {
fireEvent.click(must(section(c, sectionLabel).querySelector(`[aria-label="${hex}"]`)))
}
/** One of several: a chip that reads as chosen. */
function pick(c: HTMLElement, label: string) {
const chip = [...c.querySelectorAll('[role="radio"]')].find(b => b.textContent === label)
fireEvent.click(chip!)
}
/** A kind of paint, from the picker's tabs. */
function tab(c: HTMLElement, label: string) {
const button = [...c.querySelectorAll('.st-paint-tab')].find(b => b.textContent === label)
if (!button) throw new Error(`no ${label} tab`)
fireEvent.click(button)
}
/** On or off: the app switch, on its own line beside the words. */
function toggle(c: HTMLElement, label: string) {
const row = [...c.querySelectorAll('.st-switch')]
.find(s => s.querySelector('.st-switch-label')?.textContent === label)
fireEvent.click(row!.querySelector('button')!)
}
beforeEach(() => {
useStudioStore.getState().load({
version: 1, title: 'T', page: setup(), spreads: [],
} as unknown as BookDocument)
})
// ── The head ───────────────────────────────────────────────────────────────
describe('the panel head', () => {
it('says what is selected', () => {
load([text()])
const { container } = draw()
expect(container.querySelector('.st-what')!.textContent).toContain('journey.studio.kind.text')
})
it('says how many, once there is more than one', () => {
load([text(), shape()], ['t1', 's1'])
const { container } = draw()
const what = container.querySelector('.st-what')!.textContent!
expect(what).toContain('journey.studio.multiple')
expect(what).toContain('2')
})
it('duplicates the selection into the spread', () => {
load([text()])
const { container } = draw()
fireEvent.click(container.querySelector('[aria-label="journey.studio.duplicate"]')!)
expect(elements()).toHaveLength(2)
// Offset, so the copy is a second thing rather than an exact overlay.
expect(elements()[1].frame).toMatchObject({ x: 24, y: 34 })
})
it('locks and unlocks, and the button says which it is', () => {
load([text()])
const { container } = draw()
fireEvent.click(container.querySelector('[aria-label="journey.studio.lock"]')!)
expect(el()).toMatchObject({ locked: true })
const unlock = container.querySelector('[aria-label="journey.studio.unlock"]')!
expect(unlock.getAttribute('aria-pressed')).toBe('true')
fireEvent.click(unlock)
expect(el()).toMatchObject({ locked: false })
})
it('deletes the selection and nothing else', () => {
load([text(), shape()], ['t1'])
const { container } = draw()
fireEvent.click(container.querySelector('[aria-label="journey.studio.delete"]')!)
expect(elements().map(e => e.id)).toEqual(['s1'])
expect(useStudioStore.getState().selection).toEqual([])
})
})
// ── Text ───────────────────────────────────────────────────────────────────
describe('a text element', () => {
it('takes the words typed into it, and stops re-reading its source', () => {
load([text()])
const { container } = draw()
fireEvent.change(container.querySelector('.st-textarea')!, { target: { value: 'Bergen harbour' } })
expect(el()).toMatchObject({ text: 'Bergen harbour', overridden: true })
})
/* The weight is a slider over the weights the family ships, not a row of chips. */
it('sets the weight', () => {
load([text()])
const { container } = draw()
const slider = container.querySelector('input[type="range"][aria-label="journey.studio.weight"]') as HTMLInputElement
const at = BOOK_FONTS.sans.weights.indexOf(600)
expect(at).toBeGreaterThan(-1)
fireEvent.change(slider, { target: { value: String(at) } })
expect(el()).toMatchObject({ weight: 600 })
})
/* The four alignments are named in the reader's language, not by their raw values. */
it('sets the alignment', () => {
load([text()])
const { container } = draw()
expect(container.querySelector('[aria-label="right"]')).toBeNull()
fireEvent.click(must(container.querySelector('[aria-label="journey.studio.align.right"]')))
expect(el()).toMatchObject({ align: 'right' })
fireEvent.click(must(container.querySelector('[aria-label="journey.studio.align.justify"]')))
expect(el()).toMatchObject({ align: 'justify' })
})
/* Italic was drawn by the renderer and had no control at all before. */
it('sets italic, and the button reflects it', () => {
load([text()])
const { container } = draw()
fireEvent.click(container.querySelector('[aria-label="journey.studio.italic"]')!)
expect(el()).toMatchObject({ italic: true })
expect(container.querySelector('[aria-label="journey.studio.italic"]')!.getAttribute('aria-pressed'))
.toBe('true')
})
it('sets the size, the leading and the tracking', () => {
load([text()])
const { container } = draw()
typeNum(container, 'journey.studio.size', '18')
typeNum(container, 'journey.studio.leading', '1.8')
typeNum(container, 'journey.studio.tracking', '0.12')
expect(el()).toMatchObject({ size: 18, leading: 1.8, tracking: 0.12 })
})
it('sets the colour', () => {
load([text()])
const { container } = draw()
pickSwatchIn(container, 'journey.studio.colour', '#9f1239')
expect(el()).toMatchObject({ color: '#9f1239' })
})
it('asks nothing about notation when the text is not a point', () => {
load([text()])
const { container } = draw()
expect(line(container, 'journey.studio.coordsMark')).toBeUndefined()
})
})
describe('a text element bound to a stop', () => {
const bound = () => text({
text: '60 deg N, 5 deg E',
binding: { source: 'entry.location', entryId: 7, format: 'dms' },
})
it('offers the two notations', () => {
load([bound()])
const { container } = draw()
expect(line(container, 'journey.studio.coordsMark')).toBeDefined()
})
/*
* Both halves, in one step. Rewriting only the binding leaves the old words
* on the page until the next open, which reads as a control that does
* nothing, and choosing a notation is not writing the text: `overridden` has
* to stay off or the mark would stop following the journal.
*/
it('rewrites the binding and the words together, without claiming an override', () => {
load([bound()])
const { container } = draw()
pick(container, 'journey.studio.coordsDecimal')
expect(el()).toMatchObject({
binding: { source: 'entry.location', entryId: 7, format: 'decimal' },
text: expect.stringContaining('60.3913° N'),
overridden: false,
})
})
})
// ── Photo ──────────────────────────────────────────────────────────────────
describe('a photo frame', () => {
it('sets the crop, the look and the frame style', () => {
load([photo({ photoId: 12 })])
const { container } = draw()
pick(container, 'journey.studio.fit.contain')
pick(container, 'journey.studio.filter.bw')
pick(container, 'journey.studio.polaroidFrame')
expect(el()).toMatchObject({ fit: 'contain', filter: 'bw', frameStyle: 'polaroid' })
})
/*
* An empty frame has no picture to aim at, and asking for one by id produced
* a request for /api/photos/null/thumbnail under a caption telling you to
* drag the point around.
*/
it('offers no focal point while the frame is empty', () => {
load([photo({ photoId: null })])
const { container } = draw()
expect(container.querySelector('.st-focal')).toBeNull()
expect(container.textContent).toContain('journey.studio.emptyFrame')
})
it('offers one once there is a picture in it', () => {
load([photo({ photoId: 12 })])
const { container } = draw()
expect(container.querySelector('.st-focal')).not.toBeNull()
expect(container.querySelector('.st-focal img')!.getAttribute('src'))
.toBe('/api/photos/12/thumbnail')
})
/* A radius and a mask are the same idea at two resolutions, and a radius the
renderer ignores once a shape is cut is a control that lies. */
it('drops the corner radius once the picture is cut to a shape', () => {
load([photo({ photoId: 12, mask: null })])
const plain = draw()
expect(numLabels(plain.container)).toContain('journey.studio.radius')
plain.unmount()
load([photo({ photoId: 12, mask: 'heart' })])
expect(numLabels(draw().container)).not.toContain('journey.studio.radius')
})
})
/*
* The picture as the page (#2064). What is pinned is the geometry: the frame
* must reach the bleed on all four sides, or the trimmed page shows a white
* hairline, and the picture must end up behind everything else, or it covers
* the page it was meant to carry.
*/
describe('filling a page with a picture', () => {
/** The chips sit under the crop, open from the start: the commonest layout is one press away. */
function fillChip(c: HTMLElement, label: string): HTMLElement | undefined {
const crop = [...c.querySelectorAll<HTMLElement>('.st-section')]
.find(s => s.querySelector('.st-section-label')?.textContent === 'journey.studio.crop')
return [...(crop?.querySelectorAll('.st-chip') ?? [])]
.find(b => b.textContent === label) as HTMLElement | undefined
}
it('reaches the bleed on every side and goes behind everything else', () => {
load([shape(), photo({ photoId: 12, rotation: 15, fit: 'contain' })], ['p1'])
const { container } = draw()
fireEvent.click(fillChip(container, 'journey.studio.fillPage')!)
expect(elements().map(e => e.id)).toEqual(['p1', 's1'])
expect(el(0)).toMatchObject({
frame: { x: -3, y: -3, w: 216, h: 216 }, rotation: 0, fit: 'cover',
})
})
/* The frame, the crop and the move to the back are one press, so they are
one undo; three steps for one press would take three presses back. */
it('is a single undo step', () => {
load([shape(), photo({ photoId: 12 })], ['p1'])
const { container } = draw()
fireEvent.click(fillChip(container, 'journey.studio.fillPage')!)
expect(useStudioStore.getState().past).toHaveLength(1)
})
it('fills the right-hand page when the frame sits on it', () => {
load([photo({ photoId: 12, frame: { x: 300, y: 30, w: 60, h: 40 } })])
const { container } = draw()
fireEvent.click(fillChip(container, 'journey.studio.fillPage')!)
expect(el().frame).toEqual({ x: 207, y: -3, w: 216, h: 216 })
})
it('fills both pages when asked for the spread', () => {
load([photo({ photoId: 12, frame: { x: 300, y: 30, w: 60, h: 40 } })])
const { container } = draw()
fireEvent.click(fillChip(container, 'journey.studio.fillSpread')!)
expect(el().frame).toEqual({ x: -3, y: -3, w: 426, h: 216 })
})
it('offers no spread on a cover, which is one page', () => {
useStudioStore.getState().load({
version: 1, title: 'T', page: setup(), spreads: [
{ id: 'sp1', role: 'cover', background: null, elements: [photo({ photoId: 12 })], parked: [], entryId: null },
],
} as BookDocument)
useStudioStore.getState().select(['p1'])
const { container } = draw()
expect(fillChip(container, 'journey.studio.fillSpread')).toBeUndefined()
fireEvent.click(fillChip(container, 'journey.studio.fillPage')!)
expect(el().frame).toEqual({ x: -3, y: -3, w: 216, h: 216 })
})
it('is not offered for anything but a picture', () => {
load([shape()])
const { container } = draw()
expect(fillChip(container, 'journey.studio.fillPage')).toBeUndefined()
})
/* Behind everything, the picture is the page and nobody's lockup: it leaves
its group, and a group it leaves with one member is no group any more. */
it('takes the picture out of its group, and a group of two goes with it', () => {
load([text({ group: 'g' }), photo({ photoId: 12, group: 'g' })], ['p1'])
const { container } = draw()
const chip = fillChip(container, 'journey.studio.fillPage')
if (!chip) throw new Error('no fill chip')
fireEvent.click(chip)
expect(elements().map(e => [e.id, e.group])).toEqual([['p1', undefined], ['t1', undefined]])
})
it('keeps the rest of a larger group together', () => {
load([shape({ group: 'g' }), text({ group: 'g' }), photo({ photoId: 12, group: 'g' })], ['p1'])
const { container } = draw()
const chip = fillChip(container, 'journey.studio.fillSpread')
if (!chip) throw new Error('no fill chip')
fireEvent.click(chip)
expect(elements().map(e => [e.id, e.group])).toEqual([['p1', undefined], ['s1', 'g'], ['t1', 'g']])
})
})
// ── Shape ──────────────────────────────────────────────────────────────────
describe('a shape', () => {
/* Swapping rather than deleting and re-adding is the whole point of the
grid: the thing on the page keeps where it is and what colour it is. */
it('keeps its position and its colour when the shape is swapped', () => {
load([shape({ shape: 'rect', fill: '#c2410c' })])
const { container } = draw()
fireEvent.click(container.querySelector('.st-mini-shapes [aria-label="journey.studio.shapeName.ellipse"]')!)
expect(el()).toMatchObject({ shape: 'ellipse', fill: '#c2410c', frame: FRAME })
})
it('writes the fill and the outline separately', () => {
load([shape({ fill: '#111827', stroke: null, strokeWidth: 0 })])
const { container } = draw()
pickSwatchIn(container, 'journey.studio.fill', '#0f766e')
expect(el()).toMatchObject({ fill: '#0f766e', stroke: null })
// The outline is folded on a shape that has none, and opens when asked.
expect(head(container, 'journey.studio.stroke').getAttribute('aria-expanded')).toBe('false')
open(container, 'journey.studio.stroke')
pickSwatchIn(container, 'journey.studio.stroke', '#1e3a8a')
// A colour with no width would draw nothing, so the line gets one.
expect(el()).toMatchObject({ fill: '#0f766e', stroke: '#1e3a8a', strokeWidth: 0.5 })
})
/*
* The rule that matters here: a shape with neither fill nor stroke is a
* shape nobody can see or find again, so taking the fill away has to bring
* the outline on with it.
*/
it('cannot be turned invisible by taking the fill away', () => {
load([shape({ fill: '#111827', stroke: null, strokeWidth: 0 })])
const { container } = draw()
tab(container, 'journey.studio.paint.none')
expect(el()).toMatchObject({ fill: null, stroke: '#141414', strokeWidth: 0.5 })
})
it('brings a fill back when one colour is chosen again', () => {
load([shape({ fill: null, stroke: '#141414', strokeWidth: 0.5 })])
const { container } = draw()
tab(container, 'journey.studio.paint.solid')
expect(el()).toMatchObject({ fill: '#111111', stroke: '#141414' })
})
it('paints a gradient from its own colour, and keeps fill as the colour that stands for it', () => {
load([shape({ fill: '#1e3a8a' })])
const { container } = draw()
tab(container, 'journey.studio.paint.gradient')
const painted = el() as Extract<BookElement, { kind: 'shape' }>
expect(painted.paint).toMatchObject({ type: 'linear', angle: 180, stops: [{ at: 0, color: '#1e3a8a' }, { at: 1 }] })
expect(painted.fill).toMatch(/^#[0-9a-f]{6}$/)
expect(painted.gradient).toBe('none')
})
/* A cover fade from before paint reads as the gradient it draws, so the picker opens on it. */
it('shows an old fade as a gradient', () => {
load([shape({ gradient: 'up' })])
const { container } = draw()
const on = container.querySelector('.st-paint-tab[aria-selected="true"]')
expect(on?.textContent).toBe('journey.studio.paint.gradient')
})
})
// ── Icon ───────────────────────────────────────────────────────────────────
describe('an icon', () => {
it('narrows the swap grid to what was searched for', () => {
load([icon()])
const { container } = draw()
const all = container.querySelectorAll('.st-mini-shape').length
fireEvent.change(container.querySelector('[aria-label="journey.studio.searchIcons"]')!, {
target: { value: 'compass' },
})
const found = container.querySelectorAll('.st-mini-shape').length
expect(found).toBeGreaterThan(0)
expect(found).toBeLessThan(all)
expect(container.querySelector('[aria-label="Compass"]')).not.toBeNull()
})
it('swaps the drawing, keeping everything else about the element', () => {
load([icon({ name: 'Plane', color: '#9f1239' })])
const { container } = draw()
fireEvent.change(container.querySelector('[aria-label="journey.studio.searchIcons"]')!, {
target: { value: 'compass' },
})
fireEvent.click(container.querySelector('[aria-label="Compass"]')!)
expect(el()).toMatchObject({ name: 'Compass', color: '#9f1239', frame: FRAME })
})
it('sets its colour under Look', () => {
load([icon()])
const { container } = draw()
pickSwatchIn(container, 'journey.studio.look', '#0f766e')
expect(el()).toMatchObject({ color: '#0f766e' })
})
it('sets how heavy the drawing is', () => {
load([icon()])
const { container } = draw()
typeNum(container, 'journey.studio.lineWidth', '3')
expect(el()).toMatchObject({ lineWidth: 3 })
})
})
// ── The frame's numbers ────────────────────────────────────────────────────
describe('the position group', () => {
it('is folded away until it is asked for', () => {
load([shape()])
const { container } = draw()
expect(head(container, 'journey.studio.position').getAttribute('aria-expanded')).toBe('false')
expect(numLabels(container)).not.toContain('journey.studio.width')
open(container, 'journey.studio.position')
expect(numLabels(container)).toContain('journey.studio.width')
})
/* One value is one undo step. It used to be one per keystroke, so typing
"120" buried the previous state three steps down the stack. */
it('writes a width once, when the field is left rather than while it is typed', () => {
load([shape()])
const { container } = draw()
open(container, 'journey.studio.position')
const input = num(container, 'journey.studio.width')
fireEvent.change(input, { target: { value: '120' } })
expect(el().frame.w).toBe(60)
fireEvent.blur(input)
expect(el().frame.w).toBe(120)
expect(useStudioStore.getState().past).toHaveLength(1)
})
it('clamps a width below the minimum rather than taking it', () => {
load([shape()])
const { container } = draw()
open(container, 'journey.studio.position')
typeNum(container, 'journey.studio.width', '1')
expect(el().frame.w).toBe(4)
})
/* An emptied field used to collapse the element to zero on the keystroke
that emptied it, which typing the number back does not undo. */
it('leaves the width alone when the field is emptied', () => {
load([shape()])
const { container } = draw()
open(container, 'journey.studio.position')
typeNum(container, 'journey.studio.width', '')
expect(el().frame.w).toBe(60)
expect(useStudioStore.getState().past).toHaveLength(0)
})
it('turns and fades the element from the same group', () => {
load([shape()])
const { container } = draw()
open(container, 'journey.studio.position')
typeNum(container, 'journey.studio.rotation', '15')
typeNum(container, 'journey.studio.opacity', '40')
expect(el()).toMatchObject({ rotation: 15, opacity: 0.4 })
})
})
// ── Nothing selected ───────────────────────────────────────────────────────
describe('with nothing selected', () => {
it('turns page numbers on', () => {
load([])
const setPageNumbers = vi.fn()
const { container } = draw({ setPageNumbers })
toggle(container, 'journey.studio.pageNumbers')
expect(setPageNumbers).toHaveBeenCalledWith({ show: true })
})
it('sets where they sit', () => {
load([])
const setPageNumbers = vi.fn()
const { container } = draw({ page: setup({ show: true }), setPageNumbers })
pick(container, 'journey.studio.folio.inner')
expect(setPageNumbers).toHaveBeenCalledWith({ position: 'inner' })
})
it('sets the first number, the size and the margin', () => {
load([])
const setPageNumbers = vi.fn()
const { container } = draw({ page: setup({ show: true }), setPageNumbers })
typeNum(container, 'journey.studio.folioStart', '12')
typeNum(container, 'journey.studio.size', '10')
typeNum(container, 'journey.studio.folioMargin', '20')
expect(setPageNumbers).toHaveBeenCalledWith({ startAt: 12 })
expect(setPageNumbers).toHaveBeenCalledWith({ size: 10 })
expect(setPageNumbers).toHaveBeenCalledWith({ margin: 20 })
})
it('turns the automatic colour off, and picking one turns it off too', () => {
load([])
const setPageNumbers = vi.fn()
const { container } = draw({ page: setup({ show: true }), setPageNumbers })
toggle(container, 'journey.studio.folioAuto')
expect(setPageNumbers).toHaveBeenCalledWith({ autoColor: false })
pickSwatch(container, 'journey.studio.colour', '#111111')
expect(setPageNumbers).toHaveBeenCalledWith({ color: '#111111', autoColor: false })
})
})