mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
Replace inferred BFS topology with confirmed end-device parents and active router routes from ZHA and Zigbee2MQTT. Fail closed on ambiguous evidence, preserve stale snapshots, and request routing tables without hover traffic. Use solid LQI-coloured arrows, plan-scaled unknown-LQI outlines, and named unplaced targets. Add provider/runtime/visual regression coverage. Owner authorized author self-review and merge to dev while the review model is unavailable; no release or issue closure is included. Issue: #798 User-Visible: yes
267 lines
13 KiB
JavaScript
267 lines
13 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import {
|
|
normalizeIeee,
|
|
} from '../test-build/zigbee-topology.js';
|
|
import { zigbeeArrowGeometry } from '../test-build/zigbee-topology-geometry.js';
|
|
import {
|
|
normalizeZ2mBaseTopic, writeZigbeeTopologySettings, zigbeeTopologySettingsOf,
|
|
} from '../test-build/zigbee-topology-settings.js';
|
|
import {
|
|
readZhaTopology, refreshZ2mTopology, zigbeeTopologyRuntimeSnapshot,
|
|
} from '../test-build/zigbee-topology-runtime.js';
|
|
|
|
const z2mNetworkmapFixture = JSON.parse(readFileSync(
|
|
new URL('./fixtures/zigbee2mqtt-networkmap-real-anonymized.json', import.meta.url),
|
|
'utf8',
|
|
));
|
|
const topologySmoke = readFileSync(
|
|
new URL('../demo/smoke_zigbee_topology_hover.mjs', import.meta.url), 'utf8',
|
|
);
|
|
|
|
test('Stage 3 topology smoke follows actual raised DOM centres before and after pan/zoom', () => {
|
|
// #649: 2.5D is the General settings switch; the harness applies it like a save.
|
|
assert.doesNotMatch(topologySmoke, /hp_alpha/);
|
|
assert.match(topologySmoke,
|
|
/window\.__hpHarnessProjection\(card, 'iso'\);\s*await window\.__hpEnsureHarnessIsoRuntime\(card\)/,
|
|
'the witness must enter real 2.5D and await its lazy runtime');
|
|
assert.match(topologySmoke, /data-hp-iso-overlay-kind/,
|
|
'the witness must prove both marker roots are raised');
|
|
assert.match(topologySmoke, /svg\?\.getScreenCTM\(\)/,
|
|
'SVG route endpoints must be compared in the same CSS-pixel coordinate space as DOM markers');
|
|
assert.match(topologySmoke, /distance\(lineStart, sourceCentre\) <= 1/);
|
|
assert.match(topologySmoke, /distance\(lineEnd, neighborCentre\) <= 1/);
|
|
assert.match(topologySmoke, /distance\(haloCentre, neighborCentre\) <= 1/);
|
|
assert.match(topologySmoke, /isoTopologyTracksRaisedDomCentresAfterPanZoom/,
|
|
'the same exact geometry witness must be repeated after a real pan/zoom');
|
|
});
|
|
|
|
test('topology settings are default-off, bounded, normalized and preserved independently', () => {
|
|
assert.deepEqual(zigbeeTopologySettingsOf(undefined), { enabled: false, z2mBaseTopics: [] });
|
|
assert.deepEqual(zigbeeTopologySettingsOf({ zigbee_topology: {
|
|
enabled: true, z2m_base_topics: [' zigbee2mqtt/ ', 'zigbee2mqtt', 'bad/#'],
|
|
} }), { enabled: true, z2mBaseTopics: ['zigbee2mqtt'] });
|
|
assert.equal(normalizeZ2mBaseTopic('/house//z2m/'), 'house/z2m');
|
|
const saved = writeZigbeeTopologySettings({ keep: 7 }, {
|
|
enabled: true, z2mBaseTopics: ['zigbee2mqtt'],
|
|
});
|
|
assert.deepEqual(saved, { keep: 7, zigbee_topology: { enabled: true, z2m_base_topics: ['zigbee2mqtt'] } });
|
|
assert.deepEqual(writeZigbeeTopologySettings(saved, { enabled: false, z2mBaseTopics: [] }), { keep: 7 });
|
|
});
|
|
|
|
test('IEEE normalization is exact and rejects partial identifiers', () => {
|
|
assert.equal(normalizeIeee('0x00124B0000000001'), '00124b0000000001');
|
|
assert.equal(normalizeIeee('00:12:4b:00:00:00:00:01'), '00124b0000000001');
|
|
assert.equal(normalizeIeee('124b1'), null);
|
|
});
|
|
|
|
// Provider normalization, exact mapping and provider-only route resolution:
|
|
// test/zigbee-provider-routes.test.mjs (#798 replaces the inferred BFS-tree contract).
|
|
|
|
test('screen-pixel arrow geometry points at the requested endpoint and respects clearance', () => {
|
|
const origin = { x: 0, y: 20 };
|
|
const neighbor = { x: 100, y: 20 };
|
|
const outgoing = zigbeeArrowGeometry(origin, neighbor, 10, 12, 'toward-neighbor');
|
|
assert.deepEqual(outgoing?.tip, { x: 88, y: 20 });
|
|
assert.equal(outgoing?.points[1].x, 79);
|
|
assert.equal(outgoing?.points[2].x, 79);
|
|
const incoming = zigbeeArrowGeometry(origin, neighbor, 10, 12, 'toward-origin');
|
|
assert.deepEqual(incoming?.tip, { x: 10, y: 20 });
|
|
assert.equal(incoming?.points[1].x, 19);
|
|
const diagonal = zigbeeArrowGeometry({ x: 10, y: 10 }, { x: 70, y: 90 }, 5, 7, 'toward-neighbor');
|
|
assert.ok(diagonal && diagonal.tip.x < 70 && diagonal.tip.y < 90);
|
|
assert.equal(zigbeeArrowGeometry(origin, { x: 20, y: 20 }, 10, 8, 'toward-neighbor'), null);
|
|
});
|
|
|
|
test('ZHA runtime is explicit, admin-only and deduplicates concurrent reads', async () => {
|
|
let calls = 0;
|
|
let release;
|
|
const pending = new Promise((resolve) => { release = resolve; });
|
|
const hass = { user: { is_admin: true }, connection: {}, callWS: async (message) => {
|
|
calls++;
|
|
assert.deepEqual(message, { type: 'zha/devices' });
|
|
await pending;
|
|
return [{ ieee: '00124b0000000001', device_reg_id: 'da', neighbors: [] }];
|
|
} };
|
|
const a = readZhaTopology(hass);
|
|
const b = readZhaTopology(hass);
|
|
assert.equal(calls, 1);
|
|
release();
|
|
await Promise.all([a, b]);
|
|
assert.equal(zigbeeTopologyRuntimeSnapshot(hass).topologies.length, 1);
|
|
|
|
const denied = { user: { is_admin: false }, connection: {}, callWS: async () => assert.fail('must not call') };
|
|
await readZhaTopology(denied);
|
|
assert.equal(zigbeeTopologyRuntimeSnapshot(denied).states.zha.error, 'permission');
|
|
});
|
|
|
|
test('Z2M runtime verifies retained bridge info, correlates transaction and cleans subscriptions', async () => {
|
|
const listeners = new Map();
|
|
let cleanups = 0;
|
|
const hass = {
|
|
user: { is_admin: true },
|
|
connection: {
|
|
async subscribeMessage(callback, message) {
|
|
listeners.set(message.topic, callback);
|
|
if (message.topic.endsWith('/bridge/info')) queueMicrotask(() => callback({ retain: true, payload: '{}' }));
|
|
if (message.topic.endsWith('/bridge/response/networkmap')) {
|
|
queueMicrotask(() => callback({ retain: false, payload: 'stale-not-json' }));
|
|
}
|
|
return () => { cleanups++; listeners.delete(message.topic); };
|
|
},
|
|
},
|
|
async callService(domain, service, data) {
|
|
assert.equal(`${domain}.${service}`, 'mqtt.publish');
|
|
const request = JSON.parse(data.payload);
|
|
assert.equal(request.type, 'raw');
|
|
assert.equal(request.routes, true);
|
|
listeners.get('zigbee2mqtt/bridge/response/networkmap')?.({ retain: true,
|
|
payload: JSON.stringify({ status: 'ok', transaction: request.transaction }) });
|
|
listeners.get('zigbee2mqtt/bridge/response/networkmap')?.({ retain: false,
|
|
payload: JSON.stringify({ status: 'ok', transaction: 'foreign' }) });
|
|
queueMicrotask(() => listeners.get('zigbee2mqtt/bridge/response/networkmap')?.({
|
|
retain: false,
|
|
payload: JSON.stringify({
|
|
...z2mNetworkmapFixture,
|
|
transaction: request.transaction,
|
|
}),
|
|
}));
|
|
},
|
|
};
|
|
await refreshZ2mTopology(hass, 'zigbee2mqtt', 100);
|
|
assert.equal(cleanups, 2);
|
|
const snapshot = zigbeeTopologyRuntimeSnapshot(hass);
|
|
assert.equal(snapshot.states['z2m:zigbee2mqtt'].phase, 'ready');
|
|
assert.equal(snapshot.topologies[0].nodes.length, 3);
|
|
assert.equal(snapshot.topologies[0].links.length, 2);
|
|
assert.equal(snapshot.topologies[0].uplinkEvidence.length, 0, 'neighbors-only fixture is not route evidence');
|
|
});
|
|
|
|
test('Z2M runtime rejects a malformed response immediately instead of timing out', async () => {
|
|
const listeners = new Map();
|
|
let cleanups = 0;
|
|
const hass = {
|
|
user: { is_admin: true },
|
|
connection: {
|
|
async subscribeMessage(callback, message) {
|
|
listeners.set(message.topic, callback);
|
|
if (message.topic.endsWith('/bridge/info')) queueMicrotask(() => callback({ retain: true, payload: '{}' }));
|
|
return () => { cleanups++; listeners.delete(message.topic); };
|
|
},
|
|
},
|
|
async callService() {
|
|
queueMicrotask(() => listeners.get('zigbee2mqtt/bridge/response/networkmap')?.({
|
|
retain: false, payload: 'not-json-garbage',
|
|
}));
|
|
},
|
|
};
|
|
const startedAt = performance.now();
|
|
await refreshZ2mTopology(hass, 'zigbee2mqtt', 500);
|
|
assert.ok(performance.now() - startedAt < 250, 'malformed response must not wait for the timeout');
|
|
assert.equal(cleanups, 2);
|
|
assert.equal(zigbeeTopologyRuntimeSnapshot(hass).states['z2m:zigbee2mqtt'].error, 'invalid_payload');
|
|
});
|
|
|
|
test('Z2M runtime refuses an unconfirmed base topic without publishing and still cleans up', async () => {
|
|
let publishes = 0;
|
|
let cleanups = 0;
|
|
const hass = {
|
|
user: { is_admin: true },
|
|
connection: { async subscribeMessage() { return () => { cleanups++; }; } },
|
|
async callService() { publishes++; },
|
|
};
|
|
await refreshZ2mTopology(hass, 'zigbee2mqtt', 10);
|
|
assert.equal(publishes, 0);
|
|
assert.equal(cleanups, 2);
|
|
assert.equal(zigbeeTopologyRuntimeSnapshot(hass).states['z2m:zigbee2mqtt'].error, 'timeout');
|
|
});
|
|
|
|
// #459. Подсказка «Связи Zigbee»: легенда живёт в словаре, и её содержание —
|
|
// защитный контракт. Текст, называющий только цвет и пунктир, формально
|
|
// «подсказка есть», но не отвечает ни на один вопрос про стрелки — ради
|
|
// которых задача и ждала #457. Поэтому проверяется КАЖДЫЙ пункт легенды.
|
|
|
|
const topologyDict = (code) => JSON.parse(readFileSync(
|
|
new URL(`../src/i18n/topology/${code}.json`, import.meta.url), 'utf8',
|
|
));
|
|
const TOPOLOGY_LANGS = ['en', 'ru', 'de', 'fr'];
|
|
|
|
test('подсказка описывает provider evidence, unknown и новый сплошной рисунок (#798)', () => {
|
|
const help = topologyDict('ru').help;
|
|
for (const [pattern, reason] of [
|
|
[/родител/i, 'parent evidence'],
|
|
[/координатор/i, 'destination coordinator'],
|
|
[/интеграц/i, 'provider source'],
|
|
[/данн/i, 'unknown route'],
|
|
[/пакет/i, 'not packet tracing'],
|
|
[/LQI/i, 'LQI named'],
|
|
[/\b0\b/, 'zero LQI scale anchor'],
|
|
[/\b128\b/, 'midpoint LQI scale anchor'],
|
|
[/\b255\b/, 'full-range LQI scale anchor'],
|
|
[/сплошн/i, 'solid lines'],
|
|
[/сер/i, 'unknown-LQI color'],
|
|
[/обвод/i, 'unknown-LQI outline'],
|
|
[/ч[её]рн/i, 'unknown-LQI black outline'],
|
|
]) assert.match(help, pattern, reason);
|
|
assert.doesNotMatch(help, /запасн[а-яё]* сосед|пунктир|40.*180|дерево маршрут/i);
|
|
});
|
|
|
|
test('подсказка не полагается на переносы строк (#459 AC5)', () => {
|
|
// hp-help кладёт .text текстовым узлом: \n схлопнется в пробел.
|
|
for (const code of TOPOLOGY_LANGS) {
|
|
const dict = topologyDict(code);
|
|
assert.ok(!dict.help.includes('\n'), `${code}: перенос строки в help`);
|
|
assert.ok(dict.help.trim().length > 0, `${code}: пустая подсказка`);
|
|
assert.ok(dict.help_aria.trim().length > 0, `${code}: пустая подпись для скринридера`);
|
|
}
|
|
});
|
|
|
|
test('словари topology несут один и тот же набор ключей (#459 AC6)', () => {
|
|
// Гейт, которого не было: i18n-dead-keys и i18n.test знают основной словарь,
|
|
// бэкендные переводы и support, но не namespace topology.
|
|
const english = Object.keys(topologyDict('en')).sort();
|
|
const placeholders = (value) => (String(value).match(/\{\w+\}/gu) || []).sort();
|
|
const en = topologyDict('en');
|
|
for (const code of TOPOLOGY_LANGS) {
|
|
const dict = topologyDict(code);
|
|
assert.deepEqual(Object.keys(dict).sort(), english, `${code}: набор ключей расходится`);
|
|
for (const key of english) {
|
|
assert.deepEqual(placeholders(dict[key]), placeholders(en[key]),
|
|
`${code}: плейсхолдеры расходятся в ${key}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('кружок справки не рисуется без подписи для скринридера (#459 AC2)', async () => {
|
|
const { hasTopologyTranslation, topologyT, TOPOLOGY_LANGUAGE_RUNTIME } =
|
|
await import('../test-build/i18n/topology.js');
|
|
// #627: ru/de are lazy chunks. Without `ensure` this would prove the English
|
|
// fallback layer, not the Russian dictionary.
|
|
await TOPOLOGY_LANGUAGE_RUNTIME.ensure('ru');
|
|
await TOPOLOGY_LANGUAGE_RUNTIME.ensure('de');
|
|
assert.equal(TOPOLOGY_LANGUAGE_RUNTIME.state('ru'), 'ready');
|
|
assert.notEqual(topologyT('ru', 'help_aria'), topologyT('en', 'help_aria'));
|
|
// Проверка идёт по СЛОВАРЮ, а не по строке: topologyT на отсутствующий ключ
|
|
// отвечает именем ключа, и «help» — вполне непустая строка.
|
|
assert.equal(hasTopologyTranslation('ru', 'help'), true);
|
|
assert.equal(hasTopologyTranslation('ru', 'help_aria'), true);
|
|
assert.equal(hasTopologyTranslation('ru', 'no_such_key'), false);
|
|
// Английский — слой фолбэка: ключ, которого нет в локали, но есть в en,
|
|
// доступен, как и в topologyT.
|
|
assert.equal(hasTopologyTranslation('de', 'help'), true);
|
|
});
|
|
|
|
test('подсказка вызывается из блока настройки топологии (#459 AC1)', () => {
|
|
const source = readFileSync(
|
|
new URL('../src/hp-zigbee-topology-settings.ts', import.meta.url), 'utf8',
|
|
);
|
|
// Кружок стоит у ЗАГОЛОВКА функции, а не у тумблера: он объясняет функцию,
|
|
// а не то, что делает галочка.
|
|
assert.match(source, /<div class="section">\$\{this\._t\('title'\)\}\$\{this\._help\(\)\}<\/div>/);
|
|
assert.match(source, /<hp-help \.text=/);
|
|
// Fail-closed: оба ключа обязательны, иначе не рисуется ничего.
|
|
assert.match(source, /hasTopologyTranslation\(lang, 'help'\)/);
|
|
assert.match(source, /hasTopologyTranslation\(lang, 'help_aria'\)/);
|
|
});
|