Files
homeassistant_espscreen/web/tests/components.spec.ts
T
Xandrossian 9857cc3bda
CI / checks (push) Successful in 12m50s
CI / firmware (add-on) (push) Successful in 18m19s
CI / firmware (min_version) (push) Successful in 17m55s
Add the Elecrow CrowPanel 7.0" as a third board, and take the packages and the add-on from Gitea (0.2.93)
A board file for the Elecrow CrowPanel 7.0" (DIS08070H, ESP32-S3-WROOM-1-N4R8): 800x480 through mipi_rgb, GT911 on the
ESP32-S3's USB pins (elecrow_usb_phy.h frees them, behind a flag only this board sets), the Guition's vertical sizes
across a wider page, no rotation. Its 4 MB of flash take the CYD's budget: a partition table of the component's
(smart_display: partitions: 4mb_wide_slots, 1,984 KB slots), the icon fonts at 2 bits a pixel and the TLS client only
bring the firmware to 1,865,440 bytes, 91.8 % of a slot, with camera images kept. docs/ELECROW.md has the numbers and
the trade.

The add-on reads a large board's name from the type sensor's value, the editor offers the board under New screen and
draws the wide preview, tools/check.sh builds it with the budget, render_topbar and capture_ui take the panel's size.
Firmware 0.2.79; the CYD image is unchanged (1,633,760 bytes).

This fork builds from gittea.trippedonmy.name/Xandrossian/homeassistant_espscreen: the packages, the fonts, the
component and the add-on repository point there instead of GitHub.
2026-09-20 23:18:01 -07:00

