mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
fix+test v1.13.2: audit round 3
- 12-test buildDevices suite on a fake hass (curation, markers, groups, dedup, icon rules, lock override, device_class fallback, primary/LQI/temp) - t() substitutes all placeholder occurrences (pure subst() + regression test) - _saveConfigNow resyncs config on rev conflict before rethrowing - pointercancel clears the long-press timer (phantom info card on touch) - repairs.py: plan-file check re-runs after every config save - test build compiles devices.ts/types.ts; fix-test-build.mjs adds .js to ESM imports
This commit is contained in:
@@ -7,7 +7,6 @@ from pathlib import Path
|
|||||||
from homeassistant.components.frontend import add_extra_js_url
|
from homeassistant.components.frontend import add_extra_js_url
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers import issue_registry as ir
|
|
||||||
|
|
||||||
from . import websocket_api as hp_ws
|
from . import websocket_api as hp_ws
|
||||||
from .const import (
|
from .const import (
|
||||||
@@ -19,6 +18,7 @@ from .const import (
|
|||||||
PLANS_URL,
|
PLANS_URL,
|
||||||
VERSION,
|
VERSION,
|
||||||
)
|
)
|
||||||
|
from .repairs import async_check_plan_files
|
||||||
from .store import HouseplanConfigEntry, create_data
|
from .store import HouseplanConfigEntry, create_data
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -82,47 +82,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
|||||||
if not await _register_lovelace_resource(hass, module_url):
|
if not await _register_lovelace_resource(hass, module_url):
|
||||||
add_extra_js_url(hass, module_url)
|
add_extra_js_url(hass, module_url)
|
||||||
|
|
||||||
await _check_plan_files(hass, entry)
|
await async_check_plan_files(hass, entry)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntry) -> None:
|
|
||||||
"""Repairs: raise an issue for every space whose plan file is missing on disk."""
|
|
||||||
cfg_raw = await entry.runtime_data.config_store.async_load() or {}
|
|
||||||
spaces = cfg_raw.get("config", {}).get("spaces", [])
|
|
||||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
|
||||||
|
|
||||||
def _missing() -> list[tuple[str, str]]:
|
|
||||||
res = []
|
|
||||||
for sp in spaces:
|
|
||||||
url = sp.get("plan_url") or ""
|
|
||||||
if not url.startswith(PLANS_URL + "/"):
|
|
||||||
continue # external/legacy URL — not ours to verify
|
|
||||||
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
|
|
||||||
if not (plans_dir / fname).is_file():
|
|
||||||
res.append((sp.get("id", "?"), fname))
|
|
||||||
return res
|
|
||||||
|
|
||||||
missing = await hass.async_add_executor_job(_missing)
|
|
||||||
seen = set()
|
|
||||||
for space_id, fname in missing:
|
|
||||||
seen.add(space_id)
|
|
||||||
ir.async_create_issue(
|
|
||||||
hass,
|
|
||||||
DOMAIN,
|
|
||||||
f"broken_plan_{space_id}",
|
|
||||||
is_fixable=False,
|
|
||||||
severity=ir.IssueSeverity.WARNING,
|
|
||||||
translation_key="broken_plan",
|
|
||||||
translation_placeholders={"space": space_id, "file": fname},
|
|
||||||
)
|
|
||||||
# clear stale issues for spaces that are fine again (or gone)
|
|
||||||
for sp in spaces:
|
|
||||||
sid = sp.get("id", "?")
|
|
||||||
if sid not in seen:
|
|
||||||
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||||
"""Unload the entry.
|
"""Unload the entry.
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
|||||||
FILES_URL = "/houseplan_files/files"
|
FILES_URL = "/houseplan_files/files"
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.13.1"
|
VERSION = "1.13.2"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.13.1"
|
"version": "1.13.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Repair issues for House Plan.
|
||||||
|
|
||||||
|
The check runs at entry setup AND after every config save (ws_config_set),
|
||||||
|
so a plan file that goes missing — or gets re-uploaded — is reflected in the
|
||||||
|
Repairs UI without waiting for a restart.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
|
|
||||||
|
from .const import DOMAIN, PLANS_DIR, PLANS_URL
|
||||||
|
from .store import HouseplanConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntry) -> None:
|
||||||
|
"""Raise an issue for every space whose plan file is missing on disk."""
|
||||||
|
cfg_raw = await entry.runtime_data.config_store.async_load() or {}
|
||||||
|
spaces = cfg_raw.get("config", {}).get("spaces", [])
|
||||||
|
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||||
|
|
||||||
|
def _missing() -> list[tuple[str, str]]:
|
||||||
|
res = []
|
||||||
|
for sp in spaces:
|
||||||
|
url = sp.get("plan_url") or ""
|
||||||
|
if not url.startswith(PLANS_URL + "/"):
|
||||||
|
continue # external/legacy URL — not ours to verify
|
||||||
|
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
|
||||||
|
if not (plans_dir / fname).is_file():
|
||||||
|
res.append((sp.get("id", "?"), fname))
|
||||||
|
return res
|
||||||
|
|
||||||
|
missing = await hass.async_add_executor_job(_missing)
|
||||||
|
broken = set()
|
||||||
|
for space_id, fname in missing:
|
||||||
|
broken.add(space_id)
|
||||||
|
ir.async_create_issue(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
f"broken_plan_{space_id}",
|
||||||
|
is_fixable=False,
|
||||||
|
severity=ir.IssueSeverity.WARNING,
|
||||||
|
translation_key="broken_plan",
|
||||||
|
translation_placeholders={"space": space_id, "file": fname},
|
||||||
|
)
|
||||||
|
# clear stale issues for spaces that are fine again (or gone)
|
||||||
|
for sp in spaces:
|
||||||
|
sid = sp.get("id", "?")
|
||||||
|
if sid not in broken:
|
||||||
|
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
||||||
@@ -180,6 +180,12 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
new_rev = current_rev + 1
|
new_rev = current_rev + 1
|
||||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||||
|
# refresh repair issues (broken plan references) without waiting for a restart
|
||||||
|
entry = get_entry(hass)
|
||||||
|
if entry is not None:
|
||||||
|
from .repairs import async_check_plan_files
|
||||||
|
|
||||||
|
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
-4
File diff suppressed because one or more lines are too long
@@ -1,5 +1,25 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.13.2 — 2026-07-05 (audit round 3: fixes + buildDevices test suite)
|
||||||
|
- **buildDevices finally has a direct unit-test suite** (12 tests on a fake hass):
|
||||||
|
area filtering, curation incl. show-all, duplicate numbering, light-group folding
|
||||||
|
and its `group_lights=false` inverse, marker claim/metadata/hidden/virtual/entity
|
||||||
|
paths, custom icon rules + the deliberate lock override, device_class fallback,
|
||||||
|
primary-entity priority, LQI/temperature extraction. Frontend tests: 28 → 41.
|
||||||
|
- Fix: `t()` now substitutes **every** occurrence of a placeholder (extracted as the
|
||||||
|
pure `subst()` helper with a regression test).
|
||||||
|
- Fix: `_saveConfigNow` refreshes the local config on a rev conflict before
|
||||||
|
rethrowing — a retry no longer hits the same conflict (the debounced path already
|
||||||
|
did this; the immediate path did not).
|
||||||
|
- Fix: `pointercancel` on a device icon clears the long-press timer — no phantom
|
||||||
|
info card after an aborted touch gesture.
|
||||||
|
- Repairs check moved to `repairs.py` and now **re-runs after every config save**,
|
||||||
|
so a missing/restored plan file is reflected in the Repairs UI without a restart.
|
||||||
|
- Documented the deliberate `mdi:lock` override in `devices.ts` (wins over custom
|
||||||
|
rules — a mislabeled lock icon is safety-relevant confusion).
|
||||||
|
- Test infra: `tsconfig.test.json` also compiles `devices.ts`/`types.ts`;
|
||||||
|
`scripts/fix-test-build.mjs` appends `.js` to tsc's extensionless ESM imports.
|
||||||
|
|
||||||
## v1.13.1 — 2026-07-05 (distribution materials)
|
## v1.13.1 — 2026-07-05 (distribution materials)
|
||||||
- **Demo GIF** in the README — recorded on a fully synthetic demo home (no real
|
- **Demo GIF** in the README — recorded on a fully synthetic demo home (no real
|
||||||
floor plans): live states, tap-to-toggle, drag, zoom, info card, space tabs.
|
floor plans): live states, tap-to-toggle, drag, zoom, info card, space tabs.
|
||||||
|
|||||||
+5
-2
@@ -13,14 +13,14 @@
|
|||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.13.1** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
| Version | **v1.13.2** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
|
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
|
||||||
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
||||||
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/8995** (queue ≈2 months as of 2026-07) |
|
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/8995** (queue ≈2 months as of 2026-07) |
|
||||||
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
|
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
|
||||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
|
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed v1.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
|
||||||
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
|
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
|
||||||
| Tests | 28 frontend (node:test) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
| Tests | 41 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
|
||||||
|
|
||||||
## Recent milestones (details in CHANGELOG.md)
|
## Recent milestones (details in CHANGELOG.md)
|
||||||
|
|
||||||
@@ -40,6 +40,9 @@
|
|||||||
- **v1.13.1** — distribution: synthetic-home demo GIF in the README, issue templates,
|
- **v1.13.1** — distribution: synthetic-home demo GIF in the README, issue templates,
|
||||||
CONTRIBUTING.md, Discussions. Forum/Reddit drafts are in the user folder
|
CONTRIBUTING.md, Discussions. Forum/Reddit drafts are in the user folder
|
||||||
(`posts_drafts.md`) awaiting manual posting.
|
(`posts_drafts.md`) awaiting manual posting.
|
||||||
|
- **v1.13.2** — audit round 3: buildDevices unit-test suite, multi-placeholder t(),
|
||||||
|
conflict resync in _saveConfigNow, pointercancel long-press fix, repairs re-check
|
||||||
|
on config save (repairs.py).
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.13.1",
|
"version": "1.13.2",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "tsc --noEmit && rollup -c",
|
"build": "tsc --noEmit && rollup -c",
|
||||||
"watch": "rollup -c --watch",
|
"watch": "rollup -c --watch",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "tsc -p tsconfig.test.json && node --test test/*.test.mjs"
|
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-json": "^6.1.0",
|
"@rollup/plugin-json": "^6.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// tsc keeps extensionless relative imports; Node ESM requires explicit ".js".
|
||||||
|
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
for (const f of readdirSync('test-build')) {
|
||||||
|
if (!f.endsWith('.js')) continue;
|
||||||
|
const p = `test-build/${f}`;
|
||||||
|
const s = readFileSync(p, 'utf8').replace(/from\s+'(\.\/[\w-]+)'/g, "from '$1.js'");
|
||||||
|
writeFileSync(p, s);
|
||||||
|
}
|
||||||
+16
-6
@@ -23,7 +23,7 @@ import './editor';
|
|||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
import { langOf, t, type I18nKey } from './i18n';
|
||||||
|
|
||||||
const CARD_VERSION = '1.13.1';
|
const CARD_VERSION = '1.13.2';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
const LS_KEY = 'houseplan_card_layout_v1';
|
||||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
@@ -1554,12 +1554,21 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Immediate config save with a revision bump (no debounce). */
|
/** Immediate config save with a revision bump (no debounce).
|
||||||
|
|
||||||
|
On a rev conflict the local copy is refreshed before rethrowing, so the
|
||||||
|
user's retry starts from the fresh config instead of hitting the same
|
||||||
|
conflict again. */
|
||||||
private async _saveConfigNow(): Promise<void> {
|
private async _saveConfigNow(): Promise<void> {
|
||||||
const r = await this.hass.callWS({
|
try {
|
||||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
const r = await this.hass.callWS({
|
||||||
});
|
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
});
|
||||||
|
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === 'conflict') await this._reloadConfigOnly();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1917,6 +1926,7 @@ class HouseplanCard extends LitElement {
|
|||||||
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
|
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
|
||||||
@pointermove=${(e: PointerEvent) => this._pointerMove(e, d)}
|
@pointermove=${(e: PointerEvent) => this._pointerMove(e, d)}
|
||||||
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
|
@pointerup=${(e: PointerEvent) => this._pointerUp(e, d)}
|
||||||
|
@pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)}
|
||||||
>
|
>
|
||||||
<ha-icon icon="${d.icon}"></ha-icon>
|
<ha-icon icon="${d.icon}"></ha-icon>
|
||||||
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
|
${temp != null ? html`<span class="tval">${temp}°</span>` : nothing}
|
||||||
|
|||||||
+2
-3
@@ -5,6 +5,7 @@
|
|||||||
* HA user profile (hass.locale.language); anything that is not a known
|
* HA user profile (hass.locale.language); anything that is not a known
|
||||||
* language falls back to English.
|
* language falls back to English.
|
||||||
*/
|
*/
|
||||||
|
import { subst } from './logic';
|
||||||
import en from './i18n/en.json';
|
import en from './i18n/en.json';
|
||||||
import ru from './i18n/ru.json';
|
import ru from './i18n/ru.json';
|
||||||
|
|
||||||
@@ -22,9 +23,7 @@ export function langOf(hass: any, configLang?: string | null): Lang {
|
|||||||
|
|
||||||
/** Translate a key with optional {placeholder} substitution. */
|
/** Translate a key with optional {placeholder} substitution. */
|
||||||
export function t(lang: Lang, key: Key, vars?: Record<string, string | number>): string {
|
export function t(lang: Lang, key: Key, vars?: Record<string, string | number>): string {
|
||||||
let s = DICTS[lang][key] ?? en[key] ?? key;
|
return subst(DICTS[lang][key] ?? en[key] ?? key, vars);
|
||||||
if (vars) for (const [k, v] of Object.entries(vars)) s = s.replace('{' + k + '}', String(v));
|
|
||||||
return s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Key as I18nKey };
|
export type { Key as I18nKey };
|
||||||
|
|||||||
@@ -172,3 +172,11 @@ export function floorsOf(hass: any): FloorInfo[] {
|
|||||||
});
|
});
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Substitute every occurrence of {name} placeholders in a template string. */
|
||||||
|
export function subst(s: string, vars?: Record<string, string | number>): string {
|
||||||
|
if (!vars) return s;
|
||||||
|
let out = s;
|
||||||
|
for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor } from '../test-build/devices.js';
|
||||||
|
import { compileIconRules } from '../test-build/rules.js';
|
||||||
|
|
||||||
|
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||||
|
function mkHass({ devices = {}, entities = {}, states = {}, areas = {} } = {}) {
|
||||||
|
return { devices, entities, states, areas };
|
||||||
|
}
|
||||||
|
const loc = (k) =>
|
||||||
|
({ 'device.unnamed': 'unnamed', 'device.light_group': 'light group',
|
||||||
|
'device.fallback': 'device', 'device.virtual': 'virtual device' }[k]);
|
||||||
|
const baseCtx = (hass, over = {}) => ({
|
||||||
|
hass, areaToSpace: { living: 'f1', kitchen: 'f1' }, markers: [], settings: {},
|
||||||
|
excluded: new Set(['hacs']), showAll: false, firstSpaceId: 'f1', loc, ...over,
|
||||||
|
});
|
||||||
|
const dev = (id, name, model, area, extra = {}) =>
|
||||||
|
({ id, name, model, area_id: area, identifiers: [['demo', id]], entry_type: null, via_device_id: null, ...extra });
|
||||||
|
|
||||||
|
test('buildDevices: devices outside bound areas are dropped', () => {
|
||||||
|
const h = mkHass({ devices: {
|
||||||
|
a: dev('a', 'Lamp', 'Bulb', 'living'),
|
||||||
|
b: dev('b', 'Lamp', 'Bulb', 'garage'), // area not bound to a room
|
||||||
|
c: dev('c', 'Lamp', 'Bulb', null), // no area at all
|
||||||
|
}});
|
||||||
|
const res = buildDevices(baseCtx(h));
|
||||||
|
assert.deepEqual(res.map((d) => d.id), ['a']);
|
||||||
|
assert.equal(res[0].space, 'f1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDevices: curation hides excluded/service/group/scene/bridge, show_all reveals', () => {
|
||||||
|
const h = mkHass({ devices: {
|
||||||
|
ok: dev('ok', 'Plug', 'Smart Plug', 'living'),
|
||||||
|
ex: dev('ex', 'HACS', 'x', 'living', { identifiers: [['hacs', '1']] }),
|
||||||
|
sv: dev('sv', 'Service', 'x', 'living', { entry_type: 'service' }),
|
||||||
|
gr: dev('gr', 'Group dev', 'Group', 'living'),
|
||||||
|
sc: dev('sc', 'Scenes', 'wireless scene switch', 'living'),
|
||||||
|
br: dev('br', 'Zigbee Bridge', 'Bridge V2', 'living'),
|
||||||
|
}});
|
||||||
|
assert.deepEqual(buildDevices(baseCtx(h)).map((d) => d.id), ['ok']);
|
||||||
|
// show_all reveals everything except service entries (those are never devices on the plan)
|
||||||
|
const all = buildDevices(baseCtx(h, { showAll: true })).map((d) => d.id);
|
||||||
|
assert.deepEqual(all.sort(), ['br', 'ex', 'gr', 'ok', 'sc']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDevices: duplicate name|area gets numbered, different areas do not', () => {
|
||||||
|
const h = mkHass({ devices: {
|
||||||
|
a: dev('a', 'Lamp', 'Bulb', 'living'),
|
||||||
|
b: dev('b', 'Lamp', 'Bulb', 'living'),
|
||||||
|
c: dev('c', 'Lamp', 'Bulb', 'kitchen'),
|
||||||
|
}});
|
||||||
|
const names = buildDevices(baseCtx(h)).map((d) => d.name);
|
||||||
|
assert.deepEqual(names, ['Lamp', 'Lamp 2', 'Lamp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDevices: light groups appear, single lamps in grouped areas are suppressed', () => {
|
||||||
|
const h = mkHass({
|
||||||
|
devices: { lamp: dev('lamp', 'Ceiling lamp', 'Bulb E27', 'living') },
|
||||||
|
entities: {
|
||||||
|
'light.group_living': { entity_id: 'light.group_living', platform: 'group', area_id: 'living' },
|
||||||
|
'light.single': { entity_id: 'light.single', device_id: 'lamp', platform: 'demo' },
|
||||||
|
},
|
||||||
|
states: { 'light.group_living': { state: 'on', attributes: { friendly_name: 'Living lights' } } },
|
||||||
|
});
|
||||||
|
const res = buildDevices(baseCtx(h));
|
||||||
|
const ids = res.map((d) => d.id);
|
||||||
|
assert.ok(ids.includes('lg_light.group_living'));
|
||||||
|
assert.ok(!ids.includes('lamp'), 'single lamp must fold into the group');
|
||||||
|
// group_lights=false: no groups, the lamp comes back
|
||||||
|
const res2 = buildDevices(baseCtx(h, { settings: { group_lights: false } }));
|
||||||
|
assert.deepEqual(res2.map((d) => d.id), ['lamp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDevices: claimed device is replaced by its marker (metadata applied)', () => {
|
||||||
|
const h = mkHass({ devices: { a: dev('a', 'Boiler', 'BAXI', 'living') } });
|
||||||
|
const markers = [{ id: 'a', binding: 'device:a', name: 'Main boiler', icon: 'mdi:water-boiler',
|
||||||
|
link: 'https://x', tap_action: 'more-info' }];
|
||||||
|
const res = buildDevices(baseCtx(h, { markers }));
|
||||||
|
assert.equal(res.length, 1);
|
||||||
|
const it = res[0];
|
||||||
|
assert.equal(it.name, 'Main boiler');
|
||||||
|
assert.equal(it.icon, 'mdi:water-boiler');
|
||||||
|
assert.equal(it.link, 'https://x');
|
||||||
|
assert.equal(it.tapAction, 'more-info');
|
||||||
|
assert.equal(it.bindingKind, 'device');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDevices: hidden marker removes the device entirely', () => {
|
||||||
|
const h = mkHass({ devices: { a: dev('a', 'Noisy', 'x', 'living') } });
|
||||||
|
const res = buildDevices(baseCtx(h, { markers: [{ id: 'a', binding: 'device:a', hidden: true }] }));
|
||||||
|
assert.equal(res.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDevices: virtual marker lands in its room; entity marker resolves name from state', () => {
|
||||||
|
const h = mkHass({
|
||||||
|
entities: { 'sensor.avg': { entity_id: 'sensor.avg', platform: 'min_max', area_id: 'kitchen' } },
|
||||||
|
states: { 'sensor.avg': { state: '21', attributes: { friendly_name: 'Average temp' } } },
|
||||||
|
});
|
||||||
|
const markers = [
|
||||||
|
{ id: 'v1', binding: 'virtual', name: 'Septic tank', icon: 'mdi:pipe', space: 'f1', area: 'living' },
|
||||||
|
{ id: 'lg_sensor.avg', binding: 'entity:sensor.avg' },
|
||||||
|
];
|
||||||
|
const res = buildDevices(baseCtx(h, { markers }));
|
||||||
|
const v = res.find((d) => d.id === 'v1');
|
||||||
|
assert.ok(v.virtual);
|
||||||
|
assert.equal(v.icon, 'mdi:pipe');
|
||||||
|
const e = res.find((d) => d.id === 'lg_sensor.avg');
|
||||||
|
assert.equal(e.name, 'Average temp');
|
||||||
|
assert.equal(e.primary, 'sensor.avg');
|
||||||
|
assert.equal(e.space, 'f1'); // via the entity's area
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDevices: custom icon rules apply; lock entity still forces mdi:lock', () => {
|
||||||
|
const h = mkHass({
|
||||||
|
devices: {
|
||||||
|
p: dev('p', 'Steckdose Küche', 'Plug S26', 'living'),
|
||||||
|
l: dev('l', 'Home', 'S2', 'living'),
|
||||||
|
},
|
||||||
|
entities: { 'lock.front': { entity_id: 'lock.front', device_id: 'l', platform: 'demo' } },
|
||||||
|
});
|
||||||
|
const iconRules = compileIconRules([{ pattern: 'steckdose', icon: 'mdi:power-socket-eu' }]);
|
||||||
|
const res = buildDevices(baseCtx(h, { iconRules }));
|
||||||
|
assert.equal(res.find((d) => d.id === 'p').icon, 'mdi:power-socket-eu');
|
||||||
|
assert.equal(res.find((d) => d.id === 'l').icon, 'mdi:lock');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDevices: device_class fallback when no rule matches', () => {
|
||||||
|
const h = mkHass({
|
||||||
|
devices: { t: dev('t', 'XYZZY-42', 'unknown', 'living') },
|
||||||
|
entities: { 'sensor.x': { entity_id: 'sensor.x', device_id: 't', platform: 'demo' } },
|
||||||
|
states: { 'sensor.x': { state: '21.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } } },
|
||||||
|
});
|
||||||
|
const it = buildDevices(baseCtx(h))[0];
|
||||||
|
assert.equal(it.icon, 'mdi:thermometer');
|
||||||
|
assert.equal(it.temp, 21.5);
|
||||||
|
assert.equal(it.primary, 'sensor.x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('primaryEntity: domain priority and diagnostic-category demotion', () => {
|
||||||
|
const h = mkHass({
|
||||||
|
entities: {
|
||||||
|
'sensor.batt': { entity_id: 'sensor.batt', entity_category: 'diagnostic' },
|
||||||
|
'light.a': { entity_id: 'light.a' },
|
||||||
|
'sensor.temp': { entity_id: 'sensor.temp' },
|
||||||
|
},
|
||||||
|
states: {},
|
||||||
|
});
|
||||||
|
assert.equal(primaryEntity(h, ['sensor.batt', 'sensor.temp', 'light.a'], 'mdi:lightbulb'), 'light.a');
|
||||||
|
// only a diagnostic entity → still usable as a last resort
|
||||||
|
assert.equal(primaryEntity(h, ['sensor.batt'], 'mdi:chip'), 'sensor.batt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lqiFor: dedicated sensor wins over attribute duplication; tempFor rounds', () => {
|
||||||
|
const h = mkHass({ states: {
|
||||||
|
'sensor.x_linkquality': { state: '120', attributes: { unit_of_measurement: 'lqi' } },
|
||||||
|
'sensor.temp': { state: '22.46', attributes: { device_class: 'temperature', linkquality: 118 } },
|
||||||
|
}});
|
||||||
|
assert.equal(lqiFor(h, ['sensor.x_linkquality', 'sensor.temp']), 119); // avg(120,118)
|
||||||
|
assert.equal(tempFor(h, ['sensor.temp']), 22.5);
|
||||||
|
});
|
||||||
+7
-1
@@ -2,7 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||||
fitView, declump, safeUrl, resolveTapAction, floorsOf,
|
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst,
|
||||||
} from '../test-build/logic.js';
|
} from '../test-build/logic.js';
|
||||||
import {
|
import {
|
||||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||||
@@ -206,3 +206,9 @@ test('floorsOf: sorts by level, tolerates missing registry and odd entries', ()
|
|||||||
const res = floorsOf(hass);
|
const res = floorsOf(hass);
|
||||||
assert.deepEqual(res.map((f) => f.id), ['ground', 'attic', 'x']);
|
assert.deepEqual(res.map((f) => f.id), ['ground', 'attic', 'x']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subst: replaces every occurrence of a placeholder, ignores unknown', () => {
|
||||||
|
assert.equal(subst('{n} of {n} ({x})', { n: 2, x: 'y' }), '2 of 2 (y)');
|
||||||
|
assert.equal(subst('no vars'), 'no vars');
|
||||||
|
assert.equal(subst('keep {unknown}', { n: 1 }), 'keep {unknown}');
|
||||||
|
});
|
||||||
|
|||||||
+3
-1
@@ -9,6 +9,8 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/logic.ts",
|
"src/logic.ts",
|
||||||
"src/rules.ts"
|
"src/rules.ts",
|
||||||
|
"src/devices.ts",
|
||||||
|
"src/types.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user