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 value.replaceAll("&", "&") }) compile(LayoutWidgetPreview.template, { decodeEntities: value => value.replaceAll("&", "&") }) console.log("Projection widgets: map resizing/opacity, Home/Work/Bookmark add/move/order/remove, appearance, strict documents and payload passed")