327 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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";
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" },
{ 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" }] } },
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;
state.dirty = false; state.tab = "layout";
});
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("fills a blind's bar with its closed part, like the screen and its card", () => {
state.liveStates["cover.c"] = { state: "open", word: "Open", a: { current_position: 30 } };
const blind = placed({ entity: "cover.c", name: "", slot: 2, options: { inline: "slider" } });
expect(blind.find(".mini-slider").attributes("style")).toContain("70%");
});
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" } });
// Numbers as the screens write them (app 0.2.90): "1,234.5" until the add-on names another format.
expect(big.find(".big").text()).toBe("1,249W");
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("draws a single tile as the screen does: the icon left, the name and value beside it", () => {
state.liveStates["sensor.t"] = { state: "1249", word: null, a: { unit_of_measurement: "W" } };
const plain = placed({ entity: "sensor.t", name: "Power", slot: 0 });
expect(plain.find(".head > .ic").exists()).toBe(true);
expect(plain.find(".head > .tx > .nm").text()).toBe("Power");
expect(plain.find(".head > .tx > .st").text()).toBe("1,249 W");
// A watch card keeps the name next to the icon and puts the big value underneath.
const watch = placed({ entity: "sensor.t", name: "Power", slot: 1, options: { display: "watch" } });
expect(watch.find(".head").classes()).toContain("top");
expect(watch.find(".head .big").exists()).toBe(false);
expect(watch.find(".head + .big").text()).toBe("1,249W");
state.liveStates["light.a"] = { state: "on", word: "On", a: { brightness: 255 } };
const lamp = placed({ entity: "light.a", name: "", slot: 2, options: { inline: "slider" } });
expect(lamp.find(".head + .mini-slider").exists()).toBe(true);
});
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?.entity).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", "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");
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", "Go to page 1", "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);
});
});
describe("full-page and navigation tiles on the mockup", () => {
it("draws a full tile as one big card and a navigation tile with its page", () => {
state.liveStates["light.a"] = { state: "on", word: "On", a: {} };
const full = placed({ entity: "light.a", name: "", slot: 0, options: { size: "full" } });
expect(full.classes()).toContain("full");
expect(full.classes()).not.toContain("wide");
expect(full.find(".ic").classes()).toContain("lit");
expect(full.find(".tog").exists()).toBe(false);
const nav = placed({ entity: "screen.page_3", name: "Go to page 3", slot: 6 });
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: a live picture on a camera tile (app 0.2.91)", () => {
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());
it("offers the live picture for a camera, with its pace once chosen, and says which firmware it needs", async () => {
state.inventory.entities.push({ id: "camera.front", name: "Front", state: "idle", area: "Hall" } as any);
const tile: Tile = { entity: "camera.front", name: "", slot: 0 };
state.layout!.tiles.push(tile);
const drawer = mount(TileInspector, { props: { tile } });
expect(choices(drawer, "Display")).toEqual(["Name and status", "Large value", "Live picture"]);
expect(row(drawer, "Refresh")).toBeUndefined();
await row(drawer, "Display").findAll(".seg button")[2].trigger("click");
expect(tile.options).toEqual({ display: "live" });
expect(choices(drawer, "Refresh")).toEqual(["Every 15 s", "Every 30 s"]);
expect(row(drawer, "Refresh").find('[aria-pressed="true"]').text()).toBe("Every 15 s");
expect(row(drawer, "Display").find("small").text()).toMatch(/firmware 0\.2\.77/);
expect(row(drawer, "Display").find("small").classes()).toContain("warn");
await row(drawer, "Refresh").findAll(".seg button")[1].trigger("click");
expect(tile.options).toEqual({ display: "live", refresh: 30 });
Object.assign(state.inventory.screens[0], { firmware: "0.2.77" });
await drawer.vm.$nextTick();
expect(row(drawer, "Display").find("small").text()).toMatch(/icon's place/);
expect(row(drawer, "Display").find("small").classes()).not.toContain("warn");
// A light has no such choice.
const lamp = mount(TileInspector, { props: { tile: { entity: "light.a", name: "", slot: 1 } } });
expect(choices(lamp, "Display")).not.toContain("Live picture");
// The mockup draws the picture's rounded square instead of the icon.
const card = mount(TileCard, { props: { tile, slot: 0 } });
expect(card.find(".ic").classes()).toContain("thumb");
});
it("offers the album cover for a media player on a Guition, not on a full-page tile, and keeps its controls", async () => {
Object.assign(state.inventory.screens[0], { firmware: "0.2.78" });
state.inventory.entities.push({ id: "media_player.sonos", name: "Sonos", state: "playing", area: "Hall" } as any);
(state.inventory as any).controls.media_player = { default: "volume", choices: [{ key: "volume", label: "Volume" }, { key: "none", label: "None" }] };
const tile: Tile = { entity: "media_player.sonos", name: "", slot: 0, options: { size: "wide" } };
state.layout!.tiles.push(tile);
const drawer = mount(TileInspector, { props: { tile } });
expect(choices(drawer, "Display")).toEqual(["Name and status", "Large value", "Album cover"]);
await row(drawer, "Display").findAll(".seg button")[2].trigger("click");
expect(tile.options).toEqual({ size: "wide", display: "cover" });
expect(row(drawer, "Display").find("small").text()).toMatch(/icon's place/);
expect(row(drawer, "Refresh")).toBeUndefined();
expect(row(drawer, "Direct control on the tile")).toBeDefined();
const card = mount(TileCard, { props: { tile, slot: 0 } });
expect(card.find(".ic").classes()).toContain("thumb");
expect(card.find(".range").exists()).toBe(true);
// A CYD gets no such choice; a tile over the whole page keeps the card's big cover.
Object.assign(state.inventory.screens[0], { board: "cyd" });
tile.options = { size: "single" };
await drawer.vm.$nextTick();
expect(choices(mount(TileInspector, { props: { tile } }), "Display")).toEqual(["Name and status", "Large value"]);
Object.assign(state.inventory.screens[0], { board: "guition" });
tile.options = { size: "full" };
expect(choices(mount(TileInspector, { props: { tile } }), "Display")).toEqual(["Name and status", "Large value"]);
// An Elecrow CrowPanel 7.0" (app 0.2.93) is a large board like the Guition: the cover is on offer again.
Object.assign(state.inventory.screens[0], { board: "elecrow" });
tile.options = { size: "single" };
expect(choices(mount(TileInspector, { props: { tile } }), "Display")).toContain("Album cover");
Object.assign(state.inventory.screens[0], { board: "guition" });
});
});
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);
});
});