mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
207 lines
11 KiB
JavaScript
207 lines
11 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.deepEqual(zigbeeTopologyRuntimeSnapshot(denied).states, {}, 'non-admin cannot read runtime data');
|
|
});
|
|
|
|
// #800 moves MQTT correlation/limits/cleanup to the HA coordinator tests.
|
|
// The frontend consumes its authoritative result and preserves route normalization.
|
|
test('Z2M server job response retains the real fixture normalization without browser MQTT', async () => {
|
|
const hass = { user: { is_admin: true }, connection: { async subscribeMessage() { return () => {}; } },
|
|
async callWS(message) {
|
|
if (message.type === 'houseplan/config/get') return { zigbee_scan_api: 1 };
|
|
assert.deepEqual(message, { type: 'houseplan/zigbee/start', base_topic: 'zigbee2mqtt' });
|
|
return { kind: 'state', session_id: 'server', revision: 1, provider: {
|
|
topic: 'zigbee2mqtt', job_id: 'job', phase: 'ready', elapsed_ms: 900000,
|
|
obtained_at: 1000, result: z2mNetworkmapFixture,
|
|
} };
|
|
}, callService() { assert.fail('no frontend MQTT'); },
|
|
};
|
|
await refreshZ2mTopology(hass, 'zigbee2mqtt');
|
|
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');
|
|
});
|
|
|
|
// #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'\)/);
|
|
});
|