Files
houseplan-card/demo/smoke_plan_snap_overlay.mjs
T
2026-08-26 13:39:52 +03:00

414 lines
19 KiB
JavaScript

/** Issue #137: architectural endpoint/line overlay and exact snap in Plan. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const cfg = {
spaces: [{
id: 'snap', title: 'Snap', cell_cm: 5, view_box: [0, 0, 1, 0.7],
rooms: [
{ id: 'left', name: 'Left', area: null,
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] },
{ id: 'right', name: 'Right', area: null,
poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.5], [0.5, 0.5]] },
],
openings: [
{ id: 'door', type: 'door', x: 0.3, y: 0.1, angle: 0, length: 0.1 },
{ id: 'partition-door', type: 'door', x: 0.84, y: 0.6, angle: 0, length: 0.05,
host: { kind: 'partition', id: 'base-partition', t: 0.8 } },
],
open_spans: [{ a: [0.5, 0.2], b: [0.5, 0.3] }],
room_drafts: [{ id: 'saved', points: [[0.1, 0.6], [0.3, 0.6], [0.3, 0.7]],
segments: [{ cm: 15 }, { cm: 15 }] }],
partitions: [{ id: 'base-partition', a: [0.6, 0.6], b: [0.9, 0.6], cm: 15 }],
wall_columns: [{ id: 'ignored-column', shape: 'square', center: [0.8, 0.35], cm: 25 }],
}],
markers: [], settings: {},
};
card._serverCfg = JSON.parse(JSON.stringify(cfg));
card._layout = {};
card._space = 'snap';
card._modelCache = null;
card._frame = null;
card._cfgEpoch++;
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card._activeDraftId = null;
card._clearPlanSnapHover();
await update();
const stage = root().querySelector('.stage');
const eventAt = (x, y, type = 'pointermove', extra = {}) => {
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
const EventType = type.startsWith('pointer') ? PointerEvent : MouseEvent;
return new EventType(type, {
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
bubbles: true,
pointerId: 41,
pointerType: 'mouse',
...extra,
});
};
const overlay = () => root().querySelector('[data-hp="plan-snap-overlay"]');
const active = () => root().querySelector('.plan-snap-node[data-active="true"]');
const close = (a, b, epsilon = 1e-5) => Math.abs(a - b) <= epsilon;
result.overlayBeforeFirstClick = !!overlay();
result.overlayIsPointerTransparent = overlay()?.getAttribute('pointer-events') === 'none'
&& getComputedStyle(overlay()).pointerEvents === 'none';
const wallBodies = root().querySelector('.wallbodies');
result.overlayAfterWallBodies = !!wallBodies && !!overlay()
&& !!(wallBodies.compareDocumentPosition(overlay()) & Node.DOCUMENT_POSITION_FOLLOWING);
result.oneLinePerSolidInterval = overlay()?.querySelectorAll('.plan-snap-line').length === 12;
result.uniqueSourceEndpointsOnly = overlay()?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length === 11;
result.columnIsNotACandidate = ![...overlay().querySelectorAll('.plan-snap-node')].some((node) =>
close(+node.getAttribute('cx'), 800) && close(+node.getAttribute('cy'), 350));
const crosses = (x1, y1, x2, y2, px, py) => {
const dx = x2 - x1, dy = y2 - y1;
const length2 = dx * dx + dy * dy;
if (!length2) return false;
const t = ((px - x1) * dx + (py - y1) * dy) / length2;
if (t <= 0 || t >= 1) return false;
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) < 0.01;
};
const lines = () => [...overlay().querySelectorAll('.plan-snap-line')].map((line) => [
+line.getAttribute('x1'), +line.getAttribute('y1'),
+line.getAttribute('x2'), +line.getAttribute('y2'),
]);
result.openingGapHasNoLine = !lines().some((line) => crosses(...line, 300, 100));
result.partitionOpeningGapHasNoLine = !lines().some((line) => crosses(...line, 840, 600));
result.zeroWallKeepsAxis = lines().some((line) => crosses(...line, 500, 250));
result.cutBoundariesAreNotEndpoints = ![...overlay().querySelectorAll('[data-kind="endpoint"]')].some((node) => {
const x = +node.getAttribute('cx'), y = +node.getAttribute('cy');
return (close(y, 100) && (close(x, 250) || close(x, 350)))
|| (close(x, 500) && (close(y, 200) || close(y, 300)));
});
stage.dispatchEvent(eventAt(300, 100));
await card.updateComplete;
result.openingGapDoesNotActivateSnap = !active();
stage.dispatchEvent(eventAt(840, 600));
await card.updateComplete;
result.partitionOpeningGapDoesNotActivateSnap = !active();
stage.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true, pointerId: 41 }));
await card.updateComplete;
result.pointerLeaveClearsActiveNode = !active();
// The remainder exercises structural writes, not opening migration. Remove
// the intentionally legacy/unhosted gap fixtures after their assertions so
// the v9 fail-closed host barrier does not reject an unrelated wall edit.
delete card._curSpaceCfg.openings;
card._modelCache = null;
card._cfgEpoch++;
await update();
const cachedGeometry = card._planSnapGeometryCache?.value;
const staticNodesBefore = [...overlay().querySelectorAll(
'.plan-snap-line, .plan-snap-node[data-kind="endpoint"]',
)];
const originalRequestUpdate = card.requestUpdate;
let initialHoverUpdates = 0;
card.requestUpdate = function (...args) {
initialHoverUpdates++;
return originalRequestUpdate.apply(this, args);
};
stage.dispatchEvent(eventAt(104, 104));
await card.updateComplete;
result.endpointHoverIsActive = active()?.getAttribute('data-kind') === 'endpoint'
&& close(+active().getAttribute('cx'), 100) && close(+active().getAttribute('cy'), 100)
&& close(+active().getAttribute('r'), card._cmToUnits(10));
stage.dispatchEvent(eventAt(735, 606));
await card.updateComplete;
const initialLineActive = active()?.getAttribute('data-kind') === 'line';
stage.dispatchEvent(eventAt(500, 650));
await card.updateComplete;
const initialMissInactive = !active();
stage.dispatchEvent(eventAt(104, 104));
await card.updateComplete;
card.requestUpdate = originalRequestUpdate;
result.initialHoverAvoidsFullCardUpdate = initialHoverUpdates === 0;
result.initialHoverCyclesCandidateKinds = initialLineActive && initialMissInactive
&& active()?.getAttribute('data-kind') === 'endpoint';
result.initialHoverKeepsStaticNodeIdentity = staticNodesBefore.every(
(node, index) => node === overlay().querySelectorAll(
'.plan-snap-line, .plan-snap-node[data-kind="endpoint"]',
)[index],
);
card._markupClick(eventAt(104, 104, 'click', { shiftKey: true }));
await card.updateComplete;
result.endpointOverridesGridAndShift = card._path.length === 1
&& close(card._path[0][0], 100) && close(card._path[0][1], 100);
// Keep the line-snap append scenario independent from the room corner just
// verified above. In v9 that corner is a canonical zero-wall topology node,
// so a wall from it may legitimately complete a face and open the room flow.
card._cancelPath();
card._markupClick(eventAt(504, 654, 'click'));
await card.updateComplete;
result.freeStartForLineSnap = card._path.length === 1
&& Number.isFinite(card._path[0][0]) && Number.isFinite(card._path[0][1]);
stage.dispatchEvent(eventAt(735, 606));
await card.updateComplete;
const lineNode = active();
result.lineHoverShowsOneDynamicNode = !!lineNode
&& lineNode.getAttribute('data-kind') === 'line'
&& root().querySelectorAll('.plan-snap-node.dynamic[data-active="true"]').length === 1;
const linePoint = lineNode ? [+lineNode.getAttribute('cx'), +lineNode.getAttribute('cy')] : [NaN, NaN];
result.lineNodeStaysWallBound = close(linePoint[1], 600);
result.lineNodeQuantizesAlongWall = close((linePoint[0] - 600) / card._gridPitch,
Math.round((linePoint[0] - 600) / card._gridPitch));
result.hoverKeepsStaticGeometryCache = !!cachedGeometry
&& card._planSnapGeometryCache?.value === cachedGeometry;
const originalPartition = JSON.stringify(card._curSpaceCfg.partitions[0]);
card._markupClick(eventAt(735, 606, 'click'));
await card.updateComplete;
result.drawPathLength = card._path.length;
result.drawCommitUsesExactLineNode = card._path.length === 2
&& close(card._path[1][0], linePoint[0]) && close(card._path[1][1], linePoint[1]);
result.existingPartitionWasNotSplit = card._curSpaceCfg.partitions.length === 1
&& JSON.stringify(card._curSpaceCfg.partitions[0]) === originalPartition;
// The line-snap append above intentionally remains an unfinished draft.
// Remove that synthetic record before the independent finish-on-tool-change
// scenario below; the fixture's original `saved` draft remains untouched.
card._curSpaceCfg.room_drafts = (card._curSpaceCfg.room_drafts || [])
.filter((draft) => draft.id === 'saved');
delete card._resumeDraftBySpace[card._space];
card._modelCache = null;
card._cfgEpoch++;
card._cancelPath();
card._activeDraftId = 'saved';
card._path = [[100, 600], [300, 600], [300, 700]];
await update();
result.activeDraftExcluded = !lines().some((line) => (
close(line[1], 600) && close(line[3], 600)
&& Math.min(line[0], line[2]) < 300 && Math.max(line[0], line[2]) > 100
) || (
close(line[0], 300) && close(line[2], 300)
&& Math.min(line[1], line[3]) < 700 && Math.max(line[1], line[3]) > 600
));
stage.dispatchEvent(eventAt(302, 602));
await card.updateComplete;
result.intermediateCurrentPointDoesNotSelfSnap = !active();
stage.dispatchEvent(eventAt(102, 602));
await card.updateComplete;
result.firstCurrentPointRemainsClosureTarget = active()?.getAttribute('data-kind') === 'endpoint'
&& close(+active().getAttribute('cx'), 100) && close(+active().getAttribute('cy'), 600);
stage.dispatchEvent(eventAt(302, 698));
await card.updateComplete;
result.currentAnchorDoesNotCreateZeroSegment = !active();
card._cancelPath();
card._activateMarkupTool('draw');
await update();
// Use a fresh point on the same physical line: the earlier assertion left
// 735,600 as a saved-draft endpoint, and public Walls correctly resumes it.
card._markupClick(eventAt(775, 606, 'click'));
await card.updateComplete;
result.tapWithoutHoverSnapsFirstPoint = card._path.length === 1
&& close(card._path[0][1], 600)
&& close((card._path[0][0] - 600) / card._gridPitch,
Math.round((card._path[0][0] - 600) / card._gridPitch));
card._markupClick(eventAt(896, 496, 'click'));
await card.updateComplete;
result.secondPathLengthBeforeSelect = card._path.length;
result.secondNoRoomDialog = card._roomDialog !== true;
result.secondDraftCountBeforeSelect = (card._curSpaceCfg.room_drafts || []).length;
result.secondToast = String(card._toast || '');
card._activateMarkupTool('select');
await update();
result.partitionCountAfterDraw = card._curSpaceCfg.partitions.length;
const drawnPartition = card._curSpaceCfg.partitions.find(
(partition) => partition.id !== 'base-partition',
);
result.secondWallsClickFinishesSnappedPartition = card._path.length === 0
&& card._curSpaceCfg.partitions.length === 2
&& !!drawnPartition
&& [drawnPartition.a, drawnPartition.b].some((point) =>
close(point[0] * 1000, 900) && close(point[1] * 1000, 500));
result.originalSegmentStillUnchanged = JSON.stringify(card._curSpaceCfg.partitions[0]) === originalPartition;
const gestureGeometry = JSON.stringify({
drafts: card._curSpaceCfg.room_drafts,
partitions: card._curSpaceCfg.partitions,
});
card._activateMarkupTool('draw');
await update();
card._suppressClick = true;
card._markupClick(eventAt(700, 600, 'click'));
result.suppressedClickDoesNotCommit = card._path.length === 0;
card._suppressClick = false;
stage.dispatchEvent(eventAt(700, 600, 'pointerdown', { pointerId: 51, buttons: 1 }));
stage.dispatchEvent(eventAt(760, 640, 'pointermove', { pointerId: 51, buttons: 1 }));
stage.dispatchEvent(eventAt(760, 640, 'pointerup', { pointerId: 51 }));
stage.dispatchEvent(eventAt(650, 560, 'pointerdown', { pointerId: 61, buttons: 1 }));
stage.dispatchEvent(eventAt(800, 650, 'pointerdown', { pointerId: 62, buttons: 1 }));
stage.dispatchEvent(eventAt(620, 540, 'pointermove', { pointerId: 61, buttons: 1 }));
stage.dispatchEvent(eventAt(830, 670, 'pointermove', { pointerId: 62, buttons: 1 }));
stage.dispatchEvent(eventAt(620, 540, 'pointercancel', { pointerId: 61 }));
stage.dispatchEvent(eventAt(830, 670, 'pointerup', { pointerId: 62 }));
await card.updateComplete;
result.panPinchCancelDoNotCommit = card._path.length === 0
&& JSON.stringify({
drafts: card._curSpaceCfg.room_drafts,
partitions: card._curSpaceCfg.partitions,
}) === gestureGeometry;
card._curSpaceCfg.partitions.push({
id: 'hidden-partition', a: [0.1, 0.1], b: [0.5, 0.1], cm: 15,
});
card._modelCache = null;
card._cfgEpoch++;
card._activateMarkupTool('select');
await update();
const diagnostic = root().querySelector('[data-hp="hidden-wall-diagnostic"]');
result.otherPlanToolsKeepFullOverlayAndHiddenDiagnostic = !!overlay() && !!diagnostic
&& diagnostic.querySelectorAll('.hidden-wall-line').length === 1
&& diagnostic.querySelectorAll('.hidden-wall-node').length === 2;
const editorZeroWalls = root().querySelector('.zero-walls');
result.hiddenDiagnosticAboveAllWallBodies = !!diagnostic && !!wallBodies
&& !!(wallBodies.compareDocumentPosition(diagnostic) & Node.DOCUMENT_POSITION_FOLLOWING)
&& (!editorZeroWalls
|| !!(editorZeroWalls.compareDocumentPosition(diagnostic) & Node.DOCUMENT_POSITION_FOLLOWING));
result.hiddenDiagnosticIsPointerTransparent = diagnostic?.getAttribute('pointer-events') === 'none'
&& getComputedStyle(diagnostic).pointerEvents === 'none';
card._activateMarkupTool('draw');
await update();
const drawDiagnostic = root().querySelector('[data-hp="hidden-wall-diagnostic"]');
const drawSnap = overlay();
result.drawToolKeepsHiddenDiagnosticAndSnapOverlay = !!drawDiagnostic && !!drawSnap
&& drawDiagnostic.querySelectorAll('.hidden-wall-line').length === 1
&& drawDiagnostic.querySelectorAll('.hidden-wall-node').length === 2;
result.hiddenDiagnosticPaintsBeforeTransientSnap = !!(
drawDiagnostic.compareDocumentPosition(drawSnap) & Node.DOCUMENT_POSITION_FOLLOWING
);
// #304 regression fixture: one room plus two independent walls which overlap
// its left/bottom axes and continue beyond them. The six source endpoints are
// the exact topology from the field report, reduced to deterministic data.
const parityCfg = {
model_version: 7,
spaces: [{
id: 'axis-parity', title: 'Axis parity', cell_cm: 5, view_box: [0, 0, 1, 0.75],
rooms: [{
id: 'room', name: 'Room', area: null,
poly: [[0.2, 0.2], [0.6, 0.2], [0.6, 0.6], [0.2, 0.6]],
}],
room_drafts: [], openings: [], open_spans: [],
partitions: [
{ id: 'vertical', a: [0.2, 0.05], b: [0.2, 0.6], cm: 20 },
{ id: 'horizontal', a: [0.2, 0.6], b: [0.9, 0.6], cm: 20 },
],
settings: { fill_mode: 'none', show_borders: true, show_names: false },
}],
markers: [], settings: {},
};
card._serverCfg = JSON.parse(JSON.stringify(parityCfg));
card._layout = {};
card._space = 'axis-parity';
card._modelCache = null;
card._frame = null;
card._activeDraftId = null;
card._path = [];
card._cfgEpoch++;
card._setMode('plan');
await update();
const staticSnapshot = () => {
const current = overlay();
return JSON.stringify({
lines: [...current.querySelectorAll('.plan-snap-line')].map((line) => [
line.getAttribute('data-key'), line.getAttribute('data-source-kind'),
line.getAttribute('x1'), line.getAttribute('y1'),
line.getAttribute('x2'), line.getAttribute('y2'),
]),
endpoints: [...current.querySelectorAll('.plan-snap-node[data-kind="endpoint"]')]
.map((node) => [
node.getAttribute('data-key'), node.getAttribute('cx'), node.getAttribute('cy'),
]),
});
};
const tools = [
'select', 'draw', 'column', 'merge', 'split', 'resize',
'opening', 'wallthick', 'delroom',
];
let expectedSnapshot = null;
let everyToolMatches = true;
let everyToolIsPassive = true;
let everyToolPaintsAboveWalls = true;
let thicknessHasSixNodes = false;
for (const tool of tools) {
card._tool = tool;
card._clearPlanSnapHover();
await update();
const current = overlay();
const bodies = root().querySelector('.wallbodies');
const snapshot = current ? staticSnapshot() : null;
if (expectedSnapshot == null) expectedSnapshot = snapshot;
everyToolMatches &&= !!current && snapshot === expectedSnapshot;
everyToolIsPassive &&= current?.getAttribute('pointer-events') === 'none'
&& getComputedStyle(current).pointerEvents === 'none'
&& !current.querySelector('.plan-snap-node[data-active="true"]');
everyToolPaintsAboveWalls &&= !!bodies && !!current
&& !!(bodies.compareDocumentPosition(current) & Node.DOCUMENT_POSITION_FOLLOWING);
if (tool === 'wallthick') {
thicknessHasSixNodes = current.querySelectorAll(
'.plan-snap-node[data-kind="endpoint"]',
).length === 6 && current.querySelectorAll('.plan-snap-line').length === 6;
}
}
result.allPlanToolsShareStaticAxesAndNodes = everyToolMatches;
result.allPlanToolOverlaysStayPointerTransparent = everyToolIsPassive;
result.allPlanToolOverlaysPaintAboveWallBodies = everyToolPaintsAboveWalls;
result.thicknessShowsCompleteSixNodeFixture = thicknessHasSixNodes;
card._setMode('devices');
await update();
const devicesHaveNoPlanOverlay = !overlay();
card._setMode('decor');
await update();
const decorHasNoPlanOverlay = !overlay();
card._setMode('view');
await update();
result.nonPlanModesHaveNoOverlay = devicesHaveNoPlanOverlay && decorHasNoPlanOverlay
&& !overlay()
&& !root().querySelector('[data-hp="hidden-wall-diagnostic"]');
return result;
});
await page.emulateMedia({ forcedColors: 'active' });
out.forcedColorsStayReadable = await page.evaluate(async () => {
const card = window.__card;
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card.requestUpdate();
await card.updateComplete;
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
const line = overlay?.querySelector('.plan-snap-line');
const node = overlay?.querySelector('.plan-snap-node');
const lineStyle = line ? getComputedStyle(line) : null;
const nodeStyle = node ? getComputedStyle(node) : null;
return matchMedia('(forced-colors: active)').matches
&& lineStyle?.stroke !== 'none' && nodeStyle?.stroke !== 'none'
&& nodeStyle?.fill !== 'none';
});
await finish(browser, checkAll(out, {
drawPathLength: 2,
secondPathLengthBeforeSelect: 2,
secondDraftCountBeforeSelect: 2,
secondToast: '',
partitionCountAfterDraw: 2,
}));