Files
StarPilot/openpilot/starpilot/galaxy/tests/test_plots.mjs
T
firestar5683 8cea672429 Drive Statistics
Implement background drive analysis and statistics with explicit worker and persistence ownership.

Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
2026-10-14 20:02:39 -05:00

217 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { PlotsFeed, PlotsPage, PlotGraph, MAX_POINTS, appendPlotSample, chartGeometry, matchQuality, seriesPoints, validatePlotPayload } from '../web/js/plots.js'
const values = () => ({ desiredLateralAccel: .2, actualLateralAccel: .18, desiredLongitudinalAccel: .3,
actualLongitudinalAccel: .28, lateralP: .1, lateralI: .02, lateralD: null, lateralF: .08,
longitudinalP: .1, longitudinalI: .1, longitudinalF: .1, speedMps: 12,
controlsActive: true, lateralControlActive: true, longitudinalControlActive: true, controlsFresh: true, poseFresh: true,
speedSource: 'deviceMotion',
lateralSource: 'torqueState', longitudinalSource: 'aTarget', lateralTermsSource: 'torqueState',
longitudinalTermsSource: 'controlsState' })
const payload = (index, extra = {}) => ({ schemaVersion: 1, sessionId: 'ab'.repeat(8), state: 'current', sampleIndex: index,
sampleAgeSeconds: .1, values: values(), error: '', bootStabilizing: false, ...extra })
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() }
function fixture() {
const requests = [], states = [], timers = new Map()
let now = 1000, timerIndex = 0, unauthorized = 0
const feed = new PlotsFeed({ publish: (state) => states.push(state), unauthorized: () => unauthorized++,
fetcher: (url, options) => new Promise((resolve) => requests.push({ url, signal: options.signal, resolve })),
now: () => now,
later: (fn, delay) => { timers.set(++timerIndex, { fn, due: now + delay }); return timerIndex },
cancel: (id) => timers.delete(id) })
const reply = async (index, body, status = 200) => {
requests[index].resolve({ ok: status === 200, status, json: async () => body })
await flush()
}
const advance = (milliseconds) => {
const end = now + milliseconds
while (true) {
const next = [...timers.entries()].filter(([, timer]) => timer.due <= end).sort((a, b) => a[1].due - b[1].due)[0]
if (!next) break
now = next[1].due; timers.delete(next[0]); next[1].fn()
}
now = end
}
return { feed, states, requests, reply, advance, timers, get unauthorized() { return unauthorized } }
}
assert.equal(validatePlotPayload(payload(1)).values.lateralSource, 'torqueState')
assert.throws(() => validatePlotPayload(payload(1, { values: { ...values(), actualLateralAccel: Infinity } })))
assert.throws(() => validatePlotPayload(payload(1, { values: { ...values(), poseFresh: false } })))
assert.throws(() => validatePlotPayload(payload(1, { state: 'stale' })))
let history = []
for (let i = 1; i <= 260; i++) history = appendPlotSample(history, payload(i), i * 750)
assert.equal(history.length, MAX_POINTS)
assert.equal(history[0].index, 21)
assert.equal(appendPlotSample(history, payload(260), 10000).length, MAX_POINTS)
const restarted = appendPlotSample(history, payload(260, { sessionId: 'cd'.repeat(8) }), 10000)
assert.equal(restarted.length, 1)
assert.equal(restarted[0].sessionId, 'cd'.repeat(8))
assert.equal(matchQuality(history, 'desiredLateralAccel', 'actualLateralAccel', { minSpeed: .5,
minDemand: .008, great: .15, good: .30, fair: .50 }).label, 'Great')
assert.equal(matchQuality([{ ...values(), at: 1, controlsActive: false }], 'desiredLateralAccel',
'actualLateralAccel', { great: .15, good: .30, fair: .50 }).label, 'N/A')
const aolHistory = Array.from({ length: 8 }, (_, i) => ({ ...values(), at: i * 750, controlsActive: false,
lateralControlActive: true, longitudinalControlActive: false }))
assert.equal(matchQuality(aolHistory, 'desiredLateralAccel', 'actualLateralAccel', { minSpeed: .5,
minDemand: .008, great: .15, good: .30, fair: .50, activeKey: 'lateralControlActive' }).label, 'Great')
assert.equal(matchQuality(aolHistory, 'desiredLongitudinalAccel', 'actualLongitudinalAccel', { minDemand: .05,
great: .32, good: .52, fair: .78, activeKey: 'longitudinalControlActive' }).label, 'N/A')
assert.equal(seriesPoints(history, ['desiredLateralAccel', 'actualLateralAccel']).length, 2)
assert.deepEqual(seriesPoints([{ at: 1, desiredLateralAccel: null }, { at: 2, desiredLateralAccel: .1 }],
['desiredLateralAccel']), [[]]) // Null is a gap, never a zero line.
assert.equal(seriesPoints([{ at: 1, desiredLateralAccel: .1 }, { at: 2, desiredLateralAccel: null },
{ at: 3, desiredLateralAccel: .2 }], ['desiredLateralAccel'])[0].length, 0) // Do not bridge missing evidence.
const shortChart = chartGeometry([{ at: 1000, desiredLateralAccel: -.25 },
{ at: 2500, desiredLateralAccel: .5 }], ['desiredLateralAccel'])
assert.equal(shortChart.top, '0.50')
assert.equal(shortChart.bottom, '-0.25')
assert.equal(shortChart.from, '−1.5 s')
assert.ok(shortChart.zeroY > 18 && shortChart.zeroY < 132)
assert.equal(chartGeometry([], ['desiredLateralAccel']).valid, false)
const live = fixture()
live.feed.start()
assert.equal(live.requests[0].url, './api/plots/live')
await live.reply(0, payload(1))
assert.equal(live.states.at(-1).status, 'current')
assert.equal(live.states.at(-1).history.length, 1)
live.advance(750)
assert.equal(live.requests.length, 2)
await live.reply(1, payload(2))
assert.equal(live.states.at(-1).history.length, 2)
live.advance(750)
await live.reply(2, payload(2)) // same producer sample cannot grow history
assert.equal(live.states.at(-1).history.length, 2)
live.feed.setPaused(true)
assert.equal(live.states.at(-1).status, 'paused')
assert.equal(live.timers.size, 0)
live.feed.setPaused(false)
assert.equal(live.requests.length, 4)
live.feed.stop()
await live.reply(3, payload(3))
assert.equal(live.states.at(-1).status, 'idle')
assert.equal(live.states.at(-1).history.length, 0)
const stale = fixture()
stale.feed.start()
await stale.reply(0, payload(1))
stale.advance(1400)
assert.equal(stale.states.at(-1).status, 'stale')
assert.equal(stale.states.at(-1).data.values.desiredLateralAccel, .2)
assert.equal(stale.states.at(-1).history.length, 1)
await stale.reply(1, payload(1, { state: 'unavailable', sampleAgeSeconds: null, values: null, error: 'No fresh controls' }))
assert.equal(stale.states.at(-1).status, 'stale')
assert.equal(stale.states.at(-1).data.sampleIndex, 1)
stale.advance(750)
await stale.reply(2, payload(2))
assert.equal(stale.states.at(-1).status, 'current')
assert.equal(stale.states.at(-1).history.length, 2)
stale.feed.stop()
assert.equal(stale.states.at(-1).data, null)
const interrupted = fixture()
interrupted.feed.start()
await interrupted.reply(0, payload(1))
interrupted.advance(2250)
assert.equal(interrupted.states.at(-1).status, 'stale')
assert.equal(interrupted.states.at(-1).data.sampleIndex, 1)
assert.equal(interrupted.states.at(-1).history.length, 1)
interrupted.feed.stop()
const gap = chartGeometry([{ at: 0, value: 1 }, { at: 750, value: 2 },
{ at: 4000, value: 3 }, { at: 4750, value: 4 }], ['value'])
assert.equal(gap.paths[0].length, 2, 'reconnection must not draw across a missing interval')
const aged = fixture()
aged.feed.start()
aged.advance(90)
await aged.reply(0, payload(1, { sampleAgeSeconds: 1.4 }))
assert.equal(aged.states.at(-1).status, 'current')
assert.equal(aged.states.at(-1).history[0].at, -400)
aged.advance(10)
assert.equal(aged.states.at(-1).status, 'stale')
aged.feed.stop()
const expiredInTransit = fixture()
expiredInTransit.feed.start()
expiredInTransit.advance(200)
await expiredInTransit.reply(0, payload(1, { sampleAgeSeconds: 1.4 }))
assert.equal(expiredInTransit.states.at(-1).status, 'stale')
assert.equal(expiredInTransit.states.at(-1).history.length, 0)
expiredInTransit.feed.stop()
const denied = fixture()
denied.feed.start()
await denied.reply(0, {}, 401)
assert.equal(denied.unauthorized, 1)
assert.equal(denied.timers.size, 0)
const timeout = fixture()
timeout.feed.start()
timeout.advance(1500)
assert.equal(timeout.states.at(-1).status, 'unavailable')
await timeout.reply(0, payload(1))
assert.equal(timeout.states.at(-1).status, 'unavailable')
timeout.feed.stop()
const prior = globalThis.document, listeners = new Map()
globalThis.document = { hidden: false, addEventListener: (name, fn) => listeners.set(name, fn),
removeEventListener: (name, fn) => { if (listeners.get(name) === fn) listeners.delete(name) } }
const page = { mode: 'local', paused: false, feed: { starts: 0, pauses: [], stops: 0,
active: false, start() { this.starts++; this.active = true }, setPaused(value) { this.pauses.push(value) }, stop() { this.stops++; this.active = false } } }
PlotsPage.mounted.call(page)
assert.equal(page.feed.starts, 1)
document.hidden = true; listeners.get('visibilitychange')()
assert.deepEqual(page.feed.pauses, [true])
document.hidden = false; listeners.get('visibilitychange')()
assert.deepEqual(page.feed.pauses, [true, false])
PlotsPage.beforeUnmount.call(page)
assert.equal(page.feed.stops, 1)
assert.equal(listeners.size, 0)
const hidden = { mode: 'local', paused: false, feed: { active: false, starts: 0, pauses: [], stops: 0,
start() { this.active = true; this.starts++ }, setPaused(value) { this.pauses.push(value) }, stop() { this.stops++ } } }
document.hidden = true
PlotsPage.mounted.call(hidden)
assert.equal(hidden.feed.starts, 0)
document.hidden = false
listeners.get('visibilitychange')()
assert.equal(hidden.feed.starts, 1)
listeners.get('visibilitychange')()
assert.equal(hidden.feed.starts, 1)
PlotsPage.beforeUnmount.call(hidden)
assert.equal(listeners.size, 0)
globalThis.document = prior
assert.match(PlotsPage.template, /Lateral acceleration/)
assert.match(PlotsPage.template, /Longitudinal acceleration/)
assert.doesNotMatch(PlotsPage.template, /Download|Apply tune|Set control/)
// Compile and render the actual Vue page template with live-looking values.
globalThis.document = { createElement: () => ({
set innerHTML(value) {
this.textContent = value.replaceAll('&amp;', '&')
this.children = [{ getAttribute: () => this.textContent.match(/foo="([\s\S]*?)"/)?.[1] ?? '' }]
}, textContent: '', children: [],
}) }
const { compile } = await import('../web/vendor/vue/vue.esm-browser.js')
const render = compile(PlotsPage.template)
const chart = chartGeometry([{ ...values(), at: 1000 }, { ...values(), desiredLateralAccel: .3, at: 2500 }],
['desiredLateralAccel', 'actualLateralAccel'])
const context = { mode: 'local', status: 'current', data: payload(1), paused: false, advanced: false, error: '',
lateralChart: chart, longChart: chart, lateralTermsChart: chart, longTermsChart: chart,
lateralQuality: { label: 'Good', detail: '8 samples' }, longQuality: { label: 'Fair', detail: '8 samples' },
source: (source) => source, label: (number) => String(number), togglePause: () => {}, }
const rendered = JSON.stringify(render(context, []))
assert.match(rendered, /Lateral acceleration/)
assert.match(rendered, /Longitudinal acceleration/)
assert.match(rendered, /Good/)
assert.match(rendered, /−1.5 s/)
const graphRender = compile(PlotGraph.template)
const graph = JSON.stringify(graphRender({ chart, title: 'Lateral acceleration history', terms: false }, []))
assert.match(graph, /0.30/)
assert.match(graph, /0.00/)
assert.match(graph, /−1.5 s/)
assert.match(graph, /Latest/)
assert.match(graph, /48.0,/) // Plot geometry stays inside the labeled axes.
globalThis.document = prior
console.log('Galaxy Plots browser lifecycle and rendering contracts passed')