diff --git a/src/card/card-discovery.ts b/src/card/card-discovery.ts index 849504d..2ac4263 100644 --- a/src/card/card-discovery.ts +++ b/src/card/card-discovery.ts @@ -1,6 +1,7 @@ import { INTEGRATION_DOMAIN } from "../constants.js"; import { resolveAndAssignAreas } from "../core/area-resolver.js"; import { RetryManager } from "../core/retry-manager.js"; +import { isPositiveInteger, positionRange } from "../helpers/layout.js"; import { t } from "../i18n.js"; import type { HomeAssistant, PanelTopology, PanelDevice, DiscoveryResult, Circuit, CircuitEntities } from "../types.js"; @@ -34,8 +35,9 @@ export async function discoverTopology(hass: HomeAssistant, deviceId: string | u const topologyMsg = { type: `${INTEGRATION_DOMAIN}/panel_topology`, device_id: deviceId }; const topology = retry ? await retry.callWS(hass, topologyMsg, { errorId: "fetch:topology" }) : await hass.callWS(topologyMsg); - const panelSize = topology.panel_size ?? panelSizeFromCircuits(topology.circuits); - if (!panelSize) { + const panelSize = isPositiveInteger(topology.panel_size) ? topology.panel_size : panelSizeFromCircuits(topology.circuits); + // A panel that does not know its size still loads when it reports its positions. + if (!positionRange(topology, panelSize)) { throw new Error(t("card.topology_error")); } @@ -196,7 +198,7 @@ export async function discoverEntitiesFallback(hass: HomeAssistant, deviceId: st let panelSize = 0; for (const ent of allEntities) { const state = hass.states[ent.entity_id]; - if (state && typeof state.attributes.panel_size === "number") { + if (state && isPositiveInteger(state.attributes.panel_size)) { panelSize = state.attributes.panel_size; break; } diff --git a/src/card/span-panel-card.ts b/src/card/span-panel-card.ts index 6fb13cf..c93f41a 100644 --- a/src/card/span-panel-card.ts +++ b/src/card/span-panel-card.ts @@ -19,6 +19,7 @@ import { discoverTopology, discoverEntitiesFallback, panelConfigEntryId } from " import { RetryManager } from "../core/retry-manager.js"; import { errorText } from "../helpers/error-text.js"; import { deepEqual } from "../helpers/deep-equal.js"; +import { positionRange, positionRowCount, type PositionRange } from "../helpers/layout.js"; import { sameTopologyStructure } from "../core/topology-structure.js"; import { CARD_STYLES } from "./card-styles.js"; import "../core/side-panel.js"; @@ -73,7 +74,8 @@ export class SpanPanelCard extends LitElement { @state() private _activeTab: "panel" | "activity" | "area" = "panel"; private _panelDevice: PanelDevice | null = null; - private _panelSize = 0; + /** The breaker positions the grid draws; null until a topology places one. */ + private _positions: PositionRange | null = null; private _historyLoaded = false; private readonly _ctrl = new DashboardController(); private readonly _listCtrl = new ListViewController(this._ctrl); @@ -146,7 +148,7 @@ export class SpanPanelCard extends LitElement { this._topology = null; this._topologySource = null; this._panelDevice = null; - this._panelSize = 0; + this._positions = null; this._activeTab = "panel"; this._ctrl.reset(); this._ctrl.setConfig(config); @@ -154,7 +156,7 @@ export class SpanPanelCard extends LitElement { } getCardSize(): number { - return Math.ceil(this._panelSize / 2) + 3; + return (this._positions ? positionRowCount(this._positions) : 0) + 3; } static getConfigElement(): HTMLElement { @@ -276,7 +278,7 @@ export class SpanPanelCard extends LitElement { private _adoptTopology(result: DiscoveryResult): void { this._topology = result.topology; this._panelDevice = result.panelDevice; - this._panelSize = result.panelSize; + this._positions = positionRange(result.topology, result.panelSize); } /** @@ -424,7 +426,7 @@ export class SpanPanelCard extends LitElement { private _populateCardContent(): void { const container = this._root.querySelector("#card-content"); - if (!container || !this.hass || !this._topology || !this._panelSize) return; + if (!container || !this.hass || !this._topology || !this._positions) return; // Populate tab bar const tabsContainer = this._root.querySelector("#card-tabs"); @@ -454,11 +456,11 @@ export class SpanPanelCard extends LitElement { } if (this._activeTab === "panel") { - const totalRows = Math.ceil(this._panelSize / 2); + const totalRows = positionRowCount(this._positions); const headerHTML = buildHeaderHTML(this._topology, this._config); const monitoringStatus = this._ctrl.monitoringCache.status; const monitoringSummaryHTML = buildMonitoringSummaryHTML(monitoringStatus); - const gridHTML = buildGridHTML(this._topology, totalRows, this.hass, this._config, monitoringStatus); + const gridHTML = buildGridHTML(this._topology, this._positions, this.hass, this._config, monitoringStatus); const subDevHTML = buildSubDevicesHTML(this._topology, this.hass, this._config, { showFavorites: this._ctrl.showFavorites }); container.innerHTML = ` diff --git a/src/core/grid-renderer.ts b/src/core/grid-renderer.ts index fb596fc..80c0f32 100644 --- a/src/core/grid-renderer.ts +++ b/src/core/grid-renderer.ts @@ -1,7 +1,7 @@ import { escapeHtml } from "../helpers/sanitize.js"; import { formatCircuitCurrentHTML, formatCircuitPowerHTML } from "../helpers/format.js"; import { t } from "../i18n.js"; -import { tabToRow, tabToCol, classifyDualTab } from "../helpers/layout.js"; +import { tabToRow, tabToCol, classifyDualTab, type PositionRange } from "../helpers/layout.js"; import { getChartMetric } from "../helpers/chart.js"; import { getCircuitMonitoringInfo } from "./monitoring-status.js"; import { @@ -26,11 +26,12 @@ interface TabMapEntry { } /** - * Build the full grid HTML for the panel breaker grid. + * Build the full grid HTML for the panel breaker grid: one row per two positions + * across `positions`, labelled with the positions' own numbers. */ export function buildGridHTML( topology: PanelTopology, - totalRows: number, + positions: PositionRange, hass: HomeAssistant, config: CardConfig, monitoringStatus: MonitoringStatus | null @@ -71,41 +72,49 @@ export function buildGridHTML( return { monInfo, sheddingPriority }; } + // A row the range starts or ends inside draws only its in-range side. + const inRange = (tab: number): boolean => tab >= positions.first && tab <= positions.last; + const firstRow = tabToRow(positions.first); + const lastRow = tabToRow(positions.last); + let gridHTML = ""; - for (let row = 1; row <= totalRows; row++) { + for (let row = firstRow; row <= lastRow; row++) { + const gridRow = row - firstRow + 1; const leftTab = row * 2 - 1; const rightTab = row * 2; const leftEntry = tabMap.get(leftTab); const rightEntry = tabMap.get(rightTab); + const leftLabel = inRange(leftTab) ? `
${leftTab}
` : ""; + const rightLabel = inRange(rightTab) ? `
${rightTab}
` : ""; - gridHTML += `
${leftTab}
`; + gridHTML += leftLabel; if (leftEntry && leftEntry.layout === "row-span") { const { monInfo, sheddingPriority } = lookupMonitoring(leftEntry); - gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, row, "2 / 5", "row-span", hass, config, monInfo, sheddingPriority); - gridHTML += `
${rightTab}
`; + gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, gridRow, "2 / 5", "row-span", hass, config, monInfo, sheddingPriority); + gridHTML += rightLabel; continue; } if (!rowsToSkipLeft.has(row)) { if (leftEntry && (leftEntry.layout === "col-span" || leftEntry.layout === "single")) { const { monInfo, sheddingPriority } = lookupMonitoring(leftEntry); - gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, row, "2", leftEntry.layout, hass, config, monInfo, sheddingPriority); - } else if (!occupiedTabs.has(leftTab)) { - gridHTML += renderEmptySlot(row, "2"); + gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, gridRow, "2", leftEntry.layout, hass, config, monInfo, sheddingPriority); + } else if (!occupiedTabs.has(leftTab) && inRange(leftTab)) { + gridHTML += renderEmptySlot(gridRow, "2"); } } if (!rowsToSkipRight.has(row)) { if (rightEntry && (rightEntry.layout === "col-span" || rightEntry.layout === "single")) { const { monInfo, sheddingPriority } = lookupMonitoring(rightEntry); - gridHTML += renderCircuitSlot(rightEntry.uuid, rightEntry.circuit, row, "4", rightEntry.layout, hass, config, monInfo, sheddingPriority); - } else if (!occupiedTabs.has(rightTab)) { - gridHTML += renderEmptySlot(row, "4"); + gridHTML += renderCircuitSlot(rightEntry.uuid, rightEntry.circuit, gridRow, "4", rightEntry.layout, hass, config, monInfo, sheddingPriority); + } else if (!occupiedTabs.has(rightTab) && inRange(rightTab)) { + gridHTML += renderEmptySlot(gridRow, "4"); } } - gridHTML += `
${rightTab}
`; + gridHTML += rightLabel; } return gridHTML; } diff --git a/src/helpers/layout.ts b/src/helpers/layout.ts index 0004bf8..d745d54 100644 --- a/src/helpers/layout.ts +++ b/src/helpers/layout.ts @@ -1,4 +1,51 @@ -import type { DualTabLayout } from "../types.js"; +import type { DualTabLayout, PanelTopology } from "../types.js"; + +/** + * Whether a size or position the topology reports can be laid out. A panel that + * does not know its size reports 0, and anything that is not a whole number above + * zero is no size at all. + */ +export function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +/** The first and last breaker positions the grid draws, inclusive. */ +export interface PositionRange { + readonly first: number; + readonly last: number; +} + +/** + * The breaker positions the grid draws: the range the panel reports, else 1 to + * `panelSize`, widened to every tab a circuit occupies so that no circuit is + * dropped. Null when nothing places a breaker. + */ +export function positionRange(topology: PanelTopology | null, panelSize: number): PositionRange | null { + if (!topology) return null; + let first: number | null = null; + let last: number | null = null; + const { first_position: reportedFirst, last_position: reportedLast } = topology; + if (isPositiveInteger(reportedFirst) && isPositiveInteger(reportedLast) && reportedFirst <= reportedLast) { + first = reportedFirst; + last = reportedLast; + } else if (isPositiveInteger(panelSize)) { + first = 1; + last = panelSize; + } + for (const circuit of Object.values(topology.circuits)) { + for (const tab of circuit?.tabs ?? []) { + if (!isPositiveInteger(tab)) continue; + first = first === null ? tab : Math.min(first, tab); + last = last === null ? tab : Math.max(last, tab); + } + } + return first === null || last === null ? null : { first, last }; +} + +/** The grid rows a range spans: two positions to a row, odd on the left. */ +export function positionRowCount(positions: PositionRange): number { + return tabToRow(positions.last) - tabToRow(positions.first) + 1; +} export function tabToRow(tab: number): number { return Math.ceil(tab / 2); diff --git a/src/panel/tab-dashboard.ts b/src/panel/tab-dashboard.ts index 3205d75..1ff4e2a 100644 --- a/src/panel/tab-dashboard.ts +++ b/src/panel/tab-dashboard.ts @@ -1,6 +1,8 @@ import { discoverTopology } from "../card/card-discovery.js"; import { escapeHtml } from "../helpers/sanitize.js"; import { errorText } from "../helpers/error-text.js"; +import { positionRange, positionRowCount, type PositionRange } from "../helpers/layout.js"; +import { t } from "../i18n.js"; import { buildHeaderHTML } from "../core/header-renderer.js"; import { buildGridHTML } from "../core/grid-renderer.js"; import { buildSubDevicesHTML } from "../core/sub-device-renderer.js"; @@ -67,11 +69,12 @@ export class DashboardTab { this._container = container; this._ctrl.hass = hass; - let topology, panelSize; + let topology, positions: PositionRange | null; try { const result = await discoverTopology(hass, deviceId); topology = result.topology; - panelSize = result.panelSize; + positions = positionRange(topology, result.panelSize); + if (!positions) throw new Error(t("card.topology_error")); } catch (err) { container.innerHTML = `

${escapeHtml(errorText(err))}

`; return null; @@ -81,12 +84,12 @@ export class DashboardTab { await this._ctrl.monitoringCache.fetch(hass, configEntryId ?? null); await this._ctrl.fetchAndBuildHorizonMaps(); - const totalRows = Math.ceil(panelSize / 2); + const totalRows = positionRowCount(positions); const monitoringStatus = this._ctrl.monitoringCache.status; const headerHTML = buildHeaderHTML(topology!, config); const monitoringSummaryHTML = buildMonitoringSummaryHTML(monitoringStatus); - const gridHTML = buildGridHTML(topology!, totalRows, hass, config, monitoringStatus); + const gridHTML = buildGridHTML(topology!, positions, hass, config, monitoringStatus); const subDevHTML = buildSubDevicesHTML(topology!, hass, config, { showFavorites: this._ctrl.showFavorites }); container.innerHTML = ` diff --git a/src/types.ts b/src/types.ts index 670d899..443029e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,12 @@ export interface PanelTopology { serial?: string; firmware?: string; panel_size?: number; + /** + * The panel's first and last breaker positions, which need not start at 1. Null + * where the panel reports none; absent from an integration that predates them. + */ + first_position?: number | null; + last_position?: number | null; /** The device id the request carried, echoed as sent. */ device_id?: string; /** diff --git a/tests/card-discovery-fallback.test.ts b/tests/card-discovery-fallback.test.ts index 0bebc3d..b7f0d6a 100644 --- a/tests/card-discovery-fallback.test.ts +++ b/tests/card-discovery-fallback.test.ts @@ -38,8 +38,9 @@ function sensorsFor(serial: string): Sensor[] { /** * A registry holding the panel and its circuits' sensors, whose unique ids carry * `uidSerial`. The panel device names `deviceSerial`, or no serial when it is null. + * The site power sensor carries `siteAttributes`. */ -function hassWithRegistry(uidSerial: string = SERIAL, deviceSerial: string | null = uidSerial): HomeAssistant { +function hassWithRegistry(uidSerial: string = SERIAL, deviceSerial: string | null = uidSerial, siteAttributes: Record = {}): HomeAssistant { const sensors = sensorsFor(uidSerial); const sitePower = { entity_id: "sensor.span_panel_site_power", unique_id: `span_${uidSerial.toLowerCase()}_site_power` }; const callWS = vi.fn(async (msg: { type: string }) => { @@ -59,7 +60,7 @@ function hassWithRegistry(uidSerial: string = SERIAL, deviceSerial: string | nul s.entity_id, { entity_id: s.entity_id, state: "1", attributes: { tabs: s.tabs, friendly_name: s.friendly_name }, last_changed: "", last_updated: "" }, ]), - [sitePower.entity_id, { entity_id: sitePower.entity_id, state: "1", attributes: {}, last_changed: "", last_updated: "" }], + [sitePower.entity_id, { entity_id: sitePower.entity_id, state: "1", attributes: siteAttributes, last_changed: "", last_updated: "" }], ]); return { callWS, states, services: {}, language: "en" } as unknown as HomeAssistant; } @@ -98,3 +99,15 @@ describe("discoverEntitiesFallback", () => { expect(Object.keys(topology!.circuits).sort()).toEqual(CIRCUIT_IDS); }); }); + +describe("the panel size the fallback reads", () => { + it("is the panel_size attribute when it is a positive integer", async () => { + const { panelSize } = await discoverEntitiesFallback(hassWithRegistry(SERIAL, SERIAL, { panel_size: 32 }), PANEL_ID); + expect(panelSize).toBe(32); + }); + + it.each([0, -2, 12.5, "32"])("falls back to the circuits when the attribute is %s", async size => { + const { panelSize } = await discoverEntitiesFallback(hassWithRegistry(SERIAL, SERIAL, { panel_size: size }), PANEL_ID); + expect(panelSize).toBe(14); + }); +}); diff --git a/tests/grid-renderer.test.ts b/tests/grid-renderer.test.ts index 9626656..b77d7ae 100644 --- a/tests/grid-renderer.test.ts +++ b/tests/grid-renderer.test.ts @@ -66,7 +66,7 @@ function hassWith(states: Record[0]): HTMLElement { const topology = { circuits: { kitchen: circuit } } as unknown as PanelTopology; const div = document.createElement("div"); - div.innerHTML = buildGridHTML(topology, 1, hassWith(states), config, null); + div.innerHTML = buildGridHTML(topology, { first: 1, last: 2 }, hassWith(states), config, null); return div; } diff --git a/tests/positions.test.ts b/tests/positions.test.ts new file mode 100644 index 0000000..0ac3d59 --- /dev/null +++ b/tests/positions.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import type { CardConfig, Circuit, HomeAssistant, PanelTopology } from "../src/types.js"; +import { discoverTopology } from "../src/card/card-discovery.js"; +import { buildGridHTML } from "../src/core/grid-renderer.js"; +import { positionRange, positionRowCount, type PositionRange } from "../src/helpers/layout.js"; +import { t } from "../src/i18n.js"; +import "../src/card/span-panel-card.js"; +import { FakeConnection, hassWith } from "./fake-connection.js"; + +function circuit(name: string, tabs: number[]): Circuit { + return { name, tabs, entities: {} }; +} + +/** Circuits on tabs 1…14, so the size they imply is 14. */ +const CIRCUITS: Record = { + "c-1": circuit("Kitchen", [1]), + "c-2": circuit("Dryer", [11, 13]), + "c-3": circuit("Office", [14]), +}; + +function hassAnswering(topology: Partial & Record): HomeAssistant { + const callWS = vi.fn(async (msg: { type: string }) => { + switch (msg.type) { + case "span_panel/panel_topology": + return { circuits: {}, ...topology }; + case "config/device_registry/list": + case "config/area_registry/list": + case "config/entity_registry/list": + return []; + default: + throw new Error(`unexpected websocket call ${msg.type}`); + } + }); + return { callWS } as unknown as HomeAssistant; +} + +describe("the panel size topology reports", () => { + it("is taken when it is a positive integer", async () => { + const { panelSize } = await discoverTopology(hassAnswering({ circuits: CIRCUITS, panel_size: 32 }), "panel-1"); + expect(panelSize).toBe(32); + }); + + it("falls back to the circuits when absent", async () => { + const { panelSize } = await discoverTopology(hassAnswering({ circuits: CIRCUITS }), "panel-1"); + expect(panelSize).toBe(14); + }); + + it.each([0, -2, 12.5, Number.NaN, "32", null])("falls back to the circuits when it is %s", async size => { + const { panelSize } = await discoverTopology(hassAnswering({ circuits: CIRCUITS, panel_size: size }), "panel-1"); + expect(panelSize).toBe(14); + }); + + it("fails to load when it is 0 and nothing else places a breaker", async () => { + await expect(discoverTopology(hassAnswering({ panel_size: 0 }), "panel-1")).rejects.toThrow(t("card.topology_error")); + }); +}); + +const HASS = { states: {}, services: {}, language: "en" } as unknown as HomeAssistant; + +function topologyWith(fields: Partial): PanelTopology { + return { circuits: {}, ...fields }; +} + +function grid(topology: PanelTopology, positions: PositionRange): HTMLElement { + const div = document.createElement("div"); + div.innerHTML = buildGridHTML(topology, positions, HASS, {}, null); + return div; +} + +function labels(el: ParentNode): number[] { + return [...el.querySelectorAll(".tab-label")].map(label => Number(label.textContent)).sort((a, b) => a - b); +} + +function range(first: number, last: number): number[] { + return Array.from({ length: last - first + 1 }, (_, i) => first + i); +} + +describe("positionRange", () => { + it("is the range the panel reports, over its size", () => { + const topology = topologyWith({ circuits: { "c-9": circuit("Kitchen", [9, 11]) }, panel_size: 16, first_position: 9, last_position: 24 }); + expect(positionRange(topology, 16)).toEqual({ first: 9, last: 24 }); + }); + + it("is 1 to the panel size when no range is reported", () => { + expect(positionRange(topologyWith({ circuits: CIRCUITS, panel_size: 32 }), 32)).toEqual({ first: 1, last: 32 }); + }); + + it("is the reported range when the panel size is 0", () => { + expect(positionRange(topologyWith({ panel_size: 0, first_position: 1, last_position: 14 }), 0)).toEqual({ first: 1, last: 14 }); + }); + + it.each([ + [0, 24], + [9, 0], + [24, 9], + [9.5, 24], + [null, 24], + ])("ignores a reported range of %s to %s", (first, last) => { + const topology = topologyWith({ circuits: CIRCUITS, first_position: first, last_position: last }); + expect(positionRange(topology, 14)).toEqual({ first: 1, last: 14 }); + }); + + it("widens to every occupied tab, so no circuit is dropped", () => { + const topology = topologyWith({ circuits: { ...CIRCUITS, "c-9": circuit("Shop", [50]) }, first_position: 9, last_position: 48 }); + expect(positionRange(topology, 40)).toEqual({ first: 1, last: 50 }); + }); + + it("is null when nothing places a breaker", () => { + expect(positionRange(topologyWith({ panel_size: 0 }), 0)).toBeNull(); + expect(positionRange(null, 32)).toBeNull(); + }); + + it("counts the rows the range spans", () => { + expect(positionRowCount({ first: 1, last: 32 })).toBe(16); + expect(positionRowCount({ first: 9, last: 24 })).toBe(8); + expect(positionRowCount({ first: 10, last: 25 })).toBe(9); + }); +}); + +describe("the grid", () => { + const NINE_TO_24 = topologyWith({ + circuits: { "c-9": circuit("Kitchen", [9]), "c-12": circuit("Dryer", [12, 14]), "c-24": circuit("Office", [24]) }, + panel_size: 16, + first_position: 9, + last_position: 24, + }); + + it("draws rows labelled with the reported positions, and no slot below the first", () => { + const el = grid(NINE_TO_24, positionRange(NINE_TO_24, 16)!); + expect(labels(el)).toEqual(range(9, 24)); + // 16 positions, 4 of them occupied by three circuits. + expect(el.querySelectorAll(".circuit-empty")).toHaveLength(12); + expect(el.querySelector('.tab-label[style*="grid-row: 1;"]')!.textContent).toBe("9"); + }); + + it("draws a breaker above the panel size when the range reaches it", () => { + const topology = topologyWith({ circuits: { "c-43": circuit("Range", [43, 45]) }, panel_size: 40, first_position: 9, last_position: 48 }); + const slot = grid(topology, positionRange(topology, 40)!).querySelector('[data-uuid="c-43"]'); + expect(slot).not.toBeNull(); + // Position 43 is the 18th row from position 9; the slot spans two rows. + expect(slot!.getAttribute("style")).toContain("grid-row: 18 / span 2"); + }); + + it("labels only the positions in range on a row the range starts or ends inside", () => { + const topology = topologyWith({ first_position: 10, last_position: 25 }); + expect(labels(grid(topology, positionRange(topology, 0)!))).toEqual(range(10, 25)); + }); + + it("draws a 1 to 32 range exactly as a 32-space panel that reports none", () => { + const circuits = { "c-1": circuit("Kitchen", [1]), "c-2": circuit("Dryer", [2, 4]), "c-3": circuit("Range", [29, 31]), "c-4": circuit("Office", [32]) }; + const reported = topologyWith({ circuits, panel_size: 32, first_position: 1, last_position: 32 }); + const unreported = topologyWith({ circuits, panel_size: 32 }); + const html = grid(reported, positionRange(reported, 32)!).innerHTML; + expect(html).toBe(grid(unreported, positionRange(unreported, 32)!).innerHTML); + expect(labels(grid(reported, positionRange(reported, 32)!))).toEqual(range(1, 32)); + }); +}); + +describe("a panel that reports size 0 with positions", () => { + it("loads, rather than failing on its size", async () => { + const result = await discoverTopology(hassAnswering({ panel_size: 0, first_position: 1, last_position: 14 }), "panel-1"); + expect(result.panelSize).toBe(0); + expect(positionRange(result.topology, result.panelSize)).toEqual({ first: 1, last: 14 }); + }); + + describe("on the card", () => { + const mounted: HTMLElement[] = []; + afterEach(() => { + for (const el of mounted.splice(0)) el.remove(); + }); + + it("draws the grid over the reported positions", async () => { + const topology = { circuits: { "c-9": circuit("Kitchen", [9]) }, panel_size: 0, first_position: 9, last_position: 24 }; + const hass = hassWith(new FakeConnection(), { + callWS: (async (msg: Record) => (msg.type === "span_panel/panel_topology" ? topology : [])) as HomeAssistant["callWS"], + }); + const card = document.createElement("span-panel-card") as HTMLElement & { setConfig(c: CardConfig): void; hass: HomeAssistant }; + card.setConfig({ device_id: "panel-1" }); + document.body.appendChild(card); + mounted.push(card); + card.hass = hass; + + await vi.waitFor(() => expect(card.shadowRoot!.querySelector(".panel-grid")).not.toBeNull()); + const panelGrid = card.shadowRoot!.querySelector(".panel-grid")!; + expect(labels(panelGrid)).toEqual(range(9, 24)); + expect(panelGrid.getAttribute("style")).toContain("repeat(8, auto)"); + }); + }); +});