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 },
+ },
+ },
+});