Files
TREK/client/src/components/Map/markerDrag.test.ts
T
Maurice e11ae6f056 feat(roadtrip): shape the drive — vias, alternatives and stops along the way
The road-trip addon's second half: the parts that let a planned drive be
argued with rather than only read.

Reshaping. A via records which stop it follows by position, and that position
is resolved fresh every time the day is routed — so inserting, reordering or
removing a stop moved the ground under every via after it, and the drive
silently snapped back to the road it had been steered away from while the
handle still sat on the map where it was left. The correction is computed
against the day as it stands and written with the same change, in one
transaction: a half-applied re-anchoring mixes two numbering schemes in one
day. The one judgement that needs geometry — which half of a split leg a via
belongs to when a stop lands in the middle of it — is measured on the road
actually driven, so a fuel stop added to a bent leg keeps the bend on the side
it belongs to.

Alternatives. OSRM answers most long legs with exactly one route, which read as
"there is no other way" when a perfectly good slower road existed. When it
offers nothing, the same drive is asked for again without the motorway, and
failing that without the tolls — one question at a time, stopping as soon as
one lands. The offer says what it is rather than how much slower.

Stops along the way. Everything the corridor search can find is a pause, not a
destination: fuel, charging, rest areas, campsites, food and sights all sit
inside the leg they were found on, carry their own colour and icon, and are
left out of every stop count. That colour is defined once and read by the rail,
the corridor list, the map pin and the picker — and it replaced the chains'
logos, which turned a corridor full of petrol stations into a row of
advertisements and left the brands with no logo on file looking like something
else entirely.

How long a stop takes was readable in the rail and writable nowhere; new places
were given an invented hour that nobody had entered. It is now set from the
rail, cleared as a zero because the update statement folds a null into "leave
it alone", and new places start with nothing.

Both columns are rebuilt on one type scale, anchored to the day plan's own
sizes so switching tabs does not change the size of the text beside it. The
rail reads as trip totals, day cards and a chain of stops with the drives
between them; the corridor column as what to look for, how the search is
getting on, and what it found. Six category pills over three lines became one
dropdown, which is the space the results wanted.

Also: the offered routes are framed on the map when the picker opens (`fitKey`
carries no coordinates and each renderer reads it as "the selected day"), road
trip mode does not cluster at all so the whole drive stays readable zoomed out,
and the drive-time labels finally render in the app's own typeface instead of
Leaflet's Helvetica. All of it in both renderers.
2026-09-07 18:36:54 +02:00

