Files
houseplan-card/demo/smoke_virtual_light_toggle.mjs
T
2026-08-14 03:32:53 +03:00

172 lines
6.5 KiB
JavaScript

// #107: persistent manual virtual light, shared by two full cards and one static card.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1200, height: 900 });
const result = await page.evaluate(async () => {
const first = window.__card;
const markerId = 'smoke_manual_virtual_light';
const space = first._model[0];
const room = space.rooms.find((candidate) => candidate.id && candidate.area);
const serverConfig = structuredClone(first._serverCfg);
serverConfig.markers = [...(serverConfig.markers || []), {
id: markerId,
name: 'Manual virtual lamp',
binding: 'virtual',
is_light: true,
tap_action: 'toggle',
tap_confirm: false,
controls: ['light.ceiling'],
space: space.id,
area: room.area,
room_id: room.id,
}];
const serverLayout = {
...first._layout,
[markerId]: { s: space.id, x: 0.32, y: 0.32 },
};
const virtual = { rev: 0, config_rev: first._cfgRev, off: [] };
const subscriptions = new Map();
const serviceCalls = [];
let toggleCalls = 0;
const emit = (event, data) => {
for (const listener of subscriptions.get(event) || []) listener({ data });
};
const baseHass = first.hass;
const baseCallWS = baseHass.callWS.bind(baseHass);
const connection = {
...baseHass.connection,
addEventListener: baseHass.connection?.addEventListener?.bind(baseHass.connection),
removeEventListener: baseHass.connection?.removeEventListener?.bind(baseHass.connection),
subscribeMessage: baseHass.connection?.subscribeMessage?.bind(baseHass.connection),
subscribeEvents: async (callback, event) => {
const listeners = subscriptions.get(event) || new Set();
listeners.add(callback);
subscriptions.set(event, listeners);
return () => listeners.delete(callback);
},
};
const hass = {
...baseHass,
connection,
callService: async (domain, service, data) => {
serviceCalls.push({ domain, service, data });
},
callWS: async (message) => {
if (message.type === 'houseplan/config/get') return {
config: serverConfig,
rev: virtual.config_rev,
can_write: true,
virtual_lights: structuredClone(virtual),
};
if (message.type === 'houseplan/layout/get') return {
layout: serverLayout,
rev: first._layoutRev,
};
if (message.type === 'houseplan/virtual_light/toggle') {
toggleCalls++;
const off = new Set(virtual.off);
if (off.has(message.marker_id)) off.delete(message.marker_id);
else off.add(message.marker_id);
virtual.off = [...off].sort();
virtual.rev++;
const reply = {
marker_id: message.marker_id,
on: !off.has(message.marker_id),
rev: virtual.rev,
};
queueMicrotask(() => emit('houseplan_virtual_light_updated', reply));
return reply;
}
return baseCallWS(message);
},
};
// Reconnect the already mounted card to the fake server authority.
first._unsubVirtual?.();
first._unsubVirtual = null;
first.hass = hass;
await first._reloadConfigOnly(true);
first._layout = serverLayout;
first._regSignature = '';
first._maybeRebuildDevices();
first._ensureLiveSyncSubscriptions();
await first.updateComplete;
const second = document.createElement('houseplan-card');
second.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
document.body.append(second);
second.hass = hass;
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: space.id });
document.body.append(staticCard);
staticCard.hass = hass;
const waitFor = async (predicate, timeout = 6000) => {
const started = Date.now();
while (!predicate() && Date.now() - started < timeout) {
await new Promise((resolve) => setTimeout(resolve, 40));
}
return !!predicate();
};
await waitFor(() => second._devices?.some((device) => device.id === markerId));
await waitFor(() => staticCard.renderRoot?.querySelector(`[data-id="${markerId}"]`));
await second.updateComplete;
await staticCard.updateComplete;
const deviceOf = (card) => card._devices.find((device) => device.id === markerId);
const on = (card) => card._stateClass(deviceOf(card)).includes('on');
const staticOn = () => staticCard.renderRoot
.querySelector(`[data-id="${markerId}"]`)?.classList.contains('on');
const initialEverywhereOn = on(first) && on(second) && staticOn();
const markerNode = first.renderRoot.querySelector(`.dev[data-id="${markerId}"]`);
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await waitFor(() => virtual.rev === 1 && !on(second) && staticOn() === false);
await first.updateComplete;
const clickEverywhereOff = !on(first) && !on(second) && staticOn() === false;
// A second real click returns to on; the saved outgoing control never calls HA.
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await waitFor(() => virtual.rev === 2 && on(second) && staticOn() === true);
const secondClickEverywhereOn = on(first) && on(second) && staticOn();
const beforeTouch = toggleCalls;
markerNode.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
clientX: 100, clientY: 100,
}));
markerNode.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, composed: true, pointerType: 'touch', pointerId: 41,
clientX: 100, clientY: 100,
}));
markerNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await waitFor(() => virtual.rev === 3 && !on(second) && staticOn() === false);
const touchSingleToggle = toggleCalls === beforeTouch + 1;
// A fresh card must paint the server's off snapshot, not wait for an event.
const reloaded = document.createElement('houseplan-card');
reloaded.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
document.body.append(reloaded);
reloaded.hass = hass;
await waitFor(() => reloaded._devices?.some((device) => device.id === markerId));
await reloaded.updateComplete;
const reloadFirstStateOff = !on(reloaded);
second.remove();
staticCard.remove();
reloaded.remove();
return {
initialEverywhereOn,
clickEverywhereOff,
secondClickEverywhereOn,
touchSingleToggle,
reloadFirstStateOff,
oneServerTogglePerGesture: toggleCalls === 3,
noHaServiceCalls: serviceCalls.length === 0,
};
});
checkAll(result);
await finish(browser, result);