mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
feat: add configurable summary panel (#437)
Issue: #437 User-Visible: yes
This commit is contained in:
@@ -66,10 +66,10 @@ test('issue 266 the media wrappers survived the move', () => {
|
||||
const all = FILES.map((name) => sourceOf(name)).join('\n');
|
||||
assert.equal((all.match(/@media \(forced-colors: active\)/g) || []).length, 2,
|
||||
'both forced-colors blocks must survive — golden never emulates them');
|
||||
// 10 source wrappers; two of them were MIXED-zone and are split into
|
||||
// per-zone copies by the generator (cascade fix), hence 12 wrappers over
|
||||
// the same 10 wrappers' worth of rules — the scope-keyed refactor diff
|
||||
// proves no rule lost its wrapper.
|
||||
// 10 historical source wrappers; two were MIXED-zone and split into
|
||||
// per-zone copies by the generator. #437's overlay stylesheet is loaded
|
||||
// with its lazy runtime and has its own source contract, so the five eager
|
||||
// surface owners remain at the historical 12 wrappers.
|
||||
assert.equal((all.match(/@media \(prefers-reduced-motion: reduce\)/g) || []).length, 12,
|
||||
'reduced-motion wrappers must survive — golden always shoots reduced');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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';
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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 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' },
|
||||
},
|
||||
entities: {
|
||||
'sensor.two': { entity_id: 'sensor.two', device_id: 'd2' },
|
||||
'sensor.standalone': { entity_id: 'sensor.standalone', device_id: null },
|
||||
},
|
||||
};
|
||||
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' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual([...result].sort(), ['d1', 'd2']);
|
||||
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²');
|
||||
});
|
||||
Reference in New Issue
Block a user