175 lines
5.9 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
/**
* jsdom has no drag implementation, so these drive the events the browser would
* fire. What is worth pinning is the contract the day plan reads on the other
* end: a `placeId` in dataTransfer AND on window.__dragData, because dataTransfer
* is unreadable during dragover.
*/
function dragEvent(type: string) {
const data = new Map<string, string>()
const dataTransfer = {
setData: (k: string, v: string) => { data.set(k, v) },
getData: (k: string) => data.get(k) ?? '',
effectAllowed: 'none',
}
const e = new Event(type, { bubbles: true }) as Event & { dataTransfer: typeof dataTransfer }
Object.defineProperty(e, 'dataTransfer', { value: dataTransfer })
return e
}
describe('makeMarkerDraggable', () => {
let el: HTMLElement
beforeEach(() => {
window.__dragData = null
el = document.createElement('div')
document.body.appendChild(el)
})
it('FE-MARKERDRAG-001: marks the element as a drag source', () => {
makeMarkerDraggable(el, 42)
expect(el.getAttribute('draggable')).toBe('true')
})
it('FE-MARKERDRAG-002: hands the place id over both channels', () => {
makeMarkerDraggable(el, 42)
const e = dragEvent('dragstart')
el.dispatchEvent(e)
expect(e.dataTransfer.getData('placeId')).toBe('42')
// The second one is what the day plan reads while the pointer hovers over it.
expect(window.__dragData).toEqual({ placeId: '42' })
expect(e.dataTransfer.effectAllowed).toBe('copy')
})
it('FE-MARKERDRAG-003: clears the hand-off when the drag ends', () => {
makeMarkerDraggable(el, 42)
el.dispatchEvent(dragEvent('dragstart'))
el.dispatchEvent(dragEvent('dragend'))
expect(window.__dragData).toBeNull()
expect(el.classList.contains('marker-dragging')).toBe(false)
})
it('FE-MARKERDRAG-004: dims the marker it left behind while in flight', () => {
makeMarkerDraggable(el, 7)
el.dispatchEvent(dragEvent('dragstart'))
expect(el.classList.contains('marker-dragging')).toBe(true)
})
it('FE-MARKERDRAG-005: stops mousedown from reaching the map, which would pan it away', () => {
makeMarkerDraggable(el, 7)
const onMap = vi.fn()
document.body.addEventListener('mousedown', onMap)
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
expect(onMap).not.toHaveBeenCalled()
document.body.removeEventListener('mousedown', onMap)
})
it('FE-MARKERDRAG-006: the cleanup leaves no listeners and no draggable attribute', () => {
const cleanup = makeMarkerDraggable(el, 7)
cleanup()
el.dispatchEvent(dragEvent('dragstart'))
expect(el.hasAttribute('draggable')).toBe(false)
expect(window.__dragData).toBeNull()
})
})
describe('makePoiDraggable', () => {
afterEach(() => {
window.__dragData = null
document.body.classList.remove('dragging-poi')
})
const dragEvent = (type: string) => {
const e = new Event(type, { bubbles: true, cancelable: true }) as DragEvent & { dataTransfer: DataTransfer }
const store: Record<string, string> = {}
Object.defineProperty(e, 'dataTransfer', {
value: {
setData: (k: string, v: string) => { store[k] = v },
getData: (k: string) => store[k] ?? '',
effectAllowed: '',
dropEffect: '',
},
})
return e
}
it('FE-MAP-POIDRAG-001: a hit carries its OSM id, not a place id — it is not a place yet', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
expect(el.getAttribute('draggable')).toBe('true')
const e = dragEvent('dragstart')
el.dispatchEvent(e)
expect(e.dataTransfer.getData('poiOsmId')).toBe('node:9')
// Mirrored on window because dataTransfer is unreadable during dragover, and the
// map has to know what is coming while it is still being hovered.
expect(window.__dragData).toEqual({ poiOsmId: 'node:9' })
})
it('FE-MAP-POIDRAG-002: the drag marks the body, so the route can light up while it lasts', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
el.dispatchEvent(dragEvent('dragstart'))
expect(document.body.classList.contains('dragging-poi')).toBe(true)
el.dispatchEvent(dragEvent('dragend'))
expect(document.body.classList.contains('dragging-poi')).toBe(false)
expect(window.__dragData).toBeNull()
})
it('FE-MAP-POIDRAG-003: mousedown is swallowed, or the map pans out from under the drag', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
const seen = vi.fn()
document.body.appendChild(el)
document.body.addEventListener('mousedown', seen)
el.dispatchEvent(down)
expect(seen).not.toHaveBeenCalled()
document.body.removeEventListener('mousedown', seen)
el.remove()
})
it('FE-MAP-POIDRAG-004: unwiring leaves the element as it was found', () => {
const el = document.createElement('div')
const off = makePoiDraggable(el, 'node:9')
off()
expect(el.hasAttribute('draggable')).toBe(false)
el.dispatchEvent(dragEvent('dragstart'))
expect(window.__dragData).toBeNull()
})
it('FE-MAP-POIDRAG-005: the id is readable from the event, and from the mirror during a hover', () => {
const el = document.createElement('div')
makePoiDraggable(el, 'node:9')
const start = dragEvent('dragstart')
el.dispatchEvent(start)
expect(draggedPoiId(start)).toBe('node:9')
// A dragover event carries an unreadable dataTransfer; the mirror answers instead.
const over = new Event('dragover') as DragEvent
expect(draggedPoiId(over)).toBe('node:9')
})
it('FE-MAP-POIDRAG-006: an unrelated drag is not mistaken for a hit', () => {
const over = new Event('dragover') as DragEvent
expect(draggedPoiId(over)).toBeNull()
})
})