Files
houseplan-card/demo/smoke_room_resize.mjs
T
2026-09-09 06:51:55 +03:00

372 lines
18 KiB
JavaScript

// Safe fixed-topology Resize (#277): production pointer handlers, disabled
// reasons, exact shared preview/commit, one Undo, corner clamp and zero-write
// cancellation. The smoke loads the tracked production bundle.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch();
const enter = async (tool = 'resize') => {
await page.evaluate((next) => {
const card = window.__card;
if (!card._markup) card._setMode('plan');
card._tool = next;
card._resize.reset();
card.requestUpdate();
return card.updateComplete;
}, tool);
await page.waitForTimeout(40);
};
const setRooms = async (rooms, openings = [], walls = []) => {
await page.evaluate(({ rooms, openings, walls }) => {
const card = window.__card;
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
// Each scenario below injects the deliberately compact pre-identity
// projection. Mark it as such so the production transaction exercises the
// same atomic v8 -> v9 materialisation as a real legacy document instead
// of presenting invalid v9 data with missing wall_ids.
card._serverCfg.model_version = 8;
space.rooms = rooms;
space.openings = openings;
if (walls.length) space.walls = walls;
else delete space.walls;
delete space.wall_segments;
delete space.open_spans;
delete space.partitions;
delete space.room_drafts;
delete space.wall_columns;
card._geometryHistory.clear();
card._resize.reset();
card._cfgEpoch++;
card.requestUpdate();
return card.updateComplete;
}, { rooms, openings, walls });
await page.waitForTimeout(40);
};
const settle = () => page.evaluate(() => new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))));
const screenPt = (x, y) => page.evaluate(([px, py]) => {
const card = window.__card;
const stage = card.renderRoot.querySelector('.stage');
const svg = stage.querySelector('svg');
const point = new DOMPoint(px, py).matrixTransform(svg.getScreenCTM());
return [point.x, point.y];
}, [x, y]);
const pointer = (type, clientX, clientY, { cx, cy, pointerId = 77 } = {}) => page.evaluate((args) => {
const handles = [...window.__card.renderRoot.querySelectorAll('.rszhandle')];
const target = args.cx == null ? handles.find((handle) => !handle.classList.contains('disabled'))
: handles.find((handle) => Math.abs(Number(handle.getAttribute('cx')) - args.cx) < 1
&& Math.abs(Number(handle.getAttribute('cy')) - args.cy) < 1);
target?.dispatchEvent(new PointerEvent(args.type, {
bubbles: true, cancelable: true, pointerId: args.pointerId,
clientX: args.clientX, clientY: args.clientY, pointerType: 'mouse', buttons: args.type === 'pointerup' ? 0 : 1,
}));
return !!target;
}, { type, clientX, clientY, cx, cy, pointerId });
const roomPoly = (id, live = false) => page.evaluate(({ id, live }) => {
const card = window.__card;
const space = live ? card._curSpaceCfg : card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
return space.rooms.find((room) => room.id === id)?.poly || null;
}, { id, live });
const edgeX = async (id, edge, live = false) => {
const poly = await roomPoly(id, live);
return ((poly[edge][0] + poly[(edge + 1) % poly.length][0]) / 2) * 1000;
};
const rect = (id, x0, y0, x1, y1) => ({
id, name: id, area: null,
poly: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]].map(([x, y]) => [x / 1000, y / 1000]),
});
// No Resize interaction leaks into another tool.
await enter('draw');
check('safe_resize.hidden_outside_tool', await page.evaluate(() =>
window.__card.renderRoot.querySelectorAll('.rszhandle').length), 0);
// Exact shared pair: only two rooms move, preview stays out of _serverCfg,
// moving opening travels once, release writes one history command.
await setRooms([
rect('left', 100, 100, 400, 400),
rect('right', 400, 100, 700, 400),
rect('third', 850, 100, 950, 400),
], [{ id: 'moving-door', type: 'door', x: 0.4, y: 0.25, angle: 90, length: 0.08 }], [{
key: '0.400000,0.250000@1.5708', cm: 15, a: [0.4, 0.1], b: [0.4, 0.4],
}]);
await enter();
check('safe_resize.handles_visible', await page.evaluate(() =>
window.__card.renderRoot.querySelectorAll('.rszhandle').length), 12);
check('safe_resize.no_corner_scale', await page.evaluate(() =>
window.__card.renderRoot.querySelectorAll('.rszcorner,.rszframe,.rszknob').length), 0);
check('safe_resize.shared_enabled_handles', await page.evaluate(() =>
[...window.__card.renderRoot.querySelectorAll('.rszhandle')]
.filter((handle) => Math.abs(Number(handle.getAttribute('cx')) - 400) < 1
&& handle.getAttribute('aria-disabled') === 'false').length), 2);
const [sx, sy] = await screenPt(400, 250);
const [tx] = await screenPt(450, 250);
const safeResizeScreen = await page.evaluate(() => {
const stage = window.__card.renderRoot.querySelector('.stage');
const svg = stage.querySelector('svg');
const matrix = svg.getScreenCTM();
return { stage: [stage.clientWidth, stage.clientHeight], viewBox: svg.getAttribute('viewBox'),
matrix: matrix ? [matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f] : null };
});
await pointer('pointerdown', sx, sy, { cx: 400, cy: 250 });
check('safe_resize.drag_started', await page.evaluate(() => window.__card._resize.dragging), true);
await pointer('pointermove', tx, sy);
await settle();
const safeResizePreviewLeft = await edgeX('left', 1, true);
check('safe_resize.preview_moved', Math.abs(safeResizePreviewLeft - 450) < 6, true);
check('safe_resize.preview_not_persisted', Math.abs((await edgeX('left', 1, false)) - 400) < 1e-6, true);
await pointer('pointerup', tx, sy);
await settle();
const safeResizeCommitLeft = await edgeX('left', 1, false);
const safeResizeCommitRight = await edgeX('right', 3, false);
check('safe_resize.commit_left', Math.abs(safeResizeCommitLeft - 450) < 6, true);
check('safe_resize.commit_right', Math.abs(safeResizeCommitRight - 450) < 6, true);
check('safe_resize.third_static', Math.abs((await edgeX('third', 1, false)) - 950) < 1e-6, true);
const safeResizeOpeningX = await page.evaluate(() => {
const card = window.__card;
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
return space.openings.find((opening) => opening.id === 'moving-door').x;
});
check('safe_resize.opening_once', Math.abs(safeResizeOpeningX - 0.45) < 0.006, true);
check('safe_resize.one_undo', await page.evaluate(() => window.__card._geometryHistory.size), 1);
await page.keyboard.press('Control+z');
await settle();
check('safe_resize.undo_exact', Math.abs((await edgeX('left', 1, false)) - 400) < 1e-6, true);
// The anonymized regression topology has one long boundary owned by two
// neighbours. It is a visible, accessible disabled handle and captures no
// pointer / creates no write.
await setRooms([
{ id: 'main', name: 'main', area: null, poly: [[0, 0], [662, 0], [662, -5], [782, -5], [782, 250], [662, 250], [662, 245], [0, 245]].map(([x, y]) => [x / 1000, y / 1000]) },
rect('a', 0, 100, 339, 245),
rect('b', 339, 100, 662, 245),
]);
await enter();
const disabled = await page.evaluate(() => {
const card = window.__card;
const circles = [...card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="true"]')];
const target = circles.find((circle) => circle.getAttribute('aria-label')?.includes('part'))
|| circles[0];
return {
count: circles.length,
label: target?.getAttribute('aria-label') || '',
tab: target?.getAttribute('tabindex'),
};
});
check('safe_resize.disabled_visible', disabled.count > 0, true);
check('safe_resize.disabled_reason', /part|част/i.test(disabled.label), true);
check('safe_resize.disabled_focusable', disabled.tab, '0');
const historyBefore = await page.evaluate(() => window.__card._geometryHistory.size);
await page.evaluate(() => {
const card = window.__card;
card.__resizeDisabledWrite = card._writeConfig;
card.__resizeDisabledWrites = 0;
card._writeConfig = async () => { card.__resizeDisabledWrites++; };
});
const disabledActivation = await page.evaluate(() => {
const card = window.__card;
const target = [...card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="true"]')]
.find((circle) => circle.getAttribute('aria-label')?.includes('part'))
|| card.renderRoot.querySelector('.rszhandle[aria-disabled="true"]');
const expected = target?.getAttribute('aria-label') || '';
const activate = (event) => {
card._toast = '';
target?.dispatchEvent(event);
return card._toast;
};
return {
expected,
click: activate(new MouseEvent('click', { bubbles: true, cancelable: true })),
enter: activate(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })),
space: activate(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })),
};
});
check('safe_resize.disabled_click_reason', disabledActivation.click, disabledActivation.expected);
check('safe_resize.disabled_enter_reason', disabledActivation.enter, disabledActivation.expected);
check('safe_resize.disabled_space_reason', disabledActivation.space, disabledActivation.expected);
const [dx, dy] = await screenPt(331, 245);
await pointer('pointerdown', dx, dy, { cx: 331, cy: 245, pointerId: 78 });
await settle();
check('safe_resize.disabled_no_drag', await page.evaluate(() => window.__card._resize.dragging), false);
check('safe_resize.disabled_zero_history', await page.evaluate(() => window.__card._geometryHistory.size), historyBefore);
check('safe_resize.disabled_zero_write', await page.evaluate(() => {
const card = window.__card;
const writes = card.__resizeDisabledWrites;
card._writeConfig = card.__resizeDisabledWrite;
delete card.__resizeDisabledWrite;
delete card.__resizeDisabledWrites;
return writes;
}), 0);
// #289: the selected outer wall would lengthen two formerly shared side walls
// by exactly 43 grid steps. Both directions are disabled before capture: grow
// would append outer material to a shared run, shrink would leave the
// neighbour's continuation outer.
await setRooms([
rect('mixed-main', 0, 0, 200, 928),
rect('mixed-right', 200, 0, 400, 928),
rect('mixed-left', -200, 0, 0, 928),
]);
await enter();
const mixedBefore = JSON.stringify(await roomPoly('mixed-main'));
const mixedHandle = await page.evaluate(() => {
const target = [...window.__card.renderRoot.querySelectorAll('.rszhandle')]
.find((handle) => Math.abs(Number(handle.getAttribute('cx')) - 100) < 1
&& Math.abs(Number(handle.getAttribute('cy')) - 928) < 1);
return { disabled: target?.getAttribute('aria-disabled'), label: target?.getAttribute('aria-label') || '' };
});
check('safe_resize.mixed_role_disabled', mixedHandle.disabled, 'true');
check('safe_resize.mixed_role_reason', /only part of this wall|часть этой стены/i.test(mixedHandle.label), true);
const [mx, my] = await screenPt(100, 928);
const [, mixed43] = await screenPt(100, 971);
await pointer('pointerdown', mx, my, { cx: 100, cy: 928, pointerId: 82 });
await pointer('pointermove', mx, mixed43, { pointerId: 82 });
await pointer('pointerup', mx, mixed43, { pointerId: 82 });
await settle();
check('safe_resize.mixed_role_no_drag', await page.evaluate(() => window.__card._resize.dragging), false);
check('safe_resize.mixed_role_geometry_exact', JSON.stringify(await roomPoly('mixed-main')), mixedBefore);
check('safe_resize.mixed_role_zero_write', await page.evaluate(() => window.__card._geometryHistory.size), 0);
// An outer side may grow through empty space to another exact grid node. It
// stops before it would overlap a different room's outer edge and turn both
// runs into one newly shared stretch.
await setRooms([
rect('range-main', 0, 0, 100, 100),
rect('range-future-left', -100, 125, 0, 200),
], [], [
{ key: '0.000000,0.050000@1.5708', cm: 30, a: [0, 0], b: [0, 0.1] },
{ key: '0.000000,0.162500@1.5708', cm: 30, a: [0, 0.125], b: [0, 0.2] },
]);
await enter();
const [rx, ry] = await screenPt(50, 100);
const [, rangeFar] = await screenPt(50, 150);
await pointer('pointerdown', rx, ry, { cx: 50, cy: 100, pointerId: 83 });
await pointer('pointermove', rx, rangeFar, { pointerId: 83 });
await pointer('pointerup', rx, rangeFar, { pointerId: 83 });
await settle();
const rangePoly = await roomPoly('range-main');
check('safe_resize.owner_boundary_clamped', Math.abs(rangePoly[2][1] * 1000 - 125) < 1, true);
check('safe_resize.owner_boundary_topology', rangePoly.length, 4);
check('safe_resize.owner_boundary_no_mixed_role', await page.evaluate(() => {
const card = window.__card;
const walls = card._serverCfg.spaces.find((space) => space.id === card._space).walls || [];
const boundary = 0.125;
return walls.every((wall) => {
const ys = [wall?.a?.[1], wall?.b?.[1]].filter(Number.isFinite);
if (ys.length !== 2) return true;
return !(Math.min(...ys) < boundary - 1e-9 && Math.max(...ys) > boundary + 1e-9);
});
}), true);
check('safe_resize.owner_boundary_cm_preserved', await page.evaluate(() => {
const walls = window.__card._serverCfg.spaces.find((space) => space.id === window.__card._space).walls || [];
return JSON.stringify(walls.map((wall) => wall.cm).sort((a, b) => a - b));
}), '[30,30]');
// Irregular exact pair reaches the first safe grid position before its inner
// corner; neither polygon gains or loses a vertex.
await setRooms([
rect('left', 100, 100, 400, 400),
{ id: 'irregular', name: 'irregular', area: null, poly: [[400, 100], [700, 100], [700, 200], [650, 200], [650, 400], [400, 400]].map(([x, y]) => [x / 1000, y / 1000]) },
]);
await enter();
const [ix, iy] = await screenPt(400, 250);
const [farX] = await screenPt(720, 250);
await pointer('pointerdown', ix, iy, { cx: 400, cy: 250, pointerId: 79 });
await pointer('pointermove', farX, iy, { pointerId: 79 });
await pointer('pointerup', farX, iy, { pointerId: 79 });
await settle();
const irregular = await roomPoly('irregular');
check('safe_resize.corner_clamped', Math.abs(irregular[5][0] * 1000 - 625) < 6, true);
check('safe_resize.corner_topology', irregular.length, 6);
// Exact production preflight is fail-closed before preview/commit.
await setRooms([rect('preflight', 100, 100, 400, 400)]);
await enter();
await page.evaluate(() => {
const card = window.__card;
card.__resizePreflight = card._checkSpacePhysicalGeometry;
card.__resizeShowToast = card._showToast;
card.__resizeRejectToasts = 0;
card._checkSpacePhysicalGeometry = () => ({ ok: false, status: 'failed' });
card._showToast = function resizeRejectToast(message) {
if (/last safe position|последн/i.test(message)) card.__resizeRejectToasts++;
return card.__resizeShowToast.call(card, message);
};
});
const preflightBefore = JSON.stringify(await roomPoly('preflight'));
const [px, py] = await screenPt(400, 250);
const [preflightX] = await screenPt(500, 250);
await pointer('pointerdown', px, py, { cx: 400, cy: 250, pointerId: 80 });
await pointer('pointermove', preflightX, py, { pointerId: 80 });
await pointer('pointermove', preflightX + 20, py, { pointerId: 80 });
await settle();
check('safe_resize.preflight_visible_reason', await page.evaluate(() =>
/last safe position|последн/i.test(window.__card._toast)), true);
check('safe_resize.preflight_reason_once', await page.evaluate(() =>
window.__card.__resizeRejectToasts), 1);
await pointer('pointerup', preflightX, py, { pointerId: 80 });
await settle();
await page.evaluate(() => {
const card = window.__card;
card._checkSpacePhysicalGeometry = card.__resizePreflight;
card._showToast = card.__resizeShowToast;
delete card.__resizePreflight;
delete card.__resizeShowToast;
delete card.__resizeRejectToasts;
});
check('safe_resize.preflight_no_commit', JSON.stringify(await roomPoly('preflight')), preflightBefore);
check('safe_resize.preflight_zero_write', await page.evaluate(() => window.__card._geometryHistory.size), 0);
// The candidate may become invalid after a valid preview (for example a
// concurrent structural update). Pointerup repeats the common preflight and
// must not trust the fact that an overlay was shown.
await setRooms([rect('commit-preflight', 100, 100, 400, 400)]);
await enter();
const commitPreflightBefore = JSON.stringify(await roomPoly('commit-preflight'));
const [cpx, cpy] = await screenPt(400, 250);
const [commitPreflightX] = await screenPt(500, 250);
await pointer('pointerdown', cpx, cpy, { cx: 400, cy: 250, pointerId: 84 });
await pointer('pointermove', commitPreflightX, cpy, { pointerId: 84 });
await settle();
check('safe_resize.commit_preflight_preview_exists',
Math.abs((await edgeX('commit-preflight', 1, true)) - 500) < 6, true);
await page.evaluate(() => {
const card = window.__card;
card.__resizeCommitPreflight = card._checkSpacePhysicalGeometry;
card._checkSpacePhysicalGeometry = () => ({ ok: false, status: 'failed' });
});
await pointer('pointerup', commitPreflightX, cpy, { pointerId: 84 });
await settle();
await page.evaluate(() => {
const card = window.__card;
card._checkSpacePhysicalGeometry = card.__resizeCommitPreflight;
delete card.__resizeCommitPreflight;
});
check('safe_resize.commit_preflight_no_commit',
JSON.stringify(await roomPoly('commit-preflight')), commitPreflightBefore);
check('safe_resize.commit_preflight_zero_write',
await page.evaluate(() => window.__card._geometryHistory.size), 0);
// pointercancel follows the abort path: no persistence, history or hidden save.
await setRooms([rect('solo', 100, 100, 400, 400)]);
await enter();
const cancelBefore = JSON.stringify(await roomPoly('solo'));
const [cx, cy] = await screenPt(400, 250);
const [moveX] = await screenPt(500, 250);
await pointer('pointerdown', cx, cy, { cx: 400, cy: 250, pointerId: 81 });
await pointer('pointermove', moveX, cy, { pointerId: 81 });
await pointer('pointercancel', moveX, cy, { pointerId: 81 });
await settle();
check('safe_resize.cancel_geometry', JSON.stringify(await roomPoly('solo')), cancelBefore);
check('safe_resize.cancel_zero_write', await page.evaluate(() => window.__card._geometryHistory.size), 0);
await finish(browser, { done: true, safeResizeScreen, safeResizePoints: [sx, sy, tx],
safeResizeResult: [safeResizePreviewLeft, safeResizeCommitLeft, safeResizeCommitRight, safeResizeOpeningX] });