Files
homeassistant_espscreen/web/tests/store.spec.ts
T
Max Gramser ffe639e23c Let the page buttons make way: a Page buttons setting that gives their room to the tiles, chevrons and dots instead of words, and a warning in the editor for pages nothing leads to (0.2.82)
GitHub issue #9 asked to drop the Previous/Next bar when Go to page tiles do the navigation and to give the tiles the whole screen. Page buttons is a setting the screen owns (on by default): a row in the Screen group of the settings page, switch.<screen>_page_buttons in Home Assistant, and a row in the editor's Screen settings. Without the bar, and on a screen with one page, place_page moves and sizes the three rows so they reach the bottom margin (Guition 108 -> 122 px, CYD 52 -> 61 px); a card keeps its circle, name and state in its middle and the single analog clock keeps its dial. With page buttons and swiping both off, the editor says where the Go to page tiles lead and which pages that leaves out of reach.

The bar itself: each half of the band under the tiles is one key with an MDI chevron and lights up under a finger, and a dot per page sits between them; the screen's settings page uses the same pager. App 0.2.82, firmware 0.2.69.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:43:43 +02:00

442 lines
21 KiB
TypeScript

// 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, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageReachWarning, pageTilesRepeat, PHASES, removeTile,
retargetPageTile, save, select, setTileOption, state, supports, 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" },
// The full inventory carries the changelog next to the screens, not in the update summary (app 0.2.78).
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."] },
],
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);
});
});
describe("full-page and navigation tiles", () => {
it("moves the other tiles of the page behind a tile that grows to the whole page", () => {
select("living");
const [lamp, sensor] = state.layout!.tiles;
setTileOption(lamp, "size", "full");
expect(lamp.slot).toBe(0);
expect(sensor.slot).toBe(6);
expect(state.layout!.pages).toBe(2);
expect(state.toast).toBeNull();
});
it("takes the first empty page when the others cannot move, and gives up with a toast when none is free", () => {
select("living");
state.layout!.tiles = Array.from({ length: 48 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: i }));
const first = state.layout!.tiles[0];
setTileOption(first, "size", "full");
expect(first.options?.size).toBe("single");
expect(state.toast?.message).toMatch(/No page is free/);
});
it("lets a navigation tile point at another page, once per page", () => {
select("living");
addTile("screen.page_2");
const nav = state.layout!.tiles.find((t) => t.entity === "screen.page_2")!;
expect(nav.options).toBeUndefined();
expect(retargetPageTile(nav, 3)).toBe(true);
expect(nav.entity).toBe("screen.page_3");
addTile("screen.page_4");
expect(retargetPageTile(nav, 4)).toBe(false);
expect(state.toast?.message).toMatch(/already has a tile that goes to page 4/);
expect(retargetPageTile(nav, 9)).toBe(false);
});
});
describe("the open screen chosen again (app 0.2.78)", () => {
it("keeps unsaved edits and only brings the layout back into view", () => {
select("living");
addTile("light.b");
state.tab = "settings";
state.route = "#firmware";
select("living");
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["light.a", "sensor.t", "light.b"]);
expect(state.dirty).toBe(true);
expect(state.tab).toBe("layout");
expect(state.inspector).toBeNull();
expect(state.route).toBe("");
expect(window.confirm).not.toHaveBeenCalled();
});
it("reads the stored layout again when nothing is unsaved, so a change from Claude or another tab shows up", () => {
select("living");
state.inventory.screens[0].layout.tiles.push({ entity: "light.b", name: "", slot: 2 });
select("living");
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["light.a", "sensor.t", "light.b"]);
expect(state.dirty).toBe(false);
});
});
describe("saving while you keep editing (app 0.2.78)", () => {
// The PUT waits until the test answers it; every other request fails, which the store shrugs off.
function slowServer() {
const control = { answer: () => {} };
const fetchMock = vi.fn((_url: string, init?: RequestInit) => init?.method !== "PUT"
? Promise.reject(new Error("offline"))
: new Promise((resolve) => { control.answer = () => resolve({ ok: true, status: 200, text: async () => "{}", json: async () => ({}) }); }));
vi.stubGlobal("fetch", fetchMock);
return { control, fetchMock };
}
it("marks the layout saved when nothing changed while the request was on its way", async () => {
select("living");
addTile("light.b");
const { control } = slowServer();
const saving = save();
control.answer();
await saving;
expect(state.dirty).toBe(false);
expect(state.saved).toBeGreaterThan(0);
expect(state.toast?.message).toBe("Saved. Your screen is being updated.");
});
it("keeps a change made during the save unsaved and says so", async () => {
select("living");
addTile("light.b");
const { control, fetchMock } = slowServer();
const saving = save();
setTileOption(state.layout!.tiles[0], "background", "orange");
control.answer();
await saving;
expect(state.dirty).toBe(true);
expect(state.saved).toBe(0);
expect(state.toast?.message).toBe("Saved. Your newest change isn't sent yet: press Save & send again.");
// What went out is the layout from before that change.
const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body);
expect(body.tiles.map((t: any) => t.entity)).toEqual(["light.a", "sensor.t", "light.b"]);
expect(body.tiles[0].options?.background).toBeUndefined();
});
it("leaves the unsaved flag of a screen opened meanwhile alone", async () => {
select("living");
addTile("light.b");
const { control } = slowServer();
const saving = save();
select("kitchen");
addTile("light.b");
control.answer();
await saving;
expect(state.selected).toBe("kitchen");
expect(state.dirty).toBe(true);
expect(state.toast?.message).toBe("Saved. Living room is being updated.");
});
});
describe("moving a tile to another page without dragging (app 0.2.78)", () => {
it("takes the first free cell of that page, or a new page after the last one", () => {
select("living");
const [lamp, sensor] = state.layout!.tiles;
state.layout!.pages = 2;
expect(moveTileToPage(sensor, 1)).toBe(true);
expect(sensor.slot).toBe(6);
expect(moveTileToPage(lamp, 1)).toBe(true);
expect(lamp.slot).toBe(7);
expect(moveTileToPage(lamp, 2)).toBe(true);
expect(lamp.slot).toBe(12);
expect(state.layout!.pages).toBe(3);
expect(state.dirty).toBe(true);
expect(moveTileToPage(lamp, 2)).toBe(false);
expect(moveTileToPage(lamp, 8)).toBe(false);
});
it("swaps with the first tile of a full page, as a drop there does", () => {
select("living");
state.layout!.tiles = [
...Array.from({ length: 6 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: i })),
{ entity: "light.b", name: "", slot: 6 },
];
const b = state.layout!.tiles[6];
expect(moveTileToPage(b, 0)).toBe(true);
expect(b.slot).toBe(0);
expect(state.layout!.tiles.find((t) => t.entity === "light.l0")!.slot).toBe(6);
});
it("moves a full-page tile to a page of its own and says so when a tile finds no room", () => {
select("living");
state.layout!.tiles = [
{ entity: "light.big", name: "", slot: 0, options: { size: "full" } },
{ entity: "light.w", name: "", slot: 6, options: { size: "wide" } },
...[8, 9, 10, 11].map((slot) => ({ entity: `light.p${slot}`, name: "", slot })),
...Array.from({ length: 36 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: 12 + i })),
];
const [big, wide] = state.layout!.tiles;
// Page 1 holds the full-page tile and every other page is taken: the wide tile would have to push it off.
expect(moveTileToPage(wide, 0)).toBe(false);
expect(wide.slot).toBe(6);
expect(state.toast?.message).toBe("There is no room for this tile on page 1.");
// The full-page tile and the tiles of page 2 change places.
expect(moveTileToPage(big, 1)).toBe(true);
expect(big.slot).toBe(6);
expect(state.layout!.tiles.filter((t) => t.slot < 6).map((t) => t.entity).sort()).toEqual(["light.p10", "light.p11", "light.p8", "light.p9", "light.w"]);
});
});
describe("what the add-on says about a screen's firmware (app 0.2.78)", () => {
it("takes the tile limit and the features from the screen entry", () => {
const living = state.inventory.screens[0];
Object.assign(living, { firmware: "unknown", firmware_known: null, tile_limit: 48, full_page: true, page_tiles_repeat: true });
select("living");
expect(tileLimit.value).toBe(48);
expect(fullPage.value).toBe(true);
expect(pageTilesRepeat.value).toBe(true);
// A version Home Assistant can't report right now no longer cuts a copied or imported layout to ten.
const many = Array.from({ length: 30 }, (_, i) => ({ entity: `light.l${i}`, name: "", slot: i }));
importLayout(JSON.stringify({ tiles: many }));
expect(state.layout!.tiles).toHaveLength(30);
expect(state.toast?.message).toBe("Layout imported. Save & send when it looks right.");
});
it("goes by firmware_known for the version and the notes", () => {
const living = state.inventory.screens[0];
Object.assign(living, { firmware: "unknown", firmware_known: "0.2.60" });
select("living");
expect(supports(0, 2, 60)).toBe(true);
expect(supports(0, 2, 61)).toBe(false);
expect(whatsNew(living)).toEqual(["Full-page tiles.", "Live values."]);
expect(canAlert(living)).toBe(true);
Object.assign(living, { firmware: "0.2.63", firmware_known: null });
expect(supports(0, 2, 31)).toBe(false);
expect(canAlert(living)).toBe(false);
});
it("keeps today's rule, with a strict X.Y.Z, for a screen entry without the fields", () => {
select("living");
expect(tileLimit.value).toBe(20);
expect(fullPage.value).toBe(false);
expect(pageTilesRepeat.value).toBe(false);
state.inventory.screens[0].firmware = "0.2.65";
expect(tileLimit.value).toBe(48);
expect(fullPage.value).toBe(true);
expect(pageTilesRepeat.value).toBe(true);
state.inventory.screens[0].firmware = "0.2.65 (ESPHome 2026.6.2)";
expect(tileLimit.value).toBe(10);
expect(fullPage.value).toBe(false);
state.inventory.screens[0].firmware = "unknown";
expect(tileLimit.value).toBe(10);
});
});
describe("the changelog from the full inventory (app 0.2.78)", () => {
it("reads the notes next to the screens and ignores an update summary that still has them", () => {
const living = state.inventory.screens[0];
state.inventory.updates = { target: "0.2.62", changelog: [{ app: "0.2.1", firmware: "0.2.62", lines: ["Old place."] }] } as any;
expect(whatsNew(living)).toEqual(["Full-page tiles.", "Live values."]);
});
it("shows nothing without a changelog", () => {
delete state.inventory.changelog;
expect(whatsNew(state.inventory.screens[1])).toEqual([]);
});
});
describe("several tiles that go to the same page (firmware 0.2.65)", () => {
it("adds, retargets and imports another copy when the screen takes them", () => {
Object.assign(state.inventory.screens[0], { firmware: "0.2.65", page_tiles_repeat: true });
select("living");
addTile("screen.page_1");
addTile("screen.page_1");
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(2);
addTile("screen.page_2");
const nav = state.layout!.tiles.find((t) => t.entity === "screen.page_2")!;
expect(retargetPageTile(nav, 1)).toBe(true);
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(3);
// Only a navigation tile repeats; any other entity still appears once.
addTile("light.a");
expect(state.layout!.tiles.filter((t) => t.entity === "light.a")).toHaveLength(1);
importLayout(JSON.stringify({ tiles: [
{ entity: "screen.page_1", slot: 0 }, { entity: "screen.page_1", slot: 6 }, { entity: "light.a", slot: 1 }, { entity: "light.a", slot: 2 },
] }));
expect(state.layout!.tiles.map((t) => t.entity)).toEqual(["screen.page_1", "light.a", "screen.page_1"]);
});
it("keeps one tile per page when the screen doesn't", () => {
Object.assign(state.inventory.screens[0], { firmware: "0.2.65", page_tiles_repeat: false });
select("living");
addTile("screen.page_1");
addTile("screen.page_1");
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(1);
addTile("screen.page_2");
expect(retargetPageTile(state.layout!.tiles.find((t) => t.entity === "screen.page_2")!, 1)).toBe(false);
expect(state.toast?.message).toMatch(/already has a tile that goes to page 1/);
});
it("starts the empty page after the last one when a tile goes there", () => {
select("living");
addTile("screen.page_1");
const nav = state.layout!.tiles.find((t) => t.entity === "screen.page_1")!;
expect(state.layout!.pages).toBe(1);
expect(retargetPageTile(nav, 1)).toBe(false);
expect(retargetPageTile(nav, 2)).toBe(true);
expect(state.layout!.pages).toBe(2);
expect(state.dirty).toBe(true);
});
});
describe("page buttons and swiping both off (firmware 0.2.69)", () => {
const settings = (values: Record<string, unknown>) =>
Object.assign(state.inventory.screens[0], { settings: { owner: "screen", values, keys: Object.keys(values), unavailable: [] } });
const tiles = (list: [string, number][]) => { state.layout!.tiles = list.map(([entity, slot]) => ({ entity, name: "", slot, options: {} })); state.layout!.pages = 1; };
it("says which pages the Go to page tiles lead to, and which one they leave out", () => {
settings({ page_buttons: false, swipe_pages: false });
select("living");
tiles([["screen.page_2", 0], ["screen.page_3", 1], ["screen.page_1", 6], ["screen.page_1", 12], ["light.a", 18]]);
expect(pageReachWarning()).toBe("Page buttons and swiping are off, so only Go to page tiles change the page. "
+ "You have 4 Go to page tiles, to pages 1, 2 and 3, but none to page 4, so you can't reach it.");
tiles([["light.a", 0], ["light.b", 6], ["switch.c", 12]]);
expect(pageReachWarning()).toBe("Page buttons and swiping are off, so only Go to page tiles change the page. "
+ "You have no Go to page tiles to pages 2 and 3, so you can't reach them.");
tiles([["screen.page_2", 0], ["light.a", 6]]);
expect(pageReachWarning()).toMatch(/You have 1 Go to page tile, to page 2\. From page 2 no tile leads back to page 1\.$/);
});
it("stays quiet while either way of paging is on, the values are unknown, or every page can be reached and left", () => {
select("living");
tiles([["light.a", 0], ["light.b", 6]]);
for (const values of [{ page_buttons: true, swipe_pages: false }, { page_buttons: false, swipe_pages: true }, { page_buttons: null, swipe_pages: false }, {}]) {
settings(values);
expect(pageReachWarning()).toBe("");
}
settings({ page_buttons: false, swipe_pages: false });
tiles([["screen.page_2", 0], ["screen.page_1", 6]]);
expect(pageReachWarning()).toBe("");
tiles([["light.a", 0]]);
expect(pageReachWarning()).toBe("");
});
});