Files
TREK/client/tests/unit/studio/mapRoads.test.tsx
T
Maurice 6cbefd23e8 feat(studio): filter photos by entry, upload from Studio, and switch stops off
The content browser gets a filter over the journey's pictures: all of them,
the ones no entry holds, or one entry at a time, with the day and the place
under each name. An entry card in the Entries tab reaches its own pictures
the same way. Pictures can now come in without leaving Studio, into the
entry the filter names or into the gallery, from the panel or dropped
straight onto the page, and a photo element can fill its page or the whole
spread in one press.

Stops can be taken out of the arithmetic. A journal entry carries a
stats_excluded flag; an entry that has it is off the route, out of the
distance, out of the countries and not a step, while staying in the
journal. Studio lists every stop in the travel panel with a switch, the
entry editor carries the same switch on desktop and phone, and the entry
card says when a day is off the route.
2026-09-03 10:47:08 +02:00

262 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { BookDocument, BookElement, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { fireEvent, render } from '../../helpers/render'
import { SpreadView } from '../../../src/components/Studio/SpreadView'
import { StudioTravelPanel } from '../../../src/components/Studio/StudioTravelPanel'
import { useStudioStore } from '../../../src/store/studioStore'
import { fetchRoads } from '../../../src/components/Studio/roadRoute'
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
calculateRouteWithLegs: vi.fn(),
}))
const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator')
/**
* The line following real roads, for the legs that have them (#1973).
*
* The trap this is built around: when the map element carries a track, the
* renderer draws that INSTEAD of the stop chain, whole. A road route that only
* covers some legs would therefore have printed those legs and silently dropped
* the rest — three legs of twelve, and the book looks like the journey stopped.
* So the roads are per leg and the chain is cut around them, and the case that
* proves it is the one below with a road on the middle leg only.
*
* The second thing worth protecting is what is NOT asked: a leg long enough to
* have been a flight is never sent to a router, because the answer would be a
* three-day drive and a ferry, and that is not the journey anybody made.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({ preset: 'square-210' })
const STOPS = [
{ lat: 64.14, lng: -21.94, label: 'Reykjavík', photoId: null },
{ lat: 63.42, lng: -19.07, label: 'Vík', photoId: null },
{ lat: 65.68, lng: -18.12, label: 'Akureyri', photoId: null },
]
function map(over: Record<string, unknown> = {}): BookElement {
return {
id: 'mp1', kind: 'map', rotation: 0, opacity: 1, locked: false,
font: 'sans', color: '#1a1a1a', accent: '#ffffff', textScale: 1, weight: 400, stale: false,
frame: { x: 0, y: 0, w: 180, h: 140 },
style: 'minimal', source: 'vector', tileUrl: '', attribution: '', zoom: null, clip: 'rect',
showLand: true, showRoute: true, showPins: true, showLabels: false,
routeStyle: 'drawn', routeArc: 'bow', routeDash: 'arcs', pinStyle: 'dot',
countries: [], path: [], roads: [], fitPadding: 0.5, fitToCountries: false, tripId: null,
points: STOPS,
...over,
} as unknown as BookElement
}
function draw(el: BookElement) {
const spread: BookSpread = {
id: 's1', role: 'inner', background: null, elements: [el], parked: [], entryId: null,
}
return render(<SpreadView spread={spread} page={page} big />)
}
const strokes = (c: HTMLElement) =>
Array.from(c.querySelectorAll('path')).filter(p => p.getAttribute('fill') === 'none')
describe('a leg that has a road', () => {
it('is drawn as the road rather than as a line between its ends', () => {
const road: [number, number][] = [
[64.14, -21.94], [64.0, -21.5], [63.8, -20.6], [63.5, -19.6], [63.42, -19.07],
]
const { container } = draw(map({ roads: [road, null] }))
const ds = strokes(container).map(p => p.getAttribute('d') ?? '')
// A road is many points; a bow is one Q; a straight leg is one L.
expect(ds.some(d => (d.match(/L/g) ?? []).length >= 3)).toBe(true)
})
it('is drawn solid and unbowed, because it is the way that was taken', () => {
const road: [number, number][] = [
[64.14, -21.94], [64.0, -21.5], [63.8, -20.6], [63.42, -19.07],
]
const { container } = draw(map({ roads: [road, null] }))
const roadPaths = strokes(container).filter(p => (p.getAttribute('d') ?? '').match(/L/g))
for (const p of roadPaths) {
expect(p.getAttribute('d')).not.toContain('Q')
expect(p.getAttribute('stroke-dasharray')).toBeNull()
}
})
/*
* The bug this whole shape exists to avoid: `path` replaces the entire line,
* so a partial answer used to mean a partial journey.
*/
it('does not swallow the legs that have none', () => {
const middle: [number, number][] = [
[63.42, -19.07], [64.0, -18.5], [65.0, -18.2], [65.68, -18.12],
]
const { container } = draw(map({ roads: [null, middle] }))
const ds = strokes(container).map(p => p.getAttribute('d') ?? '')
// The first leg is still drawn: either bowed or straight, but drawn.
const hasFirstLeg = ds.some(d => d.includes('Q') || (d.match(/L/g) ?? []).length === 1)
expect(hasFirstLeg).toBe(true)
expect(ds.some(d => (d.match(/L/g) ?? []).length >= 2)).toBe(true)
})
it('is inside the frame, road and all', () => {
// A road that wanders well north of both its ends.
const road: [number, number][] = [
[64.14, -21.94], [66.5, -21.0], [66.6, -19.5], [63.42, -19.07],
]
const { container } = draw(map({ roads: [road, null] }))
const numbers = strokes(container)
.flatMap(p => (p.getAttribute('d') ?? '').match(/-?\d+(\.\d+)?/g) ?? [])
.map(Number)
// Coordinates come in pairs; none of them may leave the 180x140 element.
for (let i = 0; i < numbers.length; i += 2) {
expect(numbers[i]).toBeGreaterThanOrEqual(-0.01)
expect(numbers[i]).toBeLessThanOrEqual(180.01)
expect(numbers[i + 1]).toBeGreaterThanOrEqual(-0.01)
expect(numbers[i + 1]).toBeLessThanOrEqual(140.01)
}
})
})
describe('what gets asked for', () => {
beforeEach(() => {
vi.mocked(calculateRouteWithLegs).mockReset()
})
it('asks once per leg and keeps the answer in leg order', async () => {
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
coordinates: [[1, 1], [1.1, 1.1], [1.2, 1.2]],
distance: 1, duration: 1, legs: [],
} as never)
const roads = await fetchRoads([
{ lat: 52.5, lng: 13.4 },
{ lat: 52.4, lng: 13.1 },
{ lat: 52.3, lng: 13.0 },
])
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
expect(roads).toHaveLength(2)
expect(roads[0]).toHaveLength(3)
})
it('never asks about a leg long enough to have been a flight', async () => {
const roads = await fetchRoads([
{ lat: 64.14, lng: -21.94 },
{ lat: -33.87, lng: 151.21 },
])
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
expect(roads).toEqual([null])
})
it('leaves a leg alone when the router refuses, rather than failing the lot', async () => {
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new Error('no route'))
.mockResolvedValueOnce({
coordinates: [[1, 1], [1.1, 1.1], [1.2, 1.2]], distance: 1, duration: 1, legs: [],
} as never)
const roads = await fetchRoads([
{ lat: 52.5, lng: 13.4 },
{ lat: 52.4, lng: 13.1 },
{ lat: 52.3, lng: 13.0 },
])
expect(roads[0]).toBeNull()
expect(roads[1]).not.toBeNull()
})
it('keeps nothing when the answer is just the straight line back', async () => {
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
coordinates: [[1, 1], [1.2, 1.2]], distance: 1, duration: 1, legs: [],
} as never)
const roads = await fetchRoads([{ lat: 52.5, lng: 13.4 }, { lat: 52.4, lng: 13.1 }])
expect(roads).toEqual([null])
})
/*
* A router answers a long drive with thousands of points, at a precision no
* press can resolve, and all of it is stored in the document. That is not a
* tidiness problem: the book is saved as one body, and a route kept whole is
* what pushed a real book past the body limit and left the editor showing
* "not saved" with no way forward.
*
* Thinned evenly rather than by cutting a run out of the middle, because a
* road's shape is the reason to draw it at all — losing the middle would
* straighten exactly the bends somebody wanted to see.
*/
it('thins a long answer, keeping both ends and the shape between them', async () => {
const many: [number, number][] = Array.from({ length: 900 }, (_, i) => [
52.5 - i * 0.001,
13.4 + i * 0.001,
])
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
coordinates: many, distance: 1, duration: 1, legs: [],
} as never)
const roads = await fetchRoads([{ lat: 52.5, lng: 13.4 }, { lat: 51.6, lng: 14.3 }])
const leg = roads[0]!
expect(leg).not.toBeNull()
expect(leg.length).toBe(120)
// Both ends survive, so the line still meets the stops it runs between.
expect(leg[0]).toEqual([52.5, 13.4])
expect(leg[leg.length - 1]).toEqual([51.601, 14.299])
// Evenly spaced: no two consecutive points sit further apart than one step.
const gaps = leg.slice(1).map((p, i) => Math.abs(p[0] - leg[i][0]))
expect(Math.max(...gaps) - Math.min(...gaps)).toBeLessThan(0.002)
})
it('leaves a leg the press can already resolve exactly as it came', async () => {
const few: [number, number][] = Array.from({ length: 40 }, (_, i) => [
52.5 - i * 0.01,
13.4 + i * 0.01,
])
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
coordinates: few, distance: 1, duration: 1, legs: [],
} as never)
const roads = await fetchRoads([{ lat: 52.5, lng: 13.4 }, { lat: 52.1, lng: 13.8 }])
expect(roads[0]!.length).toBe(40)
})
})
/**
* The lookups outlive the click by design — they are paced so the router is not
* hammered — so the panel has to take them with it when it goes.
*/
describe('placing a map from the panel', () => {
const journeyStats = {
journeyId: 1, distance: 0, days: 1, steps: 3, photos: 0, places: 3, furthest: 0,
countries: [], trips: [], start: null, end: null,
points: STOPS.map(p => ({ ...p, date: null, country: null, tripId: null })),
} as unknown as JourneyStats
beforeEach(() => {
vi.mocked(calculateRouteWithLegs).mockReset()
useStudioStore.getState().load({
version: 1, title: 'T', page,
spreads: [{ id: 's1', role: 'inner', background: null, elements: [], parked: [], entryId: null }],
} as unknown as BookDocument)
})
it('drops the road lookup when the panel goes away', async () => {
let asked: AbortSignal | undefined
vi.mocked(calculateRouteWithLegs).mockImplementation((_stops, opts) => {
asked = (opts as { signal?: AbortSignal } | undefined)?.signal
return new Promise(() => {}) as never
})
const { getByTitle, unmount } = render(
<StudioTravelPanel
page={page} stats={journeyStats} path={[]} t={k => k} locale="en"
canEdit={false} onToggleStop={async () => true}
/>,
)
fireEvent.click(getByTitle('journey.studio.mapStyle.minimal'))
await vi.waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
unmount()
expect(asked?.aborted).toBe(true)
})
})