mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
A floor switch replaces the whole stage, so the card's pointer-hover MutationObserver receives hundreds of records whose targets are the same few containers. Each record re-ran `matches` and a `.devlayer` subtree `querySelector` on its target, and kept doing so after the device layer had already been found. The batch logic moves to `deviceLayerMutated` in device-hit-owner.ts: a node is checked at most once per batch, the first hit ends the checks, and every added node still goes through `_syncPointerHoverSubtree` in record order. The card shrinks by 12 lines. The View stair layer read the card's `_model` getter once more for every navigable stair; the getter rebuilds the config fingerprint on each read. `renderLayer` now reads it once. `languageRenderGate` wrote `lang` on the host on every render. It now writes it only when the value differs (language switch, English fallback, a foreign value); an unchanged value is left alone. No behaviour changes: DOM, tooltips and pixels are the same. Unit tests count subtree queries per node, `_model` reads per render and `lang` writes; one mutant per change restores the old behaviour. Issue: #694 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
202 lines
8.6 KiB
JavaScript
202 lines
8.6 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
DeviceHitIndex, DevicePointerOwnerLatch, deviceHitScrollSources, deviceLayerMutated,
|
|
observeDeviceHitGeometryScroll, pointInDeviceCapsule, resolveDeviceHitOwner,
|
|
} from '../test-build/device-hit-owner.js';
|
|
|
|
const candidate = (id, x, y, width = 12, height = 12, floorRadius = 22) => ({
|
|
id,
|
|
center: { x, y },
|
|
painted: {
|
|
left: x - width / 2,
|
|
top: y - height / 2,
|
|
right: x + width / 2,
|
|
bottom: y + height / 2,
|
|
},
|
|
floorRadius,
|
|
});
|
|
|
|
test('#564 a visible core wins the neighbour invisible 44px floor', () => {
|
|
const left = candidate('left', 100, 100);
|
|
const right = candidate('right', 112, 100);
|
|
assert.equal(resolveDeviceHitOwner([right, left], { x: 100, y: 100 })?.id, 'left');
|
|
assert.equal(resolveDeviceHitOwner([left, right], { x: 112, y: 100 })?.id, 'right');
|
|
});
|
|
|
|
test('#564 painted overlap and floor-only overlap use the nearest core', () => {
|
|
const left = candidate('left', 100, 100, 16, 16);
|
|
const right = candidate('right', 112, 100, 16, 16);
|
|
assert.equal(resolveDeviceHitOwner([right, left], { x: 105, y: 100 })?.id, 'left');
|
|
assert.equal(resolveDeviceHitOwner([left, right], { x: 107, y: 100 })?.id, 'right');
|
|
|
|
const separatedLeft = candidate('left', 100, 100);
|
|
const separatedRight = candidate('right', 130, 100);
|
|
assert.equal(resolveDeviceHitOwner([separatedRight, separatedLeft], { x: 113, y: 100 })?.id, 'left');
|
|
assert.equal(resolveDeviceHitOwner([separatedLeft, separatedRight], { x: 117, y: 100 })?.id, 'right');
|
|
});
|
|
|
|
test('#564 a capsule does not own invisible bbox corners', () => {
|
|
const horizontal = candidate('pill', 100, 100, 40, 12);
|
|
assert.equal(pointInDeviceCapsule({ x: 100, y: 100 }, horizontal.painted), true);
|
|
assert.equal(pointInDeviceCapsule({ x: 119, y: 105 }, horizontal.painted), false);
|
|
assert.equal(pointInDeviceCapsule({ x: 118, y: 100 }, horizontal.painted), true);
|
|
});
|
|
|
|
test('#564 tie-break is stable and independent of candidate/DOM order', () => {
|
|
const a = candidate('a', 90, 100);
|
|
const b = candidate('b', 110, 100);
|
|
assert.equal(resolveDeviceHitOwner([b, a], { x: 100, y: 100 })?.id, 'a');
|
|
assert.equal(resolveDeviceHitOwner([a, b], { x: 100, y: 100 })?.id, 'a');
|
|
const sameA = candidate('a', 100, 100);
|
|
const sameB = candidate('b', 100, 100);
|
|
assert.equal(resolveDeviceHitOwner([sameB, sameA], { x: 100, y: 100 })?.id, 'a');
|
|
});
|
|
|
|
test('#564 spatial index resolves transformed screen coordinates locally', () => {
|
|
const many = Array.from({ length: 200 }, (_, index) =>
|
|
candidate(`d${String(index).padStart(3, '0')}`, 1000 + index * 60, 800));
|
|
const target = candidate('target', 312.5, 487.25, 48, 16);
|
|
const index = new DeviceHitIndex([...many, target]);
|
|
assert.equal(index.resolve({ x: 330, y: 487.25 })?.id, 'target');
|
|
assert.equal(index.resolve({ x: -500, y: -500 }), null);
|
|
});
|
|
|
|
test('#564 pointer owner is held through release and consumed by click', () => {
|
|
const latch = new DevicePointerOwnerLatch();
|
|
latch.begin(7, 'left');
|
|
assert.equal(latch.owner(7), 'left');
|
|
assert.equal(latch.release(7), 'left');
|
|
assert.equal(latch.owner(7), null);
|
|
assert.equal(latch.consumeClick(7), 'left');
|
|
assert.equal(latch.consumeClick(7), null);
|
|
|
|
latch.begin(8, 'right');
|
|
assert.equal(latch.cancel(8), 'right');
|
|
assert.equal(latch.release(8), null);
|
|
latch.clear();
|
|
});
|
|
|
|
test('#613 scroll observation crosses shadow hosts and tears down exactly once', () => {
|
|
const target = (fields = {}) => Object.assign(new EventTarget(), fields);
|
|
const viewport = new EventTarget();
|
|
const win = target({ visualViewport: viewport });
|
|
const document = target({
|
|
nodeType: 9, parentElement: null, ownerDocument: null, defaultView: win,
|
|
getRootNode() { return this; },
|
|
});
|
|
const body = target({
|
|
nodeType: 1, assignedSlot: null, parentElement: null, ownerDocument: document,
|
|
getRootNode: () => document,
|
|
});
|
|
const dashboardScroller = target({
|
|
nodeType: 1, assignedSlot: null, parentElement: body, ownerDocument: document,
|
|
getRootNode: () => document,
|
|
});
|
|
const dashboardHost = target({
|
|
nodeType: 1, assignedSlot: null, parentElement: dashboardScroller,
|
|
ownerDocument: document, getRootNode: () => document,
|
|
});
|
|
const dashboardRoot = { nodeType: 11, host: dashboardHost };
|
|
const shadowScroller = target({
|
|
nodeType: 1, assignedSlot: null, parentElement: null, ownerDocument: document,
|
|
getRootNode: () => dashboardRoot,
|
|
});
|
|
const card = target({
|
|
nodeType: 1, assignedSlot: null, parentElement: shadowScroller,
|
|
ownerDocument: document, getRootNode: () => dashboardRoot,
|
|
});
|
|
|
|
assert.deepEqual(deviceHitScrollSources(card), [
|
|
card, shadowScroller, dashboardHost, dashboardScroller, body, document, win,
|
|
]);
|
|
|
|
let invalidations = 0;
|
|
const disconnect = observeDeviceHitGeometryScroll(card, () => { invalidations += 1; });
|
|
shadowScroller.dispatchEvent(new Event('scroll'));
|
|
dashboardScroller.dispatchEvent(new Event('scroll'));
|
|
viewport.dispatchEvent(new Event('resize'));
|
|
assert.equal(invalidations, 3);
|
|
|
|
disconnect();
|
|
disconnect();
|
|
shadowScroller.dispatchEvent(new Event('scroll'));
|
|
dashboardScroller.dispatchEvent(new Event('scroll'));
|
|
viewport.dispatchEvent(new Event('scroll'));
|
|
assert.equal(invalidations, 3);
|
|
|
|
const reconnect = observeDeviceHitGeometryScroll(card, () => { invalidations += 1; });
|
|
dashboardScroller.dispatchEvent(new Event('scroll'));
|
|
assert.equal(invalidations, 4);
|
|
reconnect();
|
|
});
|
|
|
|
// #694: an instrumented element counts every selector query run against it.
|
|
// `device` answers `.devlayer, .devlayer *`; `holdsDevice` answers the subtree
|
|
// query for `.devlayer` below it.
|
|
const probeNode = (name, { device = false, holdsDevice = false } = {}) => ({
|
|
name,
|
|
nodeType: 1,
|
|
queries: 0,
|
|
matches(selector) {
|
|
assert.equal(selector, '.devlayer, .devlayer *');
|
|
this.queries += 1;
|
|
return device;
|
|
},
|
|
querySelector(selector) {
|
|
assert.equal(selector, '.devlayer');
|
|
this.queries += 1;
|
|
return holdsDevice ? {} : null;
|
|
},
|
|
});
|
|
const record = (target, addedNodes = [], removedNodes = []) => ({ target, addedNodes, removedNodes });
|
|
|
|
test('#694 AC1 a batch into one subtree queries each node at most once', () => {
|
|
const stage = probeNode('stage');
|
|
const children = Array.from({ length: 40 }, (_, index) => probeNode(`child-${index}`));
|
|
const text = { nodeType: 3, name: 'text' };
|
|
const records = [
|
|
...children.map((child) => record(stage, [child])),
|
|
...children.map((child) => record(child)),
|
|
record(stage, [text], [children[0]]),
|
|
];
|
|
const synced = [];
|
|
assert.equal(deviceLayerMutated(records, (node) => synced.push(node.name)), false);
|
|
// matches + querySelector: one check is two queries, never more.
|
|
assert.equal(stage.queries, 2, 'the shared record target is checked once per batch');
|
|
for (const child of children) assert.equal(child.queries, 2, `${child.name} is checked once`);
|
|
assert.deepEqual(synced, [...children.map((child) => child.name), 'text'],
|
|
'every added node still reaches the pointer-hover sync, in record order');
|
|
});
|
|
|
|
test('#694 AC1 the first device-layer hit ends the queries, not the hover sync', () => {
|
|
const stage = probeNode('stage');
|
|
const devlayer = probeNode('devlayer', { device: true });
|
|
const later = Array.from({ length: 12 }, (_, index) => probeNode(`later-${index}`));
|
|
const added = later.map((_, index) => probeNode(`added-${index}`));
|
|
const records = [
|
|
record(stage),
|
|
record(devlayer),
|
|
...later.map((target, index) => record(target, [added[index]], [probeNode('gone')])),
|
|
];
|
|
const synced = [];
|
|
assert.equal(deviceLayerMutated(records, (node) => synced.push(node.name)), true);
|
|
assert.equal(stage.queries, 2);
|
|
assert.equal(devlayer.queries, 1, 'a match needs no subtree query');
|
|
for (const node of [...later, ...added]) {
|
|
assert.equal(node.queries, 0, `${node.name} is not queried after the first hit`);
|
|
}
|
|
assert.deepEqual(synced, added.map((node) => node.name),
|
|
'added nodes after the hit still reach the pointer-hover sync');
|
|
});
|
|
|
|
test('#694 AC1 a device layer is still found in targets, added and removed nodes', () => {
|
|
const plain = () => probeNode('plain');
|
|
const none = () => {};
|
|
assert.equal(deviceLayerMutated([record(plain(), [plain()], [plain()])], none), false);
|
|
assert.equal(deviceLayerMutated([record(plain()), record(probeNode('dev', { device: true }))], none), true);
|
|
assert.equal(deviceLayerMutated([record(plain(), [probeNode('floor', { holdsDevice: true })])], none), true);
|
|
assert.equal(deviceLayerMutated([record(plain(), [], [probeNode('old', { holdsDevice: true })])], none), true);
|
|
assert.equal(deviceLayerMutated([record({ nodeType: 3 }, [{ nodeType: 3 }])], none), false);
|
|
});
|