Files
houseplan-card/demo/smoke_editor_tabs.mjs
T

399 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const hp = window.__hpTest;
const sr = () => c.shadowRoot || c.renderRoot;
const tabs = () => [...sr().querySelectorAll('.modetab')];
const headerCross = () => sr().querySelector('.editor-close-slot .closex');
const settleMode = async () => {
const started = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет
out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
out.labels = tabs().map((t) => t.textContent.trim());
// #660: X does not live inside a mode button; its slot follows the active
// button and moves to the group end (empty but same size) in View.
out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0
&& !sr().querySelector('.editor-close-slot .closex');
out.startView = c._mode === 'view';
// 2) клик по «Редактор плана» → активна, панель с крестиком
tabs()[0].click(); await c.updateComplete;
out.planActive = c._mode === 'plan' && tabs()[0].classList.contains('active');
out.planBar = !!sr().querySelector('.editbar');
out.planBarClose = !!sr().querySelector('.editbar .barclose');
const barClose = sr().querySelector('.editbar .barclose');
const barCloseIcon = barClose?.querySelector('ha-icon');
const barCloseRect = barClose?.getBoundingClientRect();
const barCloseIconRect = barCloseIcon?.getBoundingClientRect();
const enteringChrome = sr().querySelector('.editorchrome');
// Regression #95 follow-up: the bar is already painted during the 220 ms
// mode transition, so its explicit Close command must not be inert together
// with the editing tools.
out.closeRemainsInteractiveDuringEnter = c._modeTransitionBusy
&& enteringChrome?.inert === false
&& sr().querySelector('.editbar-tools')?.inert === true
&& getComputedStyle(barClose).pointerEvents === 'auto';
const enteringStage = sr().querySelector('.stage');
const editorSceneLayer = enteringStage?.querySelector('.hp-editor-only-layer');
const enteringVisual = c._modeTransitionVisual;
out.sceneLayersUseTransitionWeights = c._modeTransitionBusy && !!enteringVisual
&& !!editorSceneLayer
&& Math.abs(Number(editorSceneLayer.getAttribute('opacity')) - enteringVisual.editorWeight) < 0.001
&& Math.abs(Number(getComputedStyle(enteringStage).getPropertyValue('--hp-mode-view-weight'))
- enteringVisual.viewWeight) < 0.001;
out.planBarCloseIconIsCentered = !!barCloseRect && !!barCloseIconRect
&& Math.abs((barCloseIconRect.left + barCloseIconRect.width / 2)
- (barCloseRect.left + barCloseRect.width / 2)) <= 1
&& Math.abs((barCloseIconRect.top + barCloseIconRect.height / 2)
- (barCloseRect.top + barCloseRect.height / 2)) <= 1;
out.tabCross = !!headerCross() && !tabs()[0].querySelector('.closex');
const planSummary = sr().querySelector('.summary-control');
const summaryToggle = planSummary?.querySelector('button:last-child');
const summaryBefore = summaryToggle?.getAttribute('aria-pressed');
summaryToggle?.click(); await c.updateComplete;
out.summaryControlsStayInPlan = planSummary?.querySelectorAll('button').length === 2
&& summaryToggle?.getAttribute('aria-pressed') !== summaryBefore
&& !sr().querySelector('.summary-overlay');
summaryToggle?.click(); await c.updateComplete;
// 3) повторный клик по активной вкладке — ничего
tabs()[0].click(); await c.updateComplete;
out.reclickNoop = c._mode === 'plan';
const enteringTabCross = headerCross();
out.tabCrossCloseStartsDuringEnter = c._modeTransitionBusy;
enteringTabCross.click();
await settleMode();
out.tabCrossCloseDuringEnterWorks = c._mode === 'view';
tabs()[0].click(); await settleMode();
// HP-UX-11: transient tool controls live in a stage overlay. Opening the
// thickness controls must not resize/refit the stage or move pinned Close.
const stageBeforeContext = sr().querySelector('.stage').getBoundingClientRect();
const closeBeforeContext = sr().querySelector('.editbar .barclose').getBoundingClientRect();
const hdrBeforeContext = c._hdrH;
c._tool = 'draw'; c.requestUpdate(); await c.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 40));
const stageWithContext = sr().querySelector('.stage').getBoundingClientRect();
const closeWithContext = sr().querySelector('.editbar .barclose').getBoundingClientRect();
const secondary = sr().querySelector('.editor-secondary-host.open .editor-secondary');
out.contextTrayLivesInStage = !!secondary
&& secondary.closest('.stage') === sr().querySelector('.stage')
&& !secondary.closest('.editorchrome');
out.contextDoesNotResizeStage = Math.abs(stageWithContext.top - stageBeforeContext.top) <= 1
&& Math.abs(stageWithContext.height - stageBeforeContext.height) <= 1
&& c._hdrH === hdrBeforeContext;
out.contextDoesNotMoveClose = Math.abs(closeWithContext.left - closeBeforeContext.left) <= 1
&& Math.abs(closeWithContext.top - closeBeforeContext.top) <= 1;
out.closeHasPinnedEndCap = !!sr().querySelector('.editbar > .editbar-end > .barclose');
// Replace the real Opening group with a side-effect-free command group to
// pin the generic launcher/keyboard/restore contract independently of any
// one editor tool's business logic.
let groupInvokes = 0;
Object.defineProperty(c, '_editorToolbarGroups', { configurable: true, get: () => [{
id: 'smoke-group', label: 'Smoke group', icon: 'mdi:dots-grid', items: [{
id: 'smoke-command', label: 'Smoke command', icon: 'mdi:check',
role: 'command', invoke: () => { groupInvokes += 1; },
}],
}] });
c.requestUpdate(); await c.updateComplete;
const groupLauncher = sr().querySelector('[data-editor-group="smoke-group"]');
groupLauncher?.focus();
groupLauncher?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
out.groupUsesSharedTray = !!sr().querySelector('.editor-secondary.kind-group')
&& sr().querySelectorAll('.editor-secondary-host').length === 1;
out.groupArrowDownMovesFocus = sr().activeElement?.classList?.contains('editor-group-item') === true;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
out.groupEscapeRestoresToolContext = c._editorSecondary.hasOpenGroup === false
&& !!sr().querySelector('.editor-secondary.kind-tool')
&& sr().activeElement === groupLauncher;
groupLauncher?.click(); await c.updateComplete;
sr().querySelector('.editor-group-item')?.click(); await c.updateComplete;
out.groupCommandClosesBeforeInvoke = groupInvokes === 1
&& c._editorSecondary.hasOpenGroup === false
&& !!sr().querySelector('.editor-secondary.kind-tool');
groupLauncher?.click(); await c.updateComplete;
const outsideTool = document.createElement('button');
const unrelatedTool = document.createElement('button');
let outsideInvokes = 0;
let unrelatedInvokes = 0;
outsideTool.addEventListener('click', () => outsideInvokes++);
unrelatedTool.addEventListener('click', () => unrelatedInvokes++);
document.body.append(outsideTool, unrelatedTool);
outsideTool.dispatchEvent(new PointerEvent('pointerdown', {
pointerId: 301, pointerType: 'mouse', button: 0,
bubbles: true, composed: true, cancelable: true,
}));
outsideTool.dispatchEvent(new MouseEvent('click', {
button: 0, bubbles: true, composed: true, cancelable: true,
}));
unrelatedTool.click();
await c.updateComplete;
out.groupOutsideDismissIsConsumed = c._editorSecondary.hasOpenGroup === false
&& outsideInvokes === 0 && c._tool === 'draw';
out.dismissTailDoesNotEatUnrelatedClick = unrelatedInvokes === 1;
outsideTool.remove();
unrelatedTool.remove();
// An open grouped submenu still consumes ordinary outside clicks, but an
// explicit editor navigation command has higher priority and must close both
// surfaces in the same pointer sequence.
groupLauncher?.click(); await c.updateComplete;
const closeWithOpenGroup = sr().querySelector('.editbar .barclose');
closeWithOpenGroup?.dispatchEvent(new PointerEvent('pointerdown', {
pointerId: 302, pointerType: 'mouse', button: 0,
bubbles: true, composed: true, cancelable: true,
}));
closeWithOpenGroup?.dispatchEvent(new MouseEvent('click', {
button: 0, bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
out.editorNavigationOutranksGroupDismiss = c._mode === 'view'
&& c._editorSecondary.hasOpenGroup === false;
await settleMode();
tabs()[0].click(); await settleMode();
delete c._editorToolbarGroups;
c._tool = 'select'; c.requestUpdate(); await c.updateComplete;
// 4) прямое переключение План → Устройства
// Deliberately make the outgoing bar taller: the editor transition must
// interpolate real content height, not merely fade two equal-height rows.
const planBarBeforeSwap = sr().querySelector('.editorchrome .editbar');
planBarBeforeSwap.style.minHeight = `${planBarBeforeSwap.getBoundingClientRect().height + 36}px`;
tabs()[1].click(); await c.updateComplete;
out.directSwitch = c._mode === 'devices';
out.devBar = !!sr().querySelector('.editbar.devbar');
const deviceToolbarButtons = [...sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)')];
const addDeviceButton = deviceToolbarButtons[0];
out.devBarBtns = deviceToolbarButtons.length === 5;
out.deviceHistoryControlsArePersistent = !!sr().querySelector('[data-device-position-history="undo"]')
&& !!sr().querySelector('[data-device-position-history="redo"]')
&& sr().querySelector('[data-device-position-history="undo"]').disabled
&& sr().querySelector('[data-device-position-history="redo"]').disabled;
out.addDeviceShortcutUsesNativeKeyboardSemantics = addDeviceButton?.tagName === 'BUTTON'
&& addDeviceButton.disabled === false;
out.addDeviceShortcut = addDeviceButton?.textContent.trim() === c._t('devbar.add')
&& addDeviceButton.title === c._t('title.add_device')
&& addDeviceButton.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:plus-box-outline'
&& deviceToolbarButtons[1]?.textContent.trim() === c._t('device_inbox.button');
const swapChrome = sr().querySelector('.editorchrome');
const swapInner = swapChrome.querySelector('.editorchrome-inner');
out.editorSwapAnimatesHeight = c._modeTransitionBusy
&& sr().querySelector('.stage').classList.contains('mode-transition');
out.editorSwapAnimatesContent = c._modeTransitionBusy && !!swapInner
&& (c._modeTransitionVisual?.toolbarContentOpacity ?? 1) < 1;
await settleMode();
addDeviceButton.click(); await c.updateComplete;
out.addDeviceShortcutOpensNewDialog = !!c._markerDialog
&& c._markerDialog.name === ''
&& c._markerDialog.bindingMode === 'virtual'
&& !Object.prototype.hasOwnProperty.call(c._markerDialog, 'devId');
c._markerDialog = null; await c.updateComplete;
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
// 6) крестик на панели → Просмотр
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
const chrome = sr().querySelector('.editorchrome');
out.barCloseStarts = c._mode === 'view' && c._modeTransitionBusy
&& sr().querySelector('.stage').inert
&& sr().querySelector('.stage').classList.contains('mode-transition');
await settleMode();
out.barCloseWorks = c._mode === 'view'
&& getComputedStyle(chrome).visibility === 'hidden'
&& chrome.getBoundingClientRect().height < 1;
// 7) #660 (+#195): the header X sits in a fixed 24 x 24 slot after the active
// mode; the slot is its hit target around a 13 px glyph and has the same size
// outside an editor, so the header never changes width. Exercise the slot's
// edge rather than calling the element's centre programmatically.
const tabCrossChecks = [];
for (let index = 0; index < tabs().length; index++) {
tabs()[index].click(); await settleMode();
const cross = headerCross();
const rect = cross.getBoundingClientRect();
const slot = sr().querySelector('.editor-close-slot').getBoundingClientRect();
const glyph = cross.querySelector('ha-icon');
const hit = sr().elementFromPoint(rect.left + 1, rect.top + rect.height / 2);
const editorSummaryVisible = sr().querySelectorAll('.summary-control button').length === 2
&& !sr().querySelector('.summary-overlay');
const glyphStays13 = getComputedStyle(glyph).getPropertyValue('--mdc-icon-size').trim() === '13px';
hit?.dispatchEvent(new MouseEvent('click', {
bubbles: true,
composed: true,
clientX: rect.left + 1,
clientY: rect.top + rect.height / 2,
}));
await settleMode();
const idleSlot = sr().querySelector('.editor-close-slot').getBoundingClientRect();
tabCrossChecks.push({
target: rect.width >= 24 && rect.height >= 24,
glyph: glyphStays13,
// The slot is the footprint: same box in the editor and after closing it.
footprint: Math.abs(slot.width - idleSlot.width) <= 0.5
&& Math.abs(slot.height - idleSlot.height) <= 0.5 && Math.abs(slot.width - 24) <= 0.5,
edgeHit: !!hit && (hit === cross || cross.contains(hit)),
summary: editorSummaryVisible,
closed: c._mode === 'view',
});
}
out.tabCrossTargetsAtLeast24 = tabCrossChecks.every((check) => check.target);
out.tabCrossGlyphStays13 = tabCrossChecks.every((check) => check.glyph);
out.tabCrossKeepsLayoutFootprint = tabCrossChecks.every((check) => check.footprint);
out.tabCrossExpandedEdgeWorks = tabCrossChecks.every((check) => check.edgeHit && check.closed);
out.tabCrossWorks = tabCrossChecks.every((check) => check.closed);
out.summaryControlsStayInEveryEditor = tabCrossChecks.every((check) => check.summary);
// Closing the editor only drops session state: every accepted segment was
// already persisted as an ordinary partition by its own click.
tabs()[0].click(); await settleMode();
const chainSpace = c._curSpaceCfg;
const savedPartitions = chainSpace.partitions ? [...chainSpace.partitions] : undefined;
const savedSaveConfig = c._saveConfig;
c._saveConfig = () => {};
const partitionCount = (chainSpace.partitions || []).length;
c._tool = 'draw';
const tabChainIds = ['tab-chain-a', 'tab-chain-b'];
chainSpace.partitions = [...(chainSpace.partitions || []),
{ id: tabChainIds[0], a: [0.1, 0.1], b: [0.2, 0.1], cm: 15 },
{ id: tabChainIds[1], a: [0.2, 0.1], b: [0.25, 0.15], cm: 15 }];
c._activeWallChainId = 'tab-chain';
c._activeWallChainPartitionIds = tabChainIds;
c._path = [[100, 100], [200, 100], [250, 150]];
c._wallChainSegmentCms = [15, 15];
headerCross().click(); await settleMode();
out.tabCrossFinishesWallChain = c._mode === 'view'
&& (chainSpace.partitions || []).length === partitionCount + 2
&& c._path.length === 0 && !c._activeWallChainId;
chainSpace.partitions = savedPartitions;
out.tabCrossDoesNotWriteAtPartitionLimit = true;
c._saveConfig = savedSaveConfig;
c._setMode('view', false); await c.updateComplete;
// Layout-independent editor history: physical KeyZ works when `key` is the
// Russian «я», while an input keeps its native browser history.
tabs()[0].click(); await settleMode();
c._tool = 'draw'; c.requestUpdate(); await c.updateComplete;
const realUndo = c._undoGeometry;
const realRedo = c._redoGeometry;
let undoCalls = 0;
let redoCalls = 0;
c._undoGeometry = () => { undoCalls += 1; };
c._redoGeometry = () => { redoCalls += 1; };
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.layoutIndependentUndo = undoCalls === 1;
const editorInput = sr().querySelector('.editor-secondary input');
editorInput.dispatchEvent(new KeyboardEvent('keydown', {
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.inputKeepsNativeUndo = undoCalls === 1;
// QWERTZ swaps the physical Y/Z codes. The labelled key wins when it is a
// Latin letter; otherwise both isZ/isY became true and Redo ran first.
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyY', ctrlKey: true, bubbles: true, composed: true,
}));
out.qwertzCtrlZIsUndo = undoCalls === 2 && redoCalls === 0;
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'y', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.qwertzCtrlYIsRedo = undoCalls === 2 && redoCalls === 1;
// On AZERTY the physical KeyZ carries W: Ctrl+W must remain the browser's
// close-tab shortcut, never an editor Undo.
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'w', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.azertyCtrlWIsNotUndo = undoCalls === 2 && redoCalls === 1;
c._undoGeometry = realUndo;
c._redoGeometry = realRedo;
// Ctrl+click closes a valid active chain without adding an arbitrary point;
// the same gesture must refuse a chain with fewer than two existing edges.
const savedPath = c._path;
const savedRoomDialog = c._roomDialog;
const savedNameSel = c._nameSel;
const savedAreaSel = c._areaSel;
const ctrlClick = () => {
let prevented = false;
c._markupClick({
ctrlKey: true, metaKey: false, clientX: 0, clientY: 0,
preventDefault() { prevented = true; }, composedPath() { return []; },
});
return prevented;
};
c._tool = 'draw';
c._roomDialog = false;
const beforeCtrlPartitions = [...(chainSpace.partitions || [])];
chainSpace.partitions = [...beforeCtrlPartitions,
{ id: 'ctrl-short', a: [-0.12, -0.12], b: [-0.08, -0.12], cm: 15 }];
c._activeWallChainId = 'ctrl-short-chain';
c._activeWallChainPartitionIds = ['ctrl-short'];
c._wallChainSegmentCms = [15];
c._path = [[-120, -120], [-80, -120]];
const shortBefore = JSON.stringify(c._path);
out.ctrlCloseRequiresTwoEdges = ctrlClick()
&& !c._roomDialog && JSON.stringify(c._path) === shortBefore;
chainSpace.partitions = [...beforeCtrlPartitions,
{ id: 'ctrl-a', a: [-0.12, -0.12], b: [-0.08, -0.12], cm: 15 },
{ id: 'ctrl-b', a: [-0.08, -0.12], b: [-0.08, -0.08], cm: 15 }];
c._activeWallChainId = 'ctrl-chain';
c._activeWallChainPartitionIds = ['ctrl-a', 'ctrl-b'];
c._wallChainSegmentCms = [15, 15];
c._path = [[-120, -120], [-80, -120], [-80, -80]];
c._cfgEpoch++; c._modelCache = null;
out.ctrlCloseValidContour = ctrlClick() && c._roomDialog
&& c._path.length === 4
&& c._path[0][0] === c._path[3][0] && c._path[0][1] === c._path[3][1];
await c.updateComplete;
const roomDialog = sr().querySelector('hp-dialog.roomdialog');
const roomBody = roomDialog?.querySelector('.body');
out.roomDialogUsesMediumWidth = roomDialog?.wide === true
&& roomDialog.hasAttribute('wide');
out.roomDialogHasNoHorizontalScroll = !!roomBody
&& roomBody.scrollWidth <= roomBody.clientWidth + 1;
c._nameSel = 'No-area room';
c._areaSel = '';
c.requestUpdate();
await c.updateComplete;
const noAreaSave = sr().querySelector('hp-dialog.roomdialog .room-save');
out.noAreaUsesRegularSave = !!noAreaSave && !noAreaSave.disabled
&& sr().querySelectorAll('hp-dialog.roomdialog .room-save').length === 1;
c._path = savedPath;
chainSpace.partitions = beforeCtrlPartitions;
c._activeWallChainId = null;
c._activeWallChainPartitionIds = [];
c._wallChainSegmentCms = [];
c._roomDialog = savedRoomDialog;
c._nameSel = savedNameSel;
c._areaSel = savedAreaSel; // private-ok: #660 restore of an existing room-dialog draft; the facade has no draft-state setter
// #660: an internal Plan/Background action consumes the first Escape; the
// next neutral Escape leaves the editor. Device editor exits immediately.
await hp.setMode('plan');
await hp.setTool('column');
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete;
out.planEscapeKeepsInternalPriority = c._mode === 'plan' && c._tool === 'draw';
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await settleMode();
out.planNeutralEscapeExits = c._mode === 'view';
await hp.setMode('devices');
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await settleMode();
out.deviceNeutralEscapeExits = c._mode === 'view';
await hp.setMode('decor');
await hp.setTool('line');
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete;
out.decorEscapeKeepsInternalPriority = c._mode === 'decor' && c._decorTool === 'select';
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await settleMode();
out.decorNeutralEscapeExits = c._mode === 'view';
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"labels": ["Plan editor", "Device editor", "Background editor"],
});
await finish(browser, res);