import { describe, it, expect, beforeEach } from 'vitest' import type { BookDocument, BookElement, JourneyStats } from '@trek/shared' import { bookBadgeElementSchema, bookCountriesElementSchema, bookListElementSchema, bookMapElementSchema, bookPageSetupSchema, bookStatsElementSchema, journeyStatsSchema, } from '@trek/shared' import { fireEvent, render, screen, within } from '../../helpers/render' import { StudioInspector } from '../../../src/components/Studio/StudioInspector' import { useStudioStore } from '../../../src/store/studioStore' /** * The inspector, on the five travel elements (#1973). * * The panel was rebuilt: same functions, new structure. Sections fold, booleans * are the app's switch instead of a chip, a set of several carries a tick, and * duplicate, lock and delete moved out of "Arrange" and into the panel head. * Nothing in the suite rendered the inspector at all, so a control that came * out of that rebuild wired to nothing would have been found in a printed book * rather than here. * * Two rules for what follows. The whole inspector is mounted rather than the * travel half on its own, because the tail every travel element shares, type, * colour, position, arrange, is drawn by the outer panel. And what is asserted * is the document the store holds, not the markup: the question is whether a * control still writes the field it used to write, whichever section it now * lives in. */ const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5, }) const frame = { x: 10, y: 10, w: 120, h: 90 } /** A journey with figures no element below starts out agreeing with. */ const stats: JourneyStats = journeyStatsSchema.parse({ journeyId: 1, distance: 1234, days: 5, steps: 7, photos: 12, places: 9, furthest: 400, countries: [{ code: 'IS', name: 'Iceland', places: 9, firstVisit: '2026-01-01' }], points: [ { lat: 64.14, lng: -21.94, label: 'Reykjavik', date: '2026-01-01', country: 'IS' }, { lat: 65.68, lng: -18.09, label: 'Akureyri', date: '2026-01-04', country: 'IS' }, ], start: '2026-01-01', end: '2026-01-05', }) const mapEl = (over: Record = {}): BookElement => bookMapElementSchema.parse({ id: 'm1', kind: 'map', frame, countries: ['IS'], points: [{ lat: 64.14, lng: -21.94, label: 'Reykjavik' }], ...over, }) as BookElement const statsEl = (over: Record = {}): BookElement => bookStatsElementSchema.parse({ id: 's1', kind: 'stats', frame, ...over, }) as BookElement const countriesEl = (over: Record = {}): BookElement => bookCountriesElementSchema.parse({ id: 'c1', kind: 'countries', frame, codes: ['IS', 'NO'], names: ['Iceland', 'Norway'], ...over, }) as BookElement const badgeEl = (over: Record = {}): BookElement => bookBadgeElementSchema.parse({ id: 'b1', kind: 'badge', frame, variant: 'flag', code: 'IS', text: 'ICELAND', ...over, }) as BookElement const listEl = (over: Record = {}): BookElement => bookListElementSchema.parse({ id: 'l1', kind: 'list', frame, items: [{ text: 'Warm all week', tone: 'pro' }, { text: 'Ferry was late', tone: 'con' }], proLabel: 'Pros', conLabel: 'Cons', ...over, }) as BookElement function show(el: BookElement, live: JourneyStats | null = null, trail: [number, number][][] = []) { useStudioStore.getState().load({ version: 1, title: 'T', page, spreads: [{ id: 'sp1', role: 'inner', background: null, elements: [el], parked: [], entryId: null }], } as BookDocument) useStudioStore.getState().select([el.id]) return render( {}} t={k => k} locale="en" />, ) } /** The element as the store holds it. Every assertion below reads this. */ function saved(): Record { return useStudioStore.getState().doc!.spreads[0].elements[0] as unknown as Record } /** One of a few. */ const pick = (name: string) => fireEvent.click(screen.getByRole('radio', { name })) /** One of a set, which carries a tick. */ const tick = (name: string) => fireEvent.click(screen.getByRole('checkbox', { name })) /** A switch, or any plain button: both answer to their accessible name. */ const press = (name: string) => fireEvent.click(screen.getByRole('button', { name })) /** Opens the typeface list from its button and picks a family by the name its row is set in. */ function pickFamily(name: string) { fireEvent.click(screen.getByRole('button', { name: /^journey\.studio\.fonts\.change: / })) const row = screen.getAllByRole('option').find(o => o.querySelector('.st-fontpick-face')?.textContent === name) if (!row) throw new Error(`no typeface row named ${name}`) fireEvent.click(row) } /** Drags the weight slider to the stop of a weight among those the family ships. */ function slideWeight(weights: readonly number[], weight: number) { const slider = screen.getByRole('slider', { name: 'journey.studio.weight' }) fireEvent.change(slider, { target: { value: String(weights.indexOf(weight)) } }) } /** A section by its heading, since a label can repeat across sections. */ function section(container: HTMLElement, label: string): HTMLElement { const found = [...container.querySelectorAll('.st-section')] .find(s => s.querySelector('.st-section-label')?.textContent === label) if (!found) throw new Error(`no section headed ${label}`) return found } /** Whether a section is open, read off its head. */ function isOpen(container: HTMLElement, label: string): boolean { return section(container, label).querySelector('.st-section-head')?.getAttribute('aria-expanded') === 'true' } /** * Some sections start folded, so a control inside one has to be uncovered. A * folded section opens by itself on an element that already uses it, and is * left open then. */ function open(container: HTMLElement, label: string) { if (!isOpen(container, label)) fireEvent.click(section(container, label).querySelector('.st-section-head')!) } /** The control side of one labelled line inside one section. */ function lineIn(container: HTMLElement, sectionLabel: string, lineLabel: string): HTMLElement { const row = [...section(container, sectionLabel).querySelectorAll('.st-line')] .find(l => l.querySelector('.st-line-label')?.textContent === lineLabel) if (!row) throw new Error(`no line labelled ${lineLabel} under ${sectionLabel}`) return row.querySelector('.st-line-body')! } beforeEach(() => { useStudioStore.getState().load({ version: 1, title: 'T', page, spreads: [] } as unknown as BookDocument) }) describe('the route map', () => { it('takes the tiles and the credit from the source it is switched to', () => { show(mapEl()) pick('journey.studio.mapSourceSatellite') expect(saved().source).toBe('tiles') expect(saved().tileUrl).toContain('tiles.maps.eox.at') // The credit travels with the source, or the page prints the wrong one — // and this one is CC BY, so printing it uncredited is a licence breach // rather than a discourtesy. expect(saved().attribution).toContain('EOX') }) /* * The planner's own street tiles and Mapbox are no longer offered: one is a * street map that reads as a screenshot on paper and whose server asks people * not to bulk-fetch, the other needs written permission before anything is * printed. Removing an option must not remove a page, though. */ it('still offers the source a map already uses, even one no longer recommended', () => { show(mapEl({ source: 'tiles', tileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors', })) const chip = screen.getByRole('radio', { name: 'journey.studio.mapSourceTiles' }) expect(chip.getAttribute('aria-checked')).toBe('true') }) it('does not offer it on a map that never used it', () => { show(mapEl()) expect(screen.queryByRole('radio', { name: 'journey.studio.mapSourceTiles' })).toBeNull() expect(screen.queryByRole('radio', { name: 'journey.studio.mapSourceStatic' })).toBeNull() }) it('writes the drawn style', () => { show(mapEl()) pick('journey.studio.mapStyle.paper') expect(saved().style).toBe('paper') }) it('fits the stops rather than the countries, and back again', () => { show(mapEl()) expect(saved().fitToCountries).toBe(true) pick('journey.studio.mapFitStops') expect(saved().fitToCountries).toBe(false) pick('journey.studio.mapFitCountry') expect(saved().fitToCountries).toBe(true) }) it('writes how much room is left around what is drawn', () => { show(mapEl()) pick('journey.studio.mapPadWide') expect(saved().fitPadding).toBe(0.5) pick('journey.studio.mapPadTight') expect(saved().fitPadding).toBe(0.04) }) it('cuts the picture to the coastline when the shape is switched', () => { show(mapEl()) pick('journey.studio.mapClipCountry') expect(saved().clip).toBe('country') }) /* A country-shaped hole with no country to cut against is a filled box. */ it('refuses the country shape on a map that knows no countries', () => { show(mapEl({ countries: [] })) const chip = screen.getByRole('radio', { name: 'journey.studio.mapClipCountry' }) expect(chip).toBeDisabled() fireEvent.click(chip) expect(saved().clip).toBe('rect') }) it('folds the zoom of a map that fits its route, and opens it on one held at a zoom', () => { const tiles = { source: 'tiles', tileUrl: 'https://tile.example/{z}/{x}/{y}.png' } const fitted = show(mapEl(tiles)) expect(isOpen(fitted.container, 'journey.studio.mapZoom')).toBe(false) fitted.unmount() const held = show(mapEl({ ...tiles, zoom: 11 })) expect(isOpen(held.container, 'journey.studio.mapZoom')).toBe(true) expect(screen.getByRole('radio', { name: '11' })).toHaveAttribute('aria-checked', 'true') }) it('writes a zoom from the folded group, and hands it back automatically', () => { const { container } = show(mapEl({ source: 'tiles', tileUrl: 'https://tile.example/{z}/{x}/{y}.png' })) open(container, 'journey.studio.mapZoom') pick('11') expect(saved().zoom).toBe(11) // Automatic is null rather than a number, which is what lets the element // choose one that fits the route. pick('journey.studio.mapZoomAuto') expect(saved().zoom).toBeNull() }) it('draws each layer, or does not', () => { show(mapEl()) press('journey.studio.showLand') expect(saved().showLand).toBe(false) press('journey.studio.showRoute') expect(saved().showRoute).toBe(false) press('journey.studio.showPins') expect(saved().showPins).toBe(false) press('journey.studio.showLabels') expect(saved().showLabels).toBe(true) }) /** A journey of two trips, so the map offers its scope at all. */ const twoTrips: JourneyStats = journeyStatsSchema.parse({ ...stats, countries: [ { code: 'IS', name: 'Iceland', places: 2, firstVisit: '2026-01-01' }, { code: 'PT', name: 'Portugal', places: 1, firstVisit: '2026-03-01' }, ], points: [ { lat: 64.14, lng: -21.94, label: 'Reykjavik', date: '2026-01-01', country: 'IS', tripId: 11 }, { lat: 65.68, lng: -18.09, label: 'Akureyri', date: '2026-01-04', country: 'IS', tripId: 11 }, { lat: 38.72, lng: -9.14, label: 'Lisboa', date: '2026-03-01', country: 'PT', tripId: 22 }, ], trips: [ { id: 11, title: 'North', start: '2026-01-01', end: '2026-01-05', points: 2 }, { id: 22, title: 'South', start: '2026-03-01', end: '2026-03-02', points: 1 }, ], }) /** The journey's recorded way, as the Studio hands it to the inspector. */ const trail: [number, number][][] = [[[64.14, -21.94], [38.72, -9.14]]] /** Roads loaded for the whole journey's legs: entry i runs from stop i to stop i + 1. */ const roads = [[[64.14, -21.94], [64.0, -21.5], [65.68, -18.09]], null] /* * The scope switch re-cuts the map as the book engine refreshes one, so a * map turned into a trip map here holds what the panel or the assistant * would have placed for that trip: its own stops, its own countries, no * recorded way, since the trail is the whole journey's, and no roads, since * those belonged to the old legs. Switched back, the map takes the trail * from the journey again, so the round trip loses nothing. */ it('STTRV-007: re-cuts a map to one trip as the engine does, and back to the journey with its trail', () => { show(mapEl({ countries: ['IS', 'PT'], path: trail, roads }), twoTrips, trail) pick('North') expect(saved()).toMatchObject({ tripId: 11, countries: ['IS'], path: [], roads: [], stale: false, points: [{ label: 'Reykjavik' }, { label: 'Akureyri' }], }) pick('journey.studio.mapWholeJourney') expect(saved()).toMatchObject({ tripId: null, countries: ['IS', 'PT'], roads: [], stale: false }) expect(saved().path).toEqual(trail) expect(saved().points).toHaveLength(3) }) it('STTRV-008: picking the scope a map already has writes nothing, so its loaded roads stay', () => { show(mapEl({ countries: ['IS', 'PT'], path: trail, roads }), twoTrips, trail) pick('journey.studio.mapWholeJourney') expect(saved()).toMatchObject({ tripId: null, path: trail, roads }) // Still the one stop it was made with: a re-cut would have brought all three. expect(saved().points).toHaveLength(1) }) }) describe('the figures', () => { it('adds a figure to the set', () => { show(statsEl()) tick('journey.studio.metric.countries') expect(saved().metrics).toEqual(['distance', 'days', 'steps', 'photos', 'countries']) }) it('takes one back out', () => { show(statsEl()) tick('journey.studio.metric.days') expect(saved().metrics).toEqual(['distance', 'steps', 'photos']) }) /* An empty panel is a blank rectangle nobody could get back out of. */ it('keeps the last figure', () => { show(statsEl({ metrics: ['days'] })) tick('journey.studio.metric.days') expect(saved().metrics).toEqual(['days']) }) it('writes the layout and the units', () => { show(statsEl()) pick('journey.studio.layoutRow') expect(saved().layout).toBe('row') pick('mi') expect(saved().units).toBe('imperial') }) it('writes the icons switch', () => { show(statsEl()) press('journey.studio.showIcons') expect(saved().showIcons).toBe(false) }) }) describe('the countries', () => { it('writes the layout and the alignment', () => { show(countriesEl()) pick('journey.studio.layoutColumn') expect(saved().layout).toBe('column') pick('journey.studio.align.right') expect(saved().align).toBe('right') }) it('writes the three things it draws', () => { show(countriesEl()) press('journey.studio.showOutline') expect(saved().showOutline).toBe(false) press('journey.studio.showFlag') expect(saved().showFlag).toBe(true) press('journey.studio.showName') expect(saved().showName).toBe(false) }) it('keeps one name per code when a line is edited', () => { show(countriesEl()) fireEvent.change(screen.getByLabelText('journey.studio.countryNames'), { target: { value: 'Island\nNoreg' }, }) expect(saved().names).toEqual(['Island', 'Noreg']) }) /* * The reason the textarea does not simply store its own lines: a removed line * must not shift every name after it onto the wrong outline, and a line * nobody has a country for must not be printed at all. */ it('ignores a line past the last country, and keeps a name whose line is gone', () => { show(countriesEl()) const box = screen.getByLabelText('journey.studio.countryNames') fireEvent.change(box, { target: { value: 'Island\nNoreg\nSverige' } }) expect(saved().names).toEqual(['Island', 'Noreg']) fireEvent.change(box, { target: { value: 'Island' } }) expect(saved().names).toEqual(['Island', 'Noreg']) }) }) describe('the mark', () => { it('writes the words and the caption', () => { const { container } = show(badgeEl()) fireEvent.change( within(lineIn(container, 'journey.studio.marks', 'journey.studio.text')).getByRole('textbox'), { target: { value: 'REYKJAVIK' } }, ) expect(saved().text).toBe('REYKJAVIK') fireEvent.change( within(lineIn(container, 'journey.studio.marks', 'journey.studio.styleCaption')).getByRole('textbox'), { target: { value: 'Day 1' } }, ) expect(saved().sub).toBe('Day 1') }) it('writes the style, in the panel language rather than in the raw value', () => { show(badgeEl()) pick('journey.studio.markStyle.chip') expect(saved().style).toBe('chip') pick('journey.studio.markStyle.stacked') expect(saved().style).toBe('stacked') // These four printed `plain`, `chip` and the rest in all twenty-three // languages before, straight out of the value. expect(screen.getByRole('radio', { name: 'journey.studio.markStyle.outline' })).toBeTruthy() expect(screen.queryByRole('radio', { name: 'outline' })).toBeNull() }) it('writes the three combinations of picture and words', () => { show(badgeEl()) pick('journey.studio.iconOnly') expect(saved().showIcon).toBe(true) expect(saved().showLabel).toBe(false) pick('journey.studio.labelOnly') expect(saved().showIcon).toBe(false) expect(saved().showLabel).toBe(true) pick('journey.studio.iconAndLabel') expect(saved().showIcon).toBe(true) expect(saved().showLabel).toBe(true) }) /* A mark with neither its picture nor its words is a way to lose an element. */ it('offers no way to switch both off', () => { const { container } = show(badgeEl()) const shows = within(section(container, 'journey.studio.icon')).getAllByRole('radio') expect(shows).toHaveLength(3) for (const chip of shows) { fireEvent.click(chip) expect(saved().showIcon || saved().showLabel).toBe(true) } }) it('writes the icon colour, and stops the icon choosing its own', () => { const { container } = show(badgeEl()) fireEvent.click( within(lineIn(container, 'journey.studio.icon', 'journey.studio.colour')) .getByRole('button', { name: '#0f766e' }), ) expect(saved().iconColor).toBe('#0f766e') expect(saved().autoIconColor).toBe(false) }) it('hands the icon colour back with the automatic switch', () => { const { container } = show(badgeEl({ autoIconColor: false, iconColor: '#0f766e' })) fireEvent.click( within(section(container, 'journey.studio.icon')) .getByRole('button', { name: 'journey.studio.autoColour' }), ) expect(saved().autoIconColor).toBe(true) }) }) describe('the pros and cons list', () => { it('writes the layout', () => { show(listEl()) pick('journey.studio.layoutColumn') expect(saved().layout).toBe('stacked') pick('journey.studio.layoutGrid') expect(saved().layout).toBe('columns') }) it('writes the marks switch', () => { show(listEl()) press('journey.studio.showMarks') expect(saved().showMarks).toBe(false) }) it('writes both headings', () => { show(listEl()) fireEvent.change(screen.getByLabelText('journey.editor.pros'), { target: { value: 'Worth it' } }) expect(saved().proLabel).toBe('Worth it') fireEvent.change(screen.getByLabelText('journey.editor.cons'), { target: { value: 'Less so' } }) expect(saved().conLabel).toBe('Less so') }) }) describe('the tail all five share', () => { it('writes the family, and moves the weight to one that family really has', () => { const { container } = show(statsEl({ font: 'sans', weight: 700 })) open(container, 'journey.studio.typography') // Bebas ships one weight; a bold it does not have prints as a smeared // regular, so the request moves rather than being passed through. pickFamily('Bebas Neue') expect(saved().font).toBe('bebas') expect(saved().weight).toBe(400) }) it('folds the type of an element set as placed, and opens it on one set otherwise', () => { const plain = show(statsEl()) expect(isOpen(plain.container, 'journey.studio.typography')).toBe(false) plain.unmount() for (const over of [{ font: 'serif' }, { weight: 500 }, { textScale: 1.5 }]) { const view = show(statsEl(over)) expect(isOpen(view.container, 'journey.studio.typography')).toBe(true) view.unmount() } }) it('keeps a weight the new family does have', () => { const { container } = show(statsEl({ font: 'sans', weight: 500 })) open(container, 'journey.studio.typography') pickFamily('Lora') expect(saved().font).toBe('serif') expect(saved().weight).toBe(500) }) it('writes the weight', () => { const { container } = show(countriesEl()) open(container, 'journey.studio.typography') // Poppins ships 100 to 900, so 500 is the fifth stop. slideWeight([100, 200, 300, 400, 500, 600, 700, 800, 900], 500) expect(saved().weight).toBe(500) }) it('writes the text scale', () => { const { container } = show(listEl()) open(container, 'journey.studio.typography') pick('1.2×') expect(saved().textScale).toBe(1.2) }) it('writes the accent and the ink, which are two different rows', () => { const { container } = show(statsEl()) fireEvent.click( within(lineIn(container, 'journey.studio.colour', 'journey.studio.accent')) .getByRole('button', { name: '#c2410c' }), ) expect(saved().accent).toBe('#c2410c') expect(saved().color).toBe('#1a1a1a') fireEvent.click( within(lineIn(container, 'journey.studio.colour', 'journey.studio.text')) .getByRole('button', { name: '#1e3a8a' }), ) expect(saved().color).toBe('#1e3a8a') expect(saved().accent).toBe('#c2410c') }) /* A chip's words answer to its fill, until somebody chooses otherwise. */ it('takes a mark off automatic when its ink is picked, and the switch puts it back', () => { const { container } = show(badgeEl()) fireEvent.click( within(lineIn(container, 'journey.studio.colour', 'journey.studio.text')) .getByRole('button', { name: '#ffffff' }), ) expect(saved().color).toBe('#ffffff') expect(saved().autoColor).toBe(false) fireEvent.click( within(section(container, 'journey.studio.colour')) .getByRole('button', { name: 'journey.studio.autoColour' }), ) expect(saved().autoColor).toBe(true) }) }) describe('an element the journey has moved past', () => { it('says so, and offers to bring it up to date', () => { show(statsEl(), stats) expect(screen.getByText('journey.studio.staleHint')).toBeTruthy() expect(screen.getByRole('button', { name: 'journey.studio.refresh' })).toBeTruthy() }) it('writes the journey figures as they stand now, and stops asking', () => { show(statsEl(), stats) press('journey.studio.refresh') expect(saved().values).toMatchObject({ distance: 1234, days: 5, steps: 7, photos: 12 }) expect(saved().stale).toBe(false) expect(screen.queryByText('journey.studio.staleHint')).toBeNull() }) /* * Compared rather than timestamped: a page showing distance and days does not * go stale because somebody fixed a typo in a caption. */ it('says nothing while the figures it shows still match', () => { show(statsEl({ values: { distance: 1234, days: 5, steps: 7, photos: 12 } }), stats) expect(screen.queryByText('journey.studio.staleHint')).toBeNull() }) })