Files
homeassistant_espscreen/web/tests/layout.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

177 lines
10 KiB
TypeScript

// 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, cellsOf, controlsLabel, defaultOptions, effectiveControls, firstFree, fits, hasGaps, MAX_PAGES, MAX_SLOTS, nearestFree,
newTile, normalize, occupied, packSlots, pageCount, pageStart, pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, spanOf, strandedPages, 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);
// Strict X.Y.Z: anything else is not a firmware release (the add-on works out firmware_known, app 0.2.78).
expect(versionAtLeast("0.2.63 (ESPHome 2026.6.2)", "0.2.1")).toBe(false);
expect(versionAtLeast(null, "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);
});
});
describe("full-page tiles and navigation tiles (firmware 0.2.62+)", () => {
it("knows the three sizes and what they cover", () => {
expect(sizeOf(tile("a", 0))).toBe("single");
expect(sizeOf(tile("a", 0, { size: "wide" }))).toBe("wide");
expect(sizeOf(tile("a", 0, { size: "full" }))).toBe("full");
expect(sizeOf(tile("a", 0, { size: "huge" }))).toBe("single");
expect(spanOf("full")).toBe(6);
expect(cellsOf(8, "full")).toEqual([6, 7, 8, 9, 10, 11]);
expect(cellsOf(3, true)).toEqual([3, 4]);
expect(pageStart(11)).toBe(6);
});
it("packs a full tile at the start of a page and lets only a page start fit it", () => {
expect(packSlots([tile("a", -1), tile("f", -1, { size: "full" }), tile("b", -1)])).toEqual([0, 6, 12]);
const taken = occupied(entries([tile("a", 0)]));
expect(fits(taken, 0, "full")).toBe(false);
expect(fits(taken, 6, "full")).toBe(true);
expect(fits(taken, 8, "full")).toBe(false);
expect(firstFree(taken, "full")).toBe(6);
expect(firstFree(taken, "single", 6)).toBe(6);
expect(pageCount(entries([tile("f", 6, { size: "full" })]))).toBe(2);
});
it("drops a full tile on its page and moves what was there to the next free cells", () => {
const a = tile("a", 0), b = tile("b", 3), f = tile("f", 6, { size: "full" });
const result = arrange([a, b, f], f, 2)!;
const by = Object.fromEntries(result.map((e) => [e.tile.entity, e.slot]));
expect(by.f).toBe(0);
// The displaced tiles take the cells the full tile left, in order.
expect(by.a).toBe(6);
expect(by.b).toBe(7);
});
it("knows a navigation tile and gives it no default card", () => {
expect(pageTarget("screen.page_3")).toBe(3);
expect(pageTarget("screen.page_9")).toBe(0);
expect(pageTarget("screen.clock")).toBe(0);
expect(defaultOptions("screen.page_2")).toEqual({});
});
it("holds 48 tiles from firmware 0.2.62 and shows no control on a full card without a choice", () => {
expect(tileLimit("0.2.62")).toBe(48);
expect(tileLimit("0.2.61")).toBe(20);
const inventory = { screens: [], entities: [], controls: { light: { default: "toggle", choices: [{ key: "toggle", label: "On/off" }, { key: "none", label: "None" }] } } } as unknown as Inventory;
expect(effectiveControls(tile("light.a", 0, { size: "full" }), inventory)).toBeNull();
expect(effectiveControls(tile("light.a", 0, { size: "full", controls: "toggle" }), inventory)).toBe("toggle");
});
});
describe("pages that only Go to page tiles reach (firmware 0.2.69)", () => {
const nav = (page: number, slot: number) => tile(`screen.page_${page}`, slot);
it("finds a page no tile leads to, and one without a way back", () => {
// Page 1 goes to 2 and 3; page 2 goes back to 1; page 3 leads nowhere; nothing goes to page 4.
const layout = entries([nav(2, 0), nav(3, 1), tile("light.a", 2), nav(1, 6), tile("light.b", 12), tile("light.c", 18)]);
expect(strandedPages(layout, 4)).toEqual({ tiles: 3, targets: [1, 2, 3], unreachable: [4], noWayBack: [3] });
});
it("follows a chain of pages, and ignores a tile to a page the screen lacks or to its own page", () => {
const chain = entries([nav(2, 0), nav(3, 6), nav(1, 12), nav(8, 13), nav(3, 14)]);
expect(strandedPages(chain, 3)).toEqual({ tiles: 4, targets: [1, 2, 3], unreachable: [], noWayBack: [] });
// A tile to page 3 that sits on a page nobody reaches leaves page 3 out too.
expect(strandedPages(entries([nav(3, 6)]), 3)).toEqual({ tiles: 1, targets: [3], unreachable: [2, 3], noWayBack: [] });
expect(strandedPages(entries([tile("light.a", 0)]), 1)).toEqual({ tiles: 0, targets: [], unreachable: [], noWayBack: [] });
});
});