Files
StarPilot/openpilot/starpilot/galaxy/tests/test_back_navigation.mjs
T
2026-10-25 18:23:30 -05:00

137 lines
7.3 KiB
JavaScript

import assert from "node:assert/strict"
import { SettingsPage } from "../web/js/settings.js"
import { OnroadLayoutPage } from "../web/js/onroad-layout.js"
const listeners = new Map()
globalThis.window = { scrollTo() {}, addEventListener(name, callback) { listeners.set(name, callback) } }
let current = new URL("https://galaxy.test/app#/cameras/pip")
globalThis.location = { get hash() { return current.hash }, pathname: "/app", search: "" }
const entries = [{ url: current.href, state: null }]
let cursor = 0
globalThis.history = {
get state() { return entries[cursor].state },
replaceState(state, _title, url) { current = new URL(url, current); entries[cursor] = { state, url: current.href } },
pushState(state, _title, url) { entries.splice(cursor + 1); current = new URL(url, current); entries.push({ state, url: current.href }); cursor++ },
go(delta) { if (cursor + delta >= 0 && cursor + delta < entries.length) { cursor += delta; current = new URL(entries[cursor].url); listeners.get("popstate")() } },
back() { if (cursor > 0) { cursor--; current = new URL(entries[cursor].url); listeners.get("popstate")() } },
forward() { if (cursor < entries.length - 1) { cursor++; current = new URL(entries[cursor].url); listeners.get("popstate")() } },
}
const { route, navigate, navigateBack, startRouter } = await import("../web/js/router.js")
startRouter()
navigateBack()
assert.equal(route.path, "/") // A direct link cannot navigate outside Galaxy.
navigate("/settings"); navigate("/appearance"); navigate("/theme_maker")
navigateBack(); assert.equal(route.path, "/appearance")
navigateBack(); assert.equal(route.path, "/settings")
history.forward(); assert.equal(route.path, "/appearance")
navigate("/cameras"); navigate("/cameras/pip")
navigateBack(); assert.equal(route.path, "/cameras")
const length = entries.length
navigate("/cameras"); assert.equal(entries.length, length)
let returned = 0, cancelled = 0
const settings = { busy: false, state: { data: { page: "standard/following" } }, initialPage: "hub", atSectionRoot: false,
back() { returned++ }, feed: { cancel() { cancelled++ } } }
assert.equal(SettingsPage.methods.navigateBack.call(settings), true)
assert.equal(returned, 1)
settings.atSectionRoot = true
assert.equal(SettingsPage.methods.navigateBack.call(settings), false)
settings.state.pending = {}; settings.busy = true
assert.equal(SettingsPage.methods.navigateBack.call(settings), true)
assert.equal(cancelled, 1)
let closed = 0
const editor = { busy: false, dirty: true, state: { drag: null, discard: null },
leave: OnroadLayoutPage.methods.leave, $emit(event) { if (event === "close") closed++ } }
settings.state = { layoutOpen: true }; settings.busy = false
settings.$refs = { layoutEditor: { requestLeave(action) { OnroadLayoutPage.methods.requestLeave.call(editor, action) } } }
assert.equal(SettingsPage.methods.navigateBack.call(settings), true)
assert.equal(editor.state.discard, "back"); assert.equal(closed, 0)
editor.state.discard = null // Cancel discard keeps the editor.
assert.equal(closed, 0)
editor.leave("back"); assert.equal(closed, 1)
console.log("Galaxy Back history and editor guard passed")
// Parent is the page actually visited, not a guessed personality parent.
const nested = { initialPage: 'hub', state: { section: 'longitudinal', query: '', parents: [], data: {page: 'hub'} },
activeSection: {id: 'longitudinal'}, feed: {load(page) { nested.state.data = {page} }} }
SettingsPage.methods.open.call(nested, 'profiles')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page, 'hub')
SettingsPage.methods.open.call(nested, 'standard')
SettingsPage.methods.open.call(nested, 'standard/following')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page, 'standard')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page, 'hub', 'direct personality returns to Longitudinal hub')
SettingsPage.methods.open.call(nested, 'profiles')
SettingsPage.methods.open.call(nested, 'standard')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page, 'profiles', 'personality opened through planner returns there')
SettingsPage.methods.selectSection.call({...nested,busy:false,feed:{active:true,load(){}}}, {id:'wheel',pages:['wheel']})
assert.deepEqual(nested.state.parents, [], 'changing sections retires nested history')
nested.initialPage = 'profiles'; nested.state.data = {page:'profiles'}
SettingsPage.methods.open.call(nested,'standard')
SettingsPage.methods.open.call(nested,'standard/following')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page,'standard','dedicated tools also retain each nested parent')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page,'profiles')
// Routed tools must return to the Settings entry that opened them. Reload
// saved values from the device; keep only the navigation state in memory.
navigate('/settings')
const settingsView = route.view
const props = {initialPage:'hub',initialSection:null,unauthorized() {}}
const original = {...SettingsPage.setup(props), ...props}
Object.assign(original.state, {data:{page:'pip',rows:[{value:'old device value'}]},
section:'visual',query:'camera',searchOpen:true,parents:['hub']})
settingsView.scroll = {x:0,y:720}
SettingsPage.beforeUnmount.call(original)
assert(!JSON.stringify(settingsView).includes('old device value'))
navigate('/cameras/pip')
assert.notEqual(route.view, settingsView)
navigateBack()
assert.equal(route.view, settingsView)
const restored = {...SettingsPage.setup(props), ...props, mode:'local'}
assert.equal(restored.restoredPage, 'pip')
assert.equal(restored.state.section, 'visual')
assert.equal(restored.state.query, 'camera')
assert.equal(restored.state.searchOpen, true)
assert.deepEqual(restored.state.parents, ['hub'])
assert.equal(restored.state.data, null, 'saved settings are always read again')
const positions = []
window.scrollTo = (x,y) => positions.push([x,y])
restored.feed = {active:true,async start(page) {assert.equal(page,'pip')}}
// Route changes capture the live scroll before unmount, independently of the
// Settings request lifecycle. Use an explicit restored position here.
settingsView.scroll = {x:0,y:720}
await SettingsPage.mounted.call(restored)
assert.deepEqual(positions.at(-1), [0,720])
history.forward()
assert.notEqual(route.view, settingsView)
navigateBack()
assert.equal(route.view, settingsView)
navigate('/tools')
navigate('/settings')
assert.notEqual(route.view, settingsView, 'a new Settings visit has a fresh context')
const fresh = SettingsPage.setup(props)
assert.equal(fresh.restoredPage, undefined)
assert.equal(fresh.state.section, 'lateral')
// Search and ordinary Toggles views cannot overwrite each other.
const searchProps = {...props,initialPage:'standard/following'}
const search = {...SettingsPage.setup(searchProps),...searchProps}
search.state.data = {page:'standard/following'}
SettingsPage.beforeUnmount.call(search)
assert.equal(route.view.searchPage, 'standard/following')
const hub = {...SettingsPage.setup(props),...props}
assert.equal(hub.restoredPage, undefined)
hub.state.data = {page:'hub'}
SettingsPage.beforeUnmount.call(hub)
assert.equal(route.view.searchPage, '')
const searchAgain = SettingsPage.setup(searchProps)
assert.equal(searchAgain.restoredPage, undefined, 'a new search after returning to Toggles starts at its requested page')
console.log('Settings entry, fresh values, search isolation and scroll restoration passed')