Files
houseplan-card/demo/smoke_space_tab_reorder.mjs
2026-08-28 05:40:28 +00:00

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);