Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 1
timeout-minutes: 2
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
Expand Down
8 changes: 6 additions & 2 deletions src/App.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeAll, beforeEach, describe, expect, test } from 'vitest'
import { configure, render, waitFor } from '@solidjs/testing-library'
import { configure, fireEvent, render, waitFor } from '@solidjs/testing-library'

import { setAccessToken, signOut } from '~/api/auth/client'
import * as Demo from '~/api/auth/demo'
Expand All @@ -26,10 +26,14 @@ describe('Demo mode', () => {
})

test('View demo route', async () => {
const { findByText, findByTestId } = renderApp(`/${Demo.DONGLE_ID}/${DEMO_LOG_ID}`)
const { findByLabelText, findByText, findByTestId } = renderApp(`/${Demo.DONGLE_ID}/${DEMO_LOG_ID}`)
expect(await findByText(DEMO_LOG_ID)).toBeTruthy()
const video = (await findByTestId('route-video')) as HTMLVideoElement
await waitFor(() => expect(video.src).toBeTruthy())
expect(video.muted).toBe(true)
await fireEvent.click(await findByLabelText('Unmute'))
expect(video.muted).toBe(false)
expect(await findByLabelText('Mute')).toBeTruthy()
})
})

Expand Down
34 changes: 31 additions & 3 deletions src/components/RouteVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
let controls!: HTMLDivElement

const [isPlaying, setIsPlaying] = createSignal(true)
const [isMuted, setIsMuted] = createSignal(true)
const [currentTime, setCurrentTime] = createSignal(0)
const [duration, setDuration] = createSignal(0)
const [videoLoading, setVideoLoading] = createSignal(true)
Expand All @@ -47,14 +48,28 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
const startProgressTracking = () => {
requestAnimationFrame(updateProgressContinuously)
}
const requestPlay = () => {
const playResult = video.play()
if (playResult) {
void playResult.catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') return
console.debug('[RouteVideoPlayer] play interrupted', error)
})
}
}

