mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
The marker half of the space-switch witness asked whether a box-shadow transition was running on the shell. That worked only because such a transition existed; #524 removed it — the shadow is sized in container units and animating it cost a real user 9.4 frames per second — and the check became trivially true. The mutant that removes the keys from the marker list has been surviving ever since, and nobody noticed until the next gate ran it. The witness now keeps references to the marker nodes and requires that none of them stays in the DOM under a different data-id after the switch. Node identity is what the keys are for, and it does not depend on any stylesheet. The door half is untouched: there the transition is part of the product contract, not a side effect. Issue: #528 User-Visible: no
155 lines
8.5 KiB
JavaScript
155 lines
8.5 KiB
JavaScript
// Переключение пространств не доигрывает чужие переходы (#525).
|
||
//
|
||
// Lit переиспользует узлы списка по позиции. Пока у створки двери
|
||
// (`.op-leaf`, `transform`) и у дуги (`.op-arc`, `stroke-dashoffset`) есть
|
||
// переход в 0.6 с, позиционное переиспользование превращает смену
|
||
// пространства в анимацию: створка нового этажа доезжает из положения двери,
|
||
// которая занимала этот слот раньше. То же у оболочки маркера
|
||
// (`.device-shell-frame`, `box-shadow`).
|
||
//
|
||
// Две ловушки, обе стоили бы свидетелю правдивости:
|
||
// 1. `document.getAnimations()` здесь пуст ДАЖЕ НА СЛОМАННОМ КОДЕ — карточка
|
||
// живёт в shadow root, и документный вызов туда не заходит. Считаем
|
||
// поэлементно, обходя вложенные теневые деревья.
|
||
// 2. В демо-доме проёмов нет вовсе (`space.openings` пуст у обоих
|
||
// пространств), поэтому дверь в двух пространствах готовит сам смок.
|
||
import { launch, checkAll, finish } from './serve.mjs';
|
||
const { page, browser } = await launch();
|
||
|
||
const res = await page.evaluate(async () => {
|
||
const out = {};
|
||
const c = window.__card;
|
||
const root = () => c.shadowRoot || c.renderRoot;
|
||
const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||
const settle = async (frames = 10, ms = 500) => {
|
||
for (let i = 0; i < frames; i++) await frame();
|
||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||
};
|
||
/** Все бегущие анимации внутри карточки, включая вложенные shadow root. */
|
||
const runningAnimations = () => {
|
||
const found = [];
|
||
const walk = (node) => {
|
||
for (const element of node.querySelectorAll('*')) {
|
||
const list = element.getAnimations ? element.getAnimations() : [];
|
||
for (const animation of list) {
|
||
if (animation.playState !== 'running') continue;
|
||
found.push({
|
||
cls: element.getAttribute?.('class') || element.tagName,
|
||
property: animation.transitionProperty || animation.animationName || '?',
|
||
});
|
||
}
|
||
if (element.shadowRoot) walk(element.shadowRoot);
|
||
}
|
||
};
|
||
walk(root());
|
||
return found;
|
||
};
|
||
// Вкладка подсвечивается, слайд едет, значения сводной панели проявляются —
|
||
// это и есть переключение. Всё остальное, что бежит сразу после него,
|
||
// доигрывает чужие данные.
|
||
const EXPECTED = (entry) => /(^|\s)tab(\s|$)|zoomwrap/.test(entry.cls)
|
||
|| /^summary-/.test(entry.property);
|
||
|
||
// ---- фикстура: дверь на одном месте в двух пространствах ---------------
|
||
const ids = c._serverCfg.spaces.map((space) => space.id).slice(0, 2);
|
||
out.twoSpacesExist = ids.length === 2;
|
||
const physicalize = (space) => {
|
||
for (const room of [...(space.rooms || [])]) {
|
||
const rendered = c._spaceModelById(space.id)?.rooms?.find((item) => item.id === room.id);
|
||
if (!rendered?.poly?.length) continue;
|
||
c._wallDialog = {
|
||
a: rendered.poly[0], b: rendered.poly[1], value: '15', roomId: room.id,
|
||
source: { kind: 'room' }, sx: 0, sy: 0,
|
||
};
|
||
c._wallThickApply(true);
|
||
}
|
||
};
|
||
for (const id of ids) physicalize(c._serverCfg.spaces.find((space) => space.id === id));
|
||
c._geometryHistory.length = 0;
|
||
const contactOpen = 'binary_sensor.probe_door_open';
|
||
const contactShut = 'binary_sensor.probe_door_shut';
|
||
const withContacts = (openState) => ({
|
||
...c.hass,
|
||
states: {
|
||
...c.hass.states,
|
||
[contactOpen]: { entity_id: contactOpen, state: openState, attributes: { device_class: 'door' } },
|
||
[contactShut]: { entity_id: contactShut, state: 'off', attributes: { device_class: 'door' } },
|
||
},
|
||
entities: {
|
||
...(c.hass.entities || {}),
|
||
[contactOpen]: { entity_id: contactOpen },
|
||
[contactShut]: { entity_id: contactShut },
|
||
},
|
||
});
|
||
c.hass = withContacts('on');
|
||
const door = (id, contact) => ({
|
||
id, type: 'door', x: 0.2, y: 0.14, angle: 0, length: 0.08, contact,
|
||
});
|
||
c._serverCfg.spaces.find((space) => space.id === ids[0]).openings = [door('opA', contactOpen)];
|
||
c._serverCfg.spaces.find((space) => space.id === ids[1]).openings = [door('opB', contactShut)];
|
||
c._cfgEpoch++;
|
||
c.requestUpdate();
|
||
await c.updateComplete;
|
||
|
||
// ---- 1) штатное переключение не анимирует чужую створку ----------------
|
||
c._pickSpace(ids[0]);
|
||
await c.updateComplete;
|
||
await settle();
|
||
const leafBefore = root().querySelector('.op-leaf');
|
||
out.doorIsDrawnInTheFirstSpace = !!leafBefore;
|
||
// #528: маркеры судятся по идентичности узлов, а не по бегущему переходу.
|
||
// Прежняя проверка смотрела на переход `box-shadow` у оболочки — он исчез
|
||
// вместе с правкой #524, и мутант «маркеры без ключей» начал выживать:
|
||
// признак стал тривиально истинным. Идентичность узла не зависит ни от
|
||
// одного перехода и переживёт любую правку стилей.
|
||
const markersBefore = [...root().querySelectorAll('[data-hp="device"]')]
|
||
.map((node) => ({ node, id: node.dataset.id }));
|
||
out.markersDrawnInTheFirstSpace = markersBefore.length >= 3;
|
||
out.quietBeforeTheSwitch = runningAnimations().filter((entry) => !EXPECTED(entry)).length === 0;
|
||
// поворот створки живёт инлайновым стилем, не атрибутом
|
||
const leafTransform = (leaf) => leaf?.style?.transform || leaf?.getAttribute('transform') || null;
|
||
const transformBefore = leafTransform(leafBefore);
|
||
|
||
c._pickSpace(ids[1]);
|
||
await c.updateComplete;
|
||
await frame();
|
||
const afterSwitch = runningAnimations();
|
||
const leafAfter = root().querySelector('.op-leaf');
|
||
out.doorIsDrawnInTheSecondSpace = !!leafAfter;
|
||
out.theTwoDoorsDifferInState = !!transformBefore && transformBefore !== leafTransform(leafAfter);
|
||
out.noOpeningTransitionOnSwitch = afterSwitch
|
||
.filter((entry) => /op-leaf|op-arc/.test(entry.cls)).length === 0;
|
||
out.noMarkerTransitionOnSwitch = afterSwitch
|
||
.filter((entry) => /device-shell-frame/.test(entry.cls)).length === 0;
|
||
// Узел, живший до переключения, не имеет права остаться в DOM под чужим
|
||
// `data-id`: это и есть позиционное переиспользование, ради которого в
|
||
// `repeat` стоят ключи (#525 AC2, #528).
|
||
const reused = markersBefore.filter((entry) => entry.node.isConnected
|
||
&& entry.node.dataset.id !== entry.id);
|
||
out.noMarkerNodeReusedForAnotherDevice = reused.length === 0;
|
||
out.reusedMarkerNodes = reused.map((entry) => `${entry.id} → ${entry.node.dataset.id}`);
|
||
const unexpected = afterSwitch.filter((entry) => !EXPECTED(entry));
|
||
out.onlyTheSwitchItselfAnimates = unexpected.length === 0;
|
||
out.unexpectedAnimations = unexpected.map((entry) => `${entry.cls}:${entry.property}`);
|
||
// ещё кадр — переход мог стартовать с задержкой в один тик
|
||
await frame();
|
||
await frame();
|
||
const later = runningAnimations().filter((entry) => !EXPECTED(entry));
|
||
out.stillQuietOneFrameLater = later.length === 0;
|
||
|
||
// ---- 2) настоящая смена состояния двери по-прежнему анимируется --------
|
||
await settle();
|
||
out.quietBeforeTheRealChange = runningAnimations()
|
||
.filter((entry) => /op-leaf|op-arc/.test(entry.cls)).length === 0;
|
||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||
[contactShut]: { entity_id: contactShut, state: 'on', attributes: { device_class: 'door' } } } };
|
||
await c.updateComplete;
|
||
await frame();
|
||
const onRealChange = runningAnimations();
|
||
out.aRealDoorOpeningStillAnimates = onRealChange
|
||
.filter((entry) => /op-leaf/.test(entry.cls)).length >= 1;
|
||
return out;
|
||
});
|
||
checkAll(res, { unexpectedAnimations: [], reusedMarkerNodes: [] });
|
||
await finish(browser, res);
|