mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
fix: сохранить identity карточек Masonry после reload (#561)
Сводная панель теперь использует канонический порядок карточек Home Assistant и не переносит локальные настройки между визуальными колонками после remount. Issue: #561 User-Visible: yes
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user