const togglePlayback = () => {
if (video.paused) {
void video.play()
requestPlay()
} else {
video.pause()
}
}
const toggleMuted = (e: Event) => {
e.preventDefault()
e.stopPropagation()
setIsMuted((muted) => !muted)
}
const onClick = (e: Event) => {
e.preventDefault()
togglePlayback()
Expand Down Expand Up @@ -83,14 +98,16 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
const onEnded = () => setIsPlaying(false)
const onStalled = () => {
if (!isPlaying()) return
void video.play()
requestPlay()
}

onMount(() => {
if (props.selection.startTime > 0) {
video.currentTime = props.selection.startTime
}

video.defaultMuted = true
video.muted = true
props.ref?.(video)

controls.addEventListener('click', onClick)
Expand Down Expand Up @@ -143,9 +160,17 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
on(routeName, () => {
setVideoLoading(true)
setErrorMessage('')
setIsMuted(true)
}),
)

createEffect(() => {
if (!video) return
const muted = isMuted()
video.defaultMuted = muted
video.muted = muted
})

createEffect(() => {
const url = streamUrl()
const player = hls()
Expand All @@ -172,7 +197,7 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
class="size-full object-cover"
data-testid="route-video"
autoplay
muted
muted={isMuted()}
controls={false}
playsinline
loop
Expand Down Expand Up @@ -205,6 +230,9 @@ const RouteVideoPlayer: VoidComponent<RouteVideoPlayerProps> = (props) => {
<div class="font-mono text-sm text-on-surface">
{formatVideoTime(currentTime())} / {formatVideoTime(duration())}
</div>

<div class="grow" />
<IconButton name={isMuted() ? 'volume_off' : 'volume_up'} aria-label={isMuted() ? 'Unmute' : 'Mute'} onClick={toggleMuted} />
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/material/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const Icons = [
'add', 'arrow_back', 'camera', 'check', 'chevron_right', 'clear', 'close', 'delete', 'description', 'directions_car', 'download', 'error',
'file_copy', 'flag', 'info', 'keyboard_arrow_down', 'keyboard_arrow_up', 'local_fire_department', 'logout', 'menu', 'my_location',
'open_in_new', 'payments', 'person', 'progress_activity', 'satellite_alt', 'search', 'settings', 'upload', 'videocam', 'refresh',
'login', 'person_off', 'autorenew', 'close_small', 'pause', 'play_arrow', 'clear_all',
'login', 'person_off', 'autorenew', 'close_small', 'pause', 'play_arrow', 'clear_all', 'volume_off', 'volume_up',
] as const

export type IconName = (typeof Icons)[number]
Expand Down
23 changes: 23 additions & 0 deletions src/map/geocode.live.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, test } from 'vitest'

import { getFullAddress, getPlaceName, reverseGeocode } from './geocode'

const describeLive = process.env.RUN_LIVE_MAP_TESTS === '1' ? describe : describe.skip

describeLive('live geocode smoke tests', () => {
test('reverseGeocode returns a feature for a known coordinate', async () => {
expect(await reverseGeocode([-0.10664, 51.514209])).not.toBeNull()
}, 15000)

test('getFullAddress returns a non-empty string', async () => {
const fullAddress = await getFullAddress([-0.10664, 51.514209])
expect(fullAddress).toBeTruthy()
expect(typeof fullAddress).toBe('string')
}, 15000)

test('getPlaceName returns a non-empty string', async () => {
const placeName = await getPlaceName([-117.168638, 32.723695])
expect(placeName).toBeTruthy()
expect(typeof placeName).toBe('string')
}, 15000)
})
137 changes: 132 additions & 5 deletions src/map/geocode.spec.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,158 @@
import { describe, expect, test } from 'vitest'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import type { Position } from 'geojson'

import type { ReverseGeocodingFeature, ReverseGeocodingResponse } from './api-types'
import { getFullAddress, getPlaceName, reverseGeocode } from './geocode'
import { MAPBOX_TOKEN } from './config'

const fetchMock = vi.fn()

type MockContext = Partial<ReverseGeocodingFeature['properties']['context']>

function createFeature(fullAddress: string, context: MockContext = {}): ReverseGeocodingFeature {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [0, 0],
},
properties: {
feature_type: 'address',
name: fullAddress,
name_preferred: fullAddress,
place_formatted: fullAddress,
full_address: fullAddress,
context,
},
} as ReverseGeocodingFeature
}

function createResponse(feature: ReverseGeocodingFeature): ReverseGeocodingResponse {
return {
type: 'FeatureCollection',
attribution: 'test fixture',
features: [feature],
} as ReverseGeocodingResponse
}

function coordinateKey(position: Position): string {
return `${position[0].toFixed(6)},${position[1].toFixed(6)}`
}

function mockReverseGeocode(featuresByCoordinate: Map<string, ReverseGeocodingFeature>) {
fetchMock.mockImplementation(async (input: string | URL | Request) => {
const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
const url = new URL(rawUrl)
const key = `${url.searchParams.get('longitude')},${url.searchParams.get('latitude')}`
const feature = featuresByCoordinate.get(key)
if (!feature) throw new Error(`Unexpected reverse geocode lookup for ${key}`)

return new Response(JSON.stringify(createResponse(feature)), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
})
}

beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})

afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})

