Files
houseplan-card/test/summary-panel-presentation.test.mjs
2026-09-09 09:32:36 +03:00

466 lines
18 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { nothing } from 'lit';
import { SummaryPanelPresentation } from '../test-build/summary-panel-presentation.js';
import { LoadedSummaryPanelRuntime } from '../test-build/summary-panel-runtime-loaded.js';
import { summaryLocalKey } from '../test-build/summary-panel.js';
const flushFinished = async () => { await Promise.resolve(); await Promise.resolve(); };
function animationEnvironment(t) {
let now = 0;
let timerId = 0;
const timers = new Map();
const listeners = new Set();
const animations = [];
const events = [];
t.mock.method(globalThis, 'setTimeout', (callback, delay) => {
const id = ++timerId;
timers.set(id, { callback, delay, at: now + delay });
return id;
});
t.mock.method(globalThis, 'clearTimeout', (id) => timers.delete(id));
const media = {
matches: false,
addEventListener(kind, listener) { assert.equal(kind, 'change'); listeners.add(listener); },
removeEventListener(kind, listener) { assert.equal(kind, 'change'); listeners.delete(listener); },
};
const view = {
matchMedia(query) { assert.equal(query, '(prefers-reduced-motion: reduce)'); return media; },
getComputedStyle(element) {
events.push('snapshot');
return { ...element.currentStyle };
},
};
const element = {
ownerDocument: { defaultView: view },
currentStyle: { opacity: '1', translate: 'none' },
scrollTop: 73,
animate(frames, options) {
events.push('animate');
let resolve;
let reject;
const animation = {
frames, options, element: this, cancellations: 0,
finished: new Promise((yes, no) => { resolve = yes; reject = no; }),
finish() { resolve(); },
reject() { reject(new Error('Animation cancelled')); },
cancel() { events.push('cancel'); this.cancellations++; },
};
animations.push(animation);
return animation;
},
};
return {
timers, listeners, animations, events, media, view, element,
motion(matches) {
media.matches = matches;
for (const listener of [...listeners]) listener({ matches });
},
advance(ms) {
const target = now + ms;
while (true) {
const due = [...timers.entries()].filter(([, timer]) => timer.at <= target)
.sort((a, b) => a[1].at - b[1].at)[0];
if (!due) break;
now = due[1].at;
timers.delete(due[0]);
due[1].callback();
}
now = target;
},
};
}
function presentationFixture(t) {
const env = animationEnvironment(t);
let repaints = 0;
const presentation = new SummaryPanelPresentation(() => repaints++);
t.after(() => presentation.reset());
return { ...env, presentation, repaints: () => repaints };
}
for (const [side, offset] of [['right', '18px 0px'], ['bottom', '0px 18px']]) {
test(`#505 ${side} entry/exit own one 190ms animation and preserve the mounted exit`, async (t) => {
const f = presentationFixture(t);
const p = f.presentation;
assert.deepEqual([p.phase, p.mounted, p.interactive], ['hidden', false, false]);
p.sync(true, side);
assert.deepEqual([p.phase, p.mounted, p.interactive], ['entering', true, true]);
p.updated(f.element);
const enter = f.animations[0];
assert.deepEqual(enter.frames, [
{ opacity: '0', translate: offset }, { opacity: '1', translate: '0px 0px' },
]);
assert.deepEqual(enter.options, { duration: 190, easing: 'ease', fill: 'both' });
assert.equal(f.timers.size, 1);
assert.equal(f.listeners.size, 1);
enter.finish();
await flushFinished();
assert.deepEqual([p.phase, p.mounted, p.interactive], ['visible', true, true]);
assert.equal(enter.cancellations, 1, 'the filled WAAPI effect is released after committing phase');
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 0);
p.sync(false, side);
assert.deepEqual([p.phase, p.mounted, p.interactive], ['exiting', true, false],
'off immediately removes interaction but retains the same subtree');
p.updated(f.element);
const exit = f.animations[1];
assert.deepEqual(exit.frames, [
{ opacity: '1', translate: 'none' }, { opacity: '0', translate: offset },
]);
assert.equal(exit.element, enter.element);
assert.equal(p.mounted, true, 'rendering/starting exit is not completion');
exit.finish();
await flushFinished();
assert.deepEqual([p.phase, p.mounted, p.interactive], ['hidden', false, false]);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 0);
});
}
test('#505 reversal snapshots current progress before cancellation; old completions cannot settle it', async (t) => {
const f = presentationFixture(t);
const p = f.presentation;
p.sync(true, 'right');
p.updated(f.element);
const enter = f.animations[0];
const enterTimeout = [...f.timers.values()][0].callback;
f.element.currentStyle = { opacity: '0.37', translate: '11.34px 0px' };
f.events.length = 0;
p.sync(false, 'right');
p.updated(f.element);
const exit = f.animations[1];
const exitTimeout = [...f.timers.values()][0].callback;
assert.deepEqual(f.events, ['snapshot', 'cancel', 'animate']);
assert.deepEqual(exit.frames[0], f.element.currentStyle,
'a partial enter must not jump to full opacity before exiting');
assert.equal(enter.cancellations, 1);
f.element.currentStyle = { opacity: '0.21', translate: '14.22px 0px' };
p.sync(true, 'right');
p.updated(f.element);
const newest = f.animations[2];
assert.deepEqual(newest.frames[0], f.element.currentStyle);
assert.equal(newest.element, enter.element, 'reverse in place, never replace the subtree');
assert.equal(exit.cancellations, 1);
assert.equal(f.timers.size, 1);
assert.equal(f.listeners.size, 1);
enter.finish();
exit.finish();
enterTimeout();
exitTimeout();
await flushFinished();
assert.equal(p.phase, 'entering', 'neither old promise nor old timeout may settle the latest run');
assert.equal(f.repaints(), 0);
assert.equal(f.timers.size, 1);
newest.finish();
await flushFinished();
assert.equal(p.phase, 'visible');
assert.equal(f.repaints(), 1);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 0);
});
test('#505 ordinary updates keep the animation, live node and scroll position intact', (t) => {
const f = presentationFixture(t);
f.presentation.sync(true, 'bottom');
f.presentation.updated(f.element);
const timer = [...f.timers.keys()][0];
for (let index = 0; index < 12; index++) {
f.presentation.sync(true, 'bottom');
f.presentation.updated(f.element);
}
assert.equal(f.animations.length, 1);
assert.equal(f.animations[0].cancellations, 0);
assert.deepEqual([...f.timers.keys()], [timer]);
assert.equal(f.listeners.size, 1);
assert.equal(f.element.scrollTop, 73);
});
test('#505 reset releases resources and stale callbacks cannot revive or remove a fresh identity', async (t) => {
const f = presentationFixture(t);
const p = f.presentation;
p.sync(true, 'right');
p.updated(f.element);
const old = f.animations[0];
const staleTimeout = [...f.timers.values()][0].callback;
const staleMedia = [...f.listeners][0];
p.reset();
assert.deepEqual([p.phase, p.mounted, p.interactive], ['hidden', false, false]);
assert.equal(old.cancellations, 1);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 0);
old.finish();
staleTimeout();
staleMedia({ matches: true });
await flushFinished();
assert.equal(p.phase, 'hidden');
assert.equal(f.repaints(), 0, 'disconnected identity must not receive an asynchronous repaint');
p.sync(true, 'bottom');
p.updated(f.element);
assert.equal(p.phase, 'entering', 'reset forgets the old anchor instead of settling a false direction change');
assert.deepEqual(f.animations[1].frames[0], { opacity: '0', translate: '0px 18px' });
staleTimeout();
await flushFinished();
assert.equal(p.phase, 'entering');
assert.equal(f.timers.size, 1);
});
test('#505 immediate boundaries and direction changes cancel old effects without replay', async (t) => {
const f = presentationFixture(t);
const p = f.presentation;
p.sync(true, 'right');
p.updated(f.element);
const old = f.animations[0];
p.sync(true, 'bottom');
p.updated(f.element);
assert.deepEqual([p.phase, p.mounted, p.interactive], ['visible', true, true]);
assert.equal(f.animations.length, 1);
assert.equal(old.cancellations, 1);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 0);
old.finish();
await flushFinished();
assert.equal(f.repaints(), 0);
p.sync(false, 'bottom');
p.updated(f.element);
const exit = f.animations[1];
p.sync(false, 'bottom', true);
assert.equal(p.phase, 'hidden');
assert.equal(exit.cancellations, 1);
assert.equal(f.timers.size, 0);
p.sync(true, 'bottom', true);
p.updated(f.element);
assert.equal(p.phase, 'visible');
assert.equal(f.animations.length, 2, 'immediate on must not animate on the next updated call');
});
test('#505 reduced motion settles both directions and removes transition listeners immediately', async (t) => {
const f = presentationFixture(t);
const p = f.presentation;
f.motion(true);
p.sync(true, 'right');
p.updated(f.element);
assert.equal(p.phase, 'visible');
p.sync(false, 'right');
p.updated(f.element);
assert.equal(p.phase, 'hidden');
assert.equal(f.animations.length, 0);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 0);
f.motion(false);
for (const wanted of [true, false]) {
p.sync(wanted, 'right');
p.updated(f.element);
const running = f.animations.at(-1);
f.motion(true);
assert.equal(p.phase, wanted ? 'visible' : 'hidden');
assert.equal(running.cancellations, 1);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 0);
const afterSettling = f.repaints();
running.finish();
await flushFinished();
assert.equal(f.repaints(), afterSettling);
f.motion(false);
}
});
test('#505 a rejected/cancelled animation still cleans up at the bounded safety deadline', async (t) => {
const f = presentationFixture(t);
const p = f.presentation;
p.sync(true, 'right', true);
p.updated(f.element);
p.sync(false, 'right');
p.updated(f.element);
const exit = f.animations[0];
const timeout = [...f.timers.values()][0];
assert.ok(timeout.delay >= 190 && timeout.delay <= 300);
exit.reject();
await flushFinished();
f.advance(timeout.delay - 1);
assert.equal(p.phase, 'exiting');
assert.equal(p.interactive, false);
f.advance(1);
assert.equal(p.phase, 'hidden');
assert.equal(f.repaints(), 1);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 0);
f.advance(1000);
assert.equal(f.repaints(), 1, 'cleanup is one-shot, not a permanent animation loop');
});
test('#505 absent render nodes defer work, while missing animation APIs settle without timers', (t) => {
const f = presentationFixture(t);
const p = f.presentation;
p.sync(true, 'right');
p.updated(null);
assert.equal(p.phase, 'entering');
assert.equal(f.animations.length, 0);
assert.equal(f.timers.size, 0);
p.updated(f.element);
assert.equal(f.animations.length, 1, 'the first committed node receives the pending enter');
p.reset();
for (const element of [
{ ownerDocument: { defaultView: null } },
{ ownerDocument: { defaultView: f.view } },
]) {
p.sync(true, 'right');
p.updated(element);
assert.equal(p.phase, 'visible');
p.sync(false, 'right');
p.updated(element);
assert.equal(p.phase, 'hidden');
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 0);
}
});
function runtimeFixture(t) {
const f = animationEnvironment(t);
const previous = {
location: Object.getOwnPropertyDescriptor(globalThis, 'location'),
localStorage: Object.getOwnPropertyDescriptor(globalThis, 'localStorage'),
};
const values = new Map();
const writes = [];
const path = { pathname: '/dashboard/home' };
Object.defineProperty(globalThis, 'location', { configurable: true, value: path });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => { writes.push([key, value]); values.set(key, value); },
} });
const key = summaryLocalKey({ userId: 'alice', path: path.pathname, host: 'panel', slot: 'houseplan-card' });
const saved = JSON.stringify({ version: 1, show: true, icon_scale: 1.85, font_scale: 1.35 });
values.set(key, saved);
const host = {
localName: 'houseplan-card', parentNode: null,
panelHost: true, narrow: false, _kiosk: false, _mode: 'view',
hass: { user: { id: 'alice', is_admin: true }, states: {}, config: {} },
_config: {}, _canManageConfiguration: true,
_settings: { summary_panel: { version: 1, title: 'Test', show_on_mobile: true, blocks: [] } },
_kioskScale: { icon: 1, font: 1 }, _space: '',
_stageEl: { clientWidth: 1024, clientHeight: 768, getBoundingClientRect: () => ({ top: 0 }) },
ownerDocument: { defaultView: f.view, visibilityState: 'visible',
createElement: () => ({ dataset: {}, textContent: '' }) },
renderRoot: { adoptedStyleSheets: [], firstChild: null,
querySelector: (selector) => selector === '.summary-overlay' ? f.element : null,
insertBefore: () => undefined },
requestUpdate: () => undefined,
isConnected: true,
};
f.element.ownerDocument = host.ownerDocument;
const runtime = new LoadedSummaryPanelRuntime(host);
// Data loading is independent of presentation and already covered by the
// metrics suite. Prevent unrelated dynamic-import continuations in this clock.
t.mock.method(runtime, 'ensureMetrics', () => undefined);
runtime.connect();
runtime.updated();
assert.notEqual(runtime.renderPanel(), nothing);
runtime.updated();
assert.equal(runtime.presentation.phase, 'entering');
t.after(() => {
runtime.disconnect();
for (const [name, descriptor] of Object.entries(previous)) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete globalThis[name];
}
});
return { ...f, runtime, host, path, values, writes, key, saved };
}
const hardBoundaries = {
'editor entry': ({ host, runtime }) => { host._mode = 'plan'; runtime.renderPanel(); },
'too-small stage': ({ host, runtime }) => { host._stageEl.clientWidth = 120; runtime.resized(); runtime.renderPanel(); },
'mobile policy': ({ host, runtime }) => { host.narrow = true; host._settings.summary_panel.show_on_mobile = false; runtime.renderPanel(); },
'future schema': ({ host, runtime }) => { host._settings.summary_panel.version = 2; runtime.renderPanel(); },
'user identity': ({ host, runtime }) => { host.hass.user.id = 'bob'; runtime.willUpdate(); },
'permission identity': ({ host, runtime }) => { host._canManageConfiguration = false; runtime.willUpdate(); },
'host identity': ({ host, runtime }) => { host.panelHost = false; runtime.willUpdate(); },
'kiosk identity': ({ host, runtime }) => { host._kiosk = true; runtime.willUpdate(); },
'route identity': ({ path, runtime }) => { path.pathname = '/dashboard/other'; runtime.willUpdate(); },
'route leave': ({ runtime }) => runtime.leaveRoute(),
disconnect: ({ runtime }) => runtime.disconnect(),
};
for (const [name, boundary] of Object.entries(hardBoundaries)) {
test(`#505 runtime ${name} synchronously removes presentation and invalidates pending completion`, async (t) => {
const f = runtimeFixture(t);
const old = f.animations[0];
const staleTimeout = [...f.timers.values()][0].callback;
boundary(f);
assert.equal(f.runtime.presentation.phase, 'hidden');
assert.equal(f.runtime.presentation.mounted, false);
assert.equal(old.cancellations, 1);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 0);
old.finish();
staleTimeout();
await flushFinished();
assert.equal(f.runtime.presentation.phase, 'hidden');
assert.equal(f.writes.length, 0, 'temporary eligibility and teardown never persist local intent');
assert.equal(f.values.get(f.key), f.saved, 'both unrelated scales and saved show survive cleanup');
});
}
test('#505 hidden document settles latest intent and returning does not replay motion', async (t) => {
const f = runtimeFixture(t);
const p = f.runtime.presentation;
f.host.ownerDocument.visibilityState = 'hidden';
f.runtime.visibility('hidden');
assert.equal(p.phase, 'visible', 'document hiding settles the existing on intent');
assert.equal(f.animations[0].cancellations, 1);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 0);
f.runtime.local = { ...f.runtime.local, show: false };
assert.equal(f.runtime.renderPanel(), nothing);
assert.equal(p.phase, 'hidden');
f.runtime.local = { ...f.runtime.local, show: true };
f.runtime.renderPanel();
f.runtime.updated();
assert.equal(p.phase, 'visible');
f.host.ownerDocument.visibilityState = 'visible';
f.runtime.visibility('visible');
f.runtime.renderPanel();
f.runtime.updated();
assert.equal(f.animations.length, 1, 'no old enter replays on foregrounding');
f.animations[0].finish();
await flushFinished();
assert.equal(p.phase, 'visible');
assert.equal(f.writes.length, 0);
});
test('#505 runtime anchor change settles in place and an ineligible panel can enter afresh', async (t) => {
const f = runtimeFixture(t);
const p = f.runtime.presentation;
f.host._stageEl.clientWidth = 700;
f.host._stageEl.clientHeight = 1000;
f.runtime.resized();
f.runtime.renderPanel();
f.runtime.updated();
assert.equal(p.phase, 'visible');
assert.equal(f.animations.length, 1);
assert.equal(f.animations[0].cancellations, 1);
f.host._stageEl.clientWidth = 100;
f.runtime.resized();
assert.equal(f.runtime.renderPanel(), nothing);
assert.equal(p.phase, 'hidden');
f.host._stageEl.clientWidth = 700;
f.runtime.resized();
f.runtime.renderPanel();
f.runtime.updated();
assert.equal(p.phase, 'entering');
assert.deepEqual(f.animations[1].frames[0], { opacity: '0', translate: '0px 18px' });
f.animations[0].finish();
await flushFinished();
assert.equal(p.phase, 'entering');
assert.equal(f.writes.length, 0);
});