Files
houseplan-card/test/summary-panel.test.mjs
T

495 lines
25 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 { 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-аргументов та объединяет кладку заново для каждой комнаты, и
// единственный наблюдаемый признак — время (S2: 176 мс на комнату).
// Разрыв семикратный, поэтому порог грубый и не флейкует.
// Этот AC измеряет повторное построение кладки. Максимальные 250 лестниц
// отдельно входят в общий large-house benchmark и не должны превращать
// узкий wall-cache witness в тест другой подсистемы (#663).
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 комнат');
const started = Date.now();
const area = totalCleanFloorAreaM2(config, model);
const elapsed = Date.now() - started;
assert.ok(area > 0, String(area));
assert.ok(elapsed < 2500, `этаж из 20 комнат посчитан за ${elapsed} мс — кладка собирается заново на каждой комнате (#509)`);
});