describe('reverseGeocode', () => {
test('return null if coords are [0, 0]', async () => {
test('return null if coords are [0, 0] without calling fetch', async () => {
expect(await reverseGeocode([0, 0])).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})

test('return first feature from reverse geocode response', async () => {
const position: Position = [-0.10664, 51.514209]
const feature = createFeature('133 Fleet Street, City of London, London, EC4A 2BB, United Kingdom')
mockReverseGeocode(new Map([[coordinateKey(position), feature]]))

expect(await reverseGeocode(position)).toEqual(feature)
expect(fetchMock).toHaveBeenCalledOnce()
})

test('request includes expected Mapbox URL, params, and cache mode', async () => {
const position: Position = [-0.10664, 51.514209]
const feature = createFeature('133 Fleet Street, City of London, London, EC4A 2BB, United Kingdom')
mockReverseGeocode(new Map([[coordinateKey(position), feature]]))

await reverseGeocode(position)

expect(fetchMock).toHaveBeenCalledOnce()
const [input, init] = fetchMock.mock.calls[0] as [string | URL | Request, RequestInit | undefined]
const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
const url = new URL(rawUrl)
expect(`${url.origin}${url.pathname}`).toBe('https://api.mapbox.com/search/geocode/v6/reverse')
expect(url.searchParams.get('longitude')).toBe('-0.106640')
expect(url.searchParams.get('latitude')).toBe('51.514209')
expect(url.searchParams.get('access_token')).toBe(MAPBOX_TOKEN)
expect(init).toMatchObject({ cache: 'force-cache' })
})

test('return null when fetch rejects', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
fetchMock.mockRejectedValueOnce(new Error('network down'))

expect(await reverseGeocode([-0.10664, 51.514209])).toBeNull()
})

test('return null on non-ok response', async () => {
fetchMock.mockResolvedValueOnce(new Response('denied', { status: 429, statusText: 'Too Many Requests' }))

expect(await reverseGeocode([-0.10664, 51.514209])).toBeNull()
})

test('return null when response json cannot be parsed', async () => {
fetchMock.mockResolvedValueOnce(new Response('{', { status: 200, headers: { 'Content-Type': 'application/json' } }))

expect(await reverseGeocode([-0.10664, 51.514209])).toBeNull()
})
})

describe('getFullAddress', () => {
test('return null if coords are [0, 0]', async () => {
test('return null if coords are [0, 0] without calling fetch', async () => {
expect(await getFullAddress([0, 0])).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})

test('normal usage', async () => {
// expect(await getFullAddress([-77.036574, 38.8976765])).toBe('1600 Pennsylvania Avenue Northwest, Washington, District of Columbia 20500, United States')
mockReverseGeocode(
new Map([
[coordinateKey([-0.10664, 51.514209]), createFeature('133 Fleet Street, City of London, London, EC4A 2BB, United Kingdom')],
[coordinateKey([-2.076843, 51.894799]), createFeature('4 Montpellier Drive, Cheltenham, GL50 1TX, United Kingdom')],
]),
)

expect(await getFullAddress([-0.10664, 51.514209])).toBe('133 Fleet Street, City of London, London, EC4A 2BB, United Kingdom')
expect(await getFullAddress([-2.076843, 51.894799])).toBe('4 Montpellier Drive, Cheltenham, GL50 1TX, United Kingdom')
})
})

describe('getPlaceName', () => {
test('return null if coords are [0, 0]', async () => {
test('return null if coords are [0, 0] without calling fetch', async () => {
expect(await getPlaceName([0, 0])).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})

test('normal usage', async () => {
mockReverseGeocode(
new Map([
[coordinateKey([-117.168638, 32.723695]), createFeature('', { neighborhood: { name: 'Little Italy' } })],
[coordinateKey([-118.192757, 33.763015]), createFeature('', { place: { name: 'Downtown Long Beach' } })],
[coordinateKey([-0.113643, 51.504546]), createFeature('', { neighborhood: { name: 'Waterloo' } })],
[coordinateKey([5.572254, 50.64428]), createFeature('', { locality: { name: 'Liège' } })],
[coordinateKey([-2.236802, 53.480931]), createFeature('', { neighborhood: { name: 'Northern Quarter' } })],
]),
)

expect(await getPlaceName([-117.168638, 32.723695])).toBe('Little Italy')
expect(await getPlaceName([-118.192757, 33.763015])).toBe('Downtown Long Beach')
expect(await getPlaceName([-0.113643, 51.504546])).toBe('Waterloo')
Expand Down
Loading