mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
234 lines
11 KiB
JavaScript
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);
|