diff --git a/demo/smoke_zigbee_tooltip_layout.mjs b/demo/smoke_zigbee_tooltip_layout.mjs new file mode 100644 index 00000000..19dbb39e --- /dev/null +++ b/demo/smoke_zigbee_tooltip_layout.mjs @@ -0,0 +1,415 @@ +// #802: real pointer + public HA/config inputs. No private card writes: the +// fixture delivers registry events and the integration's normal topology feed. +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { launch, checkAll, finish } from './serve.mjs'; + +const artifacts = new URL('../artifacts/zigbee-topology-802/', import.meta.url); +mkdirSync(artifacts, { recursive: true }); +const out = {}; +const evidence = []; +const ieee = (n) => `00124b000000000${n}`; +const remoteName = 'Landing & "night light"'; +const nodes = [ + ['d_light1', 1, 1, 'Router'], ['d_lamp', 2, 0, 'Coordinator'], + ['d_mower', 3, 3, 'EndDevice'], ['d_temp', 4, 4, 'EndDevice'], + ['not_on_plan', 6, 6, 'Router'], ['d_kettle', 7, 7, 'EndDevice'], +]; +const z2m = { + nodes: nodes.map(([id, n, nwk, type]) => ({ ieeeAddr: ieee(n), networkAddress: nwk, type, + friendlyName: id === 'not_on_plan' ? 'Unplaced & target' : 'Provider target' })), + links: [ + { source: ieee(2), target: ieee(1), linkquality: 130, routes: [ + { destinationAddress: 0, nextHopAddress: 0, status: 'ACTIVE' }, + ] }, + { source: ieee(1), target: ieee(3), relationship: 'Parent', linkquality: 140 }, + { source: ieee(6), target: ieee(4), relationship: 'Parent', linkquality: 90 }, + { source: ieee(2), target: ieee(6), linkquality: 110, routes: [ + { destinationAddress: 0, nextHopAddress: 0, status: 'ACTIVE' }, + ] }, + ], +}; +const zha = nodes.map(([id, n, nwk, type]) => ({ ieee: ieee(n), nwk, device_reg_id: id, + device_type: type, name: id === 'not_on_plan' ? 'Unplaced & target' : 'Provider target', + neighbors: n === 3 ? [{ ieee: ieee(1), relationship: 'Parent', lqi: 140 }] + : n === 4 ? [{ ieee: ieee(6), relationship: 'Parent', lqi: 90 }] : [], + ...(n === 1 || n === 6 ? { routes: [{ dest_nwk: 0, next_hop: 0, route_status: 'Active' }] } : {}), +})); + +for (const provider of ['z2m', 'zha']) { + const { page, browser } = await launch({ width: 1100, height: 900 }); + const root = page.locator('#host > houseplan-card'); + const marker = (id) => root.locator(`[data-hp="device"][data-id="${id}"]`); + const bubble = root.locator('[data-hp="zigbee-topology-parent-bubble"]'); + const settle = () => page.evaluate(() => window.__hpTest.settled()); + let pointer; + const hover = async (id, vertical = 0.5) => { + await page.mouse.move(2, 2); + const box = await marker(id).boundingBox(); + if (!box) throw new Error(`missing marker ${id}`); + pointer = { x: box.x + box.width / 2, y: box.y + box.height * vertical }; + await page.mouse.move(pointer.x, pointer.y); + await settle(); + }; + const sample = async (id) => page.evaluate((id) => { + const card = window.__card; + const root = card.shadowRoot; + const topology = root.querySelector('hp-zigbee-topology-overlay'); + const tip = root.querySelector('[data-hp-live-tip]'); + const rect = (element) => { + const r = element.getBoundingClientRect(); + return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height }; + }; + const visible = (element) => !!element && !element.hidden && element.getBoundingClientRect().width > 0 + && getComputedStyle(element).display !== 'none' && getComputedStyle(element).visibility !== 'hidden'; + const stage = rect(root.querySelector('.stage')); + const bounds = { left: Math.max(stage.left, 0), top: Math.max(stage.top, 0), + right: Math.min(stage.right, innerWidth), bottom: Math.min(stage.bottom, innerHeight) }; + const source = rect(root.querySelector(`[data-hp="device"][data-id="${id}"]`)); + const badges = [...(topology?.shadowRoot?.querySelectorAll('.parent-bubble,.remote,.route-status') || [])] + .filter(visible).map((element) => ({ rect: rect(element), text: element.textContent.trim(), + kind: element.className, pointerEvents: getComputedStyle(element).pointerEvents })); + const tipRect = tip ? rect(tip) : null; + const overlap = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)) + * Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)); + const tipVisible = visible(tip); + const badgesDisjoint = badges.every((badge, i) => badges.slice(i + 1) + .every((other) => overlap(badge.rect, other.rect) < 0.5)); + const inside = (r) => r.left >= bounds.left - 1 && r.top >= bounds.top - 1 + && r.right <= bounds.right + 1 && r.bottom <= bounds.bottom + 1; + const sourceElement = root.querySelector(`[data-hp="device"][data-id="${id}"]`); + const floor = sourceElement.getAttribute('data-hp-iso-floor')?.split(',').map(Number); + const visual = sourceElement.getAttribute('data-hp-iso-visual')?.split(',').map(Number); + return { source, bounds, badges, tip: tipRect, tipVisible, + projectionIso: root.querySelector('.stage').classList.contains('projection-iso'), + isoWalls: !!root.querySelector('[data-hp="iso-walls"]'), + sourceRaised: floor?.length === 2 && visual?.length === 2 + && Math.hypot(floor[0] - visual[0], floor[1] - visual[1]) > 0.1, + tipText: tip?.textContent || '', badgesDisjoint, + clean: tipVisible && badges.length > 0 && badges.every((badge) => overlap(tipRect, badge.rect) < 0.5) + && overlap(tipRect, source) < 0.5 && inside(tipRect), + badgeInside: badges.every((badge) => inside(badge.rect)), + pointerTransparent: !!topology && getComputedStyle(topology).pointerEvents === 'none' + && badges.every((badge) => badge.pointerEvents === 'none'), + safeText: !topology?.shadowRoot?.querySelector('router,img,script'), + endpoint: root.querySelector(`[data-hp="device"][data-id="${id}"]`)?.hasAttribute('data-hp-zigbee-topology-endpoint'), + lines: [...(topology?.shadowRoot?.querySelectorAll('line,polygon') || [])].map((element) => + ['x1', 'x2', 'y1', 'y2', 'points'].map((name) => element.getAttribute(name)).join(',')), + calls: { ...window.__tooltipFixture.calls }, + }; + }, id); + const witness = async (name, id, { hidden = false } = {}) => { + const value = await sample(id); + evidence.push({ provider, name, ...value }); + out[`${provider}_${name}`] = value.badgesDisjoint + && (hidden ? !value.tipVisible && value.badges.length > 0 : value.clean); + return value; + }; + const deliver = async (options = {}) => page.evaluate((options) => { + const fixture = window.__tooltipFixture; + if (options.name !== undefined) { + fixture.devices.d_light1 = { ...fixture.devices.d_light1, + name_by_user: options.name, name: options.registryName ?? '' }; + window.__card.hass = { ...window.__card.hass, devices: { ...fixture.devices } }; + for (const callback of fixture.registryCallbacks) callback({ action: 'update', device_id: 'd_light1' }); + } + if (options.clear) fixture.result = null; + else if (options.restore) fixture.result = fixture.originalResult; + if (options.providerName !== undefined && fixture.result) { + fixture.result = structuredClone(fixture.result); + fixture.result.nodes[0].friendlyName = options.providerName; + } + fixture.phase = options.phase || 'ready'; + fixture.stale = options.stale === true; + for (const callback of fixture.callbacks) callback(fixture.event()); + }, options); + try { + // A delayed overlay chunk makes the first hover occur before upgrade. The + // source pointer stays still while the public backend reset supplies data. + let releaseOverlay; + let overlayWasDelayed = false; + const overlayReady = new Promise((resolve) => { releaseOverlay = resolve; }); + if (provider === 'z2m') await page.route('**/*hp-zigbee-topology-overlay*.js', async (route) => { + overlayWasDelayed = true; + await overlayReady; await route.fallback(); + }); + await page.evaluate(async ({ provider, nodes, z2m, zha, remoteName }) => { + const card = window.__card; + const original = card.hass; + const fixture = { devices: structuredClone(original.devices), callbacks: new Set(), registryCallbacks: new Set(), + calls: { zha: 0, start: 0, subscriptions: 0 }, revision: 0, phase: 'ready', stale: false, + result: provider === 'z2m' ? z2m : null, originalResult: z2m, zha }; + for (const [id, n] of nodes) if (fixture.devices[id]) fixture.devices[id].identifiers = [['zha', `00124b000000000${n}`]]; + fixture.devices.d_light1 = { ...fixture.devices.d_light1, name_by_user: remoteName }; + fixture.event = () => fixture.result ? { + kind: 'state', session_id: 'tooltip-802', revision: ++fixture.revision, + provider: { topic: 'zigbee2mqtt', job_id: 'fixture', phase: fixture.phase, elapsed_ms: 1200, + obtained_at: Date.now() - (fixture.stale ? 360000 : 0), stale: fixture.stale, + ...(fixture.phase === 'error' ? { error: 'provider' } : {}), result: fixture.result }, + } : { kind: 'removed', session_id: 'tooltip-802', revision: ++fixture.revision, topic: 'zigbee2mqtt' }; + const connection = { ...original.connection, + subscribeEvents: async (callback, type) => { + if (type === 'device_registry_updated') fixture.registryCallbacks.add(callback); + const release = await original.connection.subscribeEvents(callback, type); + return () => { fixture.registryCallbacks.delete(callback); release(); }; + }, + subscribeMessage: async (callback, message) => { + if (message.type !== 'houseplan/zigbee/subscribe') return original.connection.subscribeMessage(callback, message); + fixture.calls.subscriptions++; + fixture.callbacks.add(callback); + callback({ kind: 'reset', session_id: 'tooltip-802', revision: fixture.revision, + topics: fixture.result ? ['zigbee2mqtt'] : [] }); + if (fixture.result) callback(fixture.event()); + return () => fixture.callbacks.delete(callback); + }, + }; + card.hass = { ...original, devices: fixture.devices, connection, + callWS: async (message) => { + if (message.type === 'config/device_registry/list') return Object.values(fixture.devices); + if (message.type === 'zha/devices') { fixture.calls.zha++; return fixture.zha; } + if (message.type === 'houseplan/zigbee/start') { fixture.calls.start++; return fixture.event(); } + const response = await original.callWS(message); + return message.type === 'houseplan/config/get' ? { ...response, zigbee_scan_api: 1 } : response; + }, + }; + window.__tooltipFixture = fixture; + document.getElementById('host').style.width = '900px'; + await window.__hpTest.setServerConfig((cfg) => { + cfg.settings = { ...cfg.settings, zigbee_topology: { enabled: true, z2m_base_topics: ['zigbee2mqtt'] } }; + cfg.spaces = cfg.spaces.map((space) => ({ ...space, + settings: { ...space.settings, show_borders: true, show_names: true }, + })); + return cfg; + }); + await window.__hpTest.setLayout((layout) => ({ ...layout, + d_kettle: { s: 'f1', x: 0.50, y: 0.40 }, d_temp: { s: 'f1', x: 0.22, y: 0.62 }, + d_light1: { s: 'f1', x: 0.25, y: 0.27 }, d_mower: { s: 'garden', x: 0.50, y: 0.48 }, + })); + }, { provider, nodes, z2m, zha, remoteName }); + if (provider === 'zha') { + await page.evaluate(() => window.__hpTest.setMode('plan')); + await root.locator('[data-hp="settings"]').click(); + await root.locator('hp-zigbee-topology-settings').getByRole('button', { name: /Read ZHA/ }).click(); + await page.waitForFunction(() => window.__tooltipFixture.calls.zha === 1); + await page.evaluate(() => window.__hpTest.close()); + await page.evaluate(() => window.__hpTest.setMode('view')); + } + await page.evaluate(() => window.__hpTest.switchSpace('garden')); + await hover('d_mower'); + if (provider === 'z2m') out.mouseReallyPrecedesLazyUpgrade = overlayWasDelayed && await page.evaluate(() => + !customElements.get('hp-zigbee-topology-overlay') + && !window.__card.shadowRoot.querySelector('[data-hp-live-tip]').hidden); + releaseOverlay(); + await bubble.waitFor({ state: 'visible' }); + await settle(); + out[`${provider}_remoteCaption`] = (await bubble.innerText()).trim() === `Ground floor (${remoteName})`; + const remote = await witness(provider === 'z2m' ? 'firstHoverAfterLazyUpgrade' : 'firstMouseHover', 'd_mower'); + out[`${provider}_safeTextAndPointerTransparent`] = remote.safeText && remote.pointerTransparent; + await page.screenshot({ path: fileURLToPath(new URL(`${provider}-remote-caption.png`, artifacts)) }); + + if (provider === 'z2m') { + for (const [name, registryName, providerName, expected] of [ + [' ', 'Registry target', 'Provider target', 'Ground floor (Registry target)'], + [' ', ' ', 'Provider target', 'Ground floor (Provider target)'], + [' ', ' ', ' ', 'Ground floor'], + ]) { + await deliver({ name, registryName, providerName }); + await page.waitForTimeout(300); + await hover('d_mower'); + await page.waitForFunction((expected) => window.__card.shadowRoot.querySelector('hp-zigbee-topology-overlay') + ?.shadowRoot?.querySelector('.parent-bubble')?.textContent.trim() === expected, expected); + out[`${provider}_nameFallback_${expected}`] = (await bubble.innerText()).trim() === expected; + } + await deliver({ name: remoteName }); + await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => { + cfg.spaces.find((space) => space.id === 'f1').title = ''; return cfg; + })); + await hover('d_mower'); + out.z2m_missingSpaceTitle = (await bubble.innerText()).trim() === `another space (${remoteName})`; + await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => { + cfg.spaces.find((space) => space.id === 'f1').title = 'Ground floor'; return cfg; + })); + + for (const iso of [false, true]) for (const theme of ['light', 'dark']) { + await page.evaluate(async ({ iso, theme }) => { + await window.__hpTest.setVolumetricView(iso); + const card = window.__card; + card.hass = { ...card.hass, themes: { ...card.hass.themes, darkMode: theme === 'dark' } }; + for (const [key, value] of Object.entries(theme === 'light' ? { + '--card-background-color': '#ffffff', '--ha-card-background': '#ffffff', + '--primary-text-color': '#202020', '--secondary-text-color': '#606060', + } : { '--card-background-color': '#1c2530', '--ha-card-background': '#1c2530', + '--primary-text-color': '#e1e1e1', '--secondary-text-color': '#9aa4ad' })) card.style.setProperty(key, value); + }, { iso, theme }); + await page.evaluate(() => window.__hpTest.switchSpace('f1')); + for (const [id, name] of [['d_temp', 'unplaced'], ['d_light1', 'remote-count'], ['d_kettle', 'status-only']]) { + await hover(id, 0.25); + await root.locator('.route-status').waitFor({ state: 'visible' }); + const value = await witness(`${iso ? 'iso' : 'flat'}_${theme}_${name}`, id); + out[`actualProjection_${iso}_${theme}_${name}`] = value.projectionIso === iso + && (!iso || (value.isoWalls && value.sourceRaised)); + if (name === 'remote-count') out[`remoteCount_${iso}_${theme}`] = value.badges.some((badge) => /1/.test(badge.text) && badge.kind === 'remote'); + if (name === 'status-only') { + out[`statusOnlyHasNoEndpoint_${iso}_${theme}`] = !value.endpoint && value.lines.length === 0 + && value.badges.some((badge) => /No route data/.test(badge.text)); + await page.screenshot({ path: fileURLToPath(new URL(`status-only-${iso ? 'iso' : 'flat'}-${theme}.png`, artifacts)) }); + if (!iso && theme === 'light') { + // Recreate only the old display position, not product state. The + // same intersection oracle must reject the original defect. + const negative = await page.evaluate(({ x, y }) => { + const tip = window.__card.shadowRoot.querySelector('[data-hp-live-tip]'); + const before = tip.getAttribute('style'); + tip.style.left = `${Math.min(innerWidth - tip.offsetWidth - 8, x + 12)}px`; + tip.style.top = `${Math.min(innerHeight - tip.offsetHeight - 8, y + 12)}px`; + return before; + }, pointer); + const oldPlacement = await sample(id); + evidence.push({ provider, name: 'negative-control-cursor-plus-12', ...oldPlacement }); + out.oldPlacementNegativeControlFails = !oldPlacement.clean && oldPlacement.tipVisible + && oldPlacement.badges.some(({ rect }) => Math.max(0, Math.min(rect.right, oldPlacement.tip.right) + - Math.max(rect.left, oldPlacement.tip.left)) * Math.max(0, Math.min(rect.bottom, oldPlacement.tip.bottom) + - Math.max(rect.top, oldPlacement.tip.top)) > 10); + await page.screenshot({ path: fileURLToPath(new URL('negative-control-old-placement.png', artifacts)) }); + await root.locator('[data-hp-live-tip]').evaluate((tip, style) => tip.setAttribute('style', style), negative); + } + } + } + } + // Live provider status arrives while the mouse remains stationary. + await deliver({ phase: 'error', stale: true }); + await page.waitForFunction(() => /Stale data/i.test(window.__card.shadowRoot.querySelector('hp-zigbee-topology-overlay') + ?.shadowRoot?.querySelector('.route-status')?.textContent || '')); + await settle(); + const changed = await witness('runtimeErrorStaleNoPointerMove', 'd_kettle'); + out.runtimeIncludesPartialStaleError = changed.badges.some((badge) => /Incomplete data/.test(badge.text) + && /Stale data/i.test(badge.text) && /could not be loaded/i.test(badge.text)); + await deliver(); + await page.evaluate(() => window.__hpTest.setVolumetricView(false)); + await page.evaluate(() => window.__hpTest.switchSpace('garden')); + const longName = ('Long landing router & ' + 'north corridor diagnostic lighting '.repeat(5)).trim(); + await deliver({ name: longName }); + await page.waitForTimeout(300); + await hover('d_mower'); + const long = await witness('longName', 'd_mower'); + out.longNameWrapsInsideStage = long.badgeInside && long.safeText + && long.badges.some((badge) => badge.text.includes(longName) && badge.rect.height > 25); + await page.screenshot({ path: fileURLToPath(new URL('long-remote-caption.png', artifacts)) }); + // Freeze the fixture header/stage height so horizontal container resize + // cannot move the marker away and create an unrelated native pointerout. + await deliver({ name: remoteName }); + await page.evaluate(() => { + const style = document.createElement('style'); + style.textContent = '.head{height:100px!important;min-height:100px!important;max-height:100px!important;overflow:hidden}.stage{height:400px!important;min-height:400px!important;max-height:400px!important}'; + window.__card.shadowRoot.append(style); + }); + await page.waitForTimeout(150); + await hover('d_mower'); + const beforeResize = await sample('d_mower'); + await page.evaluate(() => { document.getElementById('host').style.width = '95px'; }); + await page.waitForTimeout(150); + await witness('narrowHidesOnlyOrdinaryTip', 'd_mower', { hidden: true }); + await page.screenshot({ path: fileURLToPath(new URL('narrow-priority.png', artifacts)) }); + await deliver({ clear: true }); await settle(); + const narrowWithoutBadges = await sample('d_mower'); + out.removingBadgesRestoresHiddenTipWithoutNewHover = narrowWithoutBadges.tipVisible + && narrowWithoutBadges.badges.length === 0; + await deliver({ restore: true }); await settle(); + await witness('returningBadgesReappliesNarrowFallback', 'd_mower', { hidden: true }); + await page.evaluate(() => { document.getElementById('host').style.width = '900px'; }); + await page.waitForTimeout(150); + const restored = await witness('resizeRestoresWithoutNewHover', 'd_mower'); + out.resizePreservesSourceAndRoute = JSON.stringify(restored.source) === JSON.stringify(beforeResize.source) + && JSON.stringify(restored.lines) === JSON.stringify(beforeResize.lines); + await deliver({ clear: true }); + await settle(); + const removed = await sample('d_mower'); + out.removingDiagnosticsRestoresOrdinaryTip = removed.tipVisible && removed.badges.length === 0; + await deliver({ restore: true, name: remoteName }); + await hover('d_mower'); + const networkBefore = JSON.stringify((await sample('d_mower')).calls); + for (let i = 0; i < 12; i++) await page.mouse.move(pointer.x + i % 3, pointer.y + i % 2); + out.pointerMotionNeverFetches = JSON.stringify((await sample('d_mower')).calls) === networkBefore; + await page.mouse.move(2, 2); await settle(); + const left = await sample('d_mower'); + out.pointerLeaveClearsBoth = !left.tipVisible && left.badges.length === 0; + await page.evaluate(() => window.__hpTest.switchSpace('f1')); + await hover('d_tv'); + const ordinary = await sample('d_tv'); + out.nonZigbeeRetainsOrdinaryTooltip = ordinary.tipVisible && ordinary.badges.length === 0; + await page.evaluate(() => window.__hpTest.switchSpace('garden')); + await page.evaluate(() => window.__hpTest.setLayout((layout) => ({ ...layout, + d_mower: { s: 'garden', x: 0.94, y: 0.12 }, + }))); + await hover('d_mower'); + const edge = await witness('edgeCaption', 'd_mower'); + out.edgeCaptionInsideWorkingArea = edge.badgeInside; + await page.mouse.wheel(0, -180); + await page.waitForTimeout(450); + const zoomed = await witness('cameraZoomWithoutNewHover', 'd_mower'); + out.cameraActuallyChangesGeometry = Math.abs(edge.source.width - zoomed.source.width) > 0.1 + || Math.abs(edge.source.left - zoomed.source.left) > 0.1; + await page.screenshot({ path: fileURLToPath(new URL('edge-zoom.png', artifacts)) }); + // A second production card shares the HA connection/runtime but owns its + // own pointer/focus tip and layout observers. + await page.evaluate(() => { + const second = document.createElement('houseplan-card'); + second.id = 'second-tooltip-card'; + second.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 }); + second.hass = window.__card.hass; + second.style.cssText = 'position:fixed;left:5px;top:5px;width:350px;z-index:100'; + document.body.append(second); + }); + const second = page.locator('#second-tooltip-card'); + await second.locator('[data-hp="device"]').first().waitFor({ state: 'visible' }); + await second.locator('[data-hp="device"]').first().focus(); + await page.keyboard.press('Tab'); + await settle(); + out.secondCardKeyboardTipDoesNotActivateTopology = await second.evaluate((card) => { + const root = card.shadowRoot; + return root.activeElement?.matches('[data-hp="device"]:focus-visible') + && !root.querySelector('[data-hp-live-tip]').hidden + && !root.querySelector('hp-zigbee-topology-overlay')?.shadowRoot?.querySelector('.parent-bubble,.remote,.route-status'); + }); + await hover('d_mower'); + const firstWithSecond = await sample('d_mower'); + await second.evaluate((card) => card.remove()); + await settle(); + const afterSecondRemoved = await sample('d_mower'); + out.secondCardUnmountDoesNotClearFirstHover = firstWithSecond.clean && afterSecondRemoved.clean + && JSON.stringify(firstWithSecond.badges) === JSON.stringify(afterSecondRemoved.badges); + for (const pointerType of ['touch', 'pen']) { + await marker('d_mower').dispatchEvent('pointerdown', { pointerType, pointerId: 82, + bubbles: true, composed: true, clientX: pointer.x, clientY: pointer.y }); + await settle(); + const cleared = await sample('d_mower'); + out[`${pointerType}ClearsTooltipAndTopology`] = !cleared.tipVisible && cleared.badges.length === 0; + await hover('d_mower'); + await witness(`mouseRestoresAfter_${pointerType}`, 'd_mower'); + } + await page.mouse.move(2, 2); + await page.keyboard.press('Tab'); + await marker('d_mower').focus(); + await settle(); + const keyboardOnly = await sample('d_mower'); + out.focusAloneShowsOrdinaryTipWithoutTopology = keyboardOnly.tipVisible && keyboardOnly.badges.length === 0 + && await marker('d_mower').evaluate((element) => element.matches(':focus-visible')); + await hover('d_mower'); + await witness('focusedDeviceMouseDiagnostics', 'd_mower'); + await page.mouse.move(2, 2); await settle(); + const focusRestored = await sample('d_mower'); + out.pointerLeaveRestoresKeyboardTipAfterDiagnostics = focusRestored.tipVisible + && focusRestored.tipText === keyboardOnly.tipText && focusRestored.badges.length === 0; + await marker('d_mower').evaluate((element) => element.blur()); + } + } catch (error) { + out[`${provider}_completed`] = false; + console.error(error); + await page.screenshot({ path: fileURLToPath(new URL(`${provider}-failure.png`, artifacts)) }); + } finally { + // Keep the shared smoke page-error guard authoritative for both browsers. + await finish(browser); + } +} +writeFileSync(new URL('layout-evidence.json', artifacts), JSON.stringify({ out, evidence }, null, 2)); +checkAll(out); +await finish(null, out); diff --git a/demo/smoke_zigbee_topology_hover.mjs b/demo/smoke_zigbee_topology_hover.mjs index ebdf5f4f..ef25814f 100644 --- a/demo/smoke_zigbee_topology_hover.mjs +++ b/demo/smoke_zigbee_topology_hover.mjs @@ -343,8 +343,9 @@ const result = await page.evaluate(async () => { ?.querySelector('[data-hp="zigbee-topology-parent-bubble"]'), 'remote parent bubble'); let activeOverlay = root().querySelector('hp-zigbee-topology-overlay'); const firstTitle = card._serverCfg.spaces.find((space) => space.id === 'f1')?.title; + const remoteParentName = card.hass.devices.d_light1.name_by_user || card.hass.devices.d_light1.name; out.remoteParentBubble = activeOverlay.shadowRoot - .querySelector('[data-hp="zigbee-topology-parent-bubble"]')?.textContent.trim() === firstTitle + .querySelector('[data-hp="zigbee-topology-parent-bubble"]')?.textContent.trim() === `${firstTitle} (${remoteParentName})` && activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-bubble"]') ?.getAttribute('data-kind') === 'remote-space' && !!activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-arrow"]') diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cbe23242..ed71904b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -634,6 +634,16 @@ Unknown/partial/stale captions never trigger network work; one expiry timer updates age without requiring another HA event. Persistence and privacy: [CONFIG-COMPATIBILITY](CONFIG-COMPATIBILITY.md). +Zigbee caption layout (#802) notifies the lazy live-hover runtime after the +overlay's DOM update and coalesced active-hover resize/scroll/camera changes. +Only the matching pointer device tooltip avoids actual text-caption rectangles +and its source marker, in screen coordinates inside stage∩viewport. A pure +candidate search finds the closest feasible full rectangle or hides the tooltip +until space returns. It does not move markers, start scans or render the card. +Keyboard tooltips retain their own path. Pointer departure/disconnect disposes +the active layout observer/frame; remote caption wrapping updates its short +arrow endpoint in the same post-render pass. + ## Live viewport: a transform per frame, a `viewBox` on a budget (#531, #579) Rewriting the SVG `viewBox` re-rasterises the whole scene, so per-frame writes diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5a8ce411..1be0e2bd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Zigbee links to another space now name the destination device too. Device + tooltips move around network badges and give them priority only when the card + is too small to show both ([#802](https://github.com/Matysh/houseplan-card/issues/802)). + ## v1.80.0-beta.3 — 2026-10-05 - Zigbee2MQTT map scans now continue in Home Assistant when settings or the diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index a918511b..c9cce23a 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,11 @@ ## Не выпущено +- Подписи Zigbee-связей в другое пространство теперь содержат имя устройства. + Обычная подсказка обходит сетевые бейджи и уступает им место только тогда, + когда карточка слишком мала для обеих подсказок + ([#802](https://github.com/Matysh/houseplan-card/issues/802)). + ## v1.80.0-beta.3 — 2026-10-05 - Сканирование карты Zigbee2MQTT теперь продолжается в Home Assistant после diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index fee1aedd..aad10a92 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -30,6 +30,12 @@ topology benchmark must use confirmed route evidence, not an empty graph. Neighbour-only raw fixtures remain negative controls; do not add guessed routes to make them draw. Ordinary LQI badges keep their separate palette. +For tooltip layout (#802), also run `node demo/smoke_zigbee_tooltip_layout.mjs`: +it uses real mouse movement and screen-space overlap oracles, including a +negative control at the previous cursor-relative position. DOM presence alone +does not prove either caption is readable. A small-card fallback must restore +without requiring a new pointerover; use the actual lazy-overlay update path. + For warm-remount changes, `node demo/smoke_warm_mode_adoption.mjs` records intermediate camera frames and screen-space points, observes every memo size publication, and exercises delayed permission/runtime races and a touch floor diff --git a/docs/STATUS.md b/docs/STATUS.md index eea9259b..7b3db7aa 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -30,6 +30,7 @@ Everything computable from the tree and git; regenerate, never edit by hand | Current local cycle | **v1.80.0-beta.3 candidate** — #800 background Zigbee2MQTT scans passed owner-authorized independent local review and are merged into `dev`. Full exact-SHA Validate and artifact verification precede publication. `main` remains on stable v1.79.0. | | Branches | `main` carries stable releases only; pre-release tags point at `dev`. Work lands on `dev`, which is equal to or ahead of `main`, never behind. | | Zigbee routes | #798 replaces inferred neighbour trees with integration-reported end-parent and active coordinator next-hop evidence. Unknown/conflicting routes are not guessed; stale/partial snapshots remain labelled. Solid arrows have a separate 0–255 palette; ordinary device LQI colours are unchanged. | +| Zigbee caption layout | #802 adds remote destination names and keeps the matching pointer tooltip clear of diagnostic text; only an unplaceable tooltip yields in a small card. Focus/touch/actions and provider transport remain unchanged. | | Zigbee scan lifecycle | #800 replaces #799's 10-minute total budget with integration-owned background jobs: closing the browser does not stop waiting; after 10 minutes an exact-job cancel becomes available. MQTT setup/publish caps and the retained-info check remain bounded. Tests cover 15-minute success, cross-client restore, cancel races, stale-cache retention and reload on a live HA connection. | | LED regression acceptance | #788 covers mixed face/free and fractional-coordinate joins, winding-safe field unions, retained endpoints/acute vertices and circle–wall transition events. The actual owner export is exercised locally; public fixtures are synthetic. Pixel oracles, not just SVG counts, protect the visible falloff. | | LED core colour | #790 makes the on core follow the resolved light colour with or without Glow. Off, unavailable and non-live static strips keep their neutral presentation; geometry and field brightness are unchanged. | diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 76cc41c8..95190368 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -371,7 +371,11 @@ that scales with the plan. Device LQI badges and room colours are unchanged. The whole layer is pointer-transparent, so device and room actions continue to work normally. -If the next step is in another space, a short bubble names that space. If the +If the next step is in another space, its bubble names the space and destination +device, for example **Upstairs (Hallway socket)**; an unknown device name is +omitted. The ordinary device tooltip moves into free space around network +badges. Only when the card cannot fit both is it temporarily hidden, returning +when space becomes available. No extra setting is needed. If the needed router or coordinator is not placed on the plan, the bubble says so; an unplaced device's available name is included in parentheses. “No route data” means the outgoing route is unknown or conflicting, not that the device is diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index b1d81be1..50d5465d 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -398,8 +398,13 @@ LQI: 0 — красный, 128 — жёлтый, 255 — зелёный. Без прозрачен для указателя, поэтому действия комнат и устройств работают как раньше. -Если следующий шаг находится в другом пространстве, рядом с устройством -появляется его название. Если нужный роутер или координатор не размещён на +Если следующий шаг находится в другом пространстве, подпись содержит его +название и имя устройства в скобках: **Второй этаж (Розетка коридора)**. +Если имя неизвестно, показывается только пространство. Обычная подсказка +устройства перемещается в свободное место, не закрывая сетевые бейджи. +Только когда места для обеих подсказок недостаточно, она временно скрывается; +при освобождении места возвращается. Новая настройка для этого не нужна. +Если нужный роутер или координатор не размещён на плане, короткая подпись сообщает об этом; доступное имя отсутствующего устройства добавляется в скобках. «Нет данных о маршруте» означает отсутствие однозначных сведений об исходящей связи, а не недоступность устройства. diff --git a/docs/UX-MODES.md b/docs/UX-MODES.md index b688ebed..41ffd9b8 100644 --- a/docs/UX-MODES.md +++ b/docs/UX-MODES.md @@ -147,6 +147,13 @@ and bubbles are above room names and unrelated device markers; the complete source and locally connected marker shells stay above the topology. The layer remains pointer-transparent and therefore does not change any existing action. +The outgoing remote-space caption includes the destination device name in +parentheses when known (#802). While actual diagnostic text badges are visible, +the matching device's pointer tooltip moves to a free part of the visible stage; +it hides only if no full non-overlapping placement fits and returns when space +becomes available. Marker positions and local routes do not move. Focus-only +tooltips, rooms and devices without diagnostic text retain their usual behaviour. + Removed from this mode (they move, not die): - icon dragging ("drag anywhere", v1.9 — consciously reversed), - room-label dragging, diff --git a/docs/reviews/SPEC-REVIEW-802-r1.md b/docs/reviews/SPEC-REVIEW-802-r1.md new file mode 100644 index 00000000..e02e5051 --- /dev/null +++ b/docs/reviews/SPEC-REVIEW-802-r1.md @@ -0,0 +1,23 @@ +# Локальное ревью ТЗ #802 — r1 + +Вердикт: зелёный · High: 0 · Medium: 0. + +Дата: 2026-10-05. Материал: тело issue #802, раздел `## ТЗ`, редакция с +подтверждённым default Q1; база `7feda5b6c82b34f3b42f87a1f0c1fac5bf590265`. +По прямому исключению владельца ревью выполняется локально, без недоступной +ревью-модели. Дополнительно независимый локальный агент прочёл ТЗ и не нашёл +High/Medium. Это не отчёт CI и не подтверждение готовности к выпуску. + +## Проверка + +- Скоуп J7; одна задача объединяет подпись удалённого адресата и дефект перекрытия. +- Q1 закрыт владельцем. DoR, AC1–AC6, тесты, i18n, отсутствие миграции, + performance/input-риски и откат зафиксированы. +- Сохраняется pointer-only диагностика; обычный focus-tooltip не меняется. +- Скрытие — только при невозможности разместить tooltip, не по одному факту + включённой настройки или наличию линии. Восстановление без нового hover. +- Пересечение проверяется реальной мышью и прямоугольниками, не DOM-count. +- Отдельные риски реализации: lazy upgrade без движения мыши; screen-space + измерения при transformed ancestors; resize/runtime/lifecycle cleanup. + +Проверено чтением, не исполнением: продуктовая реализация ещё не написана. diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index 762b6404..8f6838ea 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -35,9 +35,9 @@ that transition events alone prove disposal. | Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. | | Paint, cascade and layer composition | 40 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. | | Pointer geometry and trusted interaction | 49 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. | -| Responsive DOM layout | 38 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. | +| Responsive DOM layout | 39 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. | | Custom-element and HA browser lifecycle | 103 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | -| **Total** | **238 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. | +| **Total** | **239 / 200** | Above the guideline `mutation-gate --check` warns rather than fails (#699); each guard above it is held by its own reason in this inventory and its `because`. | ## Measured effect @@ -195,6 +195,8 @@ The invariant depends on hit testing, pointer capture, touch/keyboard dispatch o ### Responsive DOM layout +- `zigbee-tooltip-collision-bypass` + The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. - `dialog-ha-disconnected-reject-reopen-enabled` diff --git a/scripts/monolith-baseline.json b/scripts/monolith-baseline.json index aaf49aa7..70cf6cb3 100644 --- a/scripts/monolith-baseline.json +++ b/scripts/monolith-baseline.json @@ -4,5 +4,5 @@ "hostRefs": 5067, "portPrivates": 103, "harnessPrivates": 100, - "bundleBytes": 2697591 + "bundleBytes": 2705820 } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 464a8a9c..67b469db 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -9657,8 +9657,8 @@ const MUTANT_DEFINITIONS = [ + 'as local routes instead of becoming permanently grey (#798)', patches: [{ file: 'src/hp-zigbee-topology-overlay.ts', - find: " ${bubbles.map((bubble) => this._route(origin, bubble.point, bubble.target.lqi, 'toward-neighbor', bubble.arrow, true))}", - replace: " ${bubbles.map((bubble) => this._route(origin, bubble.point, undefined, 'toward-neighbor', bubble.arrow, true))}", + find: " ${bubbles.map((bubble, index) => this._route(origin, bubble.point, bubble.target.lqi, 'toward-neighbor', bubble.arrow, true, index))}", + replace: " ${bubbles.map((bubble, index) => this._route(origin, bubble.point, undefined, 'toward-neighbor', bubble.arrow, true, index))}", }], }, { @@ -9799,6 +9799,28 @@ const MUTANT_DEFINITIONS = [ replace: ' } else if (isOutgoing) {\n if (other) remote.add(identity);', }], }, + { + id: 'zigbee-tooltip-collision-bypass', + guard: 'node demo/smoke_zigbee_tooltip_layout.mjs', + because: 'screen-space overlap of independently rendered shadow-DOM surfaces and ' + + 'late lazy paint needs a real mouse/browser witness; DOM presence or pure layout ' + + 'cannot prove runtime avoidance (#802 AC2/AC4)', + patches: [{ + file: 'src/live-hover.ts', + find: ' avoidTopologyCaptions(root, element, tip);', + replace: ' void tip; // mutant: keep the old overlapping cursor-relative tooltip', + }], + }, + { + id: 'zigbee-tooltip-forgets-obstacles', + guard: 'node --test test/live-tip-placement.test.mjs', + because: 'free placement must avoid diagnostic captions and the source marker (#802 AC3)', + patches: [{ + file: 'src/live-tip-placement.ts', + find: ' const forbidden = blockers', + replace: ' const forbidden = blockers.slice(0, 0)', + }], + }, { id: 'zigbee-provider-inactive-route-accepted', guard: 'node --test --test-name-pattern="only ACTIVE status" test/zigbee-provider-routes.test.mjs', diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 5802bb14..2b4b5c49 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -11249,7 +11249,7 @@ export class HouseplanCard extends LitElement { : nothing} ${this._markup ? space.rooms.map((r) => this._renderRoomGear(r, space, view)) : nothing} ${''/* #649: one floor-shadow layer (z below every tile), after the markers in DOM order */}${iso ? html`` : nothing} - ${renderZigbeeTopologyOverlay({ hass: this.hass, settings: this._settings, devices: this._renderDevices, registry: this._haRegistry, currentSpace: space.id, spaces: this._serverCfg?.spaces, viewKey: view, zoom: this._zoom, view: this._mode === 'view', kiosk: this._kiosk })} + ${renderZigbeeTopologyOverlay({ hass: this.hass, settings: this._settings, devices: this._renderDevices, registry: this._haRegistry, currentSpace: space.id, spaces: this._serverCfg?.spaces, viewKey: view, zoom: this._zoom, view: this._mode === 'view', kiosk: this._kiosk, onLayout: () => this._liveRt?.hover() })}
${this._wallDialog diff --git a/src/hp-zigbee-topology-overlay.ts b/src/hp-zigbee-topology-overlay.ts index ede933d1..3d3978fd 100644 --- a/src/hp-zigbee-topology-overlay.ts +++ b/src/hp-zigbee-topology-overlay.ts @@ -14,6 +14,7 @@ import { } from './zigbee-topology-runtime'; import type { HaRegistrySnapshot } from './ha-binding-status'; import type { DevItem } from './types'; +import { placeDeviceTooltip, type TipRect } from './live-tip-placement'; const EMPTY_RUNTIME: ZigbeeTopologyRuntimeSnapshot = { revision: 0, topologies: [], states: {} }; const ENDPOINT_ATTRIBUTE = 'data-hp-zigbee-topology-endpoint'; @@ -28,6 +29,7 @@ export class HpZigbeeTopologyOverlay extends LitElement { spaces: { attribute: false }, viewKey: { attribute: false }, zoom: { type: Number }, + onLayout: { attribute: false }, }; hass!: ZigbeeTopologyHass; @@ -37,6 +39,7 @@ export class HpZigbeeTopologyOverlay extends LitElement { spaces?: readonly { id?: unknown; title?: unknown }[]; viewKey: unknown; zoom = 1; + onLayout?: () => void; private _runtime = EMPTY_RUNTIME; private _hovered = ''; private _staleTimer?: ReturnType; @@ -46,6 +49,8 @@ export class HpZigbeeTopologyOverlay extends LitElement { private _parent?: HTMLElement; private _hoverGateObserver?: MutationObserver; private _markerObserver?: MutationObserver; + private _layoutFrame = 0; + private _layoutResize?: ResizeObserver; private _desiredEndpointIds = new Set(); private _endpointSetDirty = false; private _endpointElements = new Set(); @@ -71,6 +76,7 @@ export class HpZigbeeTopologyOverlay extends LitElement { box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap; font: 600 11px/1.2 system-ui, sans-serif; } + .parent-bubble { width: max-content; white-space: normal; overflow-wrap: anywhere; box-sizing: border-box; border-radius: 12px; } .parent-bubble.right { transform: translate(0, -50%); } .parent-bubble.left { transform: translate(-100%, -50%); } .route-status { transform: translate(-50%, 12px); max-width: 240px; white-space: normal; text-align: center; } @@ -106,6 +112,9 @@ export class HpZigbeeTopologyOverlay extends LitElement { this._hoverGateObserver = undefined; this._hovered = ''; this._desiredEndpointIds.clear(); + this.removeAttribute('data-tooltip-owner'); + this._stopLayout(); + this.onLayout?.(); super.disconnectedCallback(); } @@ -137,6 +146,100 @@ export class HpZigbeeTopologyOverlay extends LitElement { } this._connectParent(); if (this._endpointSetDirty || markerInputsChanged) this._syncEndpointOwnership(); + this.toggleAttribute('data-tooltip-owner', !!this._hovered); + if (this._hovered) this.setAttribute('data-tooltip-owner', this._hovered); + this._scheduleLayout(); + if (!this._hovered) this.onLayout?.(); + } + + private _stopLayout(): void { + cancelAnimationFrame(this._layoutFrame); + this._layoutFrame = 0; + this._layoutResize?.disconnect(); + this._layoutResize = undefined; + window.removeEventListener('resize', this._scheduleLayout); + window.removeEventListener('scroll', this._scheduleLayout, true); + } + + private _scheduleLayout = (): void => { + if (!this.isConnected || !this._hovered) return; + if (!this._layoutResize && typeof ResizeObserver !== 'undefined') { + this._layoutResize = new ResizeObserver(this._scheduleLayout); + this._layoutResize.observe(this); + const stage = (this.getRootNode() as ShadowRoot).querySelector('.stage'); + if (stage) this._layoutResize.observe(stage); + window.addEventListener('resize', this._scheduleLayout); + window.addEventListener('scroll', this._scheduleLayout, true); + } + if (this._layoutFrame) return; + this._layoutFrame = requestAnimationFrame(() => { + this._layoutFrame = 0; + if (!this.isConnected || !this._hovered) return; + this._fitParentCaptions(); + this.onLayout?.(); + }); + }; + + /** DOM-only layout, not a card render or network refresh. */ + private _fitParentCaptions(): void { + const stage = (this.getRootNode() as ShadowRoot).querySelector('.stage')?.getBoundingClientRect(); + if (!stage) return; + const layer = this.getBoundingClientRect(); + const scaleX = this.clientWidth ? layer.width / this.clientWidth : 1; + const scaleY = this.clientHeight ? layer.height / this.clientHeight : 1; + if (scaleX <= 0 || scaleY <= 0) return; + const left = Math.max(0, stage.left) + 8; + const right = Math.min(window.innerWidth, stage.right) - 8; + const top = Math.max(0, stage.top) + 8; + const bottom = Math.min(window.innerHeight, stage.bottom) - 8; + const marker = this._marker(this._hovered)?.getBoundingClientRect(); + const blockers: TipRect[] = [...this.renderRoot.querySelectorAll('.remote,.route-status')] + .map((node) => node.getBoundingClientRect()); + if (marker) blockers.push(marker); + for (const caption of this.renderRoot.querySelectorAll('.parent-bubble')) { + caption.style.marginLeft = '0px'; + caption.style.marginTop = '0px'; + caption.style.maxWidth = `${Math.max(1, Math.min(300, (right - left) / scaleX))}px`; + let rect = caption.getBoundingClientRect(); + const placement = () => placeDeviceTooltip({ + preferred: { x: rect.left, y: rect.top }, + size: rect, bounds: { left, top, right, bottom, width: right - left, height: bottom - top }, + blockers, edge: 0, gap: 8, + }); + let position = placement(); + if (!position && right > left) { + caption.style.maxWidth = `${(right - left) / scaleX}px`; + rect = caption.getBoundingClientRect(); + position = placement(); + } + const dx = (position?.left ?? Math.max(left, Math.min(rect.left, right - rect.width))) - rect.left; + const dy = (position?.top ?? Math.max(top, Math.min(rect.top, bottom - rect.height))) - rect.top; + caption.style.marginLeft = `${dx / scaleX}px`; + caption.style.marginTop = `${dy / scaleY}px`; + blockers.push({ left: rect.left + dx, right: rect.right + dx, + top: rect.top + dy, bottom: rect.bottom + dy, width: rect.width, height: rect.height }); + // Keep the short outgoing arrow attached to the nearest label edge after + // a long caption wraps/shifts at the stage boundary. + const line = this.renderRoot.querySelector( + `[data-parent-index="${caption.dataset.parentIndex}"][data-hp="zigbee-topology-parent-line"]`, + ); + if (!line) continue; + const origin = { x: Number(line.getAttribute('x1')), y: Number(line.getAttribute('y1')) }; + const screenOrigin = { x: layer.left + origin.x * scaleX, y: layer.top + origin.y * scaleY }; + const point = { + x: (Math.max(rect.left + dx, Math.min(screenOrigin.x, rect.right + dx)) - layer.left) / scaleX, + y: (Math.max(rect.top + dy, Math.min(screenOrigin.y, rect.bottom + dy)) - layer.top) / scaleY, + }; + for (const part of this.renderRoot.querySelectorAll( + `line[data-parent-index="${caption.dataset.parentIndex}"]`, + )) { part.setAttribute('x2', String(point.x)); part.setAttribute('y2', String(point.y)); } + const arrow = zigbeeArrowGeometry(origin, point, + marker ? Math.max(marker.width, marker.height) * .61 + 3 : 0, 0, 'toward-neighbor'); + const polygon = this.renderRoot.querySelector( + `polygon[data-parent-index="${caption.dataset.parentIndex}"]`, + ); + if (polygon) polygon.setAttribute('points', arrow ? this._points(arrow.points) : ''); + } } private _acceptRuntime(next: ZigbeeTopologyRuntimeSnapshot): void { @@ -174,10 +277,26 @@ export class HpZigbeeTopologyOverlay extends LitElement { parent.addEventListener('pointerout', this._pointerOut); parent.addEventListener('pointerdown', this._pointerDown, true); if (typeof MutationObserver !== 'undefined') { - this._markerObserver = new MutationObserver(() => this._syncEndpointOwnership()); - this._markerObserver.observe(parent, { childList: true }); + this._markerObserver = new MutationObserver((records) => { + if (records.some((record) => record.type === 'childList')) this._syncEndpointOwnership(); + if (records.some((record) => record.attributeName === 'data-hp-device-hover')) this._adoptPointerHover(); + this._scheduleLayout(); + }); + this._markerObserver.observe(parent, { childList: true, attributes: true, subtree: true, + attributeFilter: ['style', 'data-hp-device-hover'] }); } this._syncEndpointOwnership(); + this._adoptPointerHover(); + } + + private _adoptPointerHover(): void { + const host = (this.getRootNode() as ShadowRoot).host; + const id = host?.hasAttribute('data-pointer-hover') + ? this._parent?.querySelector('[data-hp-device-hover]')?.dataset.id || '' : ''; + if (id === this._hovered) return; + if (!id) { this._clear(); return; } + this._hovered = id; + this.requestUpdate(); } private _disconnectParent(): void { @@ -223,6 +342,8 @@ export class HpZigbeeTopologyOverlay extends LitElement { }; private _clear(): void { + this.removeAttribute('data-tooltip-owner'); + this._stopLayout(); this._setDesiredEndpointIds([]); this._clearEndpointOwnership(); this._endpointSetDirty = false; @@ -286,8 +407,11 @@ export class HpZigbeeTopologyOverlay extends LitElement { private _targetText(target: ZigbeeParentTarget): string { if (target.kind === 'remote-space') { const title = this.spaces?.find((space) => space.id === target.spaceId)?.title; - return (typeof title === 'string' && title.trim()) + const space = (typeof title === 'string' && title.trim()) || topologyT(langOf(this.hass), 'route_other_space'); + return target.deviceName?.trim() + ? topologyT(langOf(this.hass), 'route_other_space_named', { space, name: target.deviceName.trim() }) + : space; } if (target.kind === 'unplaced-coordinator') return topologyT(langOf(this.hass), 'route_coordinator_not_on_plan'); return target.deviceName @@ -305,19 +429,19 @@ export class HpZigbeeTopologyOverlay extends LitElement { private _route(origin: ZigbeePixelPoint, point: ZigbeePixelPoint, lqi: number | undefined, direction: 'toward-neighbor' | 'toward-origin', - arrow: ReturnType, parent = false) { + arrow: ReturnType, parent = false, parentIndex?: number) { const color = zigbeeLinkColor(lqi); const outline = Number.isFinite(this.zoom) && this.zoom > 0 ? this.zoom : 1; return svg`${lqi === undefined ? svg`` : nothing} ${arrow ? svg`` : nothing}`; } @@ -396,12 +520,12 @@ export class HpZigbeeTopologyOverlay extends LitElement { ${lines.length || bubbles.length ? svg`` : nothing} ${lines.map((line) => html`
`)} - ${bubbles.map((bubble) => html`
html`
${this._targetText(bubble.target)}
`)} ${hover.remoteCount ? html`
${topologyT( diff --git a/src/i18n/topology/de.json b/src/i18n/topology/de.json index 62a31b48..d1af7aff 100644 --- a/src/i18n/topology/de.json +++ b/src/i18n/topology/de.json @@ -41,6 +41,7 @@ "route_device_not_on_plan": "Gerät ist nicht im Plan", "route_coordinator_not_on_plan": "Koordinator ist nicht im Plan", "route_other_space": "anderer Bereich", + "route_other_space_named": "{space} ({name})", "route_device_not_on_plan_named": "Gerät ist nicht im Plan ({name})", "route_unknown": "Keine Routendaten", "route_stale": "Veraltete Daten", diff --git a/src/i18n/topology/en.json b/src/i18n/topology/en.json index eb7e11ce..6a853a21 100644 --- a/src/i18n/topology/en.json +++ b/src/i18n/topology/en.json @@ -41,6 +41,7 @@ "route_device_not_on_plan": "Device is not on the plan", "route_coordinator_not_on_plan": "coordinator is not on the plan", "route_other_space": "another space", + "route_other_space_named": "{space} ({name})", "route_device_not_on_plan_named": "Device is not on the plan ({name})", "route_unknown": "No route data", "route_stale": "Stale data", diff --git a/src/i18n/topology/fr.json b/src/i18n/topology/fr.json index 15e7c571..fbe4158a 100644 --- a/src/i18n/topology/fr.json +++ b/src/i18n/topology/fr.json @@ -41,6 +41,7 @@ "route_device_not_on_plan": "appareil absent du plan", "route_coordinator_not_on_plan": "coordinateur absent du plan", "route_other_space": "autre espace", + "route_other_space_named": "{space} ({name})", "route_device_not_on_plan_named": "Appareil absent du plan ({name})", "route_unknown": "Aucune donnée de route", "route_stale": "Données anciennes", diff --git a/src/i18n/topology/ru.json b/src/i18n/topology/ru.json index b5c64c69..6af0a6ff 100644 --- a/src/i18n/topology/ru.json +++ b/src/i18n/topology/ru.json @@ -41,6 +41,7 @@ "route_device_not_on_plan": "Устройства нет на плане", "route_coordinator_not_on_plan": "координатора нет на плане", "route_other_space": "другое пространство", + "route_other_space_named": "{space} ({name})", "route_device_not_on_plan_named": "Устройства нет на плане ({name})", "route_unknown": "Нет данных о маршруте", "route_stale": "Данные устарели", diff --git a/src/live-hover.ts b/src/live-hover.ts index fa7bf571..e9c83a72 100644 --- a/src/live-hover.ts +++ b/src/live-hover.ts @@ -2,6 +2,8 @@ import { lqiColor } from './logic'; import type { I18nKey } from './i18n'; import type { ResolvedDevicePresentation } from './device-presentation'; import type { DevItem } from './types'; +import { placeDeviceTooltip } from './live-tip-placement'; +import { floatingViewport } from './floating-surface'; export interface LiveTip { x: number; @@ -40,7 +42,6 @@ interface DeviceTipHost extends LiveHoverHost { _spaceDisplayForRender: () => { showLqi?: boolean }; _devicePresentation: (device: DevItem, showLqi: boolean) => ResolvedDevicePresentation; _notePointer: (event: PointerEvent) => void; - _showTip: (event: PointerEvent, title: string, meta: string) => void; _t: LiveHoverHost['_t'] & ((key: I18nKey) => string); } @@ -70,7 +71,8 @@ export function showDevicePointerTip(value: object, event: PointerEvent, device: } const tip = deviceTipContent(host, device); host._deviceHits.hover(host.renderRoot, device.id); - host._showTip(event, tip.title, tip.meta); + host._tip = { x: event.clientX, y: event.clientY, ...tip, source: 'pointer', deviceId: device.id }; + syncHouseplanHover(host); } export function showDeviceFocusTip(value: object, target: HTMLElement | null, device: DevItem): void { @@ -160,6 +162,44 @@ const syncTip = (host: LiveHoverHost, root: ParentNode): void => { const maxTop = Math.max(margin, window.innerHeight - box.height - margin); element.style.left = `${Math.min(maxLeft, Math.max(margin, tip.x + gap))}px`; element.style.top = `${Math.min(maxTop, Math.max(margin, tip.y + gap))}px`; + avoidTopologyCaptions(root, element, tip); +}; + +/** Both layers are measured after paint in screen coordinates, including 2.5D. */ +const avoidTopologyCaptions = (root: ParentNode, element: HTMLElement, tip: LiveTip): void => { + if (tip.source !== 'pointer' || tip.room || !tip.deviceId) return; + const overlay = root.querySelector('hp-zigbee-topology-overlay'); + if (overlay?.getAttribute('data-tooltip-owner') !== tip.deviceId) return; + const stage = root.querySelector('.stage'); + if (!stage || !overlay.shadowRoot) return; + const viewport = floatingViewport(window); + const area = stage.getBoundingClientRect(); + const left = Math.max(viewport.left, area.left); + const top = Math.max(viewport.top, area.top); + const right = Math.min(viewport.left + viewport.width, area.right); + const bottom = Math.min(viewport.top + viewport.height, area.bottom); + const captions = [...overlay.shadowRoot.querySelectorAll( + '.parent-bubble,.remote,.route-status', + )].map((node) => node.getBoundingClientRect()).filter((rect) => rect.width > 0 && rect.height > 0 + && rect.right > left && rect.left < right && rect.bottom > top && rect.top < bottom); + if (!captions.length) return; + const marker = [...root.querySelectorAll('[data-hp="device"]')] + .find((node) => node.dataset.id === tip.deviceId); + const blockers = marker ? [...captions, marker.getBoundingClientRect()] : captions; + const box = element.getBoundingClientRect(); + const position = placeDeviceTooltip({ + preferred: { x: tip.x + 12, y: tip.y + 12 }, + size: { width: box.width, height: box.height }, + bounds: { left, top, right, bottom, width: right - left, height: bottom - top }, + blockers, + }); + if (!position) { element.hidden = true; return; } + // A fixed tooltip may still have a transformed containing block in a HA card. + // Correct from its measured position rather than treating CSS left as clientX. + const scaleX = element.offsetWidth ? box.width / element.offsetWidth : 1; + const scaleY = element.offsetHeight ? box.height / element.offsetHeight : 1; + element.style.left = `${parseFloat(element.style.left) + (position.left - box.left) / scaleX}px`; + element.style.top = `${parseFloat(element.style.top) + (position.top - box.top) / scaleY}px`; }; const setRoomPath = (root: ParentNode, selector: string, d: string): void => { diff --git a/src/live-tip-placement.ts b/src/live-tip-placement.ts new file mode 100644 index 00000000..929d88a2 --- /dev/null +++ b/src/live-tip-placement.ts @@ -0,0 +1,85 @@ +/** Screen-space rectangles shared by the tooltip and its measured obstacles. */ +export type TipRect = { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; +}; + +export type DeviceTooltipPlacementInput = { + preferred: { x: number; y: number }; + size: { width: number; height: number }; + /** Intersection of the visible stage and viewport, in the same coordinates. */ + bounds: TipRect; + /** Visible diagnostic badges and the source marker; never route lines. */ + blockers: readonly TipRect[]; + gap?: number; + edge?: number; +}; + +const validRect = (rect: TipRect): boolean => + [rect.left, rect.top, rect.right, rect.bottom, rect.width, rect.height].every(Number.isFinite) + && rect.right >= rect.left && rect.bottom >= rect.top && rect.width >= 0 && rect.height >= 0; + +/** + * Find the closest complete, unobscured tooltip position without changing its size. + * Null means that no placement satisfies the bounds and clearance, or measurements + * are not ready. Nothing is retained: a later resize/badge update can restore it. + */ +export function placeDeviceTooltip(input: DeviceTooltipPlacementInput): { left: number; top: number } | null { + const { preferred, size, bounds, blockers, gap = 8, edge = 8 } = input; + if (![preferred.x, preferred.y, size.width, size.height, gap, edge].every(Number.isFinite) + || size.width <= 0 || size.height <= 0 || gap < 0 || edge < 0 + || !validRect(bounds) || blockers.some((rect) => !validRect(rect))) return null; + + const minLeft = bounds.left + edge; + const maxLeft = bounds.right - edge - size.width; + const minTop = bounds.top + edge; + const maxTop = bounds.bottom - edge - size.height; + if (![minLeft, maxLeft, minTop, maxTop].every(Number.isFinite) + || maxLeft < minLeft || maxTop < minTop) return null; + + // Expand each obstacle into the open rectangle forbidden to the tooltip's + // top-left corner. Touching its boundary leaves exactly the requested gap. + const forbidden = blockers + .filter((rect) => rect.right > rect.left && rect.bottom > rect.top) + .map((rect) => ({ + left: rect.left - gap - size.width, + right: rect.right + gap, + top: rect.top - gap - size.height, + bottom: rect.bottom + gap, + })); + const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); + const xs = new Set([minLeft, maxLeft, clamp(preferred.x, minLeft, maxLeft)]); + const ys = new Set([minTop, maxTop, clamp(preferred.y, minTop, maxTop)]); + for (const rect of forbidden) { + xs.add(clamp(rect.left, minLeft, maxLeft)); + xs.add(clamp(rect.right, minLeft, maxLeft)); + ys.add(clamp(rect.top, minTop, maxTop)); + ys.add(clamp(rect.bottom, minTop, maxTop)); + } + + // Feasible regions have axis-aligned edges at these coordinates. Their nearest + // point is a projected preferred coordinate or an edge intersection, so the + // cross product also finds pockets that four directional guesses would miss. + const columns = [...xs].sort((a, b) => a - b); + const rows = [...ys].sort((a, b) => a - b); + let best: { left: number; top: number } | null = null; + let bestDistance = Infinity; + for (const top of rows) { + for (const left of columns) { + if (forbidden.some((rect) => left > rect.left && left < rect.right + && top > rect.top && top < rect.bottom)) continue; + const distance = Math.hypot(left - preferred.x, top - preferred.y); + // Sorted coordinates break exact ties topmost, then leftmost, independently + // of DOM/blocker order. Keep subpixels so rounding cannot reintroduce overlap. + if (!best || distance < bestDistance) { + best = { left, top }; + bestDistance = distance; + } + } + } + return best; +} diff --git a/src/zigbee-topology-overlay-bridge.ts b/src/zigbee-topology-overlay-bridge.ts index cbaeaa67..923c67e7 100644 --- a/src/zigbee-topology-overlay-bridge.ts +++ b/src/zigbee-topology-overlay-bridge.ts @@ -15,11 +15,12 @@ export function renderZigbeeTopologyOverlay(input: { zoom: number; view: boolean; kiosk: boolean; + onLayout?: () => void; }): TemplateResult | typeof nothing { if (!input.view || input.kiosk || input.hass?.user?.is_admin !== true || !zigbeeTopologySettingsOf(input.settings).enabled) return nothing; void import('./hp-zigbee-topology-overlay'); return html``; + .viewKey=${input.viewKey} .zoom=${input.zoom} .onLayout=${input.onLayout}>`; } diff --git a/test/live-tip-placement.test.mjs b/test/live-tip-placement.test.mjs new file mode 100644 index 00000000..6205b1a6 --- /dev/null +++ b/test/live-tip-placement.test.mjs @@ -0,0 +1,197 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { placeDeviceTooltip } from '../test-build/live-tip-placement.js'; + +const rect = (left, top, width, height) => ({ + left, top, width, height, right: left + width, bottom: top + height, +}); + +const input = (overrides = {}) => ({ + preferred: { x: 200, y: 150 }, + size: { width: 160, height: 60 }, + bounds: rect(0, 0, 640, 480), + blockers: [], + ...overrides, +}); + +const assertFits = (placement, data) => { + assert.ok(placement, 'a complete free position exists'); + const { left, top } = placement; + const { width, height } = data.size; + const edge = data.edge ?? 8; + const gap = data.gap ?? 8; + assert.ok(left >= data.bounds.left + edge); + assert.ok(top >= data.bounds.top + edge); + assert.ok(left + width <= data.bounds.right - edge); + assert.ok(top + height <= data.bounds.bottom - edge); + for (const blocker of data.blockers) { + if (!blocker.width || !blocker.height) continue; + assert.ok(left + width + gap <= blocker.left || left >= blocker.right + gap + || top + height + gap <= blocker.top || top >= blocker.bottom + gap, + `tooltip ${JSON.stringify(placement)} covers ${JSON.stringify(blocker)}`); + } +}; + +test('tooltip preserves the preferred position when no obstacle occupies it', () => { + for (const blockers of [[], [rect(400, 100, 120, 40)], [rect(180, 100, 20, 20)]]) { + const data = input({ blockers }); + assert.deepEqual(placeDeviceTooltip(data), { left: 200, top: 150 }); + } +}); + +test('tooltip moves below a naturally colliding status badge, clear of the source marker', () => { + const data = input({ + preferred: { x: 310, y: 160 }, + size: { width: 190, height: 72 }, + blockers: [rect(286, 136, 24, 24), rect(308, 160, 240, 30)], + }); + const placement = placeDeviceTooltip(data); + assert.deepEqual(placement, { left: 310, top: 198 }); + assertFits(placement, data); +}); + +test('tooltip avoids every badge and the source, not only the closest diagnostic text', () => { + const data = input({ + blockers: [rect(190, 100, 30, 30), rect(200, 145, 220, 35), + rect(200, 180, 150, 40), rect(430, 120, 70, 30)], + }); + const placement = placeDeviceTooltip(data); + assertFits(placement, data); + assert.deepEqual(placement, { left: 200, top: 228 }); +}); + +test('tooltip reports no space in a tight card without shrinking the tooltip or moving blockers', () => { + const blockers = [Object.freeze(rect(75, 20, 80, 45))]; + const data = Object.freeze(input({ + preferred: Object.freeze({ x: 90, y: 40 }), + size: Object.freeze({ width: 120, height: 70 }), + bounds: Object.freeze(rect(0, 0, 180, 110)), + blockers: Object.freeze(blockers), + })); + assert.equal(placeDeviceTooltip(data), null); + assert.deepEqual(blockers[0], rect(75, 20, 80, 45)); +}); + +test('recalculation restores a hidden tooltip after expanding the bounds or removing a badge', () => { + const data = input({ size: { width: 120, height: 70 }, bounds: rect(0, 0, 180, 110), + blockers: [rect(75, 20, 80, 45)] }); + assert.equal(placeDeviceTooltip(data), null); + const expanded = { ...data, bounds: rect(0, 0, 640, 480) }; + assertFits(placeDeviceTooltip(expanded), expanded); + const cleared = { ...data, blockers: [] }; + assertFits(placeDeviceTooltip(cleared), cleared); + assert.equal(placeDeviceTooltip(data), null, 'no placement state leaks between calls'); +}); + +test('tooltip clamps all four edges without obstacles and accepts an exact safe-area fit', () => { + const bounds = rect(20, 30, 300, 200); + const size = { width: 120, height: 60 }; + for (const [preferred, expected] of [ + [{ x: -500, y: -500 }, { left: 28, top: 38 }], + [{ x: 1000, y: 1000 }, { left: 192, top: 162 }], + [{ x: 120, y: 1000 }, { left: 120, top: 162 }], + [{ x: -500, y: 120 }, { left: 28, top: 120 }], + ]) assert.deepEqual(placeDeviceTooltip(input({ bounds, size, preferred })), expected); + assert.deepEqual(placeDeviceTooltip(input({ bounds, + size: { width: 284, height: 184 } })), { left: 28, top: 38 }); +}); + +test('negative origins, subpixels and long tooltip sizes remain in screen coordinates', () => { + const data = input({ + bounds: rect(-300.5, -100.25, 1100, 360), + preferred: { x: 500, y: 160.5 }, + size: { width: 800.25, height: 100.5 }, + blockers: [rect(-250, -60, 80, 70)], + }); + const placement = placeDeviceTooltip(data); + assert.deepEqual(placement, { left: -8.75, top: 151.25 }); + assertFits(placement, data); +}); + +test('cross-product search finds an interior pocket unavailable to four directional guesses', () => { + // Four strips leave only the central 30 x 30 notch. Staying on either of the + // preferred axes, even at a bounds edge, would incorrectly claim no space. + const data = input({ + bounds: rect(0, 0, 100, 100), size: { width: 20, height: 20 }, + preferred: { x: 10, y: 10 }, gap: 0, edge: 0, + blockers: [rect(0, 0, 100, 40), rect(0, 70, 100, 30), + rect(0, 40, 40, 30), rect(70, 40, 30, 30)], + }); + const placement = placeDeviceTooltip(data); + assert.deepEqual(placement, { left: 40, top: 40 }); + assertFits(placement, data); + assert.equal(placeDeviceTooltip({ ...data, blockers: [...data.blockers, rect(40, 40, 30, 30)] }), null); +}); + +test('clearance is exact, configurable, and is never rounded back into a badge', () => { + const data = input({ + size: { width: 40.5, height: 30.25 }, preferred: { x: 150.25, y: 120.5 }, + blockers: [rect(150.25, 120.5, 90.75, 45.5)], + }); + assert.deepEqual(placeDeviceTooltip(data), { left: 150.25, top: 82.25 }); + assertFits(placeDeviceTooltip(data), data); + assert.deepEqual(placeDeviceTooltip({ ...data, gap: 0 }), { left: 150.25, top: 90.25 }); + assert.deepEqual(placeDeviceTooltip({ ...data, gap: 12.5 }), { left: 150.25, top: 77.75 }); +}); + +test('equal-distance placements are deterministic regardless of blocker order', () => { + const data = input({ preferred: { x: 100, y: 100 }, size: { width: 20, height: 20 }, + blockers: [rect(100, 100, 20, 20), rect(300, 300, 20, 20)] }); + assert.deepEqual(placeDeviceTooltip(data), { left: 100, top: 72 }); + assert.deepEqual(placeDeviceTooltip({ ...data, blockers: [...data.blockers].reverse() }), + placeDeviceTooltip(data)); +}); + +test('empty and off-stage blockers do not invent a collision', () => { + const data = input({ blockers: [rect(180, 100, 0, 160), rect(180, 160, 180, 0), + rect(1000, 1000, 400, 400), rect(-1000, -1000, 50, 50)] }); + assert.deepEqual(placeDeviceTooltip(data), { left: 200, top: 150 }); +}); + +test('closest feasible result agrees with exhaustive placement across 200 small layouts', () => { + let seed = 802; + const next = (limit) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed % limit; + }; + for (let scene = 0; scene < 200; scene++) { + const data = input({ bounds: rect(-5, -4, 32, 28), + size: { width: 3 + next(10), height: 2 + next(9) }, + preferred: { x: next(50) - 10, y: next(40) - 8 }, gap: next(4), edge: next(4), + blockers: Array.from({ length: 1 + next(5) }, () => + rect(next(40) - 8, next(36) - 8, next(13), next(13))), + }); + let expected = null; + let bestDistance = Infinity; + // All input edges and preferred coordinates are integral, so an integer + // closest point exists. This oracle scans the area, not obstacle candidates. + for (let top = data.bounds.top + data.edge; + top + data.size.height <= data.bounds.bottom - data.edge; top++) { + for (let left = data.bounds.left + data.edge; + left + data.size.width <= data.bounds.right - data.edge; left++) { + if (data.blockers.some((b) => b.width && b.height + && left < b.right + data.gap && left + data.size.width + data.gap > b.left + && top < b.bottom + data.gap && top + data.size.height + data.gap > b.top)) continue; + const distance = (left - data.preferred.x) ** 2 + (top - data.preferred.y) ** 2; + if (distance < bestDistance) { + expected = { left, top }; + bestDistance = distance; + } + } + } + assert.deepEqual(placeDeviceTooltip(data), expected, `layout ${scene}: ${JSON.stringify(data)}`); + } +}); + +test('invalid, unmeasurable and oversized inputs fail closed without throwing', () => { + for (const overrides of [ + { preferred: { x: NaN, y: 10 } }, { preferred: { x: 10, y: Infinity } }, + { size: { width: 0, height: 20 } }, { size: { width: -20, height: 20 } }, + { size: { width: 20, height: NaN } }, { size: { width: 20, height: -1 } }, + { size: { width: 20, height: Infinity } }, { size: { width: 1000, height: 20 } }, + { size: { width: 20, height: 1000 } }, { bounds: rect(0, 0, 0, 0) }, + { bounds: rect(0, 0, -100, 100) }, { bounds: rect(Infinity, 0, 100, 100) }, + { blockers: [rect(10, 10, -10, 10)] }, { blockers: [rect(10, NaN, 10, 10)] }, + { gap: -1 }, { gap: Infinity }, { edge: -1 }, { edge: NaN }, + ]) assert.equal(placeDeviceTooltip(input(overrides)), null, JSON.stringify(overrides)); +});