// 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] });