mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
343 lines
15 KiB
JavaScript
343 lines
15 KiB
JavaScript
// Issues #220/#243: space tabs reorder through the browser's real mouse input.
|
|
// Positive drag assertions must never dispatch pointermove at a chosen target:
|
|
// pointer capture is the mechanism under test and owns event delivery.
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
|
|
const { page, browser } = await launch({ width: 1100, height: 900 }, 1);
|
|
const out = {};
|
|
|
|
const settle = async () => page.evaluate(async () => {
|
|
const c = window.__card;
|
|
const started = performance.now();
|
|
do { await new Promise((done) => requestAnimationFrame(done)); }
|
|
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
|
await c.updateComplete;
|
|
});
|
|
|
|
const ids = () => page.evaluate(() => [...window.__card.renderRoot
|
|
.querySelectorAll('[data-hp="space-tab"]')].map((tab) => tab.dataset.id));
|
|
|
|
const tabPoint = (id) => page.evaluate((tabId) => {
|
|
const tab = [...window.__card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
|
.find((item) => item.dataset.id === tabId);
|
|
if (!tab) throw new Error(`space tab missing: ${tabId}`);
|
|
const rect = tab.getBoundingClientRect();
|
|
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
}, id);
|
|
|
|
const writeCount = () => page.evaluate(() => window.__tabReorderWrites.length);
|
|
const insetX = (shadow) => Number(shadow.match(
|
|
/([+-]?\d+(?:\.\d+)?)px\s+0px\s+0px\s+0px\s+inset/,
|
|
)?.[1] || 0);
|
|
const waitForWrites = async (count) => page.waitForFunction(
|
|
(expected) => window.__tabReorderWrites.length >= expected, count, { timeout: 1800 },
|
|
);
|
|
|
|
const holdDrag = async (fromId, toId) => {
|
|
const from = await tabPoint(fromId);
|
|
const to = await tabPoint(toId);
|
|
await page.mouse.move(from.x, from.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(to.x, to.y, { steps: 5 });
|
|
await settle();
|
|
return page.evaluate(({ fromId: sourceId, toId: targetId }) => {
|
|
const c = window.__card;
|
|
const tabs = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')];
|
|
const source = tabs.find((tab) => tab.dataset.id === sourceId);
|
|
const target = tabs.find((tab) => tab.dataset.id === targetId);
|
|
const style = target ? getComputedStyle(target) : null;
|
|
return {
|
|
drag: c._tabDrag ? { ...c._tabDrag } : null,
|
|
sourceClass: source?.className || '',
|
|
targetClass: target?.className || '',
|
|
targetShadow: style?.boxShadow || '',
|
|
trustedMoveSeen: window.__tabReorderTrustedMove === true,
|
|
};
|
|
}, { fromId, toId });
|
|
};
|
|
|
|
const releaseDrag = async (expectedWrites) => {
|
|
await page.mouse.up();
|
|
await waitForWrites(expectedWrites);
|
|
await settle();
|
|
};
|
|
|
|
const setup = await page.evaluate(async () => {
|
|
const c = window.__card;
|
|
c._mode = 'plan';
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
// The directional contract needs a source that can travel across both
|
|
// sides. The demo fixture has two spaces, so add a third in-memory space to
|
|
// the card and its write model without changing the shared demo fixture.
|
|
if (!c._model.some((space) => space.id === 'smoke-third')) {
|
|
const modelSpace = structuredClone(c._model.at(-1));
|
|
modelSpace.id = 'smoke-third';
|
|
modelSpace.title = 'Smoke third';
|
|
const configSpace = structuredClone(c._serverCfg.spaces.at(-1));
|
|
configSpace.id = modelSpace.id;
|
|
configSpace.title = modelSpace.title;
|
|
c._model = [...c._model, modelSpace];
|
|
c._serverCfg.spaces = [...c._serverCfg.spaces, configSpace];
|
|
}
|
|
const firstBefore = c._model[0].id;
|
|
c._space = firstBefore;
|
|
c._serverCfg.markers = [
|
|
...(c._serverCfg.markers || []).filter((marker) => marker.id !== 'smoke-dangling'),
|
|
{ id: 'smoke-dangling', binding: 'virtual', name: 'dangling' },
|
|
];
|
|
window.__tabReorderWrites = [];
|
|
window.__tabReorderOriginalWrite = c._writeConfig.bind(c);
|
|
c._writeConfig = () => {
|
|
window.__tabReorderWrites.push((c._serverCfg.spaces || []).map((space) => space.id));
|
|
return Promise.resolve();
|
|
};
|
|
window.__tabReorderTrustedMove = false;
|
|
c.renderRoot.querySelector('.tabs')?.addEventListener('pointermove', (event) => {
|
|
if (event.buttons && event.isTrusted) window.__tabReorderTrustedMove = true;
|
|
}, { capture: true });
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
return {
|
|
firstBefore,
|
|
markerHasNoSpace: !c._serverCfg.markers.find((marker) => marker.id === 'smoke-dangling')?.space,
|
|
reorderable: c.renderRoot.querySelector('[data-hp="space-tab"]')
|
|
?.hasAttribute('data-reorderable') === true,
|
|
};
|
|
});
|
|
|
|
out.plantedMarkerStartsWithoutSpace = setup.markerHasNoSpace;
|
|
out.reorderableInEditor = setup.reorderable;
|
|
const initial = await ids();
|
|
out.enoughTabsToReorder = initial.length >= 3;
|
|
|
|
// AC1/AC2: same tab moves left and then right through trusted browser input.
|
|
const moved = initial.at(-1);
|
|
const leftTarget = initial[0];
|
|
const activeBefore = await page.evaluate(() => window.__card._space);
|
|
const beforeHold = await holdDrag(moved, leftTarget);
|
|
out.realMouseEventsAreTrusted = beforeHold.trustedMoveSeen;
|
|
out.captureKeptButTargetResolved = beforeHold.drag?.targetId === leftTarget
|
|
&& beforeHold.drag?.id === moved;
|
|
out.leftDropUsesBeforeSide = beforeHold.drag?.placement === 'before'
|
|
&& beforeHold.targetClass.includes('drop-before')
|
|
&& !beforeHold.targetClass.includes('drop-after');
|
|
out.heldTabShowsDragging = beforeHold.sourceClass.includes('dragging');
|
|
out.beforeDividerHasPositiveInset = insetX(beforeHold.targetShadow) > 0;
|
|
await releaseDrag(1);
|
|
const afterLeft = await ids();
|
|
out.realMouseMovedTabLeft = afterLeft[0] === moved;
|
|
out.firstDropWroteOnce = await writeCount() === 1;
|
|
out.savedLeftOrderMatchesPanel = await page.evaluate(() => {
|
|
const panel = [...window.__card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
|
.map((tab) => tab.dataset.id);
|
|
return JSON.stringify(window.__tabReorderWrites[0]) === JSON.stringify(panel);
|
|
});
|
|
out.activeSpaceUnchanged = await page.evaluate((active) => window.__card._space === active, activeBefore);
|
|
out.danglingMarkerPinnedToItsOldSpace = await page.evaluate(
|
|
(first) => window.__card._serverCfg.markers
|
|
.find((marker) => marker.id === 'smoke-dangling')?.space === first,
|
|
setup.firstBefore,
|
|
);
|
|
out.warnedAboutPositionalFloor = await page.evaluate(() => typeof window.__card._toast === 'string'
|
|
&& window.__card._toast.length > 0);
|
|
|
|
await page.evaluate(() => { window.__card._toast = ''; });
|
|
const rightTarget = afterLeft.at(-1);
|
|
const afterHold = await holdDrag(moved, rightTarget);
|
|
out.rightDropUsesAfterSide = afterHold.drag?.placement === 'after'
|
|
&& afterHold.targetClass.includes('drop-after')
|
|
&& !afterHold.targetClass.includes('drop-before');
|
|
out.afterDividerHasNegativeInset = insetX(afterHold.targetShadow) < 0;
|
|
await releaseDrag(2);
|
|
const afterRight = await ids();
|
|
out.sameTabMovedRight = afterRight.at(-1) === moved;
|
|
out.secondDropWroteOnce = await writeCount() === 2;
|
|
out.warningNotRepeated = await page.evaluate(() => !window.__card._toast);
|
|
|
|
// AC3: below-threshold movement remains a click and cannot save config.
|
|
const currentActive = await page.evaluate(() => window.__card._space);
|
|
const smallTarget = (await ids()).find((id) => id !== currentActive);
|
|
const smallPoint = await tabPoint(smallTarget);
|
|
const writesBeforeSmall = await writeCount();
|
|
await page.mouse.move(smallPoint.x, smallPoint.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(smallPoint.x + 2, smallPoint.y);
|
|
const smallState = await page.evaluate(() => window.__card._tabDrag && !window.__card._tabDrag.moved);
|
|
await page.mouse.up();
|
|
await settle();
|
|
await page.waitForTimeout(550);
|
|
out.subThresholdStayedClick = smallState;
|
|
out.subThresholdDidNotWrite = await writeCount() === writesBeforeSmall;
|
|
out.clickStillSwitchesSpace = await page.evaluate((id) => window.__card._space === id, smallTarget);
|
|
|
|
// AC4: a previously valid target is cleared outside tabs; release cancels.
|
|
const outsideIds = await ids();
|
|
const outsideFrom = outsideIds.at(-1);
|
|
const outsideTarget = outsideIds[0];
|
|
const writesBeforeOutside = await writeCount();
|
|
await holdDrag(outsideFrom, outsideTarget);
|
|
const stagePoint = await page.evaluate(() => {
|
|
const rect = window.__card.renderRoot.querySelector('.stage').getBoundingClientRect();
|
|
return { x: rect.left + rect.width / 2, y: rect.top + Math.min(rect.height / 2, 300) };
|
|
});
|
|
await page.mouse.move(stagePoint.x, stagePoint.y, { steps: 3 });
|
|
await settle();
|
|
out.outsideClearsDropTarget = await page.evaluate(() => window.__card._tabDrag?.moved === true
|
|
&& window.__card._tabDrag.targetId === null
|
|
&& window.__card._tabDrag.placement === null
|
|
&& !window.__card.renderRoot.querySelector('.drop-before, .drop-after'));
|
|
await page.mouse.up();
|
|
await settle();
|
|
await page.waitForTimeout(550);
|
|
out.outsideReleaseEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
|
|
out.outsideReleaseDidNotWrite = await writeCount() === writesBeforeOutside;
|
|
|
|
// Exercise the window-level release fallback independently. Real mouse input
|
|
// normally grants pointer capture, which would route pointerup back to the
|
|
// source tab and let a missing window listener pass this regression.
|
|
const fallbackIds = await ids();
|
|
const writesBeforeFallback = await writeCount();
|
|
await holdDrag(fallbackIds.at(-1), fallbackIds[0]);
|
|
out.outsideFallbackReleasedCapture = await page.evaluate(() => {
|
|
const c = window.__card;
|
|
const drag = c._tabDrag;
|
|
const source = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
|
.find((tab) => tab.dataset.id === drag?.id);
|
|
if (!source || !drag || !source.hasPointerCapture(drag.pointerId)) return false;
|
|
source.releasePointerCapture(drag.pointerId);
|
|
return !source.hasPointerCapture(drag.pointerId);
|
|
});
|
|
await page.mouse.move(stagePoint.x, stagePoint.y, { steps: 3 });
|
|
await page.mouse.up();
|
|
await settle();
|
|
await page.waitForTimeout(550);
|
|
out.outsideFallbackReleaseEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
|
|
out.outsideFallbackReleaseDidNotWrite = await writeCount() === writesBeforeFallback;
|
|
|
|
const activeBeforeRecovery = await page.evaluate(() => window.__card._space);
|
|
const recoveryTarget = (await ids()).find((id) => id !== activeBeforeRecovery);
|
|
const recoveryPoint = await tabPoint(recoveryTarget);
|
|
await page.mouse.click(recoveryPoint.x, recoveryPoint.y);
|
|
await settle();
|
|
out.nextClickWorksAfterOutsideRelease = await page.evaluate(
|
|
(id) => window.__card._space === id, recoveryTarget,
|
|
);
|
|
|
|
// Navigation cannot leave a window-level drag listener behind.
|
|
const modeIds = await ids();
|
|
const writesBeforeModeChange = await writeCount();
|
|
await holdDrag(modeIds.at(-1), modeIds[0]);
|
|
await page.evaluate(() => window.__card._setMode('devices', false));
|
|
await settle();
|
|
out.modeChangeEndsDrag = await page.evaluate(() => window.__card._tabDrag === null);
|
|
await page.mouse.up();
|
|
await settle();
|
|
out.modeChangeDidNotWrite = await writeCount() === writesBeforeModeChange;
|
|
await page.evaluate(() => window.__card._setMode('plan', false));
|
|
await settle();
|
|
|
|
// AC5: pointercancel is an explicit cancellation, never a drop.
|
|
const cancelIds = await ids();
|
|
const cancelFrom = cancelIds.at(-1);
|
|
const cancelTarget = cancelIds[0];
|
|
const writesBeforeCancel = await writeCount();
|
|
await holdDrag(cancelFrom, cancelTarget);
|
|
await page.evaluate(() => {
|
|
const c = window.__card;
|
|
const drag = c._tabDrag;
|
|
const source = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
|
.find((tab) => tab.dataset.id === drag?.id);
|
|
source?.dispatchEvent(new PointerEvent('pointercancel', {
|
|
pointerId: drag?.pointerId || 1, pointerType: 'mouse', bubbles: true, composed: true,
|
|
}));
|
|
});
|
|
await page.mouse.up();
|
|
await settle();
|
|
await page.waitForTimeout(550);
|
|
out.pointerCancelEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
|
|
out.pointerCancelDidNotWrite = await writeCount() === writesBeforeCancel;
|
|
|
|
// Regression from #220 review: detached cards release listeners and never save.
|
|
const detachIds = await ids();
|
|
const detachFrom = detachIds.at(-1);
|
|
const detachTarget = detachIds[0];
|
|
const writesBeforeDetach = await writeCount();
|
|
await holdDrag(detachFrom, detachTarget);
|
|
out.dragWasActiveBeforeDetach = await page.evaluate(() => window.__card._tabDrag?.moved === true);
|
|
await page.evaluate(() => {
|
|
const c = window.__card;
|
|
window.__tabReorderParent = c.parentNode;
|
|
window.__tabReorderNext = c.nextSibling;
|
|
c.remove();
|
|
});
|
|
out.detachEndedTheDrag = await page.evaluate(() => window.__card._tabDrag === null);
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(550);
|
|
out.detachedCardDidNotWrite = await writeCount() === writesBeforeDetach;
|
|
await page.evaluate(async () => {
|
|
window.__tabReorderParent.insertBefore(window.__card, window.__tabReorderNext);
|
|
await window.__card.updateComplete;
|
|
});
|
|
out.orderSurvivedDetach = JSON.stringify(await ids()) === JSON.stringify(detachIds);
|
|
|
|
// AC6: negative boundaries stay negative. Synthetic touch is intentional here:
|
|
// the assertion is the pointerType guard, not browser mouse delivery.
|
|
const touchBefore = await ids();
|
|
await page.evaluate(() => {
|
|
const c = window.__card;
|
|
const tabs = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')];
|
|
const source = tabs.at(-1);
|
|
const target = tabs[0];
|
|
const a = source.getBoundingClientRect();
|
|
const b = target.getBoundingClientRect();
|
|
const fire = (type, x, y, node) => node.dispatchEvent(new PointerEvent(type, {
|
|
pointerId: 91, pointerType: 'touch', clientX: x, clientY: y, bubbles: true, composed: true,
|
|
}));
|
|
fire('pointerdown', a.x + a.width / 2, a.y + a.height / 2, source);
|
|
fire('pointermove', b.x + b.width / 2, b.y + b.height / 2, target);
|
|
fire('pointerup', b.x + b.width / 2, b.y + b.height / 2, target);
|
|
});
|
|
await settle();
|
|
out.touchDidNotReorder = JSON.stringify(await ids()) === JSON.stringify(touchBefore);
|
|
|
|
await page.evaluate(async () => {
|
|
const c = window.__card;
|
|
c._mode = 'view';
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
});
|
|
out.notReorderableInView = await page.evaluate(() => !window.__card.renderRoot
|
|
.querySelector('[data-hp="space-tab"]')?.hasAttribute('data-reorderable'));
|
|
const viewBefore = await ids();
|
|
const viewFromPoint = await tabPoint(viewBefore.at(-1));
|
|
const viewTargetPoint = await tabPoint(viewBefore[0]);
|
|
await page.mouse.move(viewFromPoint.x, viewFromPoint.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(viewTargetPoint.x, viewTargetPoint.y, { steps: 4 });
|
|
await page.mouse.up();
|
|
await settle();
|
|
out.viewDidNotReorder = JSON.stringify(await ids()) === JSON.stringify(viewBefore);
|
|
|
|
await page.evaluate(async () => {
|
|
const c = window.__card;
|
|
c._mode = 'plan';
|
|
c._config = { ...c._config, floor: c._model[0].id };
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
});
|
|
out.fixedFloorNotReorderable = await page.evaluate(() => !window.__card.renderRoot
|
|
.querySelector('[data-hp="space-tab"]')?.hasAttribute('data-reorderable'));
|
|
await page.evaluate(async () => {
|
|
const c = window.__card;
|
|
const { floor: _floor, ...rest } = c._config;
|
|
c._config = rest;
|
|
c._mode = 'plan';
|
|
c._writeConfig = window.__tabReorderOriginalWrite;
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
});
|
|
|
|
checkAll(out);
|
|
await finish(browser, out);
|