Files
StarPilot/openpilot/starpilot/galaxy/tests/test_controllers.mjs
T
firestar5683 80fbb4f6db Feature registration
Register vehicle controls, planner modes, UI actions and Galaxy services after their providers exist. Wire the shared runtime entrypoints and cover their combined behavior.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Daniel Koepping <elkoled@gmail.com>
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com>
Co-authored-by: Kacper Rączy <gfw.kra@gmail.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Co-authored-by: stef <19478336+stefpi@users.noreply.github.com>
Co-authored-by: whoisdomi <dcantu83@gmail.com>

Co-authored-by: Intelli <contact@intelli.software>
Co-authored-by: haraschax <6804392+haraschax@users.noreply.github.com>
Co-authored-by: prabhaavp <prabhaav@msu.edu>
2026-10-14 20:02:39 -05:00

164 lines
9.0 KiB
JavaScript

import assert from "node:assert/strict"
import { BluetoothPage } from "../web/js/bluetooth.js"
import { ControllersFeed, ControllersPage, validControllersStatus } from "../web/js/controllers.js"
const status = () => ({ version: 1, available: true, editable: true, enabled: false, valid: true,
revision: "a".repeat(64), devices: [{ id: "hardware-1", name: "Steering remote", bus: 3 }],
slots: Array.from({ length: 13 }, (_, index) => ({ index, label: index < 3 ? `Quick Select ${index + 1}` : `Button ${index - 2}`,
key: null, available: index < 3 })),
options: [{ key: "brightness", label: "Brightness", section: "Display" }], bindings: [],
learning: null, testing: false, lastPress: null })
assert.equal(validControllersStatus(status()), true)
assert.equal(validControllersStatus({ ...status(), devices: [{ id: "bad", name: "Remote", bus: 9 }] }), false)
assert.equal(validControllersStatus({ ...status(), slots: status().slots.slice(1) }), false)
assert.equal(validControllersStatus({ ...status(), options: [{ key: "x\n", label: "Bad", section: "Display" }] }), false)
assert.equal(validControllersStatus({ ...status(), lastPress: { deviceId: "hardware-1", code: 65551,
slot: null, executed: false, message: "Test press detected" } }), true)
assert.match(BluetoothPage.template, /<ControllersPage :mode="mode" :unauthorized="unauthorized"/)
assert.match(ControllersPage.template, /Test buttons \(20s\)/)
assert.match(ControllersPage.template, /Press a button for/)
assert.match(ControllersPage.template, /class="gx-switch"/)
assert.match(ControllersPage.template, /class="gx-switch__track"/)
assert.match(ControllersPage.template, /slot\.key \? 'Available when its control is ready' : 'Not assigned'/)
assert.match(ControllersPage.template, /gx-controllers__selector/)
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() }
function fixture() {
const requests = [], updates = [], timers = new Map(); let next = 0, unauthorized = 0
const feed = new ControllersFeed({ publish: (value) => updates.push(value), unauthorized: () => unauthorized++,
fetcher: (url, options) => new Promise((resolve) => requests.push({ url, options, resolve })),
later: (fn, ms) => { timers.set(++next, { fn, ms }); return next }, cancelTimer: (id) => timers.delete(id) })
async function reply(index, data = status(), code = 200) {
requests[index].resolve({ ok: code === 200, status: code, json: async () => data }); await flush()
}
function fire(ms) {
const item = [...timers.entries()].find(([, value]) => value.ms === ms)
assert.ok(item)
timers.delete(item[0]); item[1].fn()
}
return { feed, requests, updates, timers, reply, fire, get unauthorized() { return unauthorized } }
}
const normal = fixture()
normal.feed.start(); await normal.reply(0)
assert.equal(normal.requests[0].url, "./api/controllers/status")
assert.equal(normal.requests[0].options.credentials, "same-origin")
normal.fire(2000); await normal.reply(1)
assert.equal(normal.updates.at(-1).busy, false)
const saved = normal.feed.action({ operation: "save", revision: "a".repeat(64), enabled: true,
slots: ["brightness", null, null, null, null, null, null, null, null, null] })
assert.equal(normal.requests[2].url, "./api/controllers/action")
assert.equal(normal.requests[2].options.method, "POST")
assert.deepEqual(JSON.parse(normal.requests[2].options.body), { operation: "save", revision: "a".repeat(64), enabled: true,
slots: ["brightness", null, null, null, null, null, null, null, null, null] })
await normal.reply(2); await saved
assert.deepEqual(normal.updates.at(-1).draft, { revision: status().revision, enabled: false, slots: Array(10).fill(null) })
assert.equal(normal.updates.at(-1).busy, false)
const racing = fixture()
racing.feed.start(); await racing.reply(0)
racing.fire(2000)
assert.equal(racing.updates.at(-1).busy, false, "background status reads do not lock drafts")
const racingSave = racing.feed.action({ operation: "save", enabled: true })
assert.equal(racing.requests[1].options.signal.aborted, true)
assert.equal(racing.requests[2].url, "./api/controllers/action")
assert.equal(racing.updates.at(-1).busy, true)
await racing.reply(2, { ...status(), enabled: true })
await racingSave
await racing.reply(1)
assert.equal(racing.updates.at(-1).status.enabled, true, "a late poll cannot undo a saved draft")
const learning = fixture()
learning.feed.start(); await learning.reply(0, { ...status(), learning: { slot: 4, expiresIn: 20 } })
learning.feed.stop()
assert.equal(learning.requests[1].url, "./api/controllers/action")
assert.deepEqual(JSON.parse(learning.requests[1].options.body), { operation: "cancel" })
assert.equal(learning.requests[1].options.keepalive, true)
assert.equal(learning.updates.at(-1).status, null)
const testMode = fixture()
testMode.feed.start(); await testMode.reply(0, { ...status(), testing: true })
testMode.feed.stop()
assert.deepEqual(JSON.parse(testMode.requests[1].options.body), { operation: "cancel" })
const stale = fixture()
stale.feed.start(); stale.feed.stop(); await stale.reply(0)
assert.equal(stale.updates.at(-1).status, null)
const denied = fixture()
denied.feed.start(); await denied.reply(0, {}, 401)
assert.equal(denied.unauthorized, 1)
const parked = fixture()
parked.feed.start(); await parked.reply(0, status())
parked.feed.action({ operation: "learn", revision: "a".repeat(64), slot: 1 }); await parked.reply(1, {}, 403)
assert.match(parked.updates.at(-1).error, /Turn off the vehicle/)
const component = ControllersPage.setup({ unauthorized() {} })
component.feed.publish({ status: status(), busy: false, error: "" })
assert.deepEqual(component.state.draft.slots, Array(10).fill(null))
component.state.draft.slots[0] = "brightness"
component.feed.publish({ status: { ...status(), revision: "b".repeat(64) }, busy: false, error: "" })
assert.equal(component.state.draft.revision, "a".repeat(64)) // A polling update cannot replace unsaved edits.
const page = { ...component, get canEdit() { return ControllersPage.computed.canEdit.call(this) },
get editable() { return ControllersPage.computed.editable.call(this) },
get dirty() { return ControllersPage.computed.dirty.call(this) },
get changed() { return ControllersPage.computed.changed.call(this) } }
let calls = 0
component.feed.action = async () => { calls++; return status() }
ControllersPage.methods.learn.call(page, 4)
ControllersPage.methods.remove.call(page, { deviceId: "hardware-1", code: 1 })
assert.equal(calls, 0)
assert.match(ControllersPage.template, /Save these changes before learning or removing buttons/)
let finishReload
component.feed.refresh = (replaceDraft) => {
assert.equal(replaceDraft, true)
return new Promise((resolve) => { finishReload = resolve })
}
const reloading = ControllersPage.methods.reload.call(page)
assert.equal(component.state.draft.slots[0], "brightness") // The template still has a draft while reloading.
const afterReload = { ...status(), revision: "c".repeat(64) }
component.feed.publish({ status: afterReload, busy: false, error: "",
draft: { revision: afterReload.revision, enabled: afterReload.enabled, slots: afterReload.slots.slice(3).map(slot => slot.key) } })
assert.equal(page.changed, false, "confirmed status and draft publish together")
finishReload(afterReload)
await reloading
assert.equal(component.state.draft.revision, "c".repeat(64))
assert.equal(component.state.draft.slots[0], null)
component.state.draft.enabled = true
const afterSave = { ...status(), revision: "d".repeat(64), enabled: true }
component.feed.action = async (payload) => {
assert.equal(payload.operation, "save")
component.feed.publish({ status: afterSave, busy: false, error: "",
draft: { revision: afterSave.revision, enabled: afterSave.enabled, slots: afterSave.slots.slice(3).map(slot => slot.key) } })
assert.equal(page.changed, false, "confirmed status and draft publish together")
return afterSave
}
await ControllersPage.methods.save.call(page)
assert.equal(page.changed, false)
assert.equal(page.dirty, false)
assert.equal(component.state.draft.revision, afterSave.revision)
component.feed.stop()
assert.equal(component.state.draft, null)
const earlierDocument = globalThis.document
const earlierWindow = globalThis.window
globalThis.window = new EventTarget()
const listeners = new Map(), lifecycle = []
globalThis.document = { hidden: false, addEventListener(name, fn) { listeners.set(name, fn) },
removeEventListener(name) { listeners.delete(name) } }
const mounted = { mode: "local", feed: { start() { lifecycle.push("start") }, stop() { lifecycle.push("stop") } } }
ControllersPage.mounted.call(mounted)
assert.deepEqual(lifecycle, ["start"])
globalThis.document.hidden = true
listeners.get("visibilitychange")()
mounted.mode = "remote"
globalThis.document.hidden = false
ControllersPage.watch.mode.call(mounted)
ControllersPage.beforeUnmount.call(mounted)
assert.deepEqual(lifecycle, ["start", "stop", "stop", "stop"])
assert.equal(listeners.size, 0)
globalThis.document = earlierDocument
globalThis.window = earlierWindow
console.log("Controller buttons: source status validation, draft preservation, polling, auth, parked denial and capture cleanup passed")