From 5fb0eca2f2d32ab222ec41bbbb221c72416fa674 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:17:53 -0700 Subject: [PATCH 1/2] fix(discovery): take a panel size only when it is a positive integer A panel that does not know its size reports 0, which the card took as the size and then refused to load. A size of 0, below zero, fractional or not a number is now treated as absent, and the size comes from the circuits. Part of the spanos3/r202639/03 batch 2 work. --- src/card/card-discovery.ts | 5 +-- src/helpers/layout.ts | 9 +++++ tests/card-discovery-fallback.test.ts | 17 +++++++-- tests/positions.test.ts | 52 +++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 tests/positions.test.ts diff --git a/src/card/card-discovery.ts b/src/card/card-discovery.ts index 849504d..6294040 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 } from "../helpers/layout.js"; import { t } from "../i18n.js"; import type { HomeAssistant, PanelTopology, PanelDevice, DiscoveryResult, Circuit, CircuitEntities } from "../types.js"; @@ -34,7 +35,7 @@ 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); + const panelSize = isPositiveInteger(topology.panel_size) ? topology.panel_size : panelSizeFromCircuits(topology.circuits); if (!panelSize) { throw new Error(t("card.topology_error")); } @@ -196,7 +197,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/helpers/layout.ts b/src/helpers/layout.ts index 0004bf8..4f906af 100644 --- a/src/helpers/layout.ts +++ b/src/helpers/layout.ts @@ -1,5 +1,14 @@ import type { DualTabLayout } 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; +} + export function tabToRow(tab: number): number { return Math.ceil(tab / 2); } 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/positions.test.ts b/tests/positions.test.ts new file mode 100644 index 0000000..962dc60 --- /dev/null +++ b/tests/positions.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Circuit, HomeAssistant, PanelTopology } from "../src/types.js"; +import { discoverTopology } from "../src/card/card-discovery.js"; +import { t } from "../src/i18n.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")); + }); +}); From 30b4dd4fe13232dc2a08f0fff4a3d179e3533ce7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:19:44 -0700 Subject: [PATCH 2/2] feat(grid): draw the breaker positions the panel reports Topology may report the panel's first and last breaker positions. The grid draws rows over that range, labelled with the positions' own numbers, instead of 1 to the panel size, so a breaker above the size is drawn and no empty slot appears below the first position. A panel that reports size 0 with its positions loads. Without reported positions the range is 1 to the size, as before, and it always widens to every occupied tab. Part of the spanos3/r202639/03 batch 2 work. --- src/card/card-discovery.ts | 5 +- src/card/span-panel-card.ts | 16 ++-- src/core/grid-renderer.ts | 37 ++++++---- src/helpers/layout.ts | 40 +++++++++- src/panel/tab-dashboard.ts | 11 ++- src/types.ts | 6 ++ tests/grid-renderer.test.ts | 2 +- tests/positions.test.ts | 141 +++++++++++++++++++++++++++++++++++- 8 files changed, 227 insertions(+), 31 deletions(-) diff --git a/src/card/card-discovery.ts b/src/card/card-discovery.ts index 6294040..2ac4263 100644 --- a/src/card/card-discovery.ts +++ b/src/card/card-discovery.ts @@ -1,7 +1,7 @@ import { INTEGRATION_DOMAIN } from "../constants.js"; import { resolveAndAssignAreas } from "../core/area-resolver.js"; import { RetryManager } from "../core/retry-manager.js"; -import { isPositiveInteger } from "../helpers/layout.js"; +import { isPositiveInteger, positionRange } from "../helpers/layout.js"; import { t } from "../i18n.js"; import type { HomeAssistant, PanelTopology, PanelDevice, DiscoveryResult, Circuit, CircuitEntities } from "../types.js"; @@ -36,7 +36,8 @@ export async function discoverTopology(hass: HomeAssistant, deviceId: string | u const topology = retry ? await retry.callWS(hass, topologyMsg, { errorId: "fetch:topology" }) : await hass.callWS(topologyMsg); const panelSize = isPositiveInteger(topology.panel_size) ? topology.panel_size : panelSizeFromCircuits(topology.circuits); - if (!panelSize) { + // 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")); } 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 4f906af..d745d54 100644 --- a/src/helpers/layout.ts +++ b/src/helpers/layout.ts @@ -1,4 +1,4 @@ -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 @@ -9,6 +9,44 @@ 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/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 index 962dc60..0ac3d59 100644 --- a/tests/positions.test.ts +++ b/tests/positions.test.ts @@ -1,7 +1,11 @@ -import { describe, it, expect, vi } from "vitest"; -import type { Circuit, HomeAssistant, PanelTopology } from "../src/types.js"; +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: {} }; @@ -50,3 +54,136 @@ describe("the panel size topology reports", () => { 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)"); + }); + }); +});