mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
466 lines
18 KiB
JavaScript
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);
|
|
});
|