Files
houseplan-card/demo/smoke_room_resize.mjs
T
Codex 11ccd5b095 Ревью r2: лейблы доставки называют свой сценарий
Medium: две обёртки унаследовали имя соседа. Блок `aria-disabled`-ручки звался
`owner_boundary`, а блок с проверками `owner_boundary_*` — `range_role`, имени,
которого нет ни у одной проверки. Логика при этом верна, страдает ровно то,
ради чего правка и делалась: чтение красной строки с раннера.

Теперь `disabled.*` и `owner_boundary.*` стоят на своих блоках. Смок зелёный.

Issue: #533
User-Visible: no
2026-09-11 19:46:29 +03:00

377 lines
20 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))));
/**
* #533: перевод координат живёт внутри того же кадра, что и отправка события.
*
* Прежде смок считал экранные точки заранее, одним вызовом на весь жест, а
* карточка переводит их обратно в момент события — от текущего размера стейджа
* и текущего вида. Стоило раскладке осесть между замером и жестом, и 34 экранных
* пикселя превращались уже не в 50 единиц плана: ресайз коммитил не ту величину,
* а свидетель сообщал об этом четырьмя немыми `expected true, got false`.
* Раннер ловил это, локальная машина — нет.
*
* Координаты сюда передаются В ЕДИНИЦАХ ПЛАНА; `nudge` — добавка в экранных
* пикселях для случаев, где проверяется именно подрагивание указателя.
*/
const pointer = (type, planX, planY, { cx, cy, pointerId = 77, nudge } = {}) =>
page.evaluate((args) => {
const card = window.__card;
const stage = card.renderRoot.querySelector('.stage');
const svg = stage.querySelector('svg');
const matrix = svg.getScreenCTM();
const point = new DOMPoint(args.planX, args.planY).matrixTransform(matrix);
const clientX = point.x + (args.nudge ? args.nudge[0] : 0);
const clientY = point.y + (args.nudge ? args.nudge[1] : 0);
const handles = [...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, clientY, pointerType: 'mouse', buttons: args.type === 'pointerup' ? 0 : 1,
}));
return { sent: !!target, scale: matrix.a, client: [clientX, clientY],
stage: [stage.clientWidth, stage.clientHeight] };
}, { type, planX, planY, cx, cy, pointerId, nudge });
/** Жест обязан доехать: молчаливый `find` без цели — это зелёный смок ни о чём. */
const sent = (name, result) => {
check(name, result.sent, true);
return result;
};
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 grab = sent('safe_resize.down_sent', await pointer('pointerdown', 400, 250, { cx: 400, cy: 250 }));
check('safe_resize.drag_started', await page.evaluate(() => window.__card._resize.dragging), true);
const moved = sent('safe_resize.move_sent', await pointer('pointermove', 450, 250, { cx: 400, cy: 250 }));
// #533: если отображение экран↔план поехало между захватом и движением, жест
// коммитит не ту величину. Пусть это будет одна строка с числами, а не четыре
// немых `expected true, got false`.
check(`safe_resize.mapping_stable ${grab.scale} vs ${moved.scale}`
+ ` stage ${grab.stage} vs ${moved.stage}`,
Math.abs(grab.scale - moved.scale) < 1e-6, true);
const safeResizeScreen = { down: grab, move: moved };
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);
sent('safe_resize.up_sent', await pointer('pointerup', 450, 250, { cx: 400, cy: 250 }));
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);
sent('disabled.pointerdown_sent', await pointer('pointerdown', 331, 245, { 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);
sent('mixed_role.pointerdown_sent', await pointer('pointerdown', 100, 928, { cx: 100, cy: 928, pointerId: 82 }));
sent('mixed_role.pointermove_sent', await pointer('pointermove', 100, 971, { cx: 100, cy: 928, pointerId: 82 }));
sent('mixed_role.pointerup_sent', await pointer('pointerup', 100, 971, { cx: 100, cy: 928, 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();
sent('owner_boundary.pointerdown_sent', await pointer('pointerdown', 50, 100, { cx: 50, cy: 100, pointerId: 83 }));
sent('owner_boundary.pointermove_sent', await pointer('pointermove', 50, 150, { cx: 50, cy: 100, pointerId: 83 }));
sent('owner_boundary.pointerup_sent', await pointer('pointerup', 50, 150, { cx: 50, cy: 100, 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();
sent('corner_clamped.pointerdown_sent', await pointer('pointerdown', 400, 250, { cx: 400, cy: 250, pointerId: 79 }));
sent('corner_clamped.pointermove_sent', await pointer('pointermove', 720, 250, { cx: 400, cy: 250, pointerId: 79 }));
sent('corner_clamped.pointerup_sent', await pointer('pointerup', 720, 250, { cx: 400, cy: 250, 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'));
sent('preflight.pointerdown_sent', await pointer('pointerdown', 400, 250, { cx: 400, cy: 250, pointerId: 80 }));
sent('preflight.pointermove_sent', await pointer('pointermove', 500, 250, { cx: 400, cy: 250, pointerId: 80 }));
sent('preflight.pointermove2_sent', await pointer('pointermove', 500, 250, { cx: 400, cy: 250, pointerId: 80, nudge: [20, 0] }));
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);
sent('preflight.pointerup_sent', await pointer('pointerup', 500, 250, { cx: 400, cy: 250, 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'));
sent('commit_preflight.pointerdown_sent', await pointer('pointerdown', 400, 250, { cx: 400, cy: 250, pointerId: 84 }));
sent('commit_preflight.pointermove_sent', await pointer('pointermove', 500, 250, { cx: 400, cy: 250, 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' });
});
sent('commit_preflight.pointerup_sent', await pointer('pointerup', 500, 250, { cx: 400, cy: 250, 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'));
sent('cancel.pointerdown_sent', await pointer('pointerdown', 400, 250, { cx: 400, cy: 250, pointerId: 81 }));
sent('cancel.pointermove_sent', await pointer('pointermove', 500, 250, { cx: 400, cy: 250, pointerId: 81 }));
sent('cancel.pointercancel_sent', await pointer('pointercancel', 500, 250, { cx: 400, cy: 250, 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,
safeResizeResult: [safeResizePreviewLeft, safeResizeCommitLeft, safeResizeCommitRight, safeResizeOpeningX] });