Files
houseplan-card/demo/smoke_opening_preview.mjs
T
2026-08-13 00:14:51 +03:00

234 lines
11 KiB
JavaScript

// Unified Opening tool (#75/#76): the toolbar launcher opens the shared
// secondary tray, a type preset arms placement, and the hover preview is the
// real architectural symbol painted above the wall body. Tests are authored
// here but run only as part of the release gate.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
await page.evaluate(() => {
window.__card._setMode('plan');
return window.__card.updateComplete;
});
await page.waitForTimeout(240);
await page.evaluate(() => new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))));
const res = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const settle = async () => {
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const visibleGeometrySignature = (container) => (container
? [container, ...container.querySelectorAll('g, line, path, rect')]
: []).map((element) => ({
tag: element.tagName,
cls: element.getAttribute('class') || '',
geometry: [
'transform', 'd', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'width', 'height',
'stroke-dasharray', 'stroke-dashoffset', 'style',
]
.map((name) => `${name}=${element.getAttribute(name) || ''}`).join('|'),
}));
const launcher = () => root().querySelector('[data-editor-group="opening"]');
const choose = async (type) => {
launcher().click();
await settle();
const item = root().querySelector(`[data-group-item="${type}"]`);
item?.click();
await settle();
return !!item;
};
out.oneLauncher = root().querySelectorAll('[data-editor-group="opening"]').length === 1;
out.oneSecondaryHost = root().querySelectorAll('.editor-secondary-host').length === 1;
out.launcherDoesNotArm = card._tool !== 'opening' && card._openingPreset == null;
const stageHeightBeforeMenu = root().querySelector('.stage').getBoundingClientRect().height;
const geometryBeforeMenu = JSON.stringify(card._curSpaceCfg.openings || []);
const epochBeforeMenu = card._cfgEpoch;
launcher().click();
await settle();
out.menuHasThreeTypes = root().querySelectorAll('.editor-group-item').length === 3;
out.launcherStillDoesNotArm = card._tool !== 'opening' && card._openingPreset == null;
out.menuDoesNotResizeStage = Math.abs(
root().querySelector('.stage').getBoundingClientRect().height - stageHeightBeforeMenu,
) < 1;
card._editorSecondary.closeGroup(false);
await settle();
out.windowChoice = await choose('window');
out.windowDefault = card._tool === 'opening'
&& card._openingPreset?.type === 'window' && card._openingPreset?.lengthCm === 120;
out.doorChoice = await choose('door');
out.doorDefault = card._openingPreset?.type === 'door' && card._openingPreset?.lengthCm === 90;
out.launcherReflectsActivePreset = launcher()?.getAttribute('aria-pressed') === 'true';
out.gateChoice = await choose('gate');
out.gateDefault = card._openingPreset?.type === 'gate' && card._openingPreset?.lengthCm === 300;
// Return to Door for geometry assertions.
await choose('door');
const room = card._spaceModel().rooms.find((item) => item.id === 'r1')
|| card._spaceModel().rooms[0];
const poly = room.poly || [
[room.x, room.y], [room.x + room.w, room.y],
[room.x + room.w, room.y + room.h], [room.x, room.y + room.h],
];
const wallMid = [(poly[0][0] + poly[1][0]) / 2, (poly[0][1] + poly[1][1]) / 2];
await choose('window');
card._cursorPt = wallMid;
await settle();
const windowPreview = root().querySelector('.opening-preview[data-kind="window"]');
out.windowPreviewGeometry = windowPreview?.querySelectorAll('.op-leaf').length === 2
&& windowPreview?.querySelectorAll('.op-arc').length === 2;
await choose('gate');
card._cursorPt = wallMid;
await settle();
const gatePreview = root().querySelector('.opening-preview[data-kind="gate"]');
out.gatePreviewGeometry = gatePreview?.querySelectorAll('.op-leaf').length === 2
&& gatePreview?.querySelectorAll('.op-arc').length === 0;
await choose('door');
const axisCandidate = card._resolveOpeningPlacement(wallMid);
const target = axisCandidate?.target;
const dx = target ? target.b[0] - target.a[0] : 1;
const dy = target ? target.b[1] - target.a[1] : 0;
const length = Math.hypot(dx, dy) || 1;
const bodyOffset = (target?.physicalHalfWidth || 0) * 0.8;
card._cursorPt = [wallMid[0] - (dy / length) * bodyOffset,
wallMid[1] + (dx / length) * bodyOffset];
await settle();
const preview = root().querySelector('.opening-preview');
const previewGeometry = visibleGeometrySignature(preview?.querySelector(':scope > g'));
out.previewExists = !!preview;
out.previewIsDoor = preview?.getAttribute('data-kind') === 'door';
out.previewHasFullSymbol = !!preview?.querySelector('.op-arc')
&& !!preview?.querySelector('.op-leaf');
out.previewHalfTransparent = preview
? Math.abs(Number.parseFloat(getComputedStyle(preview).opacity) - 0.5) < 0.001 : false;
out.previewPointerInert = preview?.getAttribute('pointer-events') === 'none';
out.previewAriaHidden = preview?.getAttribute('aria-hidden') === 'true';
out.previewHasNoPersistentIdentity = !preview?.hasAttribute('data-id')
&& !preview?.hasAttribute('data-hp');
out.typeChoicesDoNotMutateGeometry = card._cfgEpoch === epochBeforeMenu
&& JSON.stringify(card._curSpaceCfg.openings || []) === geometryBeforeMenu;
const wallBody = root().querySelector('.wallbody');
out.previewPaintedAfterWall = !wallBody || (!!preview
&& !!(wallBody.compareDocumentPosition(preview) & Node.DOCUMENT_POSITION_FOLLOWING));
// A click is authoritative and does not require a preceding hover event.
const expectedClick = card._resolveOpeningPlacement(wallMid);
const openingIdsBeforeSave = new Set((card._curSpaceCfg.openings || []).map((item) => item.id));
card._cursorPt = null;
card._openingHoverCandidate = null;
card._openingClick(wallMid);
await settle();
out.clickWithoutHoverOpensDialog = card._openingDialog?.type === 'door'
&& card._openingDialog?.lengthCm === 90;
out.clickDialogMatchesResolver = !!expectedClick && !!card._openingDialog
&& Math.abs(card._openingDialog.x - expectedClick.x) < 1e-6
&& Math.abs(card._openingDialog.y - expectedClick.y) < 1e-6
&& Math.abs(card._openingDialog.angle - expectedClick.angle) < 1e-6
&& card._openingDialog.flipH === expectedClick.flipH
&& card._openingDialog.flipV === expectedClick.flipV;
out.previewGoneWhileDialogOpen = !root().querySelector('.opening-preview');
// Save writes exactly that candidate and keeps the serial-placement preset.
card._saveOpening();
await settle();
const saved = (card._curSpaceCfg.openings || []).find((item) => !openingIdsBeforeSave.has(item.id));
out.saveMatchesResolver = !!saved && !!expectedClick
&& saved.type === expectedClick.type
&& Math.abs(saved.x * 1000 - expectedClick.x) < 1e-6
&& Math.abs(saved.y * card._spaceH - expectedClick.y) < 1e-6
&& Math.abs(saved.angle - expectedClick.angle) < 1e-6
&& Math.abs(saved.length * 1000 - expectedClick.renderedLength) < 1e-6
&& !!saved.flip_h === expectedClick.flipH
&& !!saved.flip_v === expectedClick.flipV;
const committed = saved
? root().querySelector(`.opening[data-id="${saved.id}"] > g`)
: null;
out.previewAndCommittedShareVisibleGeometry = !!committed
&& JSON.stringify(visibleGeometrySignature(committed)) === JSON.stringify(previewGeometry);
out.saveKeepsPreset = card._tool === 'opening' && card._openingPreset?.type === 'door';
const savedRender = saved && card._openingsR.find((item) => item.id === saved.id);
const savedCenter = savedRender ? [savedRender.rx, savedRender.ry] : wallMid;
const savedRad = savedRender ? savedRender.angle * Math.PI / 180 : 0;
const farNormal = savedRender ? Math.max(40, savedRender.rlen * 0.45) : 40;
const radialLeakPoint = [
savedCenter[0] - Math.sin(savedRad) * farNormal,
savedCenter[1] + Math.cos(savedRad) * farNormal,
];
out.existingOpeningHitBoxIsBounded = !!savedRender
&& card._openingAt(savedCenter)?.id === saved.id
&& card._openingAt(radialLeakPoint) == null;
card._cursorPt = savedCenter;
await settle();
out.noPreviewOverExisting = !root().querySelector('.opening-preview');
const emptyWallMid = poly.map((point, index) => {
const next = poly[(index + 1) % poly.length];
return [(point[0] + next[0]) / 2, (point[1] + next[1]) / 2];
}).find((point) => !card._openingAt(point) && card._resolveOpeningPlacement(point));
// Cancel of a new, unsaved opening keeps the selected session preset too.
if (emptyWallMid) card._openingClick(emptyWallMid);
await settle();
out.cancelCreateDialogOpened = !!emptyWallMid
&& !card._openingDialog?.id && card._openingDialog?.type === 'door';
card._openingDialog = null;
await settle();
out.cancelKeepsPreset = card._tool === 'opening' && card._openingPreset?.type === 'door';
// A viewport transform invalidates the pointer-to-plan projection. The old
// world-space preview must disappear until a fresh pointer move arrives.
card._cursorPt = emptyWallMid || card._roomCenter(room);
await settle();
out.previewBeforeZoom = !!root().querySelector('.opening-preview');
card._stepZoom(1);
await settle();
out.zoomClearsStalePreview = !root().querySelector('.opening-preview')
&& card._openingPreset?.type === 'door';
card._cursorPt = card._roomCenter(room);
await settle();
out.noPreviewAwayFromWalls = !root().querySelector('.opening-preview');
// Real independent bodies are occluders, not room-owned wall intervals.
// They must never become accidental opening-placement targets.
const bodyCentre = card._roomCenter(room);
const bodyX = bodyCentre[0] / 1000;
const bodyY = bodyCentre[1] / card._spaceH;
card._curSpaceCfg.partitions = [{
id: 'opening-smoke-partition', a: [bodyX - 0.05, bodyY], b: [bodyX + 0.05, bodyY], cm: 20,
}];
card._cfgEpoch++;
card._openingPlacementIntervalsCache = null;
card._cursorPt = bodyCentre;
await settle();
out.partitionIsNotOpeningTarget = !root().querySelector('.opening-preview')
&& card._resolveOpeningPlacement(bodyCentre) == null;
card._curSpaceCfg.partitions = [];
card._curSpaceCfg.wall_columns = [{
id: 'opening-smoke-column', shape: 'square', cm: 30, center: [bodyX, bodyY], angle: 0,
}];
card._cfgEpoch++;
card._openingPlacementIntervalsCache = null;
await settle();
out.columnIsNotOpeningTarget = !root().querySelector('.opening-preview')
&& card._resolveOpeningPlacement(bodyCentre) == null;
card._cancelPath();
card._tool = 'draw';
await settle();
out.leavingToolClearsPreset = card._openingPreset == null
&& !root().querySelector('.opening-preview')
&& launcher()?.getAttribute('aria-pressed') === 'false';
return out;
});
checkAll(res);
await finish(browser, res);