Files
StarPilot/openpilot/starpilot/galaxy/tests/test_projection_map_widgets.mjs
T

265 lines
15 KiB
JavaScript

import assert from "node:assert/strict"
import { compile } from "../web/vendor/vue/vue.esm-browser.js"
import { snapshot } from "./test_onroad_layout.mjs"
import { editorSnapshot, projectionPayload } from "../web/js/projection-layout.js"
import { LayoutWidgetPreview, OnroadLayoutPage, clampBox, clampPlacement, orderedWidgetIds, placementLimits, validDocument, validSnapshot, widgetSize } from "../web/js/onroad-layout.js"
const copy = (v) => JSON.parse(JSON.stringify(v))
const native = snapshot()
const metadata = copy(native.metadata.profiles.large)
// The Android Auto metadata has its own canvas, so the Big screen's widened bounds and anchors do not apply.
for (const widget of Object.values(metadata.widgets)) for (const field of ["bounds", "anchorShift", "expandable"]) delete widget[field]
metadata.width = 2880
metadata.bounds.width = 2820
metadata.widgets.current_speed.default.x += 510
metadata.widgets.steering_wheel.default.x += 1020
// Mirrors projection_layout.layout_metadata_for_viewport((2880, 1080)).
metadata.widgets.nav_card = { label: "Turn-by-turn", kind: "nav_card", width: 560, height: 195, colors: {},
default: { x: 2250, y: 415, enabled: true } }
metadata.widgets.nav_map = { label: "Map overlay", kind: "nav_map", width: 860, height: 410, colors: {},
box: { minWidth: 280, maxWidth: 2820, minHeight: 200, maxHeight: 1020 }, opacity: { min: 15, max: 100, default: 50 },
default: { x: 1950, y: 625, enabled: false, width: 860, height: 410, opacity: 50 } }
for (const [index, label] of ["Home", "Work"].entries()) {
const key = `nav_${label.toLowerCase()}`
metadata.widgets[key] = { label, kind: key, width: 320, height: 110, iconSize: 110, colors: {},
default: { x: 2195 + index * 335, y: 280, enabled: false, display: 'words' } }
}
metadata.widgets.car_exit = { label: "Exit to car", kind: "car_exit", width: 96, height: 96, colors: {}, required: true, frontmost: true,
note: "Returns to the car's own screen without disconnecting Android Auto. Always enabled and above other widgets.",
default: { x: 30, y: 954, enabled: true } }
metadata.widgets.bookmark = { label: 'Bookmark', kind: 'bookmark', width: 320, height: 110, iconSize: 110, colors: {},
default: {x: 2530, y: 155, enabled: false, display: 'words'} }
for (const key of ['nav_home', 'nav_work', 'bookmark', 'car_exit']) {
Object.assign(metadata.widgets[key], {opacity: {min: 0, max: 100, default: 100}, backgroundToggle: true})
Object.assign(metadata.widgets[key].default, {opacity: 100, background: true})
}
metadata.widgetOrder = ["nav_map", ...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "nav_card", "nav_home", "nav_work", "bookmark", "car_exit"]
const widgets = Object.fromEntries(Object.entries(metadata.widgets).map(([id, widget]) => [id,
{ ...widget.default, ...(widget.resizable ? { size: widget.resizable.default } : {}) }]))
const doc = { version: 5, clock24Hour: false, canvas: { width: 2880, height: 1080 }, widgets }
const raw = { version: 1, document: doc, defaults: copy(doc), metadata,
screen: { width: 1280, height: 720, margin_width: 0, margin_height: 240 },
revision: "aa-map", editable: true, valid: true,
colors: { palette: native.document.palette, widgetColors: native.document.widgetColors.large, roadColors: {} } }
const data = editorSnapshot(raw)
const profile = data.metadata.profiles.large
assert.equal(validSnapshot(data), true)
// Size follows the placement for box widgets, the size field for the wheel, and the metadata otherwise.
const layout = data.document.layouts.large
assert.deepEqual(widgetSize(profile.widgets.nav_map, layout.nav_map), [860, 410])
assert.deepEqual(widgetSize(profile.widgets.steering_wheel, { ...layout.steering_wheel, size: 200 }), [200, 200])
assert.deepEqual(widgetSize(profile.widgets.nav_card, layout.nav_card), [560, 195])
layout.nav_map.width = 1200
assert.equal(placementLimits(profile, "nav_map", layout).maxX, 30 + 2820 - 1200)
assert.equal(clampPlacement(profile, "nav_map", 5000, 5000, layout).x, 1650)
layout.nav_map.width = 860
// Resizing stays inside the box limits and the screen from the current corner.
assert.deepEqual(clampBox(profile, "nav_map", { x: 1950, y: 625 }, 5000, 5000), { width: 900, height: 425 })
assert.deepEqual(clampBox(profile, "nav_map", { x: 30, y: 30 }, 10, 10), { width: 280, height: 200 })
assert.equal(clampBox(profile, "nav_card", { x: 30, y: 30 }, 600, 300), null, "the turn card has a fixed size")
// Documents: width, height and opacity are required for the map and range-checked.
const good = copy(data.document)
assert.equal(validDocument(good, data.metadata), true)
for (const change of [{ opacity: 101 }, { opacity: 14 }, { opacity: 50.5 }, { width: 279 }, { height: 1021 }, { width: 860.5 }]) {
const bad = copy(good)
Object.assign(bad.layouts.large.nav_map, change)
assert.equal(validDocument(bad, data.metadata), false, JSON.stringify(change))
}
const missing = copy(good)
delete missing.layouts.large.nav_map.opacity
assert.equal(validDocument(missing, data.metadata), false)
const offscreen = copy(good)
Object.assign(offscreen.layouts.large.nav_map, { x: 2000, width: 900 })
assert.equal(validDocument(offscreen, data.metadata), false, "a wide map cannot hang off the right edge")
// Box metadata is only for the map, on either screen.
const comma = snapshot()
comma.metadata.profiles.large.widgets.current_speed.box = { minWidth: 100, maxWidth: 600, minHeight: 100, maxHeight: 300 }
comma.metadata.profiles.large.widgets.current_speed.opacity = { min: 10, max: 100, default: 50 }
assert.equal(validSnapshot(comma), false)
const wrongKind = copy(data)
wrongKind.metadata.profiles.large.widgets.nav_card.box = { minWidth: 100, maxWidth: 600, minHeight: 100, maxHeight: 300 }
assert.equal(validSnapshot(wrongKind), false)
// The payload carries the map's size and opacity through untouched.
const draft = copy(data.document)
Object.assign(draft.layouts.large.nav_map, { enabled: true, width: 640, height: 480, opacity: 45 })
assert.deepEqual(projectionPayload({ revision: "r", document: draft }, data.metadata).document.widgets.nav_map,
{ x: 1950, y: 625, enabled: true, width: 640, height: 480, opacity: 45 })
// Editor methods: drag the corner handle, type a size, slide the opacity; each is one undo step.
const vm = {
state: { drag: null, profile: "large", selected: "nav_map", draft: copy(data.document), history: { undo: [], redo: [], group: null },
placementError: "", notice: "", colorError: "" },
editable: true, $refs: { preview: { getBoundingClientRect: () => ({ left: 0, top: 0, width: 2880, height: 1080 }),
setPointerCapture() {}, hasPointerCapture: () => false, releasePointerCapture() {} } },
finishColorEdit() { this.state.history.group = null },
sceneHidden() { return false },
}
Object.defineProperties(vm, {
canUndo: { get: () => vm.state.history.undo.length > 0 },
canRedo: { get: () => vm.state.history.redo.length > 0 },
profile: { get: () => profile },
limitProfile: { get: () => profile },
layout: { get: () => vm.state.draft.layouts.large },
selectedWidget: { get: () => profile.widgets[vm.state.selected] },
selectedPosition: { get: () => vm.state.draft.layouts.large[vm.state.selected] },
})
for (const name of ["watchRelease", "unwatchRelease", "recordChange", "resizeBox", "boxInput", "opacityInput", "setWidgetBackground", "undo", "redo", "startResize", "moveDrag", "endDrag", "releaseDrag", "changePosition", "add", "remove", "requestRemove", "reorderLayer", "startLayerDrag", "setFavoriteDisplay"])
vm[name] = OnroadLayoutPage.methods[name].bind(vm)
const pointer = (x, y) => ({ clientX: x, clientY: y, pointerId: 7, button: 0, preventDefault() {}, stopPropagation() {} })
vm.startResize("nav_map", pointer(1950 + 860, 625 + 410))
assert.equal(vm.state.drag.resize, true)
vm.moveDrag(pointer(1950 + 700, 625 + 300))
vm.endDrag(pointer(1950 + 700, 625 + 300))
assert.deepEqual([vm.layout.nav_map.width, vm.layout.nav_map.height], [700, 300])
assert.equal(vm.state.history.undo.length, 1)
vm.boxInput("width", { target: { value: "99999" } })
assert.equal(vm.layout.nav_map.width, 900, "typed sizes clamp to the room from the current corner")
vm.opacityInput({ target: { value: "40" } })
vm.opacityInput({ target: { value: "35" } })
assert.equal(vm.layout.nav_map.opacity, 35)
assert.equal(vm.state.history.undo.length, 3, "one slide is one undo step")
vm.opacityInput({ target: { value: "5" } })
assert.equal(vm.layout.nav_map.opacity, 35, "below the minimum is ignored")
assert.equal(validDocument(vm.state.draft, data.metadata), true)
// Home and Work use the same add, place, layer-order, remove and save controls.
vm.state.data = data
for (const key of ["nav_home", "nav_work", "bookmark"]) {
assert.equal(vm.layout[key].enabled, false)
vm.add(key)
assert.equal(vm.layout[key].enabled, true)
vm.changePosition(key, 400, 700)
assert.deepEqual([vm.layout[key].x, vm.layout[key].y], [400, 700])
vm.reorderLayer(key, "nav_map")
const order = orderedWidgetIds(vm.state.draft, data.metadata, "large")
assert.ok(order.indexOf(key) < order.indexOf("nav_map"))
vm.remove(key)
assert.equal(vm.layout[key].enabled, false)
}
const savedFavorites = projectionPayload({ revision: "favorites", document: vm.state.draft }, data.metadata).document
assert.equal(savedFavorites.widgets.nav_home.enabled, false)
assert.equal(savedFavorites.widgets.nav_work.enabled, false)
assert.deepEqual(savedFavorites.widgetOrder, vm.state.draft.widgetOrder.large)
assert.equal(validDocument(vm.state.draft, data.metadata), true)
// Independent icon choices shrink the preview footprint and survive saving.
vm.state.selected = 'nav_home'
for (const key of ['nav_home', 'nav_work']) {
vm.state.selected = key
const center = vm.layout[key].x + 160
vm.setFavoriteDisplay('icons')
assert.equal(vm.layout[key].x + 55, center, key + ' keeps its center when changing to an icon')
vm.setFavoriteDisplay('words')
assert.equal(vm.layout[key].x + 160, center, key + ' keeps its center when changing back to words')
vm.undo()
vm.undo()
}
vm.state.selected = 'nav_home'
const undoBeforeDisplay = vm.state.history.undo.length
vm.setFavoriteDisplay('icons')
assert.deepEqual(widgetSize(profile.widgets.nav_home, vm.layout.nav_home), [110, 110])
assert.equal(vm.layout.nav_work.display, 'words')
assert.equal(vm.state.history.undo.length, undoBeforeDisplay + 1)
vm.changePosition('nav_home', 2740, 700)
assert.equal(vm.layout.nav_home.x, 2740, 'compact icons can sit close to the right edge')
vm.setFavoriteDisplay('words')
assert.equal(vm.layout.nav_home.x, 2530, 'switching back to words keeps the whole card on screen')
assert.deepEqual(widgetSize(profile.widgets.nav_home, vm.layout.nav_home), [320, 110])
vm.setFavoriteDisplay('icons')
assert.equal(projectionPayload({revision:'display', document:vm.state.draft}, data.metadata).document.widgets.nav_home.display, 'icons')
assert.equal(validDocument(vm.state.draft, data.metadata), true)
for (const display of ['emoji', null, true, 1]) {
const invalid = copy(vm.state.draft)
invalid.layouts.large.nav_home.display = display
assert.equal(validDocument(invalid, data.metadata), false)
}
// Each action has independent appearance, undo/redo, strict validation and saved preview values.
vm.editAvailable = true
for (const [index, key] of ['nav_home', 'nav_work', 'bookmark', 'car_exit'].entries()) {
vm.state.selected = key
vm.finishColorEdit()
const before = copy(vm.state.draft)
vm.opacityInput({target: {value: String(25 + index * 10)}})
vm.setWidgetBackground('Off')
assert.equal(vm.layout[key].opacity, 25 + index * 10)
assert.equal(vm.layout[key].background, false)
vm.undo()
assert.equal(vm.layout[key].background, true)
vm.undo()
assert.deepEqual(vm.state.draft, before)
vm.redo()
vm.redo()
const payload = projectionPayload({revision: 'appearance', document: vm.state.draft}, data.metadata)
assert.equal(payload.document.widgets[key].opacity, 25 + index * 10)
assert.equal(payload.document.widgets[key].background, false)
const preview = OnroadLayoutPage.computed.renderWidgets.call({layerWidgets: [{id: key, ...profile.widgets[key]}], layout: vm.layout,
previewHidden: () => false})[0]
assert.equal(preview.opacity, 25 + index * 10)
assert.equal(preview.background, false)
assert.equal(validDocument(vm.state.draft, data.metadata), true)
for (const change of [{opacity: -1}, {opacity: 101}, {opacity: 50.5}, {opacity: true}, {background: 0}, {background: 'false'}]) {
const invalid = copy(vm.state.draft)
Object.assign(invalid.layouts.large[key], change)
assert.equal(validDocument(invalid, data.metadata), false)
}
for (const blocked of [{editable: false}, {state: {...vm.state, drag: {}}}, {state: {...vm.state, layerDrag: {}}}]) {
OnroadLayoutPage.methods.opacityInput.call({...vm, ...blocked}, {target: {value: '90'}})
OnroadLayoutPage.methods.setWidgetBackground.call({...vm, ...blocked}, 'On')
assert.equal(vm.layout[key].opacity, 25 + index * 10)
assert.equal(vm.layout[key].background, false)
}
}
vm.state.selected = 'bookmark'
vm.add('bookmark')
vm.setFavoriteDisplay('icons')
assert.deepEqual(widgetSize(profile.widgets.bookmark, vm.layout.bookmark), [110, 110])
assert.equal(projectionPayload({document: vm.state.draft}, data.metadata).document.widgets.bookmark.display, 'icons')
assert.equal(vm.layout.bookmark.opacity, 45)
assert.equal(vm.layout.bookmark.background, false)
vm.setFavoriteDisplay('words')
assert.deepEqual(widgetSize(profile.widgets.bookmark, vm.layout.bookmark), [320, 110])
for (const change of [{opacity: {min: 0, max: 101, default: 100}}, {backgroundToggle: false}]) {
const invalid = copy(data)
Object.assign(invalid.metadata.profiles.large.widgets.car_exit, change)
assert.equal(validSnapshot(invalid), false)
}
// The escape control moves normally but cannot be disabled, removed, or placed below another widget.
vm.state.selected = 'car_exit'
assert.equal(profile.widgets.car_exit.label, 'Exit to car')
assert.deepEqual(widgetSize(profile.widgets.car_exit, vm.layout.car_exit), [96, 96])
assert.deepEqual([vm.layout.car_exit.x, vm.layout.car_exit.y], [30, 954])
assert.equal(vm.layout.car_exit.enabled, true)
vm.changePosition('car_exit', 200, 800)
assert.deepEqual([vm.layout.car_exit.x, vm.layout.car_exit.y], [200, 800])
vm.remove('car_exit')
assert.equal(vm.layout.car_exit.enabled, true)
vm.requestRemove('car_exit')
assert.equal(vm.state.removeConfirm, undefined)
const pinnedOrder = [...orderedWidgetIds(vm.state.draft, data.metadata, 'large')]
vm.startLayerDrag({ button: 0 }, 'car_exit')
assert.equal(vm.state.layerDrag, undefined)
vm.reorderLayer('car_exit', 'nav_map')
vm.reorderLayer('nav_map', 'car_exit')
assert.deepEqual(orderedWidgetIds(vm.state.draft, data.metadata, 'large'), pinnedOrder)
const disabledExit = copy(vm.state.draft)
disabledExit.layouts.large.car_exit.enabled = false
assert.equal(validDocument(disabledExit, data.metadata), false)
const buriedExit = copy(vm.state.draft)
buriedExit.widgetOrder ||= {}
buriedExit.widgetOrder.large = ['car_exit', ...pinnedOrder.filter(id => id !== 'car_exit')]
assert.equal(validDocument(buriedExit, data.metadata), false)
assert.match(OnroadLayoutPage.template, /v-if="!widget.required"/)
assert.match(OnroadLayoutPage.template, />Required</)
assert.match(LayoutWidgetPreview.template, /data-preview-icon="door-exit"/)
compile(OnroadLayoutPage.template, { decodeEntities: value => value.replaceAll("&amp;", "&") })
compile(LayoutWidgetPreview.template, { decodeEntities: value => value.replaceAll("&amp;", "&") })
console.log("Projection widgets: map resizing/opacity, Home/Work/Bookmark add/move/order/remove, appearance, strict documents and payload passed")