Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)

A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host
harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and
Vue) found these, fixed here with Max's choices for the product questions:

- Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the
  waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap
  it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has
  no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built.
- App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its
  firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the
  live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes.
- Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone
  dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build.
- tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 17:25:23 +02:00
parent c09e4e333f
commit 1a6308aba7
80 changed files with 2623 additions and 320 deletions
+35
View File
@@ -0,0 +1,35 @@
// The add-on's answers: what a failed request says to the person using the page.
import { afterEach, describe, expect, it, vi } from "vitest";
import { getJson, send } from "../src/api";
const answer = (status: number, body: string, type = "application/json") =>
vi.stubGlobal("fetch", vi.fn(async () => new Response(body, { status, headers: { "Content-Type": type } })));
afterEach(() => vi.unstubAllGlobals());
describe("api", () => {
it("shows the add-on's own sentence for any failed answer with a JSON error (app 0.2.78)", async () => {
answer(400, JSON.stringify({ error: "Home Assistant refused light.turn_on: the entity is unavailable." }));
await expect(send("screens/x", "PUT", {})).rejects.toThrow("Home Assistant refused light.turn_on: the entity is unavailable.");
answer(503, JSON.stringify({ error: "Home Assistant didn't answer in time. Try again in a moment." }));
await expect(getJson("capabilities?entity=light.a")).rejects.toThrow("Home Assistant didn't answer in time. Try again in a moment.");
answer(502, JSON.stringify({ error: "Bad gateway from Home Assistant." }));
await expect(getJson("states")).rejects.toThrow("Bad gateway from Home Assistant.");
});
it("keeps the general sentence for an answer that isn't JSON or has no error in it", async () => {
const general = "That didn't work. Refresh the page and try again.";
answer(502, "<html><body>502 Bad Gateway</body></html>", "text/html");
await expect(getJson("inventory")).rejects.toThrow(general);
answer(500, JSON.stringify({ detail: "nope" }));
await expect(getJson("inventory")).rejects.toThrow(general);
answer(500, JSON.stringify({ error: "" }));
await expect(getJson("inventory")).rejects.toThrow(general);
});
it("sends relative URLs with the CSRF header and reads an empty answer as null", async () => {
answer(200, "");
expect(await send("screens/x/identify", "POST")).toBeNull();
const call = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0]).toBe("api/screens/x/identify");
expect(call[1].headers).toHaveProperty("X-Screen-CSRF");
});
});
+114 -4
View File
@@ -1,9 +1,12 @@
// 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 AppSettingsView from "../src/components/AppSettingsView.vue";
import CommandPalette from "../src/components/CommandPalette.vue";
import Library from "../src/components/Library.vue";
import Sidebar from "../src/components/Sidebar.vue";
import TileCard from "../src/components/TileCard.vue";
import TileInspector from "../src/components/TileInspector.vue";
import { state } from "../src/store";
import type { Inventory, Tile } from "../src/types";
@@ -17,7 +20,10 @@ function inventory(): Inventory {
{ 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" }],
builtin: [
{ id: "screen.clock", name: "Clock", device: "Built into the screen", area: "", state: "ok" },
{ id: "screen.page_1", name: "Go to page 1", 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" }] } },
@@ -34,6 +40,7 @@ beforeEach(() => {
state.search = ""; state.filter = ""; state.room = ""; state.hidePlaced = false;
state.palette = false;
state.selectedTile = null; state.inspector = null;
state.dirty = false; state.tab = "layout";
});
function placed(tile: Tile) {
@@ -90,7 +97,7 @@ describe("TileCard", () => {
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.selectedTile?.entity).toBe("light.a");
expect(state.inspector).toEqual({ kind: "tile" });
expect(lamp.classes()).toContain("chosen");
});
@@ -101,7 +108,7 @@ describe("Library", () => {
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(names()).toEqual(["Clock", "Go to page 1", "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");
@@ -110,7 +117,7 @@ describe("Library", () => {
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"]);
expect(names()).toEqual(["Clock", "Go to page 1", "Lamp B", "Temperature", "Curtains"]);
state.filter = "light";
await library.vm.$nextTick();
expect(names()).toEqual(["Lamp B"]);
@@ -146,3 +153,106 @@ describe("full-page and navigation tiles on the mockup", () => {
expect(nav.find(".goto").text()).toBe("Page 3 ›");
});
});
describe("several tiles that go to the same page in the library (firmware 0.2.65)", () => {
it("keeps offering a placed navigation tile when the screen takes several, and adds another copy", async () => {
Object.assign(state.inventory.screens[0], { firmware: "0.2.65", page_tiles_repeat: true });
state.layout!.tiles.push({ entity: "screen.page_1", name: "", slot: 0 }, { entity: "light.a", name: "", slot: 1 });
const library = mount(Library);
const row = () => library.find('.ent[title="screen.page_1"]');
expect(row().attributes("disabled")).toBeUndefined();
expect(row().find(".add").text()).toBe("+");
expect(library.find('.ent[title="light.a"]').attributes("disabled")).toBeDefined();
await library.find("#hide-placed").trigger("click");
expect(row().exists()).toBe(true);
await row().trigger("click");
expect(state.layout!.tiles.filter((t) => t.entity === "screen.page_1")).toHaveLength(2);
});
it("marks it placed when the screen takes one per page", () => {
Object.assign(state.inventory.screens[0], { page_tiles_repeat: false });
state.layout!.tiles.push({ entity: "screen.page_1", name: "", slot: 0 });
const library = mount(Library);
expect(library.find('.ent[title="screen.page_1"]').attributes("disabled")).toBeDefined();
expect(library.find('.ent[title="screen.page_1"] .add').text()).toBe("✓");
});
});
describe("TileInspector: pages (app 0.2.78)", () => {
const row = (wrapper: ReturnType<typeof mount>, label: string) =>
wrapper.findAll(".f").find((f) => f.find(".f-label").exists() && f.find(".f-label").text() === label)!;
const choices = (wrapper: ReturnType<typeof mount>, label: string) => row(wrapper, label).findAll(".seg button").map((b) => b.text());
function open(tiles: any[], index: number) {
state.layout!.tiles.push(...tiles);
state.layout!.pages = 1;
const tile = state.layout!.tiles[index];
return { tile, drawer: mount(TileInspector, { props: { tile } }) };
}
it("offers the pages the screen has and the empty one after them as targets, and keeps one beyond those", async () => {
Object.assign(state.inventory.screens[0], { firmware: "0.2.63", full_page: true });
const { tile, drawer } = open([
{ entity: "light.a", name: "", slot: 0 }, { entity: "screen.page_2", name: "", slot: 1 }, { entity: "sensor.t", name: "", slot: 6 },
], 1);
expect(choices(drawer, "Goes to page")).toEqual(["1", "2", "3 (empty)"]);
expect(row(drawer, "Goes to page").find('[aria-pressed="true"]').text()).toBe("2");
tile.entity = "screen.page_7";
await drawer.vm.$nextTick();
expect(choices(drawer, "Goes to page")).toEqual(["1", "2", "3 (empty)", "7 (empty)"]);
expect(row(drawer, "Goes to page").find("small.warn").text()).toMatch(/no page 7/);
await row(drawer, "Goes to page").findAll(".seg button")[2].trigger("click");
expect(tile.entity).toBe("screen.page_3");
expect(state.layout!.pages).toBe(3);
expect(choices(drawer, "Goes to page")).toEqual(["1", "2", "3 (empty)", "4 (empty)"]);
expect(row(drawer, "Goes to page").find("small").classes()).not.toContain("warn");
});
it("moves the tile to another page or a new one with a tap", async () => {
const { tile, drawer } = open([{ entity: "light.a", name: "", slot: 0 }, { entity: "sensor.t", name: "", slot: 6 }], 0);
expect(choices(drawer, "Page")).toEqual(["1", "2", "New page"]);
expect(row(drawer, "Page").find('[aria-pressed="true"]').text()).toBe("1");
await row(drawer, "Page").findAll(".seg button")[1].trigger("click");
expect(tile.slot).toBe(7);
expect(state.dirty).toBe(true);
// Alone on the last page now: a new page would only leave this one empty.
const sensor = state.layout!.tiles.find((t) => t.entity === "sensor.t")!;
await row(drawer, "Page").findAll(".seg button")[0].trigger("click");
expect(tile.slot).toBe(0);
expect(sensor.slot).toBe(6);
const other = mount(TileInspector, { props: { tile: sensor } });
expect(choices(other, "Page")).toEqual(["1", "2"]);
await row(other, "Page").findAll(".seg button")[0].trigger("click");
expect(sensor.slot).toBe(1);
// One tile on one page: nowhere to go, so no row.
state.layout!.tiles = [];
const only = open([{ entity: "light.b", name: "", slot: 0 }], 0).drawer;
expect(row(only, "Page")).toBeUndefined();
});
});
describe("Sidebar", () => {
it("marks the open screen with unsaved edits and keeps them when it is chosen again", async () => {
state.dirty = true;
const layout = state.layout;
state.tab = "settings";
const sidebar = mount(Sidebar);
const item = sidebar.find("#screens .nav-item");
expect(item.find(".unsaved").exists()).toBe(true);
await item.trigger("click");
expect(state.layout).toBe(layout);
expect(state.dirty).toBe(true);
expect(state.tab).toBe("layout");
});
});
describe("AppSettingsView", () => {
it("shows what the current firmware brings from the changelog of the full inventory (app 0.2.78)", () => {
state.inventory.updates = { target: "0.2.65", pending: 0 };
state.inventory.changelog = [
{ app: "0.2.78", firmware: "0.2.65", lines: ["Several tiles go to the same page."] },
{ app: "0.2.76", firmware: "0.2.63", lines: ["Older."] },
];
const view = mount(AppSettingsView);
expect(view.find(".whatsnew summary").text()).toBe("What's new in firmware 0.2.65");
expect(view.findAll(".whatsnew li").map((li) => li.text())).toEqual(["Several tiles go to the same page."]);
delete state.inventory.changelog;
expect(mount(AppSettingsView).find(".whatsnew").exists()).toBe(false);
});
});
+53
View File
@@ -0,0 +1,53 @@
// What a drag near an edge scrolls (app 0.2.78): on a phone the row of pages sideways and the page itself up and
// down, on a wide window the canvas both ways.
import { beforeEach, describe, expect, it } from "vitest";
import { dragScrollers, edgeStep, scrollsAlong } from "../src/drag";
// jsdom has no layout: give an element the sizes a browser would measure, and the overflow longhands it computes.
function sized(element: HTMLElement, size: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }) {
for (const [key, value] of Object.entries(size)) Object.defineProperty(element, key, { configurable: true, value });
return element;
}
let canvas: HTMLElement, pages: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '<div class="canvas"><div class="pages"></div></div>';
canvas = document.querySelector<HTMLElement>(".canvas")!;
pages = document.querySelector<HTMLElement>(".pages")!;
});
describe("the scroller of a drag", () => {
it("scrolls the canvas both ways on a wide window", () => {
// The canvas scrolls (overflow: auto); the row of pages is wider than it but doesn't scroll itself.
Object.assign(canvas.style, { overflowX: "auto", overflowY: "auto" });
sized(canvas, { scrollWidth: 1800, clientWidth: 900, scrollHeight: 1200, clientHeight: 700 });
sized(pages, { scrollWidth: 1800, clientWidth: 900 });
const { x, y } = dragScrollers(document);
expect(x).toBe(canvas);
expect(y).toBe(canvas);
});
it("scrolls the row of pages sideways and the page up and down on a phone", () => {
// At 960 px and narrower: the canvas is overflow: visible and the row of pages overflow-x: auto.
Object.assign(canvas.style, { overflowX: "visible", overflowY: "visible" });
pages.style.overflowX = "auto";
sized(canvas, { scrollWidth: 390, clientWidth: 390, scrollHeight: 900, clientHeight: 900 });
sized(pages, { scrollWidth: 1400, clientWidth: 358 });
const { x, y } = dragScrollers(document);
expect(x).toBe(pages);
expect(y).toBe(document.scrollingElement);
});
it("falls back to the canvas when the row of pages fits", () => {
pages.style.overflowX = "auto";
sized(pages, { scrollWidth: 358, clientWidth: 358 });
expect(dragScrollers(document).x).toBe(canvas);
expect(scrollsAlong(null, "x")).toBe(false);
});
});
describe("the step near an edge", () => {
it("moves 12 px towards an edge within 60 px of it, and not in between", () => {
expect(edgeStep(10, [0, 400])).toBe(-12);
expect(edgeStep(200, [0, 400])).toBe(0);
expect(edgeStep(390, [0, 400])).toBe(12);
expect(edgeStep(120, [100, 900])).toBe(-12);
});
});
+3
View File
@@ -103,6 +103,9 @@ describe("defaults, controls and versions", () => {
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);
+224 -4
View File
@@ -1,8 +1,8 @@
// 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, retargetPageTile, select, setTileOption, state, tileLimit,
topbarItems, topbarView, updateProgress, whatsNew,
addTile, canAlert, copyLayoutFrom, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageTilesRepeat, PHASES, removeTile,
retargetPageTile, save, select, setTileOption, state, supports, tileLimit, topbarItems, topbarView, updateProgress, whatsNew,
} from "../src/store";
import type { Inventory, Screen } from "../src/types";
@@ -22,11 +22,13 @@ function inventory(): Inventory {
{ 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: [
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."] },
] } 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" } },
@@ -188,3 +190,221 @@ describe("full-page and navigation tiles", () => {
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);
});
});