fix: сохранить identity карточек Masonry после reload (#561)

Сводная панель теперь использует канонический порядок карточек Home Assistant и не переносит локальные настройки между визуальными колонками после remount.

Issue: #561
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-13 02:04:45 +03:00
parent d6b33b115b
commit bb35af993a
54 changed files with 744 additions and 425 deletions
+107 -4
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { SUMMARY_PANEL_API_VERSION } from '../test-build/summary-panel-api.js';
import { summaryLocalKey } from '../test-build/summary-panel.js';
import { SUMMARY_PANEL_LEGACY_SCALE_KEY, summaryLocalKey } from '../test-build/summary-panel.js';
import { LoadedSummaryPanelRuntime } from '../test-build/summary-panel-runtime-loaded.js';
const installBrowserGlobals = () => {
@@ -11,18 +11,20 @@ const installBrowserGlobals = () => {
localStorage: Object.getOwnPropertyDescriptor(globalThis, 'localStorage'),
};
const values = new Map();
const reads = [];
const writes = [];
Object.defineProperty(globalThis, 'location', {
configurable: true, value: { pathname: '/dashboard/home' },
});
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
getItem: (key) => { reads.push(key); return values.get(key) ?? null; },
setItem: (key, value) => { writes.push(key); values.set(key, value); },
},
});
return {
values,
values, reads, writes,
restore() {
for (const [key, descriptor] of Object.entries(previous)) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
@@ -70,6 +72,107 @@ const hostFixture = () => {
};
};
test('#561 unresolved Masonry stays session-only and never imports an old DOM-path preference', () => {
const browser = installBrowserGlobals();
try {
const host = hostFixture();
host.panelHost = false;
const masonry = { localName: 'hui-masonry-view', parentNode: null, children: [] };
const column = { localName: 'div', parentNode: masonry, children: [host] };
masonry.children = [column];
host.parentNode = column;
const oldKey = summaryLocalKey({
userId: 'alice', path: '/dashboard/home', host: 'lovelace',
slot: 'hui-view:0/hui-masonry-view:0/div:0/houseplan-card:1',
});
const canonicalKey = summaryLocalKey({
userId: 'alice', path: '/dashboard/home', host: 'lovelace', slot: 'masonry-v2:0',
});
browser.values.set(oldKey, JSON.stringify({
version: 1, show: true, icon_scale: 3, font_scale: 3,
}));
browser.values.set(SUMMARY_PANEL_LEGACY_SCALE_KEY, JSON.stringify({ icon: 1.2, font: 0.9 }));
const runtime = new LoadedSummaryPanelRuntime(host);
runtime.connect();
assert.deepEqual(browser.reads, [], 'unresolved identity must not read any persistent fallback');
runtime.saveLocal({ show: true, icon_scale: 1.5 });
assert.equal(runtime.local.show, true, 'the preference remains effective for this session');
assert.deepEqual(browser.writes, [], 'unresolved identity must not write any persistent fallback');
masonry.cards = [host];
runtime.willUpdate();
runtime.loadLocal();
assert.deepEqual(runtime.local, {
version: 1, show: false, icon_scale: 1.2, font_scale: 0.9,
}, 'a new canonical key starts from the normal defaults and legacy scale seed');
assert.equal(browser.reads.includes(oldKey), false, 'the ambiguous legacy DOM key is not migrated');
assert.equal(browser.values.has(oldKey), true, 'upgrade leaves the old key untouched');
assert.deepEqual(browser.reads, [canonicalKey, SUMMARY_PANEL_LEGACY_SCALE_KEY]);
runtime.saveLocal({ show: false });
assert.deepEqual(browser.writes, [canonicalKey]);
assert.equal(JSON.parse(browser.values.get(canonicalKey)).show, false);
runtime.disconnect();
} finally {
browser.restore();
}
});
test('#561 identical Masonry cards reload their own local preferences', () => {
const browser = installBrowserGlobals();
const runtimes = [];
const mount = (columnIndexes) => {
const hosts = columnIndexes.flat().map(() => {
const host = hostFixture();
host.panelHost = false;
return host;
});
const masonry = { localName: 'hui-masonry-view', parentNode: null, children: [], cards: hosts };
const columns = columnIndexes.map((indexes) => {
const column = { localName: 'div', parentNode: masonry, children: [] };
column.children = indexes.map((index) => {
hosts[index].parentNode = column;
return hosts[index];
});
return column;
});
masonry.children = columns;
return hosts;
};
try {
const values = [
{ version: 1, show: true, icon_scale: 1.25, font_scale: 0.8 },
{ version: 1, show: false, icon_scale: 2.4, font_scale: 1.6 },
];
values.forEach((value, index) => browser.values.set(summaryLocalKey({
userId: 'alice', path: '/dashboard/home', host: 'lovelace', slot: `masonry-v2:${index}`,
}), JSON.stringify(value)));
const wide = mount([[0], [1]]);
const firstPass = wide.map((host) => {
const runtime = new LoadedSummaryPanelRuntime(host);
runtimes.push(runtime);
runtime.connect();
return runtime.local;
});
assert.deepEqual(firstPass, values);
runtimes.splice(0).forEach((runtime) => runtime.disconnect());
const narrow = mount([[0, 1]]);
const secondPass = narrow.map((host) => {
const runtime = new LoadedSummaryPanelRuntime(host);
runtimes.push(runtime);
runtime.connect();
return runtime.local;
});
assert.deepEqual(secondPass, values, 'new card objects after reload must not exchange preferences');
} finally {
runtimes.forEach((runtime) => runtime.disconnect());
browser.restore();
}
});
test('#493 same-key scale remains authoritative and identity changes discard the dialog', async () => {
const browser = installBrowserGlobals();
try {
+85 -1
View File
@@ -248,13 +248,14 @@ test('#437 local preferences use legacy sizes once but never legacy show', () =>
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 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];
@@ -270,6 +271,89 @@ test('#437 placement identity survives Masonry reflow and inner-card remount', (
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,