Test the editor with Vitest: grid and top bar rules, the store, and the components in jsdom

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 08:26:43 +02:00
parent 6148dfd211
commit 8d11de8a28
13 changed files with 1750 additions and 38 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ and screens for lookups and tests. If that file is missing you are not on his ma
- Keep base hardware and UI in `home-like-2432s028.yaml`; personal data belongs
in the gitignored local profiles.
- The editor is `web/` (Vue 3 + Vite, TypeScript; app 0.2.74+). `screen_manager/app/static` is its build
output: change `web/src`, run `cd web && npm run build`, and commit both. Keep every URL the page asks for
output: change `web/src`, run `cd web && npm test && npm run build`, and commit both. Keep every URL the page asks for
relative (`api/...`), so it works behind Home Assistant's ingress path. Tests read the source through
`tests/editor_sources.py`; docs/RELEASING.md "Local development" has the dev-server recipe.
- Colours live in one table: `components/smart_display/theme.h` (firmware 0.2.54+). Every role has a
+4 -3
View File
@@ -21,8 +21,8 @@
1. Update the source profiles and shared components. Run
`python3 tools/generate_packages.py`; never edit `packages/*.yaml` directly.
The editor is the Vue app in `web/`: after a change under `web/src`, run
`cd web && npm ci && npm run build` and commit `screen_manager/app/static` with it
(that folder is the build output; never edit it by hand).
`cd web && npm ci && npm test && npm run check && npm run build` and commit
`screen_manager/app/static` with it (that folder is the build output; never edit it by hand).
2. Run all Python tests with aiohttp installed, all `tests/*.cpp`, the
generator with `--check`, and compile both Easy Setup profiles. Do that
sequentially: profiles with the same `DEVICE_NAME` share one build folder, and a
@@ -58,7 +58,8 @@ python3 -m venv .venv-portal
For the editor (`web/`, Vue 3 + Vite + TypeScript): `cd web && npm ci`, then `npm run dev` serves
http://localhost:5173 with hot reload and proxies `/api` to a server on 127.0.0.1:8099 (SCREEN_DEV or a demo
home). `npm run check` type-checks, `npm run build` writes the page into `screen_manager/app/static`
home). `npm test` runs the Vitest suite in `web/tests` (grid and top bar rules, the store, the components in jsdom),
`npm run check` type-checks, `npm run build` writes the page into `screen_manager/app/static`
(clearing `assets/` first). Vite names every file after a hash of its content, which is what keeps a browser
from combining an old script with a new page; `server.py` serves `index.html` as built and `/assets/`.
Everything the page asks for is a relative URL (`api/...`, `./assets/...`), so it works under Home
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>ESP Screens</title>
<script type="module" crossorigin src="./assets/index-BuQ5a76G.js"></script>
<script type="module" crossorigin src="./assets/index-CcnhFWcS.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-PqOTdpJB.css">
</head>
<body>
+1223
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -7,15 +7,20 @@
"dev": "vite",
"build": "rm -rf ../screen_manager/app/static/assets && vite build",
"check": "vue-tsc --noEmit",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"vue": "^3.5.18"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/test-utils": "^2.5.1",
"jsdom": "^26.1.0",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "^3.2.7",
"vue-tsc": "^3.0.6"
}
}
+12 -4
View File
@@ -45,17 +45,25 @@ export const BAR_METRICS: Record<string, BarMetrics> = {
export type ItemView = { icon?: string | null; text?: string; color?: string | null; shown: boolean; analog?: boolean; loading?: boolean };
type Ink = { left: number; right: number; top: number; bottom: number; advance: number };
const measure = document.createElement("canvas").getContext("2d")!;
let measure: CanvasRenderingContext2D | null | undefined;
const inkCache = new Map<string, Ink>();
export const clearInkCache = () => inkCache.clear();
// Ink box of a string relative to its origin on the baseline: left/right, and top (negative, up)/bottom.
// Without a canvas (a test runner) the box is estimated from the font size, so the layout rules still run.
export function inkOf(text: string, font: string): Ink {
const key = `${font}|${text}`;
let ink = inkCache.get(key);
if (!ink) {
measure.font = font;
const m = measure.measureText(text);
ink = { left: -m.actualBoundingBoxLeft, right: m.actualBoundingBoxRight, top: -m.actualBoundingBoxAscent, bottom: m.actualBoundingBoxDescent, advance: m.width };
if (measure === undefined) measure = document.createElement("canvas").getContext("2d");
if (measure) {
measure.font = font;
const m = measure.measureText(text);
ink = { left: -m.actualBoundingBoxLeft, right: m.actualBoundingBoxRight, top: -m.actualBoundingBoxAscent, bottom: m.actualBoundingBoxDescent, advance: m.width };
} else {
const px = Number(/(\d+(?:\.\d+)?)px/.exec(font)?.[1] || 14);
const width = [...text].length * px * 0.55;
ink = { left: 0, right: width, top: -px * 0.72, bottom: px * 0.02, advance: width };
}
inkCache.set(key, ink);
}
return ink;
+120
View File
@@ -0,0 +1,120 @@
// The components that draw the state: a tile with live values, the library's filters, the ⌘K search.
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import CommandPalette from "../src/components/CommandPalette.vue";
import Library from "../src/components/Library.vue";
import TileCard from "../src/components/TileCard.vue";
import { state } from "../src/store";
import type { Inventory, Tile } from "../src/types";
function inventory(): Inventory {
return {
csrf: "t", connected: true,
screens: [{ id: "living", name: "Living room", online: true, firmware: "0.2.60", board: "guition", layout: { title: "Living room", tiles: [] }, alert_action: "esphome.living_show_alert" } as any],
entities: [
{ id: "light.a", name: "Lamp A", state: "on", area: "Living room", device: "Hue" },
{ id: "light.b", name: "Lamp B", state: "unavailable", area: "Kitchen" },
{ id: "sensor.t", name: "Temperature", state: "21.5", area: "Living room" },
{ id: "cover.c", name: "Curtains", state: "open", area: "Living room" },
],
builtin: [{ id: "screen.clock", name: "Clock", device: "Built into the screen", area: "", state: "ok" }],
icons: { groups: [], weather: { partlycloudy: "F0595" }, sun: { below_horizon: "F0594" }, defaults: { light: "F0335", sensor: "F050F", cover: "F1846" }, fallback: "F0335", builtin: { "screen.clock": "F0150" }, controls: { minus: "F0374", plus: "F0415" } },
backgrounds: { auto: { label: "Default" }, none: { label: "None" } },
controls: { light: { default: "toggle", choices: [{ key: "toggle", label: "On/off" }, { key: "brightness", label: "Brightness" }, { key: "none", label: "None" }] } },
alerts: { min_firmware: "0.2.31" },
} as unknown as Inventory;
}
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("offline"))));
state.inventory = inventory();
state.selected = "living";
state.layout = { title: "Living room", tiles: [] };
state.liveStates = {};
state.search = ""; state.filter = ""; state.room = ""; state.hidePlaced = false;
state.palette = false;
state.selectedTile = null; state.inspector = null;
});
function placed(tile: Tile) {
state.layout!.tiles.push(tile);
return mount(TileCard, { props: { tile, slot: tile.slot } });
}
describe("TileCard", () => {
it("shows a sensor's value with its unit and a light that is on as lit", () => {
state.liveStates["sensor.t"] = { state: "21.4", word: null, a: { unit_of_measurement: "°C" } };
state.liveStates["light.a"] = { state: "on", word: "On", a: { brightness: 128 } };
const sensor = placed({ entity: "sensor.t", name: "Temp", slot: 0 });
expect(sensor.text()).toContain("21.4 °C");
const lamp = placed({ entity: "light.a", name: "", slot: 1, options: { inline: "slider" } });
expect(lamp.text()).toContain("Lamp A");
expect(lamp.find(".ic").classes()).toContain("lit");
expect(lamp.find(".mini-slider").attributes("style")).toContain("50%");
});
it("draws the large value, the wide card's toggle and an unavailable entity in grey", () => {
state.liveStates["sensor.t"] = { state: "1249", word: null, a: { unit_of_measurement: "W" } };
const big = placed({ entity: "sensor.t", name: "Power", slot: 0, options: { display: "watch" } });
expect(big.find(".big").text()).toBe("1249W");
state.liveStates["light.a"] = { state: "off", word: "Off", a: {} };
const wide = placed({ entity: "light.a", name: "", slot: 2, options: { size: "wide" } });
expect(wide.classes()).toContain("wide");
expect(wide.find(".tog").classes()).toContain("off");
expect(wide.find(".ic").classes()).not.toContain("lit");
const gone = placed({ entity: "light.b", name: "", slot: 4 });
expect(gone.find(".st").text()).toBe("Unavailable");
expect(gone.find(".st").classes()).toContain("off");
});
it("shows the display name when Home Assistant has no value, and nothing for a scene", () => {
const graph = placed({ entity: "sensor.x", name: "Unknown sensor", slot: 0, options: { display: "graph" } });
expect(graph.find(".st").text()).toBe("graph");
state.liveStates["scene.movie"] = { state: "2026-09-14T19:15:00+00:00", word: null, a: {} };
const scene = placed({ entity: "scene.movie", name: "Movie", slot: 1 });
expect(scene.find(".st").exists()).toBe(false);
});
it("opens the tile's settings on a click", async () => {
const lamp = placed({ entity: "light.a", name: "", slot: 0 });
await lamp.trigger("click");
expect(state.selectedTile).toBe("light.a");
expect(state.inspector).toEqual({ kind: "tile" });
expect(lamp.classes()).toContain("chosen");
});
});
describe("Library", () => {
it("filters by room and hides what is placed, and tints the avatars by state", async () => {
state.layout!.tiles.push({ entity: "light.a", name: "", slot: 0 });
const library = mount(Library);
const names = () => library.findAll(".ent .tx b").map((b) => b.text());
expect(names()).toEqual(["Clock", "Lamp A", "Lamp B", "Temperature", "Curtains"]);
expect(library.find('.ent[title="light.a"]').attributes("disabled")).toBeDefined();
expect(library.find('.ent[title="light.a"] .av').classes()).toContain("on");
expect(library.find('.ent[title="light.b"] .av').classes()).toContain("gone");
expect(library.findAll("#room option").map((o) => o.text())).toEqual(["All rooms", "Kitchen", "Living room"]);
await library.find("#room").setValue("Kitchen");
expect(names()).toEqual(["Lamp B"]);
await library.find("#room").setValue("");
await library.find("#hide-placed").trigger("click");
expect(names()).toEqual(["Clock", "Lamp B", "Temperature", "Curtains"]);
state.filter = "light";
await library.vm.$nextTick();
expect(names()).toEqual(["Lamp B"]);
});
});
describe("CommandPalette", () => {
it("lists screens and actions, finds an entity to add, and runs the chosen row", async () => {
state.palette = true;
const palette = mount(CommandPalette);
await palette.vm.$nextTick();
const labels = () => palette.findAll(".palette-item .tx > span").map((s) => s.text());
expect(labels()).toContain("Living room");
expect(labels()).toContain("Save & send");
expect(labels()).toContain("Identify this screen");
await palette.find("#palette-input").setValue("curt");
expect(labels()).toEqual(["Curtains"]);
await palette.find("#palette-input").trigger("keydown", { key: "Enter" });
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["cover.c"]);
expect(state.palette).toBe(false);
});
});
+111
View File
@@ -0,0 +1,111 @@
// The grid rules: the same ones the add-on applies (core.pack_slots, validate_layout) and the firmware draws.
import { describe, expect, it } from "vitest";
import {
arrange, controlsLabel, defaultOptions, effectiveControls, firstFree, fits, hasGaps, MAX_PAGES, MAX_SLOTS, nearestFree,
newTile, normalize, occupied, packSlots, pageCount, rowStart, SLOTS_PER_PAGE, tileLimit, versionAtLeast,
} from "../src/model/layout";
import type { Inventory, Layout, Tile } from "../src/types";
const tile = (entity: string, slot: number, options: Tile["options"] = {}): Tile => ({ entity, name: "", slot, options });
const entries = (tiles: Tile[]) => tiles.map((t) => ({ tile: t, slot: t.slot }));
describe("packing and positions", () => {
it("packs in reading order and moves a wide tile to the start of a row", () => {
const tiles = [tile("a", -1), tile("b", -1, { size: "wide" }), tile("c", -1)];
expect(packSlots(tiles)).toEqual([0, 2, 4]);
expect(hasGaps(tiles)).toBe(true);
const layout: Layout = { title: "T", tiles: [tile("z", 7), tile("y", 3)] };
normalize(layout);
expect(layout.tiles.map((t) => [t.entity, t.slot])).toEqual([["y", 3], ["z", 7]]);
});
it("gives every tile a slot when an old layout has none", () => {
const layout: Layout = { title: "T", tiles: [{ entity: "a", name: "" } as Tile, { entity: "b", name: "", options: { size: "wide" } } as Tile] };
normalize(layout);
expect(layout.tiles.map((t) => t.slot)).toEqual([0, 2]);
});
it("knows where a tile fits", () => {
const taken = occupied(entries([tile("a", 0), tile("w", 2, { size: "wide" })]));
expect([...taken].sort((x, y) => x - y)).toEqual([0, 2, 3]);
expect(fits(taken, 1, false)).toBe(true);
expect(fits(taken, 2, false)).toBe(false);
expect(fits(taken, 1, true)).toBe(false); // a wide tile starts in the left column
expect(fits(taken, 4, true)).toBe(true);
expect(fits(taken, MAX_SLOTS - 1, true)).toBe(false);
expect(fits(taken, MAX_SLOTS, false)).toBe(false);
expect(firstFree(taken, false)).toBe(1);
expect(firstFree(taken, true)).toBe(4);
expect(rowStart(5)).toBe(4);
});
it("prefers the later cell on a tie, so a nudged tile moves down", () => {
const taken = occupied(entries([tile("a", 2)]));
expect(nearestFree(taken, false, 2)).toBe(3);
expect(nearestFree(new Set([3]), false, 2)).toBe(2);
});
});
describe("arrange", () => {
it("swaps two tiles when one is dropped on the other", () => {
const a = tile("a", 0), b = tile("b", 1);
const result = arrange([a, b], a, 1)!;
expect(result.map((e) => [e.tile.entity, e.slot])).toEqual([["b", 0], ["a", 1]]);
});
it("pushes a displaced tile to the nearest free cell when the vacated cells do not fit it", () => {
const wide = tile("w", 0, { size: "wide" }), c = tile("c", 2), d = tile("d", 3);
// A new single tile lands on the wide one's row: the wide tile needs two cells and takes the next free row.
const fresh = newTile("light.x");
const result = arrange([wide, c, d], fresh, 0)!;
const by = Object.fromEntries(result.map((e) => [e.tile.entity, e.slot]));
expect(by["light.x"]).toBe(0);
expect(by.w).toBe(4);
expect(by.c).toBe(2);
});
it("snaps a wide tile to the start of its row and refuses a target off the grid", () => {
const w = tile("w", 0, { size: "wide" });
expect(arrange([w], w, 3)![0].slot).toBe(2);
expect(arrange([w], w, MAX_SLOTS)).toBeNull();
const single = tile("s", 0);
expect(arrange([single], single, -1)).toBeNull();
expect(arrange([single], single, MAX_SLOTS)).toBeNull();
});
it("counts the pages the tiles need, never more than eight", () => {
expect(pageCount(entries([tile("a", 0)]))).toBe(1);
expect(pageCount(entries([tile("a", 6)]))).toBe(2);
expect(pageCount(entries([tile("w", 4, { size: "wide" })]))).toBe(1);
expect(pageCount(entries([tile("a", 0)]), 3)).toBe(3);
expect(pageCount(entries([tile("a", 0)]), 99)).toBe(MAX_PAGES);
expect(SLOTS_PER_PAGE * MAX_PAGES).toBe(MAX_SLOTS);
});
});
describe("defaults, controls and versions", () => {
const inventory = { screens: [], entities: [], controls: { light: { default: "toggle", choices: [{ key: "toggle", label: "On/off switch" }, { key: "brightness", label: "Brightness slider" }, { key: "none", label: "None" }] } } } as unknown as Inventory;
it("starts a new tile with the card that shows the entity best", () => {
expect(defaultOptions("weather.home")).toEqual({ options: { display: "forecast", size: "wide" } });
expect(defaultOptions("sun.sun")).toEqual({ options: { display: "sunpath", size: "wide" } });
expect(defaultOptions("screen.clock")).toEqual({ options: { display: "digital", size: "wide" } });
expect(defaultOptions("light.a")).toEqual({});
expect(newTile("light.a")).toEqual({ entity: "light.a", name: "", slot: -1 });
});
it("shows direct controls only on a wide standard card without a mini slider", () => {
expect(effectiveControls(tile("light.a", 0, { size: "wide" }), inventory)).toBe("toggle");
expect(effectiveControls(tile("light.a", 0, { size: "wide", controls: "brightness" }), inventory)).toBe("brightness");
expect(effectiveControls(tile("light.a", 0, { size: "wide", controls: "none" }), inventory)).toBeNull();
expect(effectiveControls(tile("light.a", 0, { size: "wide", inline: "slider" }), inventory)).toBeNull();
expect(effectiveControls(tile("light.a", 0, { size: "wide", display: "watch" }), inventory)).toBeNull();
expect(effectiveControls(tile("light.a", 0), inventory)).toBeNull();
expect(controlsLabel(tile("light.a", 0, { size: "wide" }), inventory)).toBe("on/off switch");
expect(controlsLabel(tile("sensor.t", 0, { size: "wide" }), inventory)).toBe("none");
});
it("compares firmware versions and knows the tile limit", () => {
expect(versionAtLeast("0.2.60", "0.2.58")).toBe(true);
expect(versionAtLeast("0.2.58", "0.2.58")).toBe(true);
expect(versionAtLeast("0.2.9", "0.2.10")).toBe(false);
expect(versionAtLeast("1.0.0", "0.9.9")).toBe(true);
expect(versionAtLeast(undefined, "0.2.1")).toBe(false);
expect(versionAtLeast("unknown", "0.2.1")).toBe(false);
expect(tileLimit("0.2.6")).toBe(10);
expect(tileLimit("0.2.7")).toBe(20);
expect(tileLimit("0.2.60")).toBe(20);
expect(tileLimit(undefined)).toBe(10);
});
});
+158
View File
@@ -0,0 +1,158 @@
// The store: selecting a screen, editing its layout, what's new, progress, copy and import.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addTile, canAlert, copyLayoutFrom, importLayout, layoutJson, liveOf, PHASES, removeTile, select, setTileOption, state, tileLimit,
topbarItems, topbarView, updateProgress, whatsNew,
} from "../src/store";
import type { Inventory, Screen } from "../src/types";
const screen = (id: string, name: string, firmware: string, tiles: any[]): Screen => ({
id, name, online: true, firmware, board: "guition", layout: { title: name, tiles }, update: { available: true, target: "0.2.62" },
alert_action: `esphome.${id}_show_alert`,
} as unknown as Screen);
function inventory(): Inventory {
return {
csrf: "t", connected: true,
screens: [
screen("living", "Living room", "0.2.60", [{ entity: "light.a", name: "", slot: 0 }, { entity: "sensor.t", name: "Temp", slot: 1, options: { display: "graph" } }]),
screen("kitchen", "Kitchen", "0.2.6", [{ entity: "switch.c", name: "", slot: 0, options: { background: "orange" } }]),
],
entities: [
{ id: "light.a", name: "Lamp A", state: "on", area: "Living room" }, { id: "sensor.t", name: "Temperature", state: "21.5", area: "Living room" },
{ id: "switch.c", name: "Coffee", state: "off", area: "Kitchen" }, { id: "light.b", name: "Lamp B", state: "off", area: "Kitchen" },
],
updates: { target: "0.2.62", changelog: [
{ app: "0.2.74", firmware: "0.2.62", lines: ["Full-page tiles.", "Live values."] },
{ app: "0.2.71", firmware: "0.2.60", lines: ["Slider stays put."] },
{ app: "0.2.40", firmware: "0.2.34", lines: ["English."] },
] } as any,
alerts: { min_firmware: "0.2.31" },
controls: { light: { default: "toggle", choices: [{ key: "toggle", label: "On/off" }, { key: "brightness", label: "Brightness" }, { key: "none", label: "None" }] } },
backgrounds: { auto: { label: "Default" }, orange: { label: "Orange", color: "#ffe1c6" } },
} as unknown as Inventory;
}
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("offline"))));
vi.spyOn(window, "confirm").mockReturnValue(true);
state.inventory = inventory();
state.selected = null;
state.layout = null;
state.dirty = false;
state.liveStates = {};
state.toast = null;
state.updating = [];
state.firmwareJob = null;
});
describe("selecting and editing", () => {
it("copies the stored layout so edits never touch the inventory until saved", () => {
select("living");
expect(state.layout).not.toBe(state.inventory.screens[0].layout);
expect(state.layout!.tiles.map((t) => t.slot)).toEqual([0, 1]);
expect(state.dirty).toBe(false);
state.layout!.tiles[0].name = "Changed";
expect(state.inventory.screens[0].layout.tiles[0].name).toBe("");
});
it("adds a tile to the first free cell once, within the firmware's limit", () => {
select("living");
addTile("light.b");
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["light.a", "sensor.t", "light.b"]);
expect(state.layout!.tiles[2].slot).toBe(2);
expect(state.dirty).toBe(true);
addTile("light.b");
expect(state.layout!.tiles).toHaveLength(3);
expect(tileLimit.value).toBe(20);
});
it("keeps a wide tile at the start of a row and lets a display change fix the controls", () => {
select("living");
const sensor = state.layout!.tiles[1];
setTileOption(sensor, "size", "wide");
expect(sensor.slot).toBe(2);
expect(state.layout!.tiles.map((t) => t.slot)).toEqual([0, 2]);
const lamp = state.layout!.tiles[0];
setTileOption(lamp, "size", "wide");
setTileOption(lamp, "controls", "brightness");
setTileOption(lamp, "display", "watch");
expect(lamp.options).toMatchObject({ size: "wide", display: "watch", controls: "none", inline: "none" });
});
it("removes a tile and offers to undo", () => {
select("living");
removeTile(state.layout!.tiles[0]);
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["sensor.t"]);
expect(state.toast?.action?.label).toBe("Undo");
state.toast!.action!.run();
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["light.a", "sensor.t"]);
});
it("shows the clock of the stored setting when there is no top bar yet", () => {
select("living");
expect(topbarItems()).toEqual([{ type: "clock" }]);
state.now = new Date(2026, 8, 15, 10, 8).getTime();
expect(topbarView({ type: "clock" }).text).toBe("10:08");
expect(topbarView({ type: "date" }).text).toBe("Tu 15 Sep");
expect(topbarView({ type: "analog" }).analog).toBe(true);
});
});
describe("live values", () => {
it("falls back to what the inventory knew", () => {
expect(liveOf("light.a")).toEqual({ state: "on", word: null, a: {} });
state.liveStates["light.a"] = { state: "off", word: "Off", a: { brightness: 0 } };
expect(liveOf("light.a")!.word).toBe("Off");
expect(liveOf("light.nope")).toBeNull();
});
});
describe("copy, export and import", () => {
it("copies another screen's tiles and keeps this screen's title", () => {
select("living");
copyLayoutFrom("kitchen");
expect(state.layout!.title).toBe("Living room");
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["switch.c"]);
expect(state.layout!.tiles[0].options).toEqual({ background: "orange" });
expect(state.dirty).toBe(true);
expect(JSON.parse(layoutJson())).toMatchObject({ esp_screens_layout: 1, title: "Living room", tiles: [{ entity: "switch.c" }] });
});
it("refuses garbage, drops duplicates and trims to the firmware's limit", () => {
select("kitchen"); // firmware 0.2.6: ten tiles
importLayout("not json");
expect(state.toast?.message).toMatch(/isn't JSON/);
importLayout(JSON.stringify({ hello: 1 }));
expect(state.toast?.message).toMatch(/No tiles/);
const many = Array.from({ length: 14 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: i }));
importLayout(JSON.stringify({ tiles: [...many, { entity: "light.l0" }, { bogus: true }, { entity: "no-dot" }] }));
expect(state.layout!.tiles).toHaveLength(10);
expect(state.toast?.message).toMatch(/10 of 14 tiles/);
});
});
describe("updates with content", () => {
it("lists what a screen gets, and nothing it already has", () => {
const [living, kitchen] = state.inventory.screens;
expect(whatsNew(living)).toEqual(["Full-page tiles.", "Live values."]);
expect(whatsNew(kitchen)).toEqual(["Full-page tiles.", "Live values.", "Slider stays put.", "English."]);
expect(whatsNew({ ...living, firmware: "0.2.62" })).toEqual([]);
});
it("turns the phase and the ESPHome stage into a progress bar", () => {
const living = state.inventory.screens[0];
expect(updateProgress(living)).toBeNull();
living.update!.state = "running";
living.update!.phase = "install";
expect(updateProgress(living)).toEqual({ percent: 12, text: PHASES.install });
state.firmwareJob = { job: { stage: "compile" }, logs: [] };
expect(updateProgress(living)!.percent).toBe(40);
state.firmwareJob = { job: { stage: "upload" }, logs: [] };
expect(updateProgress(living)!.percent).toBe(66);
living.update!.phase = "verify";
expect(updateProgress(living)!.percent).toBe(78);
living.update!.phase = "settle";
expect(updateProgress(living)!.percent).toBe(92);
});
it("knows which screen can show an alert", () => {
const [living, kitchen] = state.inventory.screens;
expect(canAlert(living)).toBe(true);
expect(canAlert(kitchen)).toBe(false);
expect(canAlert(undefined)).toBe(false);
});
});
+73
View File
@@ -0,0 +1,73 @@
// The top bar rules: the same wording and arithmetic as header_bar.h in the firmware.
import { describe, expect, it } from "vitest";
import { agoText, BAR_METRICS, barGaps, barLayout, clockText, dateText, dotted, itemKey } from "../src/model/topbar";
import type { HeaderItem } from "../src/types";
describe("words for the time since a change", () => {
const now = 1_000_000;
it("uses the firmware's thresholds and wording", () => {
expect(agoText(now - 10, now)).toBe("Just now");
expect(agoText(now - 300, now)).toBe("5 min ago");
expect(agoText(now - 3600, now)).toBe("1 hour ago");
expect(agoText(now - 7200, now)).toBe("2 hours ago");
expect(agoText(now - 90000, now)).toBe("Yesterday");
expect(agoText(now - 3 * 86400, now)).toBe("3 days ago");
expect(agoText(now - 8 * 86400, now)).toBe("1 week ago");
expect(agoText(now - 40 * 86400, now)).toBe("1 month ago");
expect(agoText(now - 400 * 86400, now)).toBe("1 year ago");
});
it("looks ahead too", () => {
expect(agoText(now + 120, now)).toBe("In 2 min");
expect(agoText(now + 3600, now)).toBe("In 1 hour");
expect(agoText(now + 100000, now)).toBe("Tomorrow");
expect(agoText(now + 5 * 86400, now)).toBe("In 5 days");
});
});
describe("clock and date", () => {
const evening = new Date(2026, 8, 15, 19, 5);
it("formats the clock the way the screen does", () => {
expect(clockText(true, evening)).toBe("19:05");
expect(clockText(false, evening)).toBe("07:05");
expect(clockText(false, new Date(2026, 8, 15, 0, 30))).toBe("12:30");
});
it("writes the date short", () => {
expect(dateText(evening)).toBe("Tu 15 Sep");
});
});
describe("the bar's geometry", () => {
it("uses the firmware's integer gaps", () => {
expect(barGaps(15)).toEqual({ icon: 6, item: 19, name: 24 });
expect(barGaps(1)).toEqual({ icon: 2, item: 6, name: 8 });
});
it("drops items from the left when the name needs its room", () => {
const items: HeaderItem[] = Array.from({ length: 6 }, (_, i) => ({ type: "entity", entity: `sensor.s${i}` }));
const view = () => ({ icon: "F050F", text: "1234.5 °C", shown: true });
const lay = barLayout(items, BAR_METRICS.cyd, "Living room", view);
expect(lay.parts).toHaveLength(6);
expect(lay.placed.length).toBeLessThan(6);
expect(lay.dropped.size).toBe(6 - lay.placed.length);
// The dropped ones are the first ones; the placed ones sit left to right and end at the right edge.
expect([...lay.dropped].every((i) => i < lay.placed[0].index)).toBe(true);
for (let i = 1; i < lay.placed.length; i++) expect(lay.placed[i].x!).toBeGreaterThan(lay.placed[i - 1].x!);
expect(lay.nameRoom).toBeGreaterThan(0);
});
it("keeps a hidden item out of the bar and gives the dial its own width", () => {
const items: HeaderItem[] = [{ type: "entity", entity: "binary_sensor.door" }, { type: "analog" }];
const view = (item: HeaderItem) => (item.type === "analog" ? { analog: true, shown: true } : { icon: "F050F", text: "Open", shown: false });
const lay = barLayout(items, BAR_METRICS.guition, "Home", view);
expect(lay.placed.map((p) => p.index)).toEqual([1]);
expect(lay.placed[0].dial).toBeGreaterThan(0);
});
it("shortens a long name with an ellipsis, like LVGL", () => {
const font = '500 27px "Bar Roboto"';
expect(dotted("Living room", font, 10000)).toBe("Living room");
const short = dotted("A very long living room name", font, 120);
expect(short.endsWith("...")).toBe(true);
expect(short.length).toBeLessThan("A very long living room name".length);
});
it("keys an item by its whole definition", () => {
expect(itemKey({ type: "entity", entity: "a", content: "state" })).toBe('{"type":"entity","entity":"a","content":"state"}');
});
});
+13
View File
@@ -0,0 +1,13 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vitest/config";
// Unit and component tests of the editor: the grid rules, the top bar rules, the store and the components,
// in jsdom. `npm test` runs them; `npm run test:watch` while developing.
export default defineConfig({
plugins: [vue()],
test: {
environment: "jsdom",
include: ["tests/**/*.spec.ts"],
restoreMocks: true,
},
});