mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
8f132013d2
Expose map operations and status through Galaxy presentation endpoints. Co-authored-by: Danny <dannyirwin31@yahoo.com> Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu> Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
189 lines
13 KiB
JavaScript
189 lines
13 KiB
JavaScript
import { remainingLocationLease } from '../web/js/navigation.js'
|
|
import assert from 'node:assert/strict'
|
|
import { project, unproject, relativeX, RasterMap } from '../web/js/navigation-map.js'
|
|
for (const zoom of [0, 2, 13, 18]) {
|
|
const point = { latitude: 40, longitude: -90 }
|
|
const roundTrip = unproject(...project(point, zoom), zoom)
|
|
assert.ok(Math.abs(roundTrip.latitude - point.latitude) < 1e-10)
|
|
assert.ok(Math.abs(roundTrip.longitude - point.longitude) < 1e-10)
|
|
}
|
|
assert.equal(relativeX(5, 1010, 1024), 1029)
|
|
assert.ok(project({ latitude: 90, longitude: 0 }, 2).every(Number.isFinite))
|
|
const frames = new Map(); let frameId = 0
|
|
const originalRaf = globalThis.requestAnimationFrame, originalCancelRaf = globalThis.cancelAnimationFrame
|
|
globalThis.requestAnimationFrame = fn => { frames.set(++frameId, fn); return frameId }
|
|
globalThis.cancelAnimationFrame = id => frames.delete(id)
|
|
const calls = [], listeners = new Map()
|
|
globalThis.ResizeObserver = class { observe() {} disconnect() { calls.push('disconnect') } }
|
|
const ctx = new Proxy({}, { get: (obj, key) => obj[key] || (() => {}) })
|
|
const canvas = { clientWidth: 400, clientHeight: 360, getContext: () => ctx, setPointerCapture() {}, addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: name => listeners.delete(name) }
|
|
const map = new RasterMap(canvas)
|
|
map.load = () => {}
|
|
map.update({ destination: { id: 'd', latitude: 40, longitude: -90 }, route: [{ latitude: 40, longitude: -90 }, { latitude: 40.01, longitude: -89.99 }], routeKey: '0123456789abcdef' }, false)
|
|
const fitted = map.zoom
|
|
assert.ok(fitted >= 10 && Number.isInteger(fitted) && map.follow === false, 'a new route is shown whole')
|
|
assert.ok(Math.abs(map.center.latitude - 40.005) < 1e-6 && Math.abs(map.center.longitude + 89.995) < 1e-6)
|
|
const before = project(map.center, map.zoom)
|
|
listeners.get('pointerdown')({ clientX: 100, clientY: 100, pointerId: 1 })
|
|
listeners.get('pointermove')({ clientX: 150, clientY: 120 })
|
|
assert.ok(Math.abs(project(map.center, map.zoom)[0] - before[0] + 50) < 1e-6)
|
|
listeners.get('pointerup')({})
|
|
const panned = { ...map.center }
|
|
map.update(map.data, false)
|
|
assert.deepEqual(map.center, panned)
|
|
map.changeZoom(1); assert.equal(map.zoom, fitted + 1)
|
|
map.recenter(); assert.equal(map.zoom, 2); assert.equal(map.follow, true)
|
|
map.close(); assert.equal(listeners.size, 0); assert.deepEqual(calls, ['disconnect'])
|
|
console.log('Map projection, dateline, pan, zoom, recenter, poll stability and cleanup passed')
|
|
const timers = new Map(); let next = 0
|
|
const savedSet = globalThis.setTimeout, savedClear = globalThis.clearTimeout
|
|
globalThis.setTimeout = (fn, ms) => { timers.set(++next, {fn, ms}); return next }
|
|
globalThis.clearTimeout = id => timers.delete(id)
|
|
const leased = new RasterMap(canvas); leased.load = () => {}
|
|
leased.update({ location: {latitude:40,longitude:-90,validForMs:100} }, false)
|
|
assert.equal(leased.locationFresh, true); assert.ok(Math.abs([...timers.values()][0].ms - 100) < 1e-6)
|
|
leased.update({ location: {latitude:41,longitude:-89,bearing:125,validForMs:200} }, false)
|
|
assert.equal(timers.size, 1); [...timers.values()][0].fn(); assert.equal(leased.locationFresh, false)
|
|
leased.update({ location: {latitude:41,longitude:-89,validForMs:0} }, false)
|
|
assert.equal(leased.locationFresh, false); assert.equal(timers.size, 0)
|
|
leased.update({location:null}, false); assert.equal(leased.lastLocation.latitude,41); assert.equal(leased.lastLocation.bearing,125);
|
|
leased.recenter(); assert.equal(leased.center.latitude,41); assert.equal(leased.locationFresh,false);
|
|
leased.close(); globalThis.setTimeout = savedSet; globalThis.clearTimeout = savedClear
|
|
console.log('GPS lease expiry, replacement, delayed-expired response and teardown passed')
|
|
|
|
assert.equal(remainingLocationLease(2000,100,600),1500)
|
|
assert.equal(remainingLocationLease(2000,100,2200),0)
|
|
const actualPerformance = globalThis.performance
|
|
let now = 1000
|
|
globalThis.performance = { now: () => now }
|
|
globalThis.setTimeout = (fn, ms) => { timers.set(++next, {fn, ms}); return next }
|
|
globalThis.clearTimeout = id => timers.delete(id)
|
|
const samePayload = { location: {latitude:40,longitude:-90,validForMs:100} }
|
|
const stableLease = new RasterMap(canvas); stableLease.load = () => {}
|
|
stableLease.update(samePayload, false); now += 60
|
|
stableLease.update(samePayload, true); assert.equal(stableLease.locationFresh, false)
|
|
stableLease.update(samePayload, false); assert.equal([...timers.values()][0].ms, 40)
|
|
now += 41; stableLease.update(samePayload, false); assert.equal(stableLease.locationFresh, false)
|
|
stableLease.close(); timers.clear()
|
|
const realFetch = globalThis.fetch
|
|
let requests = 0, message = ''
|
|
globalThis.fetch = (_, options) => { requests++; return new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted')))) }
|
|
const deadlineMap = new RasterMap(canvas, value => { message = value }); deadlineMap.draw = () => {}
|
|
const pendingLoad = deadlineMap.load('0/0/0')
|
|
const deadline = [...timers.values()].find(timer => timer.ms === 8000)
|
|
deadline.fn(); await pendingLoad
|
|
assert.equal(deadlineMap.failed.has('0/0/0'), true); assert.ok(message.includes('Reconnecting')); assert.equal(requests, 1)
|
|
deadlineMap.close(); timers.clear()
|
|
globalThis.fetch = realFetch; globalThis.performance = actualPerformance; globalThis.setTimeout = savedSet; globalThis.clearTimeout = savedClear
|
|
console.log('Same-payload stale recovery cannot extend GPS lease; tile deadline requires explicit Retry')
|
|
|
|
const restored = new RasterMap(canvas); restored.load = () => {}
|
|
restored.update({location:{latitude:42,longitude:-88,bearing:170,lastKnown:true,validForMs:0}}, false)
|
|
assert.equal(restored.center.latitude,42); assert.equal(restored.zoom,15); assert.equal(restored.locationFresh,false)
|
|
const savedCenter = {...restored.center}
|
|
restored.update({location:null},true); assert.deepEqual(restored.center,savedCenter)
|
|
restored.update({location:{latitude:43,longitude:-87,validForMs:100}},false)
|
|
assert.equal(restored.center.latitude,43); assert.equal(restored.lastLocation.latitude,43); assert.equal(restored.lastLocation.bearing,170); assert.equal(restored.locationFresh,true)
|
|
restored.close()
|
|
console.log('Durable last-known context initializes map without a live lease; reconnect and new live fix retain bearing')
|
|
|
|
// Completed requests share one browser frame, without a 200ms wait or repeated canvas allocation.
|
|
let widthWrites = 0, heightWrites = 0, width = 0, height = 0
|
|
const measuredCanvas = { ...canvas, get width() { return width }, set width(value) { width = value; widthWrites++ }, get height() { return height }, set height(value) { height = value; heightWrites++ } }
|
|
const measured = new RasterMap(measuredCanvas); measured.load = () => {}
|
|
measured.draw(); measured.draw(); assert.equal(widthWrites, 1); assert.equal(heightWrites, 1)
|
|
measuredCanvas.clientWidth = 401; measured.draw(); assert.equal(widthWrites, 2); assert.equal(heightWrites, 1)
|
|
const savedFetch = globalThis.fetch, savedBitmap = globalThis.createImageBitmap
|
|
let closedImages = 0
|
|
globalThis.fetch = async () => ({ ok: true, blob: async () => ({}) })
|
|
globalThis.createImageBitmap = async () => ({ close() { closedImages++ } })
|
|
await Promise.all([RasterMap.prototype.load.call(measured, '0/0/0'), RasterMap.prototype.load.call(measured, '1/0/0')])
|
|
assert.equal(frames.size, 1)
|
|
const paint = [...frames.values()][0]; frames.clear(); paint(); assert.equal(widthWrites, 2)
|
|
await RasterMap.prototype.load.call(measured, '1/1/0'); assert.equal(frames.size, 1)
|
|
measured.close(); assert.equal(frames.size, 0); assert.equal(closedImages, 3)
|
|
globalThis.fetch = savedFetch; globalThis.createImageBitmap = savedBitmap
|
|
globalThis.requestAnimationFrame = originalRaf; globalThis.cancelAnimationFrame = originalCancelRaf
|
|
console.log('Tile completions coalesce into one frame; canvas allocation changes only on resize; close cancels paint')
|
|
|
|
{
|
|
// Following keeps the car centered until a pan; pinch and double-tap zoom around the fingers and settle on whole levels.
|
|
const gestures = new Map()
|
|
const surface = { clientWidth: 400, clientHeight: 400, getContext: () => ctx, getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
|
addEventListener: (name, fn) => gestures.set(name, fn), removeEventListener: name => gestures.delete(name) }
|
|
globalThis.requestAnimationFrame = fn => { fn(); return 1 }
|
|
globalThis.cancelAnimationFrame = () => {}
|
|
const live = new RasterMap(surface); live.load = () => {}
|
|
live.update({ location: { latitude: 40, longitude: -90, validForMs: 2000 } }, false)
|
|
assert.deepEqual([live.center.latitude, live.zoom, live.follow], [40, 15, true])
|
|
live.update({ location: { latitude: 40.001, longitude: -90, validForMs: 2000 } }, false)
|
|
assert.equal(live.center.latitude, 40.001, 'following moves with the car')
|
|
live.changeZoom(1); assert.equal(live.center.latitude, 40.001, 'zoom buttons keep the car centered while following')
|
|
const anchor = live.pointAt(300, 100)
|
|
gestures.get('pointerdown')({ pointerId: 1, clientX: 280, clientY: 100 })
|
|
gestures.get('pointerdown')({ pointerId: 2, clientX: 320, clientY: 100 })
|
|
gestures.get('pointermove')({ pointerId: 2, clientX: 360, clientY: 100 })
|
|
assert.ok(Math.abs(live.zoom - 17) < 1e-9 && live.follow === false, 'spreading two fingers to twice the distance zooms one level')
|
|
const held = live.pointAt(320, 100)
|
|
assert.ok(Math.abs(held.latitude - anchor.latitude) < 1e-9 && Math.abs(held.longitude - anchor.longitude) < 1e-9, 'the pinched place stays under the fingers')
|
|
gestures.get('pointermove')({ pointerId: 2, clientX: 370, clientY: 100 })
|
|
gestures.get('pointerup')({ pointerId: 2, type: 'pointerup', clientX: 370, clientY: 100 })
|
|
assert.ok(Number.isInteger(live.zoom), 'a pinch settles on a whole zoom level')
|
|
gestures.get('pointerup')({ pointerId: 1, type: 'pointerup', clientX: 280, clientY: 100 })
|
|
live.update({ location: { latitude: 41, longitude: -90, validForMs: 2000 } }, false)
|
|
assert.notEqual(live.center.latitude, 41, 'a paused map does not jump back to the car')
|
|
const zoom = live.zoom, target = live.pointAt(100, 300)
|
|
for (let tap = 0; tap < 2; tap++) {
|
|
gestures.get('pointerdown')({ pointerId: 3, clientX: 100, clientY: 300 })
|
|
gestures.get('pointerup')({ pointerId: 3, type: 'pointerup', clientX: 100, clientY: 300 })
|
|
}
|
|
assert.equal(live.zoom, zoom + 1, 'double-tap zooms in')
|
|
const kept = live.pointAt(100, 300)
|
|
assert.ok(Math.abs(kept.latitude - target.latitude) < 1e-9 && Math.abs(kept.longitude - target.longitude) < 1e-9)
|
|
live.recenter()
|
|
assert.deepEqual([live.center.latitude, live.zoom, live.follow], [41, 15, true])
|
|
live.close()
|
|
globalThis.requestAnimationFrame = originalRaf; globalThis.cancelAnimationFrame = originalCancelRaf
|
|
}
|
|
console.log('Follow, pinch and double-tap zoom passed')
|
|
|
|
// A live Galaxy theme change discards the old imagery without moving the map.
|
|
{
|
|
const savedObserver = globalThis.MutationObserver, savedDocument = globalThis.document
|
|
const savedFetch = globalThis.fetch, savedBitmap = globalThis.createImageBitmap
|
|
let observeTheme, disconnected = false, oldClosed = false, lateClosed = false, finishOld
|
|
globalThis.document = { documentElement: { dataset: { theme: 'light' } } }
|
|
globalThis.MutationObserver = class {
|
|
constructor(callback) { observeTheme = callback }
|
|
observe(target, options) { assert.deepEqual(options.attributeFilter, ['data-theme']) }
|
|
disconnect() { disconnected = true }
|
|
}
|
|
globalThis.requestAnimationFrame = fn => { frames.set(++frameId, fn); return frameId }
|
|
globalThis.cancelAnimationFrame = id => frames.delete(id)
|
|
const themed = new RasterMap(canvas)
|
|
themed.draw = () => {}
|
|
themed.center = { latitude: 40, longitude: -90 }; themed.zoom = 12
|
|
themed.tiles.set('old', { close() { oldClosed = true } })
|
|
const urls = [], signals = []
|
|
globalThis.fetch = (url, options) => {
|
|
urls.push(url); signals.push(options.signal)
|
|
return new Promise(resolve => { finishOld = resolve })
|
|
}
|
|
globalThis.createImageBitmap = async () => ({ close() { lateClosed = true } })
|
|
const oldRequest = themed.load('0/0/0')
|
|
document.documentElement.dataset.theme = 'dark'; observeTheme()
|
|
assert.equal(themed.theme, 'dark'); assert.equal(signals[0].aborted, true)
|
|
assert.equal(oldClosed, true); assert.equal(themed.tiles.size, 0)
|
|
assert.deepEqual(themed.center, {latitude:40, longitude:-90}); assert.equal(themed.zoom, 12)
|
|
finishOld({ ok: true, blob: async () => ({}) }); await oldRequest
|
|
assert.equal(lateClosed, true); assert.equal(themed.tiles.size, 0, 'late light tiles cannot enter the dark cache')
|
|
globalThis.fetch = async url => { urls.push(url); return { ok: true, blob: async () => ({}) } }
|
|
await themed.load('0/0/0')
|
|
assert.ok(urls[0].endsWith('?theme=light')); assert.ok(urls[1].endsWith('?theme=dark'))
|
|
themed.close(); assert.equal(disconnected, true)
|
|
globalThis.MutationObserver = savedObserver; globalThis.document = savedDocument
|
|
globalThis.fetch = savedFetch; globalThis.createImageBitmap = savedBitmap
|
|
globalThis.requestAnimationFrame = originalRaf; globalThis.cancelAnimationFrame = originalCancelRaf
|
|
}
|
|
console.log('Live map theme changes: correct requests, cache disposal, late-response isolation, camera preservation and observer cleanup passed')
|