Files
houseplan-card/test/space-order.test.mjs
Codex a8aeecc32c fix: judge order dependence by the area in force, close the stuck drag
Review CODE-REVIEW-220-r1.

H1: markersNeedingPlacement decided who depends on the order by reading
marker.area alone, while resolveExplicitMarkerPlacement reads
`marker.area || <area of the HA device>`. The ordinary marker — bind an HA
device, store neither field — is anchored by the registry and never depended
on the order, yet it was being written a space it never asked for. Dormant
today, and the day that HA area changes it moves the marker to whatever space
used to be first. The resolver now asks for the area actually in force.

M1: a mouse released past the panel left the gesture stuck, swallowing the
next click. Pointer capture is the usual answer and is now taken, but it is
not a guarantee — the browser grants it only for a live pointer. The window
listener is what actually closes the gesture.

M2: the fifth mutant from the spec is registered, plus a sixth for the stuck
drag above.

Writing the smoke for M1 turned up why the first attempt passed against
broken code: synthetic PointerEvents default to composed:false and never
leave the shadow root, so nothing outside the panel could ever hear them.
Real pointer events are composed; the smoke now says so.

Issue: #220
User-Visible: no
2026-08-21 00:50:15 +03:00

105 lines
4.9 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
TAB_DRAG_THRESHOLD_PX, applySpaceOrder, canStartTabDrag, markersNeedingPlacement,
passedDragThreshold, reorderSpaceIds,
} from '../test-build/space-order.js';
const ctx = (over = {}) => ({
canEdit: true, kiosk: false, mode: 'plan', pointerType: 'mouse',
spaceCount: 3, fixedFloor: false, ...over,
});
// --- AC5: где перетаскивание вообще включается -------------------------------
test('issue 220 tab drag is available to an editor with a mouse', () => {
assert.equal(canStartTabDrag(ctx()), true);
assert.equal(canStartTabDrag(ctx({ mode: 'devices' })), true);
assert.equal(canStartTabDrag(ctx({ mode: 'decor' })), true);
});
test('issue 220 tab drag is not exposed to touch, View, kiosk or a fixed floor', () => {
// Touch is excluded by product decision, not by omission: the same tabs
// switch spaces in View, where a tap must stay a tap.
assert.equal(canStartTabDrag(ctx({ pointerType: 'touch' })), false);
assert.equal(canStartTabDrag(ctx({ pointerType: 'pen' })), false);
assert.equal(canStartTabDrag(ctx({ mode: 'view' })), false);
assert.equal(canStartTabDrag(ctx({ kiosk: true })), false);
assert.equal(canStartTabDrag(ctx({ canEdit: false })), false);
assert.equal(canStartTabDrag(ctx({ fixedFloor: true })), false);
assert.equal(canStartTabDrag(ctx({ spaceCount: 1 })), false);
});
test('issue 220 a click stays a click until the pointer really travels', () => {
assert.equal(passedDragThreshold(0, 0), false);
assert.equal(passedDragThreshold(TAB_DRAG_THRESHOLD_PX - 1, 0), false);
assert.equal(passedDragThreshold(TAB_DRAG_THRESHOLD_PX, 0), true);
assert.equal(passedDragThreshold(0, TAB_DRAG_THRESHOLD_PX), true);
});
// --- AC1: сама перестановка --------------------------------------------------
test('issue 220 a tab lands where it was dropped and the rest keep their order', () => {
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'c', 'a'), ['c', 'a', 'b']);
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'a', 'c'), ['b', 'c', 'a']);
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'b', 'b'), ['a', 'b', 'c']);
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'b', 'zz'), ['a', 'b', 'c']);
});
test('issue 220 stored spaces follow the new order', () => {
const spaces = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
assert.deepEqual(applySpaceOrder(spaces, ['c', 'a', 'b']).map((s) => s.id), ['c', 'a', 'b']);
// an id the order does not mention keeps its tail position instead of moving
assert.deepEqual(
applySpaceOrder([...spaces, { id: 'd' }], ['c', 'a', 'b']).map((s) => s.id),
['c', 'a', 'b', 'd'],
);
});
// --- AC3: маркеры не двигаются ----------------------------------------------
test('issue 220 only order-dependent markers are pinned, and to where they are now', () => {
const markers = [
{ id: 'dangling' }, // no space, no area
{ id: 'by-area', area: 'kitchen' }, // area names a space
{ id: 'explicit', space: 'f2' }, // already explicit
{ id: 'area-unknown', area: 'nowhere' }, // area names nothing
{ id: 'gone', removed: true }, // tombstone
];
const pinned = markersNeedingPlacement(markers, { kitchen: 'f1' }, 'f1');
assert.deepEqual(pinned, [
{ id: 'dangling', space: 'f1' },
{ id: 'area-unknown', space: 'f1' },
]);
});
test('issue 220 a marker anchored by its HA area is left alone (review r1 H1)', () => {
// The ordinary marker: it binds an HA device and stores neither area nor
// space, because resolveExplicitMarkerPlacement reads the area from the
// registry. Such a marker never depended on the order, so writing it a space
// would plant a field that moves it the day its HA area changes.
const markers = [{ id: 'ha-device' }];
const areaOf = (id) => (id === 'ha-device' ? 'kitchen' : '');
assert.deepEqual(markersNeedingPlacement(markers, { kitchen: 'f1' }, 'f1', areaOf), []);
// …while a registry area that names no space leaves the marker order-bound.
assert.deepEqual(markersNeedingPlacement(markers, { hall: 'f1' }, 'f1', areaOf), [
{ id: 'ha-device', space: 'f1' },
]);
// The marker's own area still wins over the registry, as in devices.ts.
assert.deepEqual(
markersNeedingPlacement([{ id: 'ha-device', area: 'hall' }], { hall: 'f2' }, 'f1', areaOf),
[],
);
});
test('issue 220 pinning writes the space the marker has before the reorder', () => {
// The fallback is the FIRST space of the current order. If the write used
// the order after the move, the marker would follow the reorder — the very
// thing this pinning exists to prevent.
const markers = [{ id: 'dangling' }];
assert.deepEqual(markersNeedingPlacement(markers, {}, 'garden'), [
{ id: 'dangling', space: 'garden' },
]);
assert.deepEqual(markersNeedingPlacement(markers, {}, ''), []);
});