import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; import { defaultSummaryPanel, effectiveSummaryVisible, normalizeSummaryDraft, normalizeSummaryScale, parseSummaryLocal, resolveSummaryLayout, summaryLocalKey, summaryPanelOf, validateSummaryDraft, visibleSummaryBlocks, } from '../test-build/summary-panel.js'; import { representedHaDeviceIds, summaryEntityValue, summarySystemValue, totalCleanFloorAreaM2, } 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'; 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 style = readFileSync(new URL('../src/summary-panel-style.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.match(style, /@media \(prefers-reduced-motion: reduce\)/); 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.match(editor, /\?selected=/, 'native selects must project their saved selection'); assert.doesNotMatch(editor, /maxlength=/i, 'limits count Unicode code points, not UTF-16 units'); assert.match(style, /\.summary-editor-row button\s*\{[^}]*min-width:\s*44px;[^}]*height:\s*44px;/s); }); 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('#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: [] }; 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]; 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'); }); 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²'); });