mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
267 lines
12 KiB
JavaScript
267 lines
12 KiB
JavaScript
// #293: exercise the real browser pointer pipeline on the tracked second-floor
|
|
// fixture. The card is recreated so the fixture enters through config/get,
|
|
// exactly as it does in Home Assistant; no private Resize method is invoked.
|
|
import { readFileSync } from 'node:fs';
|
|
import { launch, check, finish } from './serve.mjs';
|
|
|
|
const fixture = JSON.parse(readFileSync(
|
|
new URL('../test/fixtures/real-plan-second-floor.json', import.meta.url),
|
|
'utf8',
|
|
));
|
|
const { page, browser } = await launch({ width: 1180, height: 920 });
|
|
|
|
await page.evaluate(async (space) => {
|
|
const previous = window.__card;
|
|
const hass = window.__mkHass();
|
|
const callWS = hass.callWS.bind(hass);
|
|
window.__resizeWrites = [];
|
|
hass.callWS = async (message) => {
|
|
if (message.type === 'houseplan/config/get') {
|
|
return { config: { spaces: [structuredClone(space)], markers: [], settings: {} }, rev: 1, can_write: true };
|
|
}
|
|
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
|
if (message.type === 'houseplan/config/set') {
|
|
window.__resizeWrites.push(structuredClone(message));
|
|
return { ok: true, rev: 2 };
|
|
}
|
|
return callWS(message);
|
|
};
|
|
const card = document.createElement('houseplan-card');
|
|
card.setConfig({ type: 'custom:houseplan-card', title: 'House Plan' });
|
|
previous.remove();
|
|
document.getElementById('host').appendChild(card);
|
|
window.__card = card;
|
|
card.hass = hass;
|
|
}, fixture.space);
|
|
|
|
await page.waitForFunction(() => {
|
|
const card = window.__card;
|
|
return card?._booting === false
|
|
&& card._serverCfg?.spaces?.[0]?.id === 'real-second-floor'
|
|
&& card._space === 'real-second-floor';
|
|
}, { timeout: 9000 });
|
|
|
|
await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
await card._requestMode('plan');
|
|
await card.updateComplete;
|
|
const button = [...card.renderRoot.querySelectorAll('button')]
|
|
.find((entry) => entry.textContent?.trim() === 'Resize');
|
|
button?.click();
|
|
await card.updateComplete;
|
|
});
|
|
await page.waitForFunction(() => window.__card.renderRoot.querySelectorAll('.rszhandle').length > 0);
|
|
// Entering Plan animates the editor chrome and stage for 220 ms. Reading a
|
|
// handle's screen CTM while that transition is still moving makes the real
|
|
// mouse click land at a stale coordinate and turns this pointer smoke flaky.
|
|
await page.waitForFunction(() => !window.__card._modeTransitionBusy);
|
|
await page.evaluate(() => new Promise((resolve) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
|
|
|
await page.evaluate(() => {
|
|
window.__resizePointerId = null;
|
|
window.__card.renderRoot.addEventListener('pointerdown', (event) => {
|
|
if (event.target?.classList?.contains('rszhandle')) window.__resizePointerId = event.pointerId;
|
|
}, true);
|
|
});
|
|
|
|
const target = await page.evaluate(() => {
|
|
const card = window.__card;
|
|
const handles = [...card.renderRoot.querySelectorAll('.rszhandle')];
|
|
const handle = handles.find((entry) => entry.getAttribute('aria-disabled') === 'false'
|
|
&& Math.abs(Number(entry.getAttribute('cx')) - 400) < 1
|
|
&& Math.abs(Number(entry.getAttribute('cy')) - 529.166667) < 2);
|
|
if (!handle) return null;
|
|
const svg = handle.ownerSVGElement;
|
|
const screen = (x, y) => {
|
|
const point = svg.createSVGPoint();
|
|
point.x = x; point.y = y;
|
|
const mapped = point.matrixTransform(handle.getScreenCTM());
|
|
return [mapped.x, mapped.y];
|
|
};
|
|
const cx = Number(handle.getAttribute('cx'));
|
|
const cy = Number(handle.getAttribute('cy'));
|
|
const rect = handle.getBoundingClientRect();
|
|
return {
|
|
start: [rect.left + rect.width / 2, rect.top + rect.height / 2],
|
|
end: screen(cx + 10 * (1000 / 240), cy),
|
|
mappedStart: screen(cx, cy),
|
|
hitWidth: rect.width,
|
|
hitRadiusSvg: Number(handle.getAttribute('r')),
|
|
count: handles.length,
|
|
};
|
|
});
|
|
|
|
const settle = () => page.evaluate(() => new Promise((resolve) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
|
const persistedGeometry = () => page.evaluate(() => {
|
|
const space = window.__card._serverCfg.spaces.find((entry) => entry.id === 'real-second-floor');
|
|
return JSON.stringify({
|
|
rooms: space.rooms,
|
|
openings: space.openings || [],
|
|
walls: space.walls || [],
|
|
open_spans: space.open_spans || [],
|
|
});
|
|
});
|
|
const sharedX = () => page.evaluate(() => {
|
|
const space = window.__card._serverCfg.spaces.find((entry) => entry.id === 'real-second-floor');
|
|
const a = space.rooms.find((room) => room.id === 'room-a');
|
|
const b = space.rooms.find((room) => room.id === 'room-b');
|
|
// v8 may add owner-role breakpoints to either contour. Locate the two
|
|
// physical shared endpoints instead of relying on their legacy ordinals.
|
|
return a.poly.filter((point) => b.poly.some((other) => (
|
|
Math.abs(point[0] - other[0]) < 1e-9 && Math.abs(point[1] - other[1]) < 1e-9
|
|
))).map((point) => point[0]);
|
|
});
|
|
const domHasSharedX = (x) => page.evaluate((wanted) =>
|
|
[...(window.__card.renderRoot.querySelector('[data-hp-live-editor] .hp-live-resize')
|
|
|| window.__card.renderRoot).querySelectorAll('.rszhandle[aria-disabled="false"]')]
|
|
.some((entry) => Math.abs(Number(entry.getAttribute('cx')) - wanted) < 1), x);
|
|
const sharedHandleX = () => page.evaluate(() => {
|
|
const activeRoot = window.__card.renderRoot.querySelector('[data-hp-live-editor] .hp-live-resize')
|
|
|| window.__card.renderRoot;
|
|
const candidates = [...activeRoot.querySelectorAll(
|
|
'.rszhandle[aria-disabled="false"]',
|
|
)].filter((entry) => Math.abs(Number(entry.getAttribute('cy')) - 529.166667) < 2);
|
|
return candidates
|
|
.map((entry) => Number(entry.getAttribute('cx')))
|
|
.sort((left, right) => Math.abs(left - 400) - Math.abs(right - 400))[0] ?? null;
|
|
});
|
|
|
|
check('resize_pointer.fixture_loaded', await page.evaluate(() =>
|
|
window.__card._serverCfg.spaces[0].id), 'real-second-floor');
|
|
check('resize_pointer.fixture_is_current_server_space', await page.evaluate(() => {
|
|
const card = window.__card;
|
|
const current = card._serverCfg.spaces.find((space) => space.id === card._space);
|
|
return current?.rooms?.length === 8
|
|
&& current.rooms.find((room) => room.id === 'room-a')?.poly?.[2]?.[0] === 0.4;
|
|
}), true);
|
|
check('resize_pointer.target_enabled', !!target, true);
|
|
if (target) {
|
|
const before = await persistedGeometry();
|
|
const writesBefore = await page.evaluate(() => window.__resizeWrites.length);
|
|
await page.mouse.move(...target.start);
|
|
await page.mouse.down();
|
|
await page.mouse.move(...target.end, { steps: 8 });
|
|
await settle();
|
|
check('resize_pointer.dom_preview_ten_steps', await domHasSharedX(400 + 10 * (1000 / 240)), true);
|
|
check('resize_pointer.preview_not_persisted', await persistedGeometry(), before);
|
|
await page.mouse.up();
|
|
await settle();
|
|
const expected = 0.4 + 10 / 240;
|
|
check('resize_pointer.both_rooms_commit_ten_steps',
|
|
(await sharedX()).every((value) => Math.abs(value - expected) < 1e-9), true);
|
|
check('resize_pointer.one_history_command', await page.evaluate(() =>
|
|
window.__card._geometryHistory.size), 1);
|
|
await page.waitForTimeout(650);
|
|
check('resize_pointer.one_atomic_write', await page.evaluate(() => window.__resizeWrites.length), writesBefore + 1);
|
|
check('resize_pointer.undo_ready_after_write_ack', await page.evaluate(() => {
|
|
const card = window.__card;
|
|
return {
|
|
history: card._geometryHistory.size,
|
|
mode: card._mode,
|
|
tool: card._tool,
|
|
canCommit: card._canCommitSpace('real-second-floor'),
|
|
};
|
|
}), { history: 1, mode: 'plan', tool: 'resize', canCommit: true });
|
|
check('resize_pointer.wall_metadata_preserved', await page.evaluate(() => {
|
|
const beforeSegments = window.__card._geometryHistory._undo.at(-1)?.before?.wall_segments || [];
|
|
const afterSegments = window.__card._serverCfg.spaces[0].wall_segments || [];
|
|
const beforeById = new Map(beforeSegments.map((segment) => [segment.id, segment]));
|
|
return beforeSegments.length === afterSegments.length
|
|
&& afterSegments.every((segment) => beforeById.get(segment.id)?.cm === segment.cm);
|
|
}), true);
|
|
|
|
const migratedBefore = await page.evaluate(() => {
|
|
const command = window.__card._geometryHistory._undo.at(-1);
|
|
const state = command?.before;
|
|
return JSON.stringify({
|
|
rooms: state?.rooms || [], openings: state?.openings || [],
|
|
walls: state?.walls || [], open_spans: state?.open_spans || [],
|
|
});
|
|
});
|
|
check('resize_pointer.first_edit_materializes_identity', await page.evaluate(() => {
|
|
const space = window.__card._serverCfg.spaces[0];
|
|
return window.__card._serverCfg.model_version === 10
|
|
&& space.rooms.every((room) => room.wall_ids?.length === room.poly.length)
|
|
&& space.wall_segments?.length > 0;
|
|
}), true);
|
|
|
|
await page.keyboard.press('Control+z');
|
|
await settle();
|
|
check('resize_pointer.undo_keyboard_consumed_history', await page.evaluate(() => ({
|
|
history: window.__card._geometryHistory.size,
|
|
canRedo: window.__card._geometryHistory.canRedo,
|
|
})), { history: 0, canRedo: true });
|
|
check('resize_pointer.undo_byte_exact', await persistedGeometry(), migratedBefore);
|
|
await page.waitForTimeout(650);
|
|
check('resize_pointer.undo_one_atomic_write', await page.evaluate(() =>
|
|
window.__resizeWrites.length), writesBefore + 2);
|
|
const stableAfterUndo = await persistedGeometry();
|
|
|
|
// The second gesture leaves the circle by much more than its hit radius.
|
|
// Pointer capture must keep the real browser stream alive; Esc then cancels
|
|
// the already-visible overlay without a second persistence write.
|
|
await page.evaluate(() => {
|
|
window.__resizeWallUnionBefore = window.__card._wallUnionGeometry();
|
|
});
|
|
const outsideDistance = Math.max(120, target.hitWidth * 2);
|
|
await page.mouse.move(...target.start);
|
|
await page.mouse.down();
|
|
await page.mouse.move(target.start[0] + outsideDistance, target.start[1], { steps: 12 });
|
|
await settle();
|
|
check('resize_pointer.capture_beyond_handle', await domHasSharedX(400), false);
|
|
check('resize_pointer.capture_travels_past_hit_area',
|
|
Math.abs((await sharedHandleX()) - 400) > target.hitRadiusSvg * 1.5, true);
|
|
await page.keyboard.press('Escape');
|
|
await page.mouse.up();
|
|
await settle();
|
|
check('resize_pointer.escape_restores_config', await persistedGeometry(), stableAfterUndo);
|
|
check('resize_pointer.escape_reuses_pre_drag_wall_union', await page.evaluate(() =>
|
|
window.__card._wallUnionGeometry() === window.__resizeWallUnionBefore), true);
|
|
await page.waitForTimeout(650);
|
|
check('resize_pointer.escape_zero_extra_write', await page.evaluate(() => window.__resizeWrites.length), writesBefore + 2);
|
|
|
|
// A different pointer cannot take over an active drag. Losing capture for
|
|
// the owning pointer is an abort, never a commit of the visible preview.
|
|
await page.evaluate(() => {
|
|
window.__resizeWallUnionBefore = window.__card._wallUnionGeometry();
|
|
});
|
|
await page.mouse.move(...target.start);
|
|
await page.mouse.down();
|
|
await page.evaluate(({ x, y }) => {
|
|
const card = window.__card;
|
|
const handle = [...card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="false"]')]
|
|
.find((entry) => Math.abs(Number(entry.getAttribute('cx')) - 400) < 1);
|
|
handle?.dispatchEvent(new PointerEvent('pointermove', {
|
|
bubbles: true,
|
|
pointerId: Number(window.__resizePointerId) + 100,
|
|
clientX: x + 100,
|
|
clientY: y,
|
|
}));
|
|
}, { x: target.start[0], y: target.start[1] });
|
|
await settle();
|
|
check('resize_pointer.unrelated_pointer_ignored', await domHasSharedX(400), true);
|
|
await page.mouse.move(target.start[0] + 30, target.start[1], { steps: 6 });
|
|
await settle();
|
|
check('resize_pointer.preview_before_capture_loss', await domHasSharedX(400), false);
|
|
await page.evaluate(() => {
|
|
const card = window.__card;
|
|
const handle = [...card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="false"]')][0];
|
|
handle?.dispatchEvent(new PointerEvent('lostpointercapture', {
|
|
bubbles: true,
|
|
pointerId: Number(window.__resizePointerId),
|
|
}));
|
|
});
|
|
await page.mouse.up();
|
|
await settle();
|
|
check('resize_pointer.capture_loss_restores_dom', await domHasSharedX(400), true);
|
|
check('resize_pointer.capture_loss_restores_config', await persistedGeometry(), stableAfterUndo);
|
|
check('resize_pointer.capture_loss_reuses_pre_drag_wall_union', await page.evaluate(() =>
|
|
window.__card._wallUnionGeometry() === window.__resizeWallUnionBefore), true);
|
|
await page.waitForTimeout(650);
|
|
check('resize_pointer.capture_loss_zero_extra_write', await page.evaluate(() => window.__resizeWrites.length), writesBefore + 2);
|
|
}
|
|
|
|
await finish(browser, { done: true });
|