// 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, phaseText, 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: phaseText("install") }); expect(phaseText("install")).toBe("Building and installing…"); 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) => 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(""); }); });