mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
Merge issue/802-zigbee-hover-labels into dev
Owner-authorized local review; task CI and WSL acceptance are green. Issue: #802 User-Visible: no
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
// #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 <router> & "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 <router> & 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 <router> & 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 <safe> & ' + '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());
|
||||
// A transformed HA/dashboard ancestor establishes the containing block
|
||||
// for a position:fixed tooltip. Measure the actual screen result, not
|
||||
// the CSS left/top values, before and after document scrolling.
|
||||
await hover('d_mower');
|
||||
const beforeAncestorTransform = await sample('d_mower');
|
||||
await page.evaluate(() => {
|
||||
document.body.style.minHeight = '1800px';
|
||||
const host = document.getElementById('host');
|
||||
host.style.transformOrigin = '0 0';
|
||||
host.style.transform = 'translate(35px, 125px) scale(0.82)';
|
||||
});
|
||||
await hover('d_mower');
|
||||
const transformed = await witness('transformedContainingAncestor', 'd_mower');
|
||||
out.ancestorScaleReallyAppliesToFixedTooltip = Math.abs(
|
||||
transformed.source.width / beforeAncestorTransform.source.width - 0.82,
|
||||
) < 0.02 && await root.locator('[data-hp-live-tip]').evaluate((tip) =>
|
||||
getComputedStyle(tip).position === 'fixed');
|
||||
out.transformedBadgesInsideVisibleStage = transformed.badgeInside;
|
||||
await page.screenshot({ path: fileURLToPath(new URL('ancestor-transform.png', artifacts)) });
|
||||
// Six CSS pixels keep the pointer inside the marker, so this check also
|
||||
// witnesses the scroll observer without a new pointer event/hover.
|
||||
await page.evaluate(() => window.scrollTo(0, 6));
|
||||
await settle();
|
||||
const smallScroll = await witness('ancestorScrollWithoutNewHover', 'd_mower');
|
||||
out.scrollActuallyMovesScreenGeometry = Math.abs(transformed.source.top - smallScroll.source.top - 6) < 1;
|
||||
// A larger document scroll moves the source away from the old pointer;
|
||||
// re-enter the still-visible marker with a real mouse.
|
||||
await page.evaluate(() => window.scrollTo(0, 180));
|
||||
await hover('d_mower');
|
||||
const scrolled = await witness('transformedAncestorAfterPageScroll', 'd_mower');
|
||||
out.largePageScrollKeepsBadgesInsideVisibleStage = scrolled.badgeInside
|
||||
&& Math.abs(transformed.source.top - scrolled.source.top - 180) < 1
|
||||
&& await page.evaluate(() => window.scrollY === 180);
|
||||
await page.screenshot({ path: fileURLToPath(new URL('ancestor-transform-scrolled.png', artifacts)) });
|
||||
}
|
||||
} 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);
|
||||
@@ -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"]')
|
||||
@@ -961,7 +962,11 @@ for (const scale of [0.5, 1, 2]) {
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
window.__zigbeeOutlineProbeStyle.textContent = `[data-hp-zigbee-outline-probe] {
|
||||
transform: scale(${scale}) !important; transform-origin: 0 0 !important; background: #fff !important;
|
||||
}`;
|
||||
}
|
||||
/* #802: isolate route ink. At scale=.5 the ordinary pointer tooltip can
|
||||
cover the target arrow; its dark background is not outline coverage.
|
||||
Real tooltip/badge coexistence is witnessed in smoke_zigbee_tooltip_layout. */
|
||||
[data-hp-live-tip] { display: none !important; }`;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const overlay = root.querySelector('hp-zigbee-topology-overlay');
|
||||
const svg = overlay.shadowRoot.querySelector('svg');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 после
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
@@ -398,8 +398,13 @@ LQI: 0 — красный, 128 — жёлтый, 255 — зелёный. Без
|
||||
прозрачен для указателя, поэтому действия комнат и устройств работают как
|
||||
раньше.
|
||||
|
||||
Если следующий шаг находится в другом пространстве, рядом с устройством
|
||||
появляется его название. Если нужный роутер или координатор не размещён на
|
||||
Если следующий шаг находится в другом пространстве, подпись содержит его
|
||||
название и имя устройства в скобках: **Второй этаж (Розетка коридора)**.
|
||||
Если имя неизвестно, показывается только пространство. Обычная подсказка
|
||||
устройства перемещается в свободное место, не закрывая сетевые бейджи.
|
||||
Только когда места для обеих подсказок недостаточно, она временно скрывается;
|
||||
при освобождении места возвращается. Новая настройка для этого не нужна.
|
||||
Если нужный роутер или координатор не размещён на
|
||||
плане, короткая подпись сообщает об этом; доступное имя отсутствующего
|
||||
устройства добавляется в скобках. «Нет данных о маршруте» означает отсутствие
|
||||
однозначных сведений об исходящей связи, а не недоступность устройства.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Локальное код-ревью #802 — r1
|
||||
|
||||
Вердикт: зелёный · High: 0 · Medium: 0 · маршрут: merge.
|
||||
|
||||
## Материал и полномочия
|
||||
|
||||
- Дата: 2026-10-05; ветка `issue/802-zigbee-hover-labels`.
|
||||
- База: `7feda5b6c82b34f3b42f87a1f0c1fac5bf590265`.
|
||||
- Продукт: `db887cd7f0612267e74d06700ae5fe8deb14a561`.
|
||||
- Итоговый материал тестов: `077e9c1b1be693757009f748bfccb1c77bb39aee`.
|
||||
- ТЗ: тело [#802](https://github.com/Matysh/houseplan-card/issues/802), раздел
|
||||
`## ТЗ`, default Q1 принят. [Исключение владельца](https://github.com/Matysh/houseplan-card/issues/802#issuecomment-6000425421)
|
||||
разрешает саморевью, ручное слияние и следующую бету вместо недоступной
|
||||
ревью-модели. Это не вердикт штатного автоматического ревьюера.
|
||||
|
||||
Автор прочитал весь diff; дополнительно отдельный локальный агент проверил
|
||||
продуктовый материал и повторно проверил исправление своей находки. Итог
|
||||
обоих прочтений — без High/Medium. Изначально длинная подпись адресата могла
|
||||
перекрыть сетевой status-бейдж; это исправлено тем же измеряемым размещением,
|
||||
с переносом текста и присоединением стрелки к фактическому краю подписи.
|
||||
|
||||
## Приёмка
|
||||
|
||||
| Контракт | Проверка |
|
||||
| --- | --- |
|
||||
| AC1: адресат | ZHA/Z2M, имя из текущего маппинга, fallback, длинные строки и специальные символы; Lit выводит текст, входящий счётчик не изменён. |
|
||||
| AC2: читаемость | Реальный pointer, remote/unplaced/status-only, partial/stale/error, края, Flat и настоящий 2.5D, обе темы; измеряются пересечения DOM-прямоугольников. |
|
||||
| AC3: тесная карточка | 13 unit-тестов чистого расчёта, включая независимый перебор 200 малых раскладок; browser hide/restore без нового hover. |
|
||||
| AC4: обновления | Lazy adoption уже наведённого устройства, runtime, resize и camera, отсутствие новых запросов от pointer; измерения после render объединены через rAF. |
|
||||
| AC5: input/lifecycle | Keyboard restore, mouse/touch/pen, несколько карточек и unmount; соседние input/render smokes. Наблюдатели отключаются при clear/disconnect. |
|
||||
| AC6: визуал | Просмотрены кадры длинной подписи, status-only 2.5D, узкой карточки и transformed/scrolled ancestor. Эталоны не переписываются вслепую. |
|
||||
|
||||
## Исполненные проверки
|
||||
|
||||
- WSL Ubuntu, Node 22.23.2: `npm run gate:small` — PASS, 0 упавших гейтов.
|
||||
Нативный Windows не использован как доказательство полного unit-набора:
|
||||
платформенные shell-тесты проверены в WSL.
|
||||
- `node demo/smoke_zigbee_tooltip_layout.mjs` — 77/77 PASS на Windows с pinned
|
||||
Node и повторно 77/77 PASS в WSL на итоговом материале. Дополнительные семь проверяют
|
||||
реальный `position:fixed` при `translate + scale` предка, scroll без нового
|
||||
hover и повторное наведение после большой прокрутки.
|
||||
- WSL: `smoke_zigbee_topology_hover`, `smoke_household_journeys`,
|
||||
`smoke_render_invalidation`, `smoke_touch_tips` — PASS.
|
||||
- WSL: полный `npm run golden:verify` — PASS; ни один эталон не изменён.
|
||||
- [Validate 37357451243](https://github.com/Matysh/houseplan-card/actions/runs/37357451243)
|
||||
на `077e9c1b1be693757009f748bfccb1c77bb39aee` — success. После него
|
||||
добавлен только этот отчёт/индекс; повторный WSL `gate:small` также PASS.
|
||||
- Отрицательный контроль старого cursor-position даёт 1596 CSS px² перекрытия;
|
||||
новый layout устраняет его. Это не DOM-count и не поиск строки в исходнике.
|
||||
- Изолированный raster-oracle обводки временно скрывает обычную подсказку
|
||||
только внутри тестовой сцены, иначе её тёмный фон закрывает измеряемую стрелку
|
||||
при scale 0.5. Все пороги и проверки масштабирования сохранены; совместное
|
||||
отображение подсказок защищено отдельным новым smoke.
|
||||
- `git diff --check`, process gate с живыми issue-метками,
|
||||
`node scripts/mutation-gate.mjs --check` — PASS. Два мутанта размещения
|
||||
зарегистрированы; их исполнение оставлено ночному гейту по процессу.
|
||||
|
||||
Smoke-select перечисляет много потребителей общего live-hover; локально выбран
|
||||
Zigbee, household actions, invalidation и touch, поскольку новая ветка активна
|
||||
только для pointer-tooltip с совпадающим владельцем реально показанных
|
||||
Zigbee-бейджей. Остальные режимы ранним return сохраняют старое поведение.
|
||||
Полная CI-матрица остаётся обязательной на точном SHA беты.
|
||||
|
||||
## Ограничения и заключение
|
||||
|
||||
В физически слишком маленькой области невозможно гарантировать размещение
|
||||
сколь угодно длинного диагностического текста. Текст не обрезается до потери
|
||||
имени, обычный tooltip уступает место; новая настройка не добавлена.
|
||||
Изменения не затрагивают маршруты, LQI, transport, сохранённые данные или
|
||||
действия устройств. Рост bundleBytes на 8229 байт обоснован в issue; прочие
|
||||
монолитные метрики и initial-load budget не расширены.
|
||||
|
||||
Разрешено ручное слияние после зелёных проверок ветки. Этот документ сам по
|
||||
себе не подтверждает готовность точного release-кандидата или публикацию.
|
||||
@@ -1,12 +1,14 @@
|
||||
# Индекс ревью
|
||||
|
||||
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 305, issue: 152. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
|
||||
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 307, issue: 153. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
|
||||
|
||||
| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |
|
||||
|---|---|---|---|---:|---:|---|---|
|
||||
| бета v1.80.0-beta.1 | [SHIP-REVIEW-v1.80.0-beta.1.md](SHIP-REVIEW-v1.80.0-beta.1.md) | пакетное ревью ship · — | ⚪ — | 0 | 0 | — | — |
|
||||
| бета v1.79.0-beta.2 | [SHIP-REVIEW-v1.79.0-beta.2.md](SHIP-REVIEW-v1.79.0-beta.2.md) | пакетное ревью ship · — | ⚪ — | 0 | 0 | — | — |
|
||||
| бета v1.79.0-beta.1 | [SHIP-REVIEW-v1.79.0-beta.1.md](SHIP-REVIEW-v1.79.0-beta.1.md) | пакетное ревью ship · — | ⚪ — | 0 | 0 | — | — |
|
||||
| #802 | [SPEC-REVIEW-802-r1.md](SPEC-REVIEW-802-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #802 | [CODE-REVIEW-802-r1.md](CODE-REVIEW-802-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #800 | [SPEC-REVIEW-800-r1.md](SPEC-REVIEW-800-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #800 | [CODE-REVIEW-800-r1.md](CODE-REVIEW-800-r1.md) | code · r1 | 🟡 жёлтый | 0 | 3 | живой браузер остаётся на умершем job после reload интеграции; AC1 не имеет свидетеля, чувствительного к прежнему 600-секундному deadline; материал не проходит обязательный gate:small; English guide всё ещё обещает прекращение ожидания через 10 минут | `custom_components/houseplan/zigbee_topology.py` `scripts/mutation-registry.mjs` `test/data-hp-contract.test.mjs` `test/reviews-index.test.mjs` `PREFLIGHT-SPEC-800.md` `test/smoke-select.test.mjs` `docs/USER-GUIDE.md` |
|
||||
| #800 | [CODE-REVIEW-800-r2.md](CODE-REVIEW-800-r2.md) | code · r2 | 🟡 жёлтый | 0 | 1 | временный not_ready во время reload навсегда блокирует явный retry | `src/zigbee-topology-runtime.ts` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Проверено чтением, не исполнением: продуктовая реализация ещё не написана.
|
||||
@@ -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`
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"hostRefs": 5067,
|
||||
"portPrivates": 103,
|
||||
"harnessPrivates": 100,
|
||||
"bundleBytes": 2697591
|
||||
"bundleBytes": 2705820
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`<div class="iso-tile-shadows" aria-hidden="true">${repeat(devs, (d) => d.id, (d) => this._renderDevice(d, view, showLqi, isoOverlays?.devices.get(d.id), true))}${this._renderOpeningLocks(view, isoOverlays?.locks, true)}</div>` : 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() })}
|
||||
</div>
|
||||
<div data-hp-live-editor-html></div>
|
||||
${this._wallDialog
|
||||
|
||||
@@ -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<typeof setTimeout>;
|
||||
@@ -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<string>();
|
||||
private _endpointSetDirty = false;
|
||||
private _endpointElements = new Set<HTMLElement>();
|
||||
@@ -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<HTMLElement>('.remote,.route-status')]
|
||||
.map((node) => node.getBoundingClientRect());
|
||||
if (marker) blockers.push(marker);
|
||||
for (const caption of this.renderRoot.querySelectorAll<HTMLElement>('.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<SVGLineElement>(
|
||||
`[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<SVGLineElement>(
|
||||
`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<SVGPolygonElement>(
|
||||
`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<HTMLElement>('[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<typeof zigbeeArrowGeometry>, parent = false) {
|
||||
arrow: ReturnType<typeof zigbeeArrowGeometry>, 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`<line class="link-casing" data-hp="zigbee-topology-line-casing"
|
||||
x1=${origin.x} y1=${origin.y} x2=${point.x} y2=${point.y}
|
||||
data-parent-index=${parentIndex ?? nothing} x1=${origin.x} y1=${origin.y} x2=${point.x} y2=${point.y}
|
||||
stroke="#000000" stroke-width=${2 + 2 * outline} data-direction=${direction}></line>` : nothing}
|
||||
<line class=${parent ? 'parent-route' : 'link-core'}
|
||||
data-hp=${parent ? 'zigbee-topology-parent-line' : 'zigbee-topology-line'}
|
||||
x1=${origin.x} y1=${origin.y} x2=${point.x} y2=${point.y}
|
||||
data-parent-index=${parentIndex ?? nothing} x1=${origin.x} y1=${origin.y} x2=${point.x} y2=${point.y}
|
||||
stroke=${color} stroke-width=${lqi === undefined ? 2 : 2.2} data-direction=${direction}></line>
|
||||
${arrow ? svg`<polygon class="route-arrow"
|
||||
data-hp=${parent ? 'zigbee-topology-parent-arrow' : 'zigbee-topology-arrow'}
|
||||
data-direction=${direction} points=${this._points(arrow.points)} fill=${color}
|
||||
data-parent-index=${parentIndex ?? nothing} data-direction=${direction} points=${this._points(arrow.points)} fill=${color}
|
||||
stroke=${lqi === undefined ? '#000000' : nothing} stroke-width=${lqi === undefined ? 2 * outline : nothing}
|
||||
stroke-linejoin="round" paint-order="stroke fill"></polygon>` : nothing}`;
|
||||
}
|
||||
@@ -396,12 +520,12 @@ export class HpZigbeeTopologyOverlay extends LitElement {
|
||||
${lines.length || bubbles.length ? svg`<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"
|
||||
aria-hidden="true" data-hp="zigbee-topology-lines">
|
||||
${lines.map((line) => this._route(origin, line.point!, line.lqi, line.routeDirection, line.arrow))}
|
||||
${bubbles.map((bubble) => this._route(origin, bubble.point, bubble.target.lqi, 'toward-neighbor', bubble.arrow, true))}
|
||||
${bubbles.map((bubble, index) => this._route(origin, bubble.point, bubble.target.lqi, 'toward-neighbor', bubble.arrow, true, index))}
|
||||
</svg>` : nothing}
|
||||
${lines.map((line) => html`<div class="halo" data-hp="zigbee-topology-neighbor"
|
||||
data-id=${line.neighborMarkerId} style="left:${line.point!.x}px;top:${line.point!.y}px;width:${line.point!.width * 1.22}px;height:${line.point!.height * 1.22}px"></div>`)}
|
||||
${bubbles.map((bubble) => html`<div class="parent-bubble ${placeLeft ? 'left' : 'right'}"
|
||||
data-hp="zigbee-topology-parent-bubble" data-kind=${bubble.target.kind}
|
||||
${bubbles.map((bubble, index) => html`<div class="parent-bubble ${placeLeft ? 'left' : 'right'}"
|
||||
data-parent-index=${index} data-hp="zigbee-topology-parent-bubble" data-kind=${bubble.target.kind}
|
||||
style="left:${bubble.point.x}px;top:${bubble.point.y}px">${this._targetText(bubble.target)}</div>`)}
|
||||
${hover.remoteCount ? html`<div class="remote" data-hp="zigbee-topology-remote"
|
||||
style="left:${origin.x}px;top:${origin.y}px">${topologyT(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Данные устарели",
|
||||
|
||||
+42
-2
@@ -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<HTMLElement>('hp-zigbee-topology-overlay');
|
||||
if (overlay?.getAttribute('data-tooltip-owner') !== tip.deviceId) return;
|
||||
const stage = root.querySelector<HTMLElement>('.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<HTMLElement>(
|
||||
'.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<HTMLElement>('[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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`<hp-zigbee-topology-overlay aria-hidden="true" .hass=${input.hass} .devices=${input.devices}
|
||||
.registry=${input.registry} .currentSpace=${input.currentSpace} .spaces=${input.spaces}
|
||||
.viewKey=${input.viewKey} .zoom=${input.zoom}></hp-zigbee-topology-overlay>`;
|
||||
.viewKey=${input.viewKey} .zoom=${input.zoom} .onLayout=${input.onLayout}></hp-zigbee-topology-overlay>`;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user