diff --git a/AGENTS.md b/AGENTS.md index 310295d..6e44b63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,18 +14,19 @@ reads only: the integration's WebSocket command `managed_switch/ports` and entit ```sh npm ci --ignore-scripts npm run typecheck # tsc, strict -npm test # vitest +npm test # vitest with coverage (thresholds in vitest.config.ts) npm run build # dist/managed-switch-card.js ``` ## Rules that matter here -- **Logic in `src/model.ts`** as pure functions with tests in `test/`; `src/managed-switch-card.ts` - only renders. Entities are found through the integration's command, never by guessing entity +- **Logic in `src/model.ts`** (and loading in `src/loader.ts`) as pure functions with tests in + `test/`; `src/managed-switch-card.ts` only renders, tested in a browser stand-in (happy-dom, + `test/card.test.ts`). Entities are found through the integration's command, never by guessing entity IDs (they follow the user's language). - **Model layouts** go into `MODEL_LAYOUTS`, keyed by `sysObjectID`, only for models someone has seen (CONTRIBUTING.md); everything else uses the automatic layout. -- **Texts** in English and German (`WORDS` in `src/model.ts`, labels in the editor). +- **Texts** in English and German (`WORDS` in `src/model.ts`, the editor's labels included). - The card never calls a service or changes anything. - **Screenshots** come from the integration's demo instance (`scripts/demo/screenshots.py --card` in ha-managed-switch), never from a real network. diff --git a/CHANGELOG.md b/CHANGELOG.md index e1be782..bd72004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.2.0] - 2026-10-30 + +### Added + +- The speed in each port with link (`1G`, `2.5G`, `100`, `10`), so its state does not rest on + colour alone; `show_speed: false` turns it off. The trunk mark sits next to it. +- Tests for the card and its settings dialog in a browser stand-in, with a minimum coverage. + ## [0.1.2] - 2026-10-30 ### Fixed @@ -39,7 +47,8 @@ First release. tries again after 15 seconds and reads the ports again once the switch is back; it says when the integration is too old. -[Unreleased]: https://github.com/eizedev/managed-switch-card/compare/v0.1.2...HEAD +[Unreleased]: https://github.com/eizedev/managed-switch-card/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/eizedev/managed-switch-card/compare/v0.1.2...v0.2.0 [0.1.2]: https://github.com/eizedev/managed-switch-card/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/eizedev/managed-switch-card/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/eizedev/managed-switch-card/releases/tag/v0.1.0 diff --git a/README.md b/README.md index a252e1c..de9c8e8 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ type: custom:managed-switch-card device_id: 0123456789abcdef0123456789abcdef # the switch, chosen in the card's dialog title: Hallway # optional, else the switch's name show_names: true # device names under the ports (default) +show_speed: true # speed in the ports: 1G, 100, 10 (default) show_rates: false # data rates; the integration's rate sensors must be on layout: # optional: your own front panel, by port name rows: [["1", "3", "5", "7"], ["2", "4", "6", "8"]] diff --git a/assets/card.png b/assets/card.png index 4ec0953..8c09fd8 100644 Binary files a/assets/card.png and b/assets/card.png differ diff --git a/package-lock.json b/package-lock.json index 74df195..4c146ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,83 @@ { "name": "managed-switch-card", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "managed-switch-card", - "version": "0.1.2", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@types/node": "^26.6.5", + "@vitest/coverage-v8": "^5.0.5", "esbuild": "^0.28.2", + "happy-dom": "^21.0.4", "lit": "^3.3.3", "typescript": "^7.0.2", "vitest": "^5.0.5" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -852,6 +914,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -1192,6 +1271,58 @@ "node": ">=16.20.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.5.tgz", + "integrity": "sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/istanbul-lib-coverage": "^1.0.2", + "@vitest/istanbul-lib-report": "^1.0.2", + "ast-v8-to-istanbul": "^1.0.7", + "magicast": "^0.5.5", + "obug": "^3.0.0", + "std-env": "^4.3.0", + "tinyrainbow": "^3.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "5.0.5", + "vitest": "5.0.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/istanbul-lib-coverage": "1.0.2" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@vitest/mocker": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", @@ -1240,6 +1371,31 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/chai": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", @@ -1261,6 +1417,19 @@ "node": ">=8" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -1364,6 +1533,32 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/happy-dom": { + "version": "21.0.4", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.4.tgz", + "integrity": "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1693,6 +1888,18 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, "node_modules/nanoid": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -1819,7 +2026,6 @@ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -1868,6 +2074,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -2072,6 +2288,16 @@ } } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/why-is-node-running": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", @@ -2084,6 +2310,28 @@ "engines": { "node": ">=20.11" } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index ba135a9..f031cbb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "managed-switch-card", - "version": "0.1.2", + "version": "0.2.0", "description": "Lovelace card that draws a managed switch's front panel from the Managed Switch (SNMP) Home Assistant integration", "license": "MIT", "private": true, @@ -8,12 +8,14 @@ "scripts": { "build": "node build.mjs", "typecheck": "tsc", - "test": "vitest run", + "test": "vitest run --coverage", "check": "npm run typecheck && npm test && npm run build" }, "devDependencies": { "@types/node": "^26.6.5", + "@vitest/coverage-v8": "^5.0.5", "esbuild": "^0.28.2", + "happy-dom": "^21.0.4", "lit": "^3.3.3", "typescript": "^7.0.2", "vitest": "^5.0.5" diff --git a/src/managed-switch-card.ts b/src/managed-switch-card.ts index e9cc764..49dfb6d 100644 --- a/src/managed-switch-card.ts +++ b/src/managed-switch-card.ts @@ -13,6 +13,7 @@ import { portTitle, powered, rateLabel, + speedLabel, tone, words, type Layout, @@ -37,6 +38,7 @@ interface CardConfig { device_id?: string; title?: string; show_names?: boolean; + show_speed?: boolean; show_rates?: boolean; layout?: Layout; } @@ -61,11 +63,16 @@ export class ManagedSwitchCard extends LitElement { const entity = Object.values(hass.entities ?? {}).find( (e) => e?.platform === "managed_switch" && e.device_id, ); - return { device_id: entity?.device_id ?? undefined, show_names: true, show_rates: false }; + return { + device_id: entity?.device_id ?? undefined, + show_names: true, + show_speed: true, + show_rates: false, + }; } setConfig(config: CardConfig): void { - this._config = { show_names: true, show_rates: false, ...config }; + this._config = { show_names: true, show_speed: true, show_rates: false, ...config }; this._loader.reset(); this._available = ""; } @@ -125,7 +132,12 @@ export class ManagedSwitchCard extends LitElement { ${port.poe && powered(states[e.poe ?? ""]) ? html`` : nothing} - ${port.trunk !== null ? html`T` : nothing} + + ${port.trunk !== null ? html`T` : nothing} + ${this._config?.show_speed && ["fast", "slow", "slowest"].includes(kind) + ? html`${speedLabel(states[e.speed ?? ""])}` + : nothing} + ${port.name} ${this._config?.show_names @@ -329,6 +341,21 @@ export class ManagedSwitchCard extends LitElement { background: var(--disabled-color, #bdbdbd); opacity: 0.5; } + /* trunk mark and speed in the lower half, under the link and PoE symbols */ + .bottom { + position: absolute; + left: 0; + right: 0; + bottom: 1px; + display: flex; + justify-content: center; + gap: 3px; + font-size: 0.75em; + font-weight: 600; + line-height: 1.2; + color: var(--text-primary-color, #fff); + pointer-events: none; + } .badge { position: absolute; --mdc-icon-size: 14px; @@ -342,13 +369,6 @@ export class ManagedSwitchCard extends LitElement { right: 2px; top: 2px; } - .badge.trunk { - left: 50%; - bottom: 1px; - transform: translateX(-50%); - font-size: 0.8em; - font-weight: bold; - } .name { font-weight: 500; /* as wide as the port, however long the text: it does not widen the port */ @@ -407,6 +427,7 @@ export class ManagedSwitchCardEditor extends LitElement { name: "", schema: [ { name: "show_names", selector: { boolean: {} } }, + { name: "show_speed", selector: { boolean: {} } }, { name: "show_rates", selector: { boolean: {} } }, ], }, diff --git a/src/model.ts b/src/model.ts index b6a01a1..092b889 100644 --- a/src/model.ts +++ b/src/model.ts @@ -99,6 +99,16 @@ export function tone(link: StateLike | undefined, speed: StateLike | undefined): return mbits >= 100 ? "slow" : "slowest"; } +/** + * The speed written in a port with link, so its state does not rest on colour alone: "10G", + * "2.5G", "1G", "100", "10"; "" without a known speed. + */ +export function speedLabel(speed: StateLike | undefined): string { + const mbits = speed?.state ? Number(speed.state) : NaN; + if (!Number.isFinite(mbits) || mbits <= 0) return ""; + return mbits >= 1000 ? `${mbits / 1000}G` : `${mbits}`; +} + /** A number for display, or undefined for a state that is none ("unknown", "unavailable"). */ export function numeric(state: StateLike | undefined): number | undefined { const value = state?.state ? Number(state.state) : NaN; @@ -182,6 +192,7 @@ export const WORDS: Record = { device_id: "Switch", title: "Title (optional)", show_names: "Device names under the ports", + show_speed: "Speed in the ports", show_rates: "Data rates (sensors must be on)", }, }, @@ -200,6 +211,7 @@ export const WORDS: Record = { device_id: "Switch", title: "Titel (optional)", show_names: "Gerätenamen unter den Ports", + show_speed: "Geschwindigkeit in den Ports", show_rates: "Datenraten (Sensoren müssen an sein)", }, }, diff --git a/test/card.test.ts b/test/card.test.ts new file mode 100644 index 0000000..f8a3e01 --- /dev/null +++ b/test/card.test.ts @@ -0,0 +1,251 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import "../src/managed-switch-card"; +import type { ManagedSwitchCard, ManagedSwitchCardEditor } from "../src/managed-switch-card"; +import type { PortsReply, StateLike } from "../src/model"; + +const state = (s: string, attributes: Record = {}): StateLike => ({ state: s, attributes }); + +const REPLY: PortsReply = { + switch: { + entry_id: "e", + name: "switch-1", + vendor: "HPE", + model: "1920S", + firmware: "PD.02.22", + object_id: "1.3.6.1.4.1.11.2.3.7.11.186", + entities: { poe_consumption: "sensor.poe", poe_budget: "sensor.budget" }, + }, + ports: [ + { + if_index: 1, + name: "1", + alias: null, + kind: "ethernet", + trunk: null, + members: [], + uplink: true, + poe: true, + entities: { link: "l1", speed: "s1", device: "d1", poe: "p1", received: "r1", sent: "t1" }, + }, + { + if_index: 2, + name: "2", + alias: null, + kind: "ethernet", + trunk: 100, + members: [], + uplink: false, + poe: false, + entities: { link: "l2", speed: "s2", device: "d2" }, + }, + { + if_index: 100, + name: "TRK 1", + alias: null, + kind: "trunk", + trunk: null, + members: [2], + uplink: false, + poe: false, + entities: { link: "lt", speed: "st", device: "dt" }, + }, + ], +}; + +const STATES = { + l1: state("on"), + s1: state("1000"), + d1: state("Router +1", { devices: [{ name: "Router" }, { mac: "02:00:00:00:00:01" }] }), + p1: state("delivering_power"), + r1: state("12.5", { unit_of_measurement: "Mbit/s" }), + t1: state("3", { unit_of_measurement: "Mbit/s" }), + l2: state("off"), + lt: state("on"), + st: state("100"), + "sensor.poe": state("4.2"), + "sensor.budget": state("65"), +}; + +function hass(callWS = vi.fn(async () => REPLY), states: Record = STATES) { + return { + states, + entities: { "event.switch_1": { platform: "managed_switch", device_id: "dev1" } }, + language: "de", + formatEntityState: (s: StateLike) => `«${s.state}»`, + callWS, + }; +} + +async function card(config: Record, h: object = hass()): Promise { + const el = document.createElement("managed-switch-card") as ManagedSwitchCard; + el.setConfig({ type: "custom:managed-switch-card", ...config }); + el.hass = h as never; + document.body.append(el); + await el.updateComplete; + await new Promise((r) => setTimeout(r)); + await el.updateComplete; + return el; +} + +const root = (el: Element): ShadowRoot => el.shadowRoot as ShadowRoot; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("the card", () => { + it("asks for a switch without one", async () => { + const el = await card({}); + expect(root(el).textContent).toContain("Wähle in den Einstellungen"); + }); + + it("draws the ports, trunks and PoE total", async () => { + const el = await card({ device_id: "dev1" }); + const ports = [...root(el).querySelectorAll("button.port")]; + expect(ports.map((p) => p.querySelector(".name")?.textContent)).toEqual(["1", "2", "TRK 1"]); + const [one, two, trunk] = ports; + expect(one.classList.contains("fast")).toBe(true); + expect(one.querySelector(".speed")?.textContent).toBe("1G"); + expect(one.querySelector(".badge.link")).not.toBeNull(); + expect(one.querySelector(".badge.poe")).not.toBeNull(); + expect(one.querySelector(".device")?.textContent).toBe("Router +1"); + expect(one.getAttribute("aria-label")).toContain("PoE: «delivering_power»"); + expect(one.getAttribute("aria-label")).not.toContain("\n"); + expect(two.classList.contains("down")).toBe(true); + expect(two.querySelector(".speed")).toBeNull(); + expect(two.querySelector(".bottom .trunk")).not.toBeNull(); + expect(trunk.querySelector(".speed")?.textContent).toBe("100"); + expect(root(el).querySelector(".poe-total")?.textContent).toContain("4.2 / 65 W"); + expect(root(el).querySelector(".title")?.textContent).toBe("switch-1"); + expect(root(el).querySelector(".model")?.textContent).toBe("1920S · PD.02.22"); + expect(el.getCardSize()).toBe(5); + expect(el.getGridOptions()).toMatchObject({ rows: "auto" }); + }); + + it("follows its options", async () => { + const el = await card({ + device_id: "dev1", + title: "Hallway", + show_names: false, + show_speed: false, + show_rates: true, + }); + const one = root(el).querySelector("button.port") as HTMLElement; + expect(root(el).querySelector(".title")?.textContent).toBe("Hallway"); + expect(one.querySelector(".device")).toBeNull(); + expect(one.querySelector(".speed")).toBeNull(); + expect(one.querySelector(".rate")?.textContent).toBe("↓13 ↑3.0"); + }); + + it("opens the device sensor on a click and the PoE sensor from the total", async () => { + const el = await card({ device_id: "dev1" }); + const opened: string[] = []; + el.addEventListener("hass-more-info", (e) => opened.push((e as CustomEvent).detail.entityId)); + (root(el).querySelector("button.port") as HTMLElement).click(); + (root(el).querySelector(".poe-total") as HTMLElement).click(); + expect(opened).toEqual(["d1", "sensor.poe"]); + }); + + it("explains an integration that is too old", async () => { + const callWS = vi.fn(async () => { + throw { code: "unknown_command", message: "Unknown command." }; + }); + const el = await card({ device_id: "dev1" }, hass(callWS)); + expect(root(el).textContent).toContain("0.2.0 oder neuer"); + }); + + it("explains a device that is not a set-up switch", async () => { + const callWS = vi.fn(async () => REPLY); + const el = await card({ device_id: "dev1" }, hass(callWS)); + callWS.mockRejectedValueOnce({ code: "not_found" }); + el.setConfig({ type: "custom:managed-switch-card", device_id: "dev2" }); + el.hass = hass(callWS) as never; + await new Promise((r) => setTimeout(r)); + await el.updateComplete; + expect(root(el).textContent).toContain("nicht eingerichtet"); + expect(root(el).querySelector("button.port")).toBeNull(); + }); + + it("puts SFP ports apart on a 1930 and reads again when the switch comes back", async () => { + const port = (name: string) => ({ + ...REPLY.ports[1], + if_index: Number(name), + name, + trunk: null, + entities: { link: `l${name}`, speed: `s${name}`, device: `d${name}` }, + }); + const reply: PortsReply = { + switch: { ...REPLY.switch, object_id: "1.3.6.1.4.1.11.2.3.7.11.194", model: null, entities: {} }, + ports: ["1", "9", "10"].map(port), + }; + const states: Record = { l1: state("on"), s1: state("2500"), l9: state("unavailable") }; + const callWS = vi.fn(async () => reply); + const h = { ...hass(callWS, states), language: undefined, formatEntityState: undefined }; + const el = await card({ device_id: "dev3" }, h); + expect([...root(el).querySelectorAll(".sfp-row .name")].map((n) => n.textContent)).toEqual(["9", "10"]); + expect(root(el).querySelector(".speed")?.textContent).toBe("2.5G"); + expect(root(el).querySelector(".poe-total")).toBeNull(); + expect(root(el).querySelector(".trunks")).toBeNull(); + expect(root(el).querySelector(".model")?.textContent).toBe("PD.02.22"); + expect(el.getCardSize()).toBe(3); + // the switch's entities come back (set up again): read again; a failure keeps the picture + callWS.mockRejectedValueOnce({ code: "x", message: "boom" }); + el.hass = { ...h, states: { ...states, l9: state("off") } } as never; + await new Promise((r) => setTimeout(r)); + await el.updateComplete; + expect(callWS).toHaveBeenCalledTimes(2); + expect(root(el).querySelectorAll("button.port")).toHaveLength(3); + expect(root(el).textContent).toContain("boom"); + }); + + it("shows a port without link, PoE state or device names plainly", async () => { + const reply: PortsReply = { ...REPLY, ports: [{ ...REPLY.ports[0], uplink: false, entities: {} }] }; + const el = await card({ device_id: "dev1", show_rates: true }, hass(vi.fn(async () => reply), {})); + const one = root(el).querySelector("button.port") as HTMLElement; + expect(one.classList.contains("unknown")).toBe(true); + expect(one.querySelector(".rate")).toBeNull(); + expect(one.getAttribute("aria-label")).toBe("Port 1, Unbekannt, PoE: ?"); + one.click(); // nothing to open + }); + + it("offers the first managed switch in the card picker", () => { + const Card = customElements.get("managed-switch-card") as unknown as { + getStubConfig(h: unknown): Record; + getConfigElement(): HTMLElement; + }; + expect(Card.getStubConfig(hass())).toMatchObject({ device_id: "dev1", show_speed: true }); + expect(Card.getStubConfig({ ...hass(), entities: {} }).device_id).toBeUndefined(); + expect(Card.getConfigElement().tagName.toLowerCase()).toBe("managed-switch-card-editor"); + const listed = (window as unknown as { customCards: { type: string }[] }).customCards; + expect(listed.filter((c) => c.type === "managed-switch-card")).toHaveLength(1); + }); +}); + +describe("the editor", () => { + it("passes the changed configuration on, with German labels", async () => { + const el = document.createElement("managed-switch-card-editor") as ManagedSwitchCardEditor; + el.setConfig({ type: "custom:managed-switch-card", device_id: "dev1", layout: { rows: [["1"]], sfp: [] } }); + el.hass = hass() as never; + document.body.append(el); + await el.updateComplete; + const form = root(el).querySelector("ha-form") as HTMLElement & { + computeLabel: (f: { name: string }) => string; + schema: unknown[]; + }; + expect(form.computeLabel({ name: "show_speed" })).toBe("Geschwindigkeit in den Ports"); + expect(form.computeLabel({ name: "unknown" })).toBe("unknown"); + const changed: unknown[] = []; + el.addEventListener("config-changed", (e) => changed.push((e as CustomEvent).detail.config)); + form.dispatchEvent(new CustomEvent("value-changed", { detail: { value: { device_id: "dev2" } } })); + expect(changed).toEqual([{ device_id: "dev2" }]); + }); + + it("draws nothing before it has Home Assistant", async () => { + const el = document.createElement("managed-switch-card-editor") as ManagedSwitchCardEditor; + document.body.append(el); + await el.updateComplete; + expect(root(el).querySelector("ha-form")).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..defe7cb --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // what build.mjs puts in for the card's version + define: { __VERSION__: JSON.stringify("test") }, + test: { + coverage: { + provider: "v8", + include: ["src/**"], + thresholds: { lines: 95, branches: 90, functions: 95, statements: 95 }, + }, + }, +});