mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
eae8480728
* feat(studio): widen the document, and stop a save from losing it The schema gains what the elements coming after it need: an icon element, a badge that can be restyled rather than only placed, a date and a pair of coordinates on a text binding, and the map's route, pin and road settings. Widening it turned up two ways a book could fail to come back. The mood badge writes its code into the document, and the schema said that code was exactly two characters, from a first draft where it was a country. The real codes are words, so every save was rejected and the read that followed parsed to an empty document: seventeen spreads in, nothing out. Widened to what the badge actually writes, and normalizeBookDocument now drops the elements it cannot read rather than the whole book, so one bad element costs a picture frame instead of a year of work. The other was the body limit. The parser allowed 100kb for everything, and a book with a routed map measures past that, so the save came back 413 and the editor sat on "not saved" with no way forward. Book writes get their own parser at 8mb; everything else keeps the limit it had. * feat(studio): a date, a pair of coordinates and an icon library Three things that had to be drawn by hand before. A journal entry knows when it happened and where, and neither could be put on a page: you read the date off the entry and typed it into a text box, where it then stayed while the entry moved. Both are chips now, next to the title and the story, and both follow their entry the way the others do. The formatting lives in one place so the chip, the template and the resolver cannot drift by a character — coordinates in degrees or decimals, the date in the reader's language. The icons are the whole Lucide set, searchable, placed as a real element with its own colour and stroke rather than as a character in a font. The stroke is deliberately not called strokeWidth: three separate places rescale a field of that name by page width, and an icon whose line thins as the book grows is not what anyone asked for. And the badges finally have their settings. A mood or weather badge could be placed and then only moved: the icon's colour was fixed, the icon could not be turned off, and there was no way to keep the icon and drop the words. All three are switches now, with the colour following the badge unless you say otherwise. * fix(studio): let a placed entry field follow its journal entry again The inspector says a bound field follows the journal entry and that editing it here breaks the link. The first half was not true: nothing ever re-read the entry, so renaming a title in Journey left the old words on the page for good, and the same went for every other bound field. The resolver runs where the journey is loaded and re-reads the fields that still point at an entry. What it will not do is as important as what it does. A field somebody has typed over stays typed over, an entry that is gone leaves its element alone rather than blanking it, and an entry that answers with nothing never wipes what is already on the page. Formatted fields — the date, the coordinates — are only re-read when the format itself changed, which is what keeps this from quietly rewriting books that already exist: an entry whose date was placed as a heading and then set in capitals keeps its capitals. Checked against the real database before it shipped, where the first version overwrote three of them. * feat(studio): start a new book empty, with six layouts to choose from A new journey opened onto a book that had already been laid out, which is the wrong first move: it decides the shape of somebody's book before they have seen it, and undoing it means deleting pages one at a time. A new book starts empty now, and the auto layout runs when it is asked to. The layouts themselves went from two to six, so choosing one is a real choice: what was there covered a photo-heavy spread and a text-heavy one and nothing in between. * fix(studio): hyphenate the export the way the editor does, and set the bleed Two things the press sees and the editor did not. A paragraph broke in different places in the PDF than on screen. The print document is written from scratch and never carried a language, so the browser hyphenated the editor's German and the export's nothing-in- particular. It takes the page's own language now. The wait for the sheet to be ready also covers the images inside SVG, with a ceiling, so a map that is slow to draw does not print half-finished. And the bleed and the safe margin are typeable. Both were baked into every page preset at what a photo-book vendor usually asks for, 3mm and 5mm, with no control anywhere. That is fine until a printer asks for 5 and 10, at which point the book cannot be made here at all: the export carries the wrong bleed and the crop marks sit in the wrong place, which nobody finds until the guillotine has run. * feat(studio): rebuild the inspector The panel on the right had grown by accretion: every element type had added its own controls in whatever shape suited it that day, so the same idea — a colour, a size, a toggle — looked different depending on what you had selected, and long panels were a wall of unlabelled fields. It is one set of controls now, used by every element: foldable sections, a line that holds a label and its control, radio choices, checkboxes, the app's own toggle, and a number field that holds a draft so typing "500" is not read as "5", clamped and written back under the cursor. Duplicated controls were merged rather than deleted; nothing an element could do before it cannot do now. The head carries the element's identity and its actions, so what you have selected is readable without counting fields, and the sections have room to breathe: what was there was correct and unreadable. * feat(studio): a route map worth printing The map in a book was a straight grey line between grey dots on a tile that was chosen for a screen. At print size those tiles are read at roughly four times the resolution they were fetched for, which is why it looked soft next to everything around it. The imagery comes from sources that hold up on paper: NASA's shaded relief and EOX's cloudless Sentinel-2, both licensed for it, picked at a zoom that matches the printer rather than the screen. Tiles are fetched at twice the density where the source offers it, and the seam between them scales with the tile instead of sitting at a fixed fraction of a millimetre, which under editor zoom fell below one device pixel and showed as white threads. The route is drawn rather than plotted. A leg that was never recorded bows, by an amount that grows with its length and turns away from the pole, and it dashes, because an inferred line should not claim to be a track — a recorded track never bows. Both strokes are laid down in two passes, casing then core, so the line stays legible over dark sea and bright desert alike. Legs can follow roads instead, routed leg by leg with the geometry thinned before it is stored, which is the setting a road trip wants and the one a flight cannot have. Stops carry their own first photograph, cut to a circle by a stencil that exists — the first one was written into a defs block that only appears on a map clipped to a coastline, so on an ordinary map it clipped nothing and the photographs sat square on the page. A stop with no photograph of its own is numbered rather than handed one from elsewhere in the journey: a marker at one place showing a picture taken at another is a caption that lies. Whatever the frame, the whole route is inside it. The window takes the frame's aspect, so the fit and the imagery agree instead of one containing what the other covers, and a map dragged narrow no longer cuts off its end. Each linked trip can carry its own map, and a map notices a photograph added to a stop afterwards rather than having to be placed again. * i18n(journey): the new Studio strings, in all 23 locales * test(journey): pin the coverage the map work brought with it The journey domain measures higher than its floor after this round, so the floor follows it up. Only journey moved; the generator's suggestion would have taken memories' functions down a point, and a ratchet that can fall is not a ratchet. * test(studio): cover the thinning that keeps a road route saveable The one path in roadRoute nothing exercised, and the one that matters most: a router answers a long drive with thousands of points at a precision no press can resolve, and all of it goes into the document. Keeping a leg whole is what pushed a real book past the body limit. Both ends have to survive so the line still meets its stops, and the spacing has to stay even, because cutting a run out of the middle would straighten exactly the bends somebody wanted to see.
553 lines
21 KiB
TypeScript
553 lines
21 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 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: [],
|
|
}],
|
|
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}
|
|
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}"]`)!)
|
|
}
|
|
|
|
/** 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!)
|
|
}
|
|
|
|
/** 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 })
|
|
})
|
|
|
|
it('sets the weight', () => {
|
|
load([text()])
|
|
const { container } = draw()
|
|
pick(container, '600')
|
|
expect(el()).toMatchObject({ weight: 600 })
|
|
})
|
|
|
|
it('sets the alignment', () => {
|
|
load([text()])
|
|
const { container } = draw()
|
|
fireEvent.click(container.querySelector('[aria-label="right"]')!)
|
|
expect(el()).toMatchObject({ align: 'right' })
|
|
})
|
|
|
|
/* 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()
|
|
pickSwatch(container, 'journey.studio.text', '#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')
|
|
})
|
|
})
|
|
|
|
// ── 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="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()
|
|
|
|
pickSwatch(container, 'journey.studio.fill', '#0f766e')
|
|
expect(el()).toMatchObject({ fill: '#0f766e', stroke: null })
|
|
|
|
pickSwatch(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 turning the fill off has to bring
|
|
* the outline on with it.
|
|
*/
|
|
it('cannot be turned invisible by switching the fill off', () => {
|
|
load([shape({ fill: '#111827', stroke: null, strokeWidth: 0 })])
|
|
const { container } = draw()
|
|
|
|
toggle(container, 'journey.studio.fillOn')
|
|
|
|
expect(el()).toMatchObject({ fill: null, stroke: '#141414', strokeWidth: 0.5 })
|
|
})
|
|
|
|
it('brings the fill back when it is switched on again', () => {
|
|
load([shape({ fill: null, stroke: '#141414', strokeWidth: 0.5 })])
|
|
const { container } = draw()
|
|
|
|
toggle(container, 'journey.studio.fillOn')
|
|
|
|
expect(el()).toMatchObject({ fill: '#111111', stroke: '#141414' })
|
|
})
|
|
|
|
it('fades the fill, and takes a direction once it does', () => {
|
|
load([shape()])
|
|
const { container } = draw()
|
|
|
|
toggle(container, 'journey.studio.gradient')
|
|
expect(el()).toMatchObject({ gradient: 'down' })
|
|
|
|
pick(container, 'journey.studio.gradientUp')
|
|
expect(el()).toMatchObject({ gradient: 'up' })
|
|
})
|
|
})
|
|
|
|
// ── 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 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 })
|
|
})
|
|
})
|