Files
StarPilot/openpilot/starpilot/galaxy/tests/test_navigation.mjs
T
Danny e11ce3071d Galaxy: Add companion, Spotify and phone-aware web controls
Add companion installation and updates, Spotify setup and playback, destination
sharing, and Android Auto controls. Adapt Galaxy pages to phone transports and
device type, improve Bluetooth pairing feedback, and cover the new web flows.
2026-10-22 15:37:20 -07:00

261 lines
12 KiB
JavaScript

import assert from "node:assert/strict"
import { NavigationPage, NavigationClient, validNavigation, validSearchResult, searchUuid, routePath } from "../web/js/navigation.js"
const place = { id: "place", name: "Library", latitude: 41, longitude: -88 }
const snapshot = (revision = "a") => ({ enabled: true, isMetric: false, hasKey: true, destination: null, favorites: [],
status: "noDestination", instruction: null, route: [], revision })
const response = (value, status = 200) => ({ ok: status === 200, status, json: async () => value })
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() }
assert(validNavigation(snapshot()))
for (const key of ['avoidTolls', 'avoidHighways', 'avoidFerries']) {
for (const value of [true, false]) assert(validNavigation({ ...snapshot(), [key]: value }))
for (const value of ['true', 1, null, []]) assert(!validNavigation({ ...snapshot(), [key]: value }))
}
assert(!validNavigation({ ...snapshot(), route: [{ latitude: 90, longitude: Infinity }] }))
for (const value of ['backup', 'comma', 'phone']) assert(validNavigation({ ...snapshot(), gpsSource: value }))
assert(!validNavigation({ ...snapshot(), gpsSource: 'tunnel' }))
const located = { latitude: 1, longitude: 2, validForMs: 1000 }
assert(validNavigation({ ...snapshot(), location: { ...located, source: 'phone' } }))
assert(!validNavigation({ ...snapshot(), location: { ...located, source: 'satellite' } }))
assert(!validNavigation({ ...snapshot(), destination: { ...place, latitude: 120 } }))
assert(!validNavigation({ ...snapshot(), instruction: { text: "Turn left" } }))
for (const network of ['online', 'offline', 'unknown']) assert(validNavigation({ ...snapshot(), network }))
assert(!validNavigation({ ...snapshot(), network: 'bad' }))
assert.equal(routePath([]), "")
assert.equal(routePath([place]), "")
assert.match(routePath([place, { latitude: 41.1, longitude: -88.1 }]), /^M[\d.,]+ L[\d.,]+$/)
assert(!routePath([{ latitude: 0, longitude: 179.9 }, { latitude: 0, longitude: -179.9 }]).includes("NaN"))
assert.equal(NavigationPage.computed.statusLabel.call({ data: { ...snapshot(), status: "waitingForLocation", route: [place] } }),
"Route preview from last saved location")
assert.equal(NavigationPage.computed.statusLabel.call({ data: { ...snapshot(), status: "waitingForLocation" } }), "Waiting for GPS")
assert.equal(NavigationPage.computed.statusLabel.call({ data: { ...snapshot(), status: "routing", location: { ...place, lastKnown: true } } }),
"Finding a route from last saved location…")
{
const state = { mode: 'local', ready: true, stale: false, data: { ...snapshot(), network: 'online',
location: { ...place, validForMs: 0, lastKnown: true } } }
assert.equal(NavigationPage.computed.usingSavedLocation.call(state), true)
assert.equal(NavigationPage.computed.offline.call(state), false)
state.data.network = 'offline'
assert.equal(NavigationPage.computed.usingSavedLocation.call(state), false)
assert.equal(NavigationPage.computed.offline.call(state), true)
for (const network of ['unknown', undefined]) {
state.data.network = network
assert.equal(NavigationPage.computed.offline.call(state), false)
assert.equal(NavigationPage.computed.usingSavedLocation.call(state), true)
}
state.data.network = 'offline'
state.stale = true
assert.equal(NavigationPage.computed.offline.call(state), false)
const paths = [], page = { tab: 'route', go: path => paths.push(path) }
NavigationPage.methods.selectTab.call(page, 'maps')
assert.equal(page.tab, 'maps')
assert.deepEqual(paths, ['/navigation/maps'])
NavigationPage.methods.selectTab.call(page, 'route')
assert.equal(page.tab, 'route')
assert.equal(paths.at(-1), '/navigation')
assert.equal(NavigationPage.data.call({initialTab:'maps'}).tab, 'maps')
NavigationPage.watch.initialTab.call(page, 'maps')
assert.equal(page.tab, 'maps')
}
function setup() {
const requests = [], states = [], timers = new Map()
let sequence = 0, unauthorized = 0
const client = new NavigationClient({ publish: (state) => states.push(state), unauthorized: () => unauthorized++,
later: (fn, ms) => { const key = ++sequence; timers.set(key, { fn, ms }); return key },
cancelTimer: (key) => timers.delete(key),
fetcher: (url, options) => new Promise((resolve, reject) => requests.push({ url, options, resolve, reject })),
})
return { client, requests, states, timers, unauthorized: () => unauthorized }
}
{
const { client, requests, states } = setup()
const start = client.start()
requests[0].resolve(response(snapshot()))
await start
const search = client.search("Library")
requests[1].resolve(response({ results: [place] }))
await search
assert.equal(states.at(-1).results[0].name, "Library")
const action = client.action("select", { destination: place })
assert.deepEqual(JSON.parse(requests[2].options.body), { action: "select", revision: "a", destination: place })
requests[2].resolve(response({ ...snapshot("b"), destination: place }))
await action
assert.equal(states.at(-1).data.revision, "b")
assert.deepEqual(states.at(-1).results, [])
client.stop()
}
{
const { client, requests, states } = setup()
const start = client.start()
requests[0].resolve(response(snapshot()))
await start
const poll = client.load()
const action = client.action("clear")
assert(requests[1].options.signal.aborted)
requests[2].resolve(response(snapshot("new")))
await action
requests[1].resolve(response(snapshot("old")))
await poll
assert.equal(states.at(-1).data.revision, "new")
client.stop()
}
{
const { client, requests, states } = setup()
const start = client.start()
requests[0].resolve(response(snapshot()))
await start
const poll = client.load()
requests[1].reject(new Error("offline"))
await poll
assert.equal(states.at(-1).data.revision, "a")
assert.equal(states.at(-1).stale, true)
await client.action("clear")
assert.equal(requests.length, 2)
const refresh = client.load()
requests[2].resolve(response(snapshot("fresh")))
await refresh
assert.equal(states.at(-1).stale, false)
client.stop()
}
{
const { client, requests, states, unauthorized } = setup()
const start = client.start()
requests[0].resolve(response(snapshot()))
await start
const action = client.action("configure", { patch: { token: "pk.secret" } })
requests[1].resolve(response({ error: "Sign in" }, 401))
await action
assert.equal(unauthorized(), 1)
assert.equal(states.at(-1).data, null)
assert(!JSON.stringify(states).includes("pk.secret"))
}
{
const { client, requests, states } = setup()
const start = client.start()
client.stop()
requests[0].resolve(response(snapshot()))
await start
await flush()
assert.equal(states.at(-1).data, null)
assert(!client.active)
}
console.log("Navigation client: validation, route geometry, mutation ordering, transient loss, authentication and teardown passed")
{
const { client, requests, states } = setup()
const start = client.start()
requests[0].resolve(response(snapshot()))
await start
const search = client.search("Coffee")
const sent = JSON.parse(requests[1].options.body)
const poi = { id: "poi/id", name: "Coffee Shop", description: "123 Main St, Springfield", searchId: sent.searchId }
assert(validSearchResult(poi))
assert(!validSearchResult({ ...poi, searchId: "bad" }))
assert(!validSearchResult({ ...poi, description: "x".repeat(513) }))
assert.notEqual(sent.searchId, sent.clientId)
requests[1].resolve(response({ results: [poi] }))
await search
const choose = client.choose(poi)
assert.deepEqual(JSON.parse(requests[2].options.body), { action: "selectPlace", revision: "a", id: "poi/id", searchId: sent.searchId })
requests[2].resolve(response({ ...snapshot("poi"), destination: { ...place, address: "123 Main St" }, recents: [place] }))
await choose
assert.equal(states.at(-1).data.destination.address, "123 Main St")
assert.equal(states.at(-1).data.recents[0].name, "Library")
assert.deepEqual(states.at(-1).results, [])
client.stop()
assert.equal(JSON.parse(requests.at(-1).options.body).action, "cancelSearch")
}
console.log("POI suggestions: validation, selected-only retrieval, destination state and cancellation passed")
{
// Saved and recent places travel without their ids; suggestions are saved through the search that found them.
const { client, requests } = setup()
const start = client.start()
requests[0].resolve(response({ ...snapshot(), favorites: [{ ...place, label: "home" }], recents: [{ ...place, id: "other", address: "1 Main" }] }))
await start
client.choose({ ...place, label: "home" })
assert.deepEqual(JSON.parse(requests[1].options.body), { action: "select", revision: "a", destination: { name: "Library", latitude: 41, longitude: -88 } })
requests[1].resolve(response(snapshot("b"))); await flush()
client.save({ id: "poi/id", name: "Coffee", searchId: "0".repeat(36) })
assert.deepEqual(JSON.parse(requests[2].options.body), { action: "favoritePlace", revision: "b", id: "poi/id", searchId: "0".repeat(36) })
requests[2].resolve(response(snapshot("c"))); await flush()
client.save({ ...place, address: "1 Main" })
assert.deepEqual(JSON.parse(requests[3].options.body).destination, { name: "Library", latitude: 41, longitude: -88, address: "1 Main" })
client.stop()
assert(!validNavigation({ ...snapshot(), recents: [{ ...place, label: "gym" }] }))
assert(!validNavigation({ ...snapshot(), recents: Array(11).fill(place) }))
}
console.log("Favorites and recents: choose, save and validation passed")
{
// Polls send the held route key; an unchanged reply reuses the lines already drawn.
const { client, requests, states } = setup()
const line = [{ latitude: 41, longitude: -88 }, { latitude: 41.1, longitude: -88.1 }]
const guiding = { ...snapshot(), destination: place, status: "routing", route: line, routeKey: "0123456789abcdef" }
const start = client.start()
assert.equal(requests[0].url, "./api/navigation/status")
requests[0].resolve(response(guiding))
await start
const held = states.at(-1).data
assert(Object.isFrozen(held.route))
const poll = client.load()
assert.equal(requests[1].url, "./api/navigation/status?routeKey=0123456789abcdef")
const { route, ...light } = guiding
requests[1].resolve(response({ ...light, routeUnchanged: true }))
await poll
assert.equal(states.at(-1).data, held, "an identical reply keeps the same object, so nothing re-renders")
const moved = client.load()
requests[2].resolve(response({ ...light, routeUnchanged: true, status: "guiding" }))
await moved
assert.equal(states.at(-1).data.status, "guiding")
assert.equal(states.at(-1).data.route, held.route)
const lost = client.load()
requests[3].resolve(response({ ...light, routeKey: "fedcba9876543210", routeUnchanged: true }))
await lost
assert.deepEqual(states.at(-1).data.route, [])
const again = client.load()
assert.equal(requests[4].url, "./api/navigation/status", "a reply for lines the page does not hold asks for them again")
requests[4].resolve(response(guiding)); await again
client.stop()
}
console.log("Route key polling: light replies, reuse, and recovery passed")
{
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto")
const realCrypto = globalThis.crypto
Object.defineProperty(globalThis, "crypto", { configurable: true, value: {
getRandomValues: (bytes) => realCrypto.getRandomValues(bytes),
} })
try {
assert.match(searchUuid(), /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
const { client } = setup()
assert.match(client.clientId, /^[0-9a-f-]{36}$/)
} finally { Object.defineProperty(globalThis, "crypto", descriptor) }
}
console.log("Local HTTP search UUID fallback passed")
{
const actions = []
const page = { token: ' pk.example ', client: { action: async (...args) => actions.push(args) } }
await NavigationPage.methods.saveKey.call(page, true)
assert.equal(page.token, '')
assert.deepEqual(actions[0], ['configure', { patch: { token: 'pk.example', enabled: true } }])
page.token = 'pk.replacement'; await NavigationPage.methods.saveKey.call(page, false)
assert.deepEqual(actions[1], ['configure', { patch: { token: 'pk.replacement' } }])
assert.ok(NavigationPage.template.includes("selectTab('setup')"))
assert.ok(NavigationPage.template.includes('Save key'))
assert.ok(NavigationPage.template.includes('<BluetoothNotice v-if="bluetooth" feature="Map preview"'))
assert.ok(NavigationPage.template.includes('<NavigationMap v-else-if="ready"'))
}
await import("./test_navigation_map.mjs")