Merge audit-3: fixes + buildDevices test suite v1.13.2

This commit is contained in:
Matysh
2026-07-06 03:39:33 +03:00
17 changed files with 303 additions and 64 deletions
+2 -39
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
} }
+52
View File
@@ -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})
+5 -4
View File
File diff suppressed because one or more lines are too long
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
+8
View File
@@ -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;
}
+160
View File
@@ -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
View File
@@ -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
View File
@@ -9,6 +9,8 @@
}, },
"include": [ "include": [
"src/logic.ts", "src/logic.ts",
"src/rules.ts" "src/rules.ts",
"src/devices.ts",
"src/types.ts"
] ]
} }