mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
240 lines
9.3 KiB
TypeScript
240 lines
9.3 KiB
TypeScript
// FE-COMP-LOCMARKERGL-001 to FE-COMP-LOCMARKERGL-016
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { attachLocationMarker } from './locationMarkerMapbox'
|
|
import type { GeoPosition } from '../../hooks/useGeolocation'
|
|
|
|
const SOURCE_ID = 'trek-location-accuracy'
|
|
|
|
type FeatureCollection = {
|
|
type: 'FeatureCollection'
|
|
features: { geometry: { type: string; coordinates: number[][][] } }[]
|
|
}
|
|
|
|
// Minimal mapbox-gl stand-in: sources/layers live in plain maps so the tests can
|
|
// assert what the handle added and removed, and `loaded` is switchable to drive
|
|
// the "map not ready yet" branch.
|
|
function fakeMap({ loaded = true }: { loaded?: boolean } = {}) {
|
|
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>()
|
|
const layers = new Map<string, unknown>()
|
|
const once = vi.fn()
|
|
return {
|
|
_sources: sources,
|
|
_layers: layers,
|
|
once,
|
|
loaded: () => loaded,
|
|
getSource: (id: string) => sources.get(id),
|
|
addSource: vi.fn((id: string) => { sources.set(id, { setData: vi.fn() }) }),
|
|
addLayer: vi.fn((layer: { id: string }) => { layers.set(layer.id, layer) }),
|
|
getLayer: (id: string) => layers.get(id),
|
|
removeLayer: vi.fn((id: string) => { layers.delete(id) }),
|
|
removeSource: vi.fn((id: string) => { sources.delete(id) }),
|
|
// Fires the handler the marker registered for the given event.
|
|
fire: (event: string) => {
|
|
const call = once.mock.calls.find(c => c[0] === event)
|
|
;(call?.[1] as (() => void) | undefined)?.()
|
|
},
|
|
}
|
|
}
|
|
|
|
type FakeMarker = {
|
|
setLngLat: ReturnType<typeof vi.fn>
|
|
addTo: ReturnType<typeof vi.fn>
|
|
remove: ReturnType<typeof vi.fn>
|
|
getElement: () => HTMLElement
|
|
_el: HTMLElement
|
|
}
|
|
|
|
let markers: FakeMarker[] = []
|
|
|
|
// A marker that mirrors mapbox's DOM contract: addTo appends the element (so
|
|
// `getElement().parentElement` becomes truthy), remove detaches it again.
|
|
const FakeMarkerCtor = vi.fn(function (options?: { element?: HTMLElement }) {
|
|
const el = options?.element ?? document.createElement('div')
|
|
const marker: FakeMarker = {
|
|
_el: el,
|
|
setLngLat: vi.fn(() => marker),
|
|
addTo: vi.fn(() => { document.body.appendChild(el); return marker }),
|
|
remove: vi.fn(() => { el.remove() }),
|
|
getElement: () => el,
|
|
}
|
|
markers.push(marker)
|
|
return marker
|
|
}) as unknown as new (options?: { element?: HTMLElement; anchor?: string }) => unknown
|
|
|
|
function position(over: Partial<GeoPosition> = {}): GeoPosition {
|
|
return { lat: 48.2, lng: 16.37, accuracy: 20, heading: null, speed: null, timestamp: 1 , ...over } as GeoPosition
|
|
}
|
|
|
|
function lastData(map: ReturnType<typeof fakeMap>): FeatureCollection | undefined {
|
|
const src = map._sources.get(SOURCE_ID)
|
|
const calls = src?.setData.mock.calls ?? []
|
|
return calls[calls.length - 1]?.[0] as FeatureCollection | undefined
|
|
}
|
|
|
|
function attach(map: ReturnType<typeof fakeMap>) {
|
|
return attachLocationMarker(map as never, FakeMarkerCtor as never)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
markers = []
|
|
vi.clearAllMocks()
|
|
document.getElementById('trek-location-style')?.remove()
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('attachLocationMarker', () => {
|
|
it('FE-COMP-LOCMARKERGL-001: injects the pulse keyframes exactly once per document', () => {
|
|
attach(fakeMap())
|
|
attach(fakeMap())
|
|
const styles = document.querySelectorAll('#trek-location-style')
|
|
expect(styles).toHaveLength(1)
|
|
expect(styles[0].textContent).toContain('@keyframes trek-location-pulse')
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-002: builds a marker element with a pulse, a heading cone and a dot', () => {
|
|
attach(fakeMap())
|
|
const root = markers[0]._el
|
|
expect(root.children).toHaveLength(3)
|
|
// The cone starts hidden — there is no heading until the first fix.
|
|
expect((root.children[1] as HTMLElement).style.display).toBe('none')
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-003: adds the accuracy source and fill layer when the map is already loaded', () => {
|
|
const map = fakeMap({ loaded: true })
|
|
attach(map)
|
|
expect(map.addSource).toHaveBeenCalledWith(SOURCE_ID, expect.objectContaining({ type: 'geojson' }))
|
|
expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: SOURCE_ID, type: 'fill' }))
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-004: defers the layer to the load event when the map is not ready', () => {
|
|
const map = fakeMap({ loaded: false })
|
|
attach(map)
|
|
expect(map.addSource).not.toHaveBeenCalled()
|
|
expect(map.once).toHaveBeenCalledWith('load', expect.any(Function))
|
|
|
|
map.fire('load')
|
|
expect(map.addSource).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-005: does not re-add the layer when the source already exists', () => {
|
|
const map = fakeMap({ loaded: true })
|
|
attach(map)
|
|
map.addSource.mockClear()
|
|
map.addLayer.mockClear()
|
|
// A second marker on the same map (e.g. a remount) must reuse the source.
|
|
attach(map)
|
|
expect(map.addSource).not.toHaveBeenCalled()
|
|
expect(map.addLayer).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-006: swallows an addSource failure instead of breaking the map', () => {
|
|
const map = fakeMap({ loaded: true })
|
|
map.addSource.mockImplementation(() => { throw new Error('style not loaded') })
|
|
expect(() => attach(map)).not.toThrow()
|
|
expect(map.addLayer).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-007: the first update positions the marker and adds it to the map', () => {
|
|
const map = fakeMap()
|
|
attach(map).update(position({ lat: 10, lng: 20 }))
|
|
expect(markers[0].setLngLat).toHaveBeenCalledWith([20, 10])
|
|
expect(markers[0].addTo).toHaveBeenCalledWith(map)
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-008: a follow-up update moves the marker without re-adding it', () => {
|
|
const handle = attach(fakeMap())
|
|
handle.update(position())
|
|
handle.update(position({ lat: 11, lng: 21 }))
|
|
expect(markers[0].addTo).toHaveBeenCalledTimes(1)
|
|
expect(markers[0].setLngLat).toHaveBeenLastCalledWith([21, 11])
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-009: a heading shows the cone and rotates it', () => {
|
|
const handle = attach(fakeMap())
|
|
handle.update(position({ heading: 135 }))
|
|
const cone = markers[0]._el.children[1] as HTMLElement
|
|
expect(cone.style.display).toBe('block')
|
|
expect(cone.style.transform).toContain('rotate(135deg)')
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-010: a null or NaN heading hides the cone again', () => {
|
|
const handle = attach(fakeMap())
|
|
const cone = markers[0]._el.children[1] as HTMLElement
|
|
handle.update(position({ heading: 90 }))
|
|
expect(cone.style.display).toBe('block')
|
|
|
|
handle.update(position({ heading: null }))
|
|
expect(cone.style.display).toBe('none')
|
|
|
|
handle.update(position({ heading: 90 }))
|
|
handle.update(position({ heading: NaN }))
|
|
expect(cone.style.display).toBe('none')
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-011: an accuracy radius becomes a 48-segment geodesic ring', () => {
|
|
const map = fakeMap()
|
|
attach(map).update(position({ lat: 48, lng: 16, accuracy: 100 }))
|
|
const data = lastData(map)!
|
|
expect(data.features).toHaveLength(1)
|
|
const ring = data.features[0].geometry.coordinates[0]
|
|
// 48 segments produces 49 points, with the ring closed back on itself.
|
|
expect(ring).toHaveLength(49)
|
|
expect(ring[0][0]).toBeCloseTo(ring[48][0], 9)
|
|
expect(ring[0][1]).toBeCloseTo(ring[48][1], 9)
|
|
// 100 m at this latitude is well under a hundredth of a degree from the fix.
|
|
ring.forEach(([lng, lat]) => {
|
|
expect(Math.abs(lat - 48)).toBeLessThan(0.01)
|
|
expect(Math.abs(lng - 16)).toBeLessThan(0.01)
|
|
})
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-012: a sub-metre or missing accuracy clears the ring', () => {
|
|
const map = fakeMap()
|
|
const handle = attach(map)
|
|
handle.update(position({ accuracy: 0.5 }))
|
|
expect(lastData(map)!.features).toHaveLength(0)
|
|
|
|
handle.update(position({ accuracy: 0 }))
|
|
expect(lastData(map)!.features).toHaveLength(0)
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-013: update(null) removes the marker and empties the accuracy ring', () => {
|
|
const map = fakeMap()
|
|
const handle = attach(map)
|
|
handle.update(position({ accuracy: 80 }))
|
|
expect(lastData(map)!.features).toHaveLength(1)
|
|
|
|
handle.update(null)
|
|
expect(markers[0].remove).toHaveBeenCalled()
|
|
expect(lastData(map)!.features).toHaveLength(0)
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-014: an update before the source exists does not throw', () => {
|
|
const map = fakeMap({ loaded: false })
|
|
const handle = attach(map)
|
|
expect(() => handle.update(position({ accuracy: 50 }))).not.toThrow()
|
|
expect(() => handle.update(null)).not.toThrow()
|
|
expect(markers[0].setLngLat).toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-015: destroy removes the marker, the layer and the source', () => {
|
|
const map = fakeMap()
|
|
const handle = attach(map)
|
|
handle.update(position())
|
|
handle.destroy()
|
|
expect(markers[0].remove).toHaveBeenCalled()
|
|
expect(map.removeLayer).toHaveBeenCalledWith(SOURCE_ID)
|
|
expect(map.removeSource).toHaveBeenCalledWith(SOURCE_ID)
|
|
})
|
|
|
|
it('FE-COMP-LOCMARKERGL-016: destroy survives a torn-down map and a throwing marker', () => {
|
|
const map = fakeMap()
|
|
const handle = attach(map)
|
|
markers[0].remove.mockImplementation(() => { throw new Error('marker gone') })
|
|
map.removeLayer.mockImplementation(() => { throw new Error('style gone') })
|
|
expect(() => handle.destroy()).not.toThrow()
|
|
// The source removal is in the same try block, so it never ran.
|
|
expect(map.removeSource).not.toHaveBeenCalled()
|
|
})
|
|
})
|