mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
The large-house AC3 witness asserted an absolute 2.5 s budget for one floor's clean-floor total. On a loaded 2-CPU machine the healthy path took 2.7-4.3 s and the test went red while the code was fine. The property #509 AC3 protects is structural: the summary panel builds the space's wall masonry once and hands it to innerContourForRoom (shared.roomGeom / shared.multiWallNodes); without it the masonry is rebuilt for every room. Every masonry build walks the contours of all rooms, so the test now counts reads of room.poly and compares the floor total against one explicit spaceWallGeometry pass of the same floor in the same run. Healthy code costs ~1.3 passes; the registered mutant summary-area-recomputes-walls-per-room costs 21.3 and is red, and so are the half-regressions that drop only one of the two shared arguments (4.6 and 18.0 passes). The count is deterministic, so machine load no longer matters. Issue: #721 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
521 lines
27 KiB
JavaScript
521 lines
27 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { makeLargeHouseFixture } from '../demo/fixtures/large-house.mjs';
|
|
import { spaceModels } from '../test-build/space-geometry.js';
|
|
import { prepareSpacePhysicalGeometryInputs } from '../test-build/plan-geometry-preflight.js';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
import {
|
|
confirmedSummaryPanelWriteRecovery,
|
|
defaultSummaryPanel,
|
|
effectiveSummaryVisible,
|
|
normalizeSummaryDraft,
|
|
normalizeSummaryScale,
|
|
parseSummaryLocal,
|
|
resolveSummaryLayout,
|
|
summaryPanelEntityIds,
|
|
summaryLocalKey,
|
|
summaryPanelOf,
|
|
validateSummaryDraft,
|
|
visibleSummaryBlocks,
|
|
} from '../test-build/summary-panel.js';
|
|
import {
|
|
representedHaDeviceIds,
|
|
summaryEntityValue,
|
|
summarySystemValue,
|
|
totalCleanFloorAreaM2,
|
|
spaceWallGeometry,
|
|
} from '../test-build/summary-panel-metrics.js';
|
|
import { summaryPanelDictionaries, summaryPanelText } from '../test-build/summary-panel-i18n.js';
|
|
import { stableSummaryPlacementSlot } from '../test-build/summary-panel-identity.js';
|
|
import {
|
|
SUMMARY_ENTITY_RESULT_LIMIT,
|
|
refreshSummaryEntityIndex,
|
|
searchSummaryEntityIndex,
|
|
} from '../test-build/summary-panel-picker.js';
|
|
|
|
const tr = (key) => key;
|
|
|
|
test('#437 keeps the settings form lazy and every summary surface action-free', () => {
|
|
const loaded = readFileSync(new URL('../src/summary-panel-runtime-loaded.ts', import.meta.url), 'utf8');
|
|
const editor = readFileSync(new URL('../src/summary-panel-editor.ts', import.meta.url), 'utf8');
|
|
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
|
const manifest = JSON.parse(readFileSync(new URL('../dist/houseplan-assets.json', import.meta.url), 'utf8'));
|
|
assert.match(card, /import\('\.\/summary-panel-runtime-loaded'\)/);
|
|
assert.match(loaded, /import\('\.\/summary-panel-editor'\)/);
|
|
assert.ok(manifest.lazyFiles.some((path) => path.includes('summary-panel-runtime-loaded-')));
|
|
assert.ok(manifest.initialViewFiles.every((path) => !path.includes('summary-panel')));
|
|
assert.doesNotMatch(card, /from ['"]\.\/summary-panel-editor/);
|
|
assert.doesNotMatch(`${loaded}\n${editor}`, /callService\s*\(/);
|
|
assert.doesNotMatch(editor, /Object\.entries\(host\.hass\?\.states/);
|
|
assert.doesNotMatch(editor, /entities\.map\([^)]*<option/s,
|
|
'closed value rows must not contain a full entity option list');
|
|
assert.match(editor, /data-summary-source-owner/);
|
|
assert.doesNotMatch(editor, /maxlength=/i, 'limits count Unicode code points, not UTF-16 units');
|
|
// #505 measures reduced-motion behaviour and actual editor, switch and
|
|
// footer hit areas in the summary browser smokes. Neither a media-query
|
|
// spelling nor a CSS selector proves a Web Animations/DOM contract.
|
|
});
|
|
|
|
test('#493 entity picker searches the full index while bounding rendered rows', () => {
|
|
const states = Object.fromEntries(Array.from({ length: 10_000 }, (_, index) => {
|
|
const id = `sensor.item_${String(index).padStart(5, '0')}`;
|
|
return [id, { state: String(index), attributes: { friendly_name: `Reading ${index}` } }];
|
|
}));
|
|
const index = refreshSummaryEntityIndex(states, null);
|
|
assert.equal(index.entries.length, 10_000);
|
|
assert.equal(index.rebuilds, 1);
|
|
const broad = searchSummaryEntityIndex(index, 'Reading');
|
|
assert.equal(broad.entries.length, SUMMARY_ENTITY_RESULT_LIMIT);
|
|
assert.equal(broad.total, 10_000);
|
|
assert.equal(broad.truncated, true);
|
|
const exact = searchSummaryEntityIndex(index, 'sensor.item_09999');
|
|
assert.deepEqual(exact.entries.map((entry) => entry.id), ['sensor.item_09999']);
|
|
});
|
|
|
|
test('#493 entity index ignores state values and rebuilds once for composition or name', () => {
|
|
const firstStates = {
|
|
'sensor.a': { state: '1', attributes: { friendly_name: 'Alpha' } },
|
|
'sensor.b': { state: '2', attributes: { friendly_name: 'Beta' } },
|
|
};
|
|
const first = refreshSummaryEntityIndex(firstStates, null);
|
|
const stateOnly = refreshSummaryEntityIndex({
|
|
...firstStates, 'sensor.a': { ...firstStates['sensor.a'], state: '3' },
|
|
}, first);
|
|
assert.equal(stateOnly, first);
|
|
assert.equal(stateOnly.rebuilds, 1);
|
|
const renamed = refreshSummaryEntityIndex({
|
|
...firstStates,
|
|
'sensor.a': { state: '3', attributes: { friendly_name: 'Aleph' } },
|
|
}, stateOnly);
|
|
assert.notEqual(renamed, stateOnly);
|
|
assert.equal(renamed.rebuilds, 2);
|
|
const added = refreshSummaryEntityIndex({ ...firstStates, 'sensor.c': { state: '4', attributes: {} } }, renamed);
|
|
assert.equal(added.rebuilds, 3);
|
|
const { 'sensor.b': _removed, ...withoutB } = firstStates;
|
|
const removed = refreshSummaryEntityIndex({ ...withoutB, 'sensor.c': { state: '4', attributes: {} } }, added);
|
|
assert.equal(removed.rebuilds, 4);
|
|
});
|
|
|
|
test('#493 runtime owns same-key scales and binds async UI to lifecycle generation', () => {
|
|
const runtime = readFileSync(new URL('../src/summary-panel-runtime-loaded.ts', import.meta.url), 'utf8');
|
|
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
|
assert.match(card, /!this\._summary\?\.applyLocalScaleForCurrentIdentity\(\)/);
|
|
assert.match(card, /this\._summary\?\.leaveRoute\(\)/);
|
|
assert.match(runtime, /lifecycleGeneration/);
|
|
assert.match(runtime, /if \(!this\.current\(generation\)\) return;/);
|
|
assert.match(runtime, /activeSource: \{ blockId: string; valueId: string \}/);
|
|
});
|
|
|
|
test('#437 lazy summary dictionaries have exact parity and locale fallback', () => {
|
|
const englishKeys = Object.keys(summaryPanelDictionaries.en).sort();
|
|
for (const code of ['ru', 'de', 'fr']) {
|
|
assert.deepEqual(Object.keys(summaryPanelDictionaries[code]).sort(), englishKeys);
|
|
assert.ok(Object.values(summaryPanelDictionaries[code]).every((value) => value.trim()));
|
|
}
|
|
assert.equal(summaryPanelText('ru-RU', 'summary.default_title'), 'Сводная информация');
|
|
assert.equal(summaryPanelText('xx', 'summary.default_title'), 'Summary');
|
|
});
|
|
|
|
test('#437 absent config derives deterministic defaults but explicit emptiness stays empty', () => {
|
|
const first = summaryPanelOf({}, tr);
|
|
const second = summaryPanelOf({}, tr);
|
|
assert.equal(first.derived, true);
|
|
assert.deepEqual(first.config, second.config);
|
|
const empty = { version: 1, title: 'Mine', show_on_mobile: true, blocks: [] };
|
|
assert.deepEqual(summaryPanelOf({ summary_panel: empty }, tr), {
|
|
config: empty, derived: false, unsupported: false,
|
|
});
|
|
assert.equal(defaultSummaryPanel(tr).blocks[0].values.length, 3);
|
|
assert.equal(summaryPanelOf({ summary_panel: {
|
|
version: 1, title: 'Broken', show_on_mobile: true, blocks: [{}],
|
|
} }, tr).unsupported, true);
|
|
});
|
|
|
|
test('#437 layout uses the stage, equality chooses right, and fit thresholds are stable', () => {
|
|
assert.equal(resolveSummaryLayout({ width: 800, height: 600, minimumHeight: 162 }).side, 'right');
|
|
assert.equal(resolveSummaryLayout({ width: 600, height: 800, minimumHeight: 162 }).side, 'bottom');
|
|
assert.equal(resolveSummaryLayout({ width: 600, height: 600, minimumHeight: 162 }).side, 'right');
|
|
assert.equal(resolveSummaryLayout({ width: 800, height: 185, minimumHeight: 162 }).fits, false);
|
|
assert.equal(resolveSummaryLayout({ width: 800, height: 186, minimumHeight: 162 }).fits, true);
|
|
assert.equal(resolveSummaryLayout({
|
|
width: 800, height: 245, controlTop: 72, minimumHeight: 162,
|
|
}).fits, false);
|
|
assert.equal(resolveSummaryLayout({
|
|
width: 800, height: 246, controlTop: 72, minimumHeight: 162,
|
|
}).fits, true);
|
|
assert.equal(resolveSummaryLayout({
|
|
width: 324, height: 600, safeLeft: 10, safeRight: 10, minimumHeight: 162,
|
|
}).fits, true);
|
|
assert.equal(resolveSummaryLayout({
|
|
width: 323, height: 600, safeLeft: 10, safeRight: 10, minimumHeight: 162,
|
|
}).fits, false);
|
|
});
|
|
|
|
test('#437 local show, native narrow and fit are independent gates', () => {
|
|
const base = { view: true, localShow: true, showOnMobile: false, fits: true };
|
|
assert.equal(effectiveSummaryVisible({ ...base, narrow: false }), true);
|
|
assert.equal(effectiveSummaryVisible({ ...base, narrow: true }), false);
|
|
assert.equal(effectiveSummaryVisible({ ...base, narrow: null }), false);
|
|
assert.equal(effectiveSummaryVisible({ ...base, showOnMobile: true, narrow: null }), true);
|
|
assert.equal(effectiveSummaryVisible({ ...base, narrow: false, fits: false }), false);
|
|
assert.equal(effectiveSummaryVisible({ ...base, narrow: false, view: false }), false);
|
|
});
|
|
|
|
test('#437 stable ids distinguish old broken references from new broken references', () => {
|
|
const base = {
|
|
version: 1, title: 'Summary', show_on_mobile: true,
|
|
blocks: [{ id: 'b1', title: 'Block', visible: true,
|
|
scope: { type: 'space', space_id: 'gone' },
|
|
values: [{ id: 'v1', label: 'Old', source: { type: 'entity', entity_id: 'sensor.gone' } }] }],
|
|
};
|
|
const warnings = validateSummaryDraft(base, base, new Set(['floor']), new Set(['sensor.live']));
|
|
assert.deepEqual(warnings.map((p) => p.kind), ['warning', 'warning']);
|
|
const changed = structuredClone(base);
|
|
changed.blocks[0].title = 'Renamed';
|
|
changed.blocks[0].values[0].label = 'Renamed';
|
|
assert.equal(validateSummaryDraft(changed, base, new Set(['floor']), new Set(['sensor.live']))
|
|
.some((p) => p.kind === 'error'), false);
|
|
changed.blocks[0].values[0].source.entity_id = 'sensor.other_gone';
|
|
assert.equal(validateSummaryDraft(changed, base, new Set(['floor']), new Set(['sensor.live']))
|
|
.some((p) => p.kind === 'error' && p.code === 'missing_entity'), true);
|
|
});
|
|
|
|
test('#437 scope filters blocks without changing their order', () => {
|
|
const config = { version: 1, title: 'S', show_on_mobile: true, blocks: [
|
|
{ id: 'a', title: 'A', visible: true, scope: { type: 'all' }, values: [] },
|
|
{ id: 'b', title: 'B', visible: true, scope: { type: 'space', space_id: 'f2' }, values: [] },
|
|
{ id: 'c', title: 'C', visible: false, scope: { type: 'all' }, values: [] },
|
|
] };
|
|
assert.deepEqual(visibleSummaryBlocks(config, 'f2').map((b) => b.id), ['a', 'b']);
|
|
assert.deepEqual(visibleSummaryBlocks(config, 'f1').map((b) => b.id), ['a']);
|
|
});
|
|
|
|
test('#490 summary entity dependencies are complete, bounded and schema-aware', () => {
|
|
const config = { version: 1, title: 'S', show_on_mobile: true, blocks: [
|
|
{ id: 'visible', title: 'Visible', visible: true, scope: { type: 'all' }, values: [
|
|
{ id: 'a', label: 'A', source: { type: 'entity', entity_id: 'sensor.a' } },
|
|
{ id: 'system', label: 'Time', source: { type: 'system', key: 'datetime' } },
|
|
] },
|
|
{ id: 'hidden', title: 'Hidden', visible: false,
|
|
scope: { type: 'space', space_id: 'another-floor' }, values: [
|
|
{ id: 'a-again', label: 'A again', source: { type: 'entity', entity_id: 'sensor.a' } },
|
|
{ id: 'b', label: 'B', source: { type: 'entity', entity_id: ' sensor.b ' } },
|
|
] },
|
|
] };
|
|
assert.deepEqual(summaryPanelEntityIds(config), ['sensor.a', 'sensor.b']);
|
|
assert.deepEqual(summaryPanelEntityIds(null), []);
|
|
assert.deepEqual(summaryPanelEntityIds({ ...config, version: 2 }), []);
|
|
});
|
|
|
|
test('#490 lost-ACK proof requires the exact panel, full config and revision', () => {
|
|
const draft = defaultSummaryPanel(tr);
|
|
draft.title = 'Saved summary';
|
|
const authoritative = {
|
|
spaces: [{ id: 'f1', title: 'Concurrent title' }], markers: [],
|
|
settings: { summary_panel: structuredClone(draft), concurrent: true },
|
|
};
|
|
assert.deepEqual(confirmedSummaryPanelWriteRecovery({ config: authoritative, rev: 42 }, draft), {
|
|
config: authoritative, rev: 42,
|
|
});
|
|
|
|
const different = structuredClone(authoritative);
|
|
different.settings.summary_panel.title = 'Another summary';
|
|
for (const response of [
|
|
null,
|
|
{ config: authoritative },
|
|
{ config: authoritative, rev: 1.5 },
|
|
{ config: authoritative, rev: -1 },
|
|
{ config: null, rev: 42 },
|
|
{ config: { settings: authoritative.settings }, rev: 42 },
|
|
{ config: different, rev: 43 },
|
|
]) assert.equal(confirmedSummaryPanelWriteRecovery(response, draft), null);
|
|
});
|
|
|
|
test('#437 local preferences use legacy sizes once but never legacy show', () => {
|
|
assert.deepEqual(parseSummaryLocal(null, { icon: 1.35, font: 0.8 }), {
|
|
version: 1, show: false, icon_scale: 1.35, font_scale: 0.8,
|
|
});
|
|
assert.deepEqual(parseSummaryLocal({ show: true, icon_scale: 99, font_scale: 'bad' }, { font: 1.2 }), {
|
|
version: 1, show: true, icon_scale: 3, font_scale: 1,
|
|
});
|
|
assert.notEqual(
|
|
summaryLocalKey({ userId: 'u', path: '/dash', host: 'card', slot: '0' }),
|
|
summaryLocalKey({ userId: 'u', path: '/dash', host: 'card', slot: '1' }),
|
|
);
|
|
assert.equal(summaryLocalKey({ userId: '', slot: '1' }), null);
|
|
assert.equal(normalizeSummaryScale(1.234), 1.25);
|
|
});
|
|
|
|
test('#437 placement identity survives Masonry reflow and inner-card remount', () => {
|
|
const page = { localName: 'hui-view', parentNode: null, children: [] };
|
|
const masonry = { localName: 'hui-masonry-view', parentNode: page, children: [], cards: [] };
|
|
const firstWrapper = { localName: 'hui-card', parentNode: masonry, children: [] };
|
|
const secondWrapper = { localName: 'hui-card', parentNode: masonry, children: [] };
|
|
const firstCard = { localName: 'houseplan-card', parentNode: firstWrapper, children: [] };
|
|
const secondCard = { localName: 'houseplan-card', parentNode: secondWrapper, children: [] };
|
|
page.children = [masonry];
|
|
masonry.children = [firstWrapper, secondWrapper];
|
|
masonry.cards = [firstWrapper, secondWrapper];
|
|
firstWrapper.children = [firstCard];
|
|
secondWrapper.children = [secondCard];
|
|
|
|
const firstSlot = stableSummaryPlacementSlot(firstCard);
|
|
const secondSlot = stableSummaryPlacementSlot(secondCard);
|
|
assert.notEqual(firstSlot, secondSlot, 'two logical cards need distinct preference keys');
|
|
|
|
masonry.children = [secondWrapper, firstWrapper];
|
|
assert.equal(stableSummaryPlacementSlot(firstCard), firstSlot, 'visual column reflow must not change the key');
|
|
|
|
const remountedCard = { localName: 'houseplan-card', parentNode: firstWrapper, children: [] };
|
|
firstWrapper.children = [remountedCard];
|
|
assert.equal(stableSummaryPlacementSlot(remountedCard), firstSlot, 'native wrapper owns the key across remount');
|
|
});
|
|
|
|
function masonryReloadFixture(columnIndexes) {
|
|
const page = { localName: 'hui-view', parentNode: null, children: [] };
|
|
const masonry = { localName: 'hui-masonry-view', parentNode: page, children: [], cards: [] };
|
|
const root = { localName: 'div', parentNode: masonry, children: [] };
|
|
const cards = ['a', 'b', 'c'].map((id) => ({
|
|
id, localName: 'houseplan-card', parentNode: null, children: [], config: { type: 'custom:houseplan-card' },
|
|
}));
|
|
const columns = columnIndexes.map((indexes) => {
|
|
const column = { localName: 'div', parentNode: root, children: [] };
|
|
column.children = indexes.map((index) => {
|
|
cards[index].parentNode = column;
|
|
return cards[index];
|
|
});
|
|
return column;
|
|
});
|
|
page.children = [masonry];
|
|
masonry.children = [root];
|
|
masonry.cards = cards;
|
|
root.children = columns;
|
|
return { cards, masonry };
|
|
}
|
|
|
|
test('#561 full Masonry reload uses canonical cards instead of visual columns', () => {
|
|
const wide = masonryReloadFixture([[0, 2], [1]]);
|
|
const narrow = masonryReloadFixture([[0, 1, 2]]);
|
|
const wideSlots = wide.cards.map((card) => stableSummaryPlacementSlot(card));
|
|
const narrowSlots = narrow.cards.map((card) => stableSummaryPlacementSlot(card));
|
|
|
|
assert.deepEqual(wideSlots, ['masonry-v2:0', 'masonry-v2:1', 'masonry-v2:2']);
|
|
assert.deepEqual(narrowSlots, wideSlots, 'new DOM objects keep the logical config order after reload');
|
|
assert.equal(new Set(wideSlots).size, 3, 'identical configs still own separate preferences');
|
|
assert.notEqual(wideSlots[2], narrowSlots[1], 'old C must never become new B');
|
|
|
|
const [a, b, c] = wide.cards;
|
|
const [firstColumn, secondColumn] = wide.masonry.children[0].children;
|
|
firstColumn.children = [a];
|
|
secondColumn.children = [b, c];
|
|
c.parentNode = secondColumn;
|
|
assert.deepEqual(wide.cards.map((card) => stableSummaryPlacementSlot(card)), wideSlots,
|
|
'live responsive reflow keeps the same canonical slots');
|
|
});
|
|
|
|
test('#561 nested Masonry cards use a stable descendant suffix', () => {
|
|
const page = { localName: 'hui-view', parentNode: null, children: [] };
|
|
const masonry = { localName: 'hui-masonry-view', parentNode: page, children: [], cards: [] };
|
|
const column = { localName: 'div', parentNode: masonry, children: [] };
|
|
const stack = { localName: 'hui-vertical-stack-card', parentNode: column, children: [] };
|
|
const shadow = { localName: 'shadow-root', parentNode: null, host: stack, children: [] };
|
|
const shell = { localName: 'div', parentNode: shadow, children: [] };
|
|
const first = { localName: 'houseplan-card', parentNode: shell, children: [] };
|
|
const second = { localName: 'houseplan-card', parentNode: shell, children: [] };
|
|
page.children = [masonry];
|
|
masonry.children = [column];
|
|
masonry.cards = [stack];
|
|
column.children = [stack];
|
|
shadow.children = [shell];
|
|
shell.children = [first, second];
|
|
|
|
const firstSlot = stableSummaryPlacementSlot(first);
|
|
const secondSlot = stableSummaryPlacementSlot(second);
|
|
assert.notEqual(firstSlot, secondSlot, 'two nested House Plan instances must not share a key');
|
|
assert.match(firstSlot, /^masonry-v2:0\/shadow-root\/div:0\/houseplan-card:0$/);
|
|
|
|
const remounted = { localName: 'houseplan-card', parentNode: shell, children: [] };
|
|
shell.children = [remounted, second];
|
|
assert.equal(stableSummaryPlacementSlot(remounted), firstSlot,
|
|
'inner-card remount at the same logical slot keeps the preference');
|
|
});
|
|
|
|
test('#561 unresolved native Masonry identity never falls back to a visual DOM path', () => {
|
|
const masonry = { localName: 'hui-masonry-view', parentNode: null, children: [] };
|
|
const column = { localName: 'div', parentNode: masonry, children: [] };
|
|
const card = { localName: 'houseplan-card', parentNode: column, children: [] };
|
|
masonry.children = [column];
|
|
column.children = [card];
|
|
|
|
assert.equal(stableSummaryPlacementSlot(card), null, 'missing canonical cards stays session-only');
|
|
masonry.cards = [{ localName: 'hui-other-card' }];
|
|
assert.equal(stableSummaryPlacementSlot(card), null, 'an unmatched element is not guessed by position');
|
|
masonry.cards = [card];
|
|
assert.equal(stableSummaryPlacementSlot(card), 'masonry-v2:0', 'an unresolved result is never cached');
|
|
});
|
|
|
|
test('#437 device total counts unique represented real HA device ids before visual filters', () => {
|
|
const registry = {
|
|
authoritative: true,
|
|
devices: {
|
|
d1: { id: 'd1', area_id: 'kitchen' },
|
|
d2: { id: 'd2', area_id: null },
|
|
d3: { id: 'd3', area_id: 'missing' },
|
|
d4: { id: 'd4', area_id: 'kitchen' },
|
|
d5: { id: 'd5', area_id: 'kitchen' },
|
|
},
|
|
entities: {
|
|
'sensor.two': { entity_id: 'sensor.two', device_id: 'd2' },
|
|
'sensor.standalone': { entity_id: 'sensor.standalone', device_id: null },
|
|
'sensor.restored': { entity_id: 'sensor.restored', device_id: 'd5' },
|
|
},
|
|
};
|
|
const result = representedHaDeviceIds({
|
|
registry, areaToSpace: { kitchen: 'f1' }, spaceIds: new Set(['f1']), firstSpaceId: 'f1',
|
|
markers: [
|
|
{ id: 'm2', binding: 'entity:sensor.two', space: 'f1', hidden: true },
|
|
{ id: 'm2-copy', binding: 'device:d2', space: 'f1' },
|
|
{ id: 'standalone', binding: 'entity:sensor.standalone', space: 'f1' },
|
|
{ id: 'orphan', binding: 'device:d3', space: 'gone' },
|
|
{ id: 'virtual', binding: 'virtual', space: 'f1' },
|
|
{ id: 'removed-area-device', binding: 'device:d4', removed: true, hidden: true },
|
|
{ id: 'removed-parent', binding: 'device:d5', removed: true, hidden: true },
|
|
{ id: 'restored-child', binding: 'entity:sensor.restored', space: 'f1' },
|
|
],
|
|
});
|
|
assert.deepEqual([...result].sort(), ['d1', 'd2', 'd5']);
|
|
assert.equal(representedHaDeviceIds({
|
|
...{ registry: { ...registry, authoritative: false } }, areaToSpace: {},
|
|
spaceIds: new Set(), firstSpaceId: '', markers: [],
|
|
}), null);
|
|
});
|
|
|
|
test('#437 clean-floor total unions overlapping rooms before measuring', () => {
|
|
const rooms = [
|
|
{ id: 'r1', name: 'A', poly: [[0, 0], [100, 0], [100, 100], [0, 100]] },
|
|
{ id: 'r2', name: 'B', poly: [[50, 0], [150, 0], [150, 100], [50, 100]] },
|
|
];
|
|
const model = [{
|
|
id: 'f1', title: 'Floor', cellCm: 5, vb: [0, 0, 1000, 1000], bg: null,
|
|
rooms, wall_segments: [], partitions: [], wall_columns: [],
|
|
}];
|
|
const config = { spaces: [{
|
|
id: 'f1', title: 'Floor', cell_cm: 5, view_box: [0, 0, 1, 1], rooms,
|
|
wall_segments: [], partitions: [], wall_columns: [], openings: [], walls: [],
|
|
}], markers: [], settings: {} };
|
|
assert.equal(totalCleanFloorAreaM2(config, model), 2.16);
|
|
});
|
|
|
|
test('#437 labels use Unicode code points, normalize only known text, and retain extensions', () => {
|
|
const draft = defaultSummaryPanel(tr);
|
|
draft.title = ` ${'😀'.repeat(48)} `;
|
|
draft.extension = { future: true };
|
|
draft.blocks[0].title = ' General ';
|
|
const normalized = normalizeSummaryDraft(draft);
|
|
assert.equal(normalized.title, '😀'.repeat(48));
|
|
assert.equal(normalized.blocks[0].title, 'General');
|
|
assert.deepEqual(normalized.extension, { future: true });
|
|
assert.equal(validateSummaryDraft(normalized, null, new Set(), new Set()).some(
|
|
(problem) => problem.path === 'title' && problem.kind === 'error',
|
|
), false);
|
|
normalized.title += '😀';
|
|
assert.equal(validateSummaryDraft(normalized, null, new Set(), new Set()).some(
|
|
(problem) => problem.path === 'title' && problem.code === 'limit',
|
|
), true);
|
|
});
|
|
|
|
test('#437 entity and system sources preserve real zero and delegate HA formatting', () => {
|
|
const state = { state: '12.345', attributes: { unit_of_measurement: '°C' } };
|
|
const hass = {
|
|
states: { 'sensor.room': state, 'sensor.offline': { state: 'unavailable', attributes: {} } },
|
|
formatEntityState: (value) => value === state ? '12,3 °C' : value.state,
|
|
config: { unit_system: { length: 'km' }, time_zone: 'UTC' },
|
|
};
|
|
assert.equal(summaryEntityValue(hass, 'sensor.room'), '12,3 °C');
|
|
assert.equal(summaryEntityValue(hass, 'sensor.offline'), 'unavailable');
|
|
assert.equal(summaryEntityValue(hass, 'sensor.gone'), null);
|
|
assert.equal(summarySystemValue(
|
|
{ type: 'system', key: 'device_count' },
|
|
{ deviceCount: 0, areaM2: 0, now: new Date('2026-01-01T00:00:00Z') }, hass, 'en',
|
|
), '0');
|
|
assert.equal(summarySystemValue(
|
|
{ type: 'system', key: 'total_area' },
|
|
{ deviceCount: 0, areaM2: 0, now: new Date('2026-01-01T00:00:00Z') }, hass, 'en',
|
|
), '0.0 m²');
|
|
});
|
|
|
|
test('#509 AC3: площадь считает геометрию стен один раз на пространство, результат не меняется', () => {
|
|
const roomsOf = (prefix) => [
|
|
{ id: `${prefix}1`, name: 'A', poly: [[0, 0], [100, 0], [100, 100], [0, 100]] },
|
|
{ id: `${prefix}2`, name: 'B', poly: [[50, 0], [150, 0], [150, 100], [50, 100]] },
|
|
];
|
|
const space = (id, prefix) => ({
|
|
id, title: id, cellCm: 5, vb: [0, 0, 1000, 1000], bg: null,
|
|
rooms: roomsOf(prefix), wall_segments: [], partitions: [], wall_columns: [],
|
|
});
|
|
const raw = (id, prefix) => ({
|
|
id, title: id, cell_cm: 5, view_box: [0, 0, 1, 1], rooms: roomsOf(prefix),
|
|
wall_segments: [], partitions: [], wall_columns: [], openings: [], walls: [],
|
|
});
|
|
const model = [space('f1', 'r'), space('f2', 'q')];
|
|
const config = { spaces: [raw('f1', 'r'), raw('f2', 'q')], markers: [], settings: {} };
|
|
|
|
const calls = [];
|
|
const spy = (spaceModel, prepared) => {
|
|
calls.push(spaceModel.id);
|
|
return spaceWallGeometry(spaceModel, prepared);
|
|
};
|
|
const withSpy = totalCleanFloorAreaM2(config, model, spy);
|
|
// Один вызов на ПРОСТРАНСТВО, а не на комнату: без этого innerContourForRoom
|
|
// объединял кладку заново для каждой комнаты (11 с на большом доме, #509).
|
|
assert.deepEqual(calls, ['f1', 'f2']);
|
|
assert.equal(withSpy, totalCleanFloorAreaM2(config, model), 'общая геометрия не меняет результат');
|
|
assert.equal(withSpy, 4.32);
|
|
});
|
|
|
|
test('#509 AC3: один этаж большого дома считается без пересборки кладки на каждой комнате', () => {
|
|
// Синтетический счётчик выше доказывает «один вызов на пространство», но не
|
|
// то, что результат этого вызова ДОШЁЛ до innerContourForRoom: без
|
|
// shared-аргументов та объединяет кладку заново для каждой комнаты.
|
|
// Этот AC измеряет повторное построение кладки. Максимальные 250 лестниц
|
|
// отдельно входят в общий large-house benchmark и не должны превращать
|
|
// узкий wall-cache witness в тест другой подсистемы (#663).
|
|
//
|
|
// Меряется работа, а не миллисекунды: абсолютный порог по времени краснел
|
|
// на занятой машине (#721). Любая сборка кладки обходит контуры всех комнат
|
|
// пространства, поэтому счётчик чтений `room.poly` детерминированно
|
|
// отражает число таких сборок. Эталон — один явный проход кладки того же
|
|
// этажа в том же прогоне: с общей геометрией площадь этажа стоит ~1,3
|
|
// прохода (сам проход + подготовка + лёгкая работа по комнатам), с
|
|
// пересборкой на каждой комнате — ~21.
|
|
const fixture = makeLargeHouseFixture({ includeStairs: false });
|
|
const config = { ...fixture.config, spaces: fixture.config.spaces.slice(0, 1) };
|
|
const model = spaceModels(config);
|
|
assert.equal(model[0].rooms.length, 20, 'фикстура даёт этаж из 20 комнат');
|
|
let contourReads = 0;
|
|
for (const room of model[0].rooms) {
|
|
let poly = room.poly;
|
|
assert.ok(poly?.length >= 3, `комната ${room.id} задана контуром`);
|
|
Object.defineProperty(room, 'poly', {
|
|
configurable: true, enumerable: true,
|
|
get() { contourReads += 1; return poly; },
|
|
set(next) { poly = next; },
|
|
});
|
|
}
|
|
const prepared = prepareSpacePhysicalGeometryInputs(config.spaces[0], model[0]);
|
|
const beforePass = contourReads;
|
|
spaceWallGeometry(model[0], prepared);
|
|
const onePass = contourReads - beforePass;
|
|
assert.ok(onePass >= model[0].rooms.length, `эталонный проход кладки прочитал ${onePass} контуров — счётчик ослеп`);
|
|
|
|
const beforeArea = contourReads;
|
|
const area = totalCleanFloorAreaM2(config, model);
|
|
const areaWork = contourReads - beforeArea;
|
|
assert.ok(area > 0, String(area));
|
|
const passes = areaWork / onePass;
|
|
assert.ok(passes < 2, `площадь этажа из 20 комнат стоила ${areaWork} чтений контуров = `
|
|
+ `${passes.toFixed(1)} прохода кладки (эталон ${onePass}); ожидается один общий проход — `
|
|
+ 'кладка собирается заново на каждой комнате (#509)');
|
|
});
|