// #174: a manual-eligible virtual lamp becomes a real, HA-owned light while // another marker controls it. State, actions and touch gestures must all use // the same incoming controller drivers; unlink restores the dormant #107 bit. import { launch, checkAll, finish } from './serve.mjs'; const { page, browser } = await launch({ width: 1200, height: 900 }); const result = await page.evaluate(async () => { const card = window.__card; const sourceId = 'linked_virtual_lamp'; const controllerId = 'linked_wall_relay'; const driverId = 'switch.kettle'; const spaceId = 'f1'; const roomId = 'r2'; const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const frame = () => new Promise((resolve) => requestAnimationFrame( () => requestAnimationFrame(resolve), )); const until = async (predicate, timeout = 6000, label = '') => { const started = performance.now(); while (!predicate()) { if (performance.now() - started > timeout) { throw new Error(`linked-light smoke timed out${label ? `: ${label}` : ''}`); } await wait(25); } }; const serverConfig = structuredClone(card._serverCfg); const floor = serverConfig.spaces.find((space) => space.id === spaceId); floor.settings = { ...(floor.settings || {}), show_names: true, fill_mode: 'light', glow_enabled: true, label_light: true, }; serverConfig.markers = [...(serverConfig.markers || []), { id: controllerId, name: 'Linked wall relay', binding: 'device:d_kettle', tap_action: 'toggle', tap_confirm: false, controls: [`marker:${sourceId}`], space: spaceId, area: 'kitchen', room_id: roomId, }, { id: sourceId, name: 'Linked virtual lamp', binding: 'virtual', is_light: true, tap_action: 'toggle', tap_confirm: false, space: spaceId, area: 'kitchen', room_id: roomId, }]; const serverLayout = { ...card._layout, [controllerId]: { s: spaceId, x: 0.64, y: 0.28 }, [sourceId]: { s: spaceId, x: 0.82, y: 0.28 }, }; const virtual = { rev: 4, config_rev: card._cfgRev, off: [sourceId], }; const serviceCalls = []; let operationalToggleCalls = 0; const baseHass = card.hass; const baseCallWS = baseHass.callWS.bind(baseHass); let testHass; const 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: card._layoutRev, }; if (message.type === 'houseplan/virtual_light/toggle') { operationalToggleCalls++; 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++; return { marker_id: message.marker_id, on: !off.has(message.marker_id), rev: virtual.rev, }; } return baseCallWS(message); }; testHass = { ...baseHass, states: { ...baseHass.states, [driverId]: { ...baseHass.states[driverId], state: 'on' }, }, callService: async (domain, service, data) => { serviceCalls.push({ domain, service, data: structuredClone(data) }); }, callWS, }; card._unsubVirtual?.(); card._unsubVirtual = null; card.hass = testHass; await card._reloadConfigOnly(true); card._layout = serverLayout; card._space = spaceId; card._regSignature = ''; card._maybeRebuildDevices(); card._setMode('view'); await until(() => card._devices.some((device) => device.id === sourceId) && card._devices.some((device) => device.id === controllerId)); await card.updateComplete; await frame(); const item = (id) => card._devices.find((device) => device.id === id); const node = (id) => card.renderRoot.querySelector(`.dev[data-id="${id}"]`); const isOn = (id) => card._stateClass(item(id)).includes('on'); const roomVisual = () => { const fill = card._resolvedRoomFills( card._spaceModel(), card._spaceDisplayForRender(), ).byId.get(roomId); const label = card.renderRoot.querySelector(`.roomlabel[data-id="${roomId}"] .rlm`); return { fill: fill?.color || null, labelExists: !!label, labelLit: !!label?.classList.contains('lit'), labelText: label?.textContent?.trim() || '', glow: card._glowRenderedSources.has(`${spaceId}|${sourceId}`), }; }; const setDriver = async (state) => { testHass = { ...testHass, states: { ...testHass.states, [driverId]: { ...testHass.states[driverId], state }, }, callService: testHass.callService, callWS, }; card.hass = testHass; await card.updateComplete; await frame(); try { await until(() => isOn(sourceId) === (state === 'on')); } catch (error) { throw new Error(`linked driver state did not settle: ${JSON.stringify({ expected: state, live: card.hass?.states?.[driverId]?.state, rendered: card._renderPlanHass?.states?.[driverId]?.state, sourceClass: card._stateClass(item(sourceId)), controllerClass: card._stateClass(item(controllerId)), continuity: card._continuity?.state, })}`, { cause: error }); } }; let clickPointerId = 17300; const click = async (id) => { const target = node(id); const rect = target.getBoundingClientRect(); const clientX = rect.left + rect.width / 2; const clientY = rect.top + rect.height / 2; const pointerId = ++clickPointerId; target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId, pointerType: 'mouse', clientX, clientY, button: 0, buttons: 1, })); target.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId, pointerType: 'mouse', clientX, clientY, button: 0, buttons: 0, })); target.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX, clientY, button: 0, })); await wait(0); }; const targetsOf = (call) => { const value = call?.data?.entity_id; return Array.isArray(value) ? value : value ? [value] : []; }; const initial = roomVisual(); const displayHasLightFill = card._spaceDisplayForRender().fill === 'light'; const displayHasLightLabel = card._spaceDisplayForRender().labelLight === true; const initialSourceOn = isOn(sourceId); const initialControllerOn = isOn(controllerId); const linkedManualOffFollowsDriver = isOn(sourceId) && isOn(controllerId) && initial.glow && initial.labelLit && !!initial.fill; // Source click issues a real HA command. Resolving that promise is not an // optimistic state change: every visual waits for the next HA snapshot. await click(sourceId); const sourceCall = serviceCalls.at(-1); const sourceCallsRelay = sourceCall?.service === 'turn_off' && JSON.stringify(targetsOf(sourceCall)) === JSON.stringify([driverId]); const noOptimisticVisual = isOn(sourceId) && roomVisual().glow; await setDriver('off'); await wait(560); // Glow keeps its DOM node for the deliberate fade-out. await card.updateComplete; const off = roomVisual(); const offSource = !isOn(sourceId); const offController = !isOn(controllerId); const offTickUpdatesAll = !isOn(sourceId) && !isOn(controllerId) && !off.glow && !off.labelLit && off.labelText !== initial.labelText && off.fill !== initial.fill; await click(controllerId); const controllerCall = serviceCalls.at(-1); const controllerCallsRelay = controllerCall?.service === 'turn_on' && JSON.stringify(targetsOf(controllerCall)) === JSON.stringify([driverId]); await setDriver('on'); const relit = roomVisual(); const relitSource = isOn(sourceId); const relitController = isOn(controllerId); const controllerTickUpdatesAll = relitSource && relitController && relit.glow && relit.labelLit; const beforeAutomation = serviceCalls.length; await setDriver('off'); const externalStateUpdateWorks = serviceCalls.length === beforeAutomation && !isOn(sourceId) && !isOn(controllerId) && !roomVisual().labelLit; // One short touch tap is one service call and still waits for HA state. const sourceNode = node(sourceId); const rect = sourceNode.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const pointer = (type, id, target, px = x, py = y, buttons = 0) => target.dispatchEvent( new PointerEvent(type, { bubbles: true, composed: true, pointerId: id, pointerType: 'touch', clientX: px, clientY: py, button: type === 'pointerdown' ? 0 : -1, buttons, }), ); const beforeTap = serviceCalls.length; pointer('pointerdown', 17401, sourceNode, x, y, 1); pointer('pointerup', 17401, sourceNode, x, y, 0); sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); await wait(0); const touchTapCallsOnce = serviceCalls.length === beforeTap + 1 && serviceCalls.at(-1)?.service === 'turn_on' && !isOn(sourceId); // Long-press opens the existing card; its synthetic click is swallowed. const beforeLongPress = serviceCalls.length; pointer('pointerdown', 17402, sourceNode, x, y, 1); await wait(680); const longPressOpenedInfo = !!card._infoCard; pointer('pointerup', 17402, sourceNode, x, y, 0); sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); const longPressNoService = serviceCalls.length === beforeLongPress; card._closeInfoCard(); const beforeCancel = serviceCalls.length; pointer('pointerdown', 17403, sourceNode, x, y, 1); pointer('pointercancel', 17403, sourceNode, x, y, 0); const pointerCancelNoService = serviceCalls.length === beforeCancel && !isOn(sourceId); // A moved one-finger gesture and a two-finger sequence both suppress the // immediately following synthetic click on the marker. const beforePan = serviceCalls.length; pointer('pointerdown', 17404, sourceNode, x, y, 1); pointer('pointermove', 17404, sourceNode, x + 70, y + 45, 1); pointer('pointerup', 17404, sourceNode, x + 70, y + 45, 0); sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); const panNoService = serviceCalls.length === beforePan; await wait(20); const beforePinch = serviceCalls.length; const stage = card._stageEl; pointer('pointerdown', 17405, sourceNode, x, y, 1); pointer('pointerdown', 17406, stage, x + 100, y, 1); pointer('pointermove', 17405, sourceNode, x - 35, y, 1); pointer('pointermove', 17406, stage, x + 135, y, 1); pointer('pointerup', 17405, sourceNode, x - 35, y, 0); pointer('pointerup', 17406, stage, x + 135, y, 0); sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); const pinchNoService = serviceCalls.length === beforePinch; await wait(520); // Removing the last relation exposes the preserved manual off-bit. The // same lamp then returns to #107 and uses operational WS, not HA service. const controllerCfg = serverConfig.markers.find((marker) => marker.id === controllerId); controllerCfg.controls = []; await card._reloadConfigOnly(true); card._layout = serverLayout; card._regSignature = ''; card._maybeRebuildDevices(); await card.updateComplete; await until(() => !isOn(sourceId), 6000, 'unlink did not restore manual off'); const unlinkRestoresManualOff = !isOn(sourceId) && virtual.off.includes(sourceId); const beforeManualService = serviceCalls.length; await click(sourceId); try { await until(() => isOn(sourceId), 6000, 'manual virtual toggle did not turn on'); } catch (error) { throw new Error(`manual virtual toggle did not settle: ${JSON.stringify({ operationalToggleCalls, serviceCalls: serviceCalls.length, beforeManualService, off: virtual.off, suppressClick: card._suppressClick, holdFired: card._holdFired, deviceDrag: card._deviceDrag, sourceAction: item(sourceId)?.tapAction, sourceBinding: item(sourceId)?.binding, sourceState: card._stateClass(item(sourceId)), sourceIntent: card._toggleIntent(item(sourceId)), sourceConnected: node(sourceId)?.isConnected, })}`, { cause: error }); } const manualToggleRestored = operationalToggleCalls === 1 && serviceCalls.length === beforeManualService && !virtual.off.includes(sourceId); return { linkedManualOffFollowsDriver, initialSourceOn, initialControllerOn, initialGlow: initial.glow, initialRoomLabelLit: initial.labelLit, initialRoomLabelExists: initial.labelExists, initialRoomFill: !!initial.fill, displayHasLightFill, displayHasLightLabel, sourceCallsRelay, noOptimisticVisual, offTickUpdatesAll, offSource, offController, offGlowGone: !off.glow, offRoomLabel: !off.labelLit, offRoomFillChanged: off.fill !== initial.fill, controllerCallsRelay, controllerTickUpdatesAll, relitSource, relitController, relitGlow: relit.glow, relitRoomLabel: relit.labelLit, externalStateUpdateWorks, touchTapCallsOnce, longPressOpenedInfo, longPressNoService, pointerCancelNoService, panNoService, pinchNoService, noOperationalToggleWhileLinked: operationalToggleCalls === 1, unlinkRestoresManualOff, manualToggleRestored, }; }); checkAll(result); await finish(browser, result);