mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
Keep diagnostic captions and the matching pointer tooltip readable, including late overlay paint, camera/resize changes and narrow-card fallback. Preserve focus, touch and device actions. Add pure placement and real-mouse witnesses. Issue: #802 User-Visible: yes
198 lines
8.8 KiB
JavaScript
198 lines
8.8 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { placeDeviceTooltip } from '../test-build/live-tip-placement.js';
|
|
|
|
const rect = (left, top, width, height) => ({
|
|
left, top, width, height, right: left + width, bottom: top + height,
|
|
});
|
|
|
|
const input = (overrides = {}) => ({
|
|
preferred: { x: 200, y: 150 },
|
|
size: { width: 160, height: 60 },
|
|
bounds: rect(0, 0, 640, 480),
|
|
blockers: [],
|
|
...overrides,
|
|
});
|
|
|
|
const assertFits = (placement, data) => {
|
|
assert.ok(placement, 'a complete free position exists');
|
|
const { left, top } = placement;
|
|
const { width, height } = data.size;
|
|
const edge = data.edge ?? 8;
|
|
const gap = data.gap ?? 8;
|
|
assert.ok(left >= data.bounds.left + edge);
|
|
assert.ok(top >= data.bounds.top + edge);
|
|
assert.ok(left + width <= data.bounds.right - edge);
|
|
assert.ok(top + height <= data.bounds.bottom - edge);
|
|
for (const blocker of data.blockers) {
|
|
if (!blocker.width || !blocker.height) continue;
|
|
assert.ok(left + width + gap <= blocker.left || left >= blocker.right + gap
|
|
|| top + height + gap <= blocker.top || top >= blocker.bottom + gap,
|
|
`tooltip ${JSON.stringify(placement)} covers ${JSON.stringify(blocker)}`);
|
|
}
|
|
};
|
|
|
|
test('tooltip preserves the preferred position when no obstacle occupies it', () => {
|
|
for (const blockers of [[], [rect(400, 100, 120, 40)], [rect(180, 100, 20, 20)]]) {
|
|
const data = input({ blockers });
|
|
assert.deepEqual(placeDeviceTooltip(data), { left: 200, top: 150 });
|
|
}
|
|
});
|
|
|
|
test('tooltip moves below a naturally colliding status badge, clear of the source marker', () => {
|
|
const data = input({
|
|
preferred: { x: 310, y: 160 },
|
|
size: { width: 190, height: 72 },
|
|
blockers: [rect(286, 136, 24, 24), rect(308, 160, 240, 30)],
|
|
});
|
|
const placement = placeDeviceTooltip(data);
|
|
assert.deepEqual(placement, { left: 310, top: 198 });
|
|
assertFits(placement, data);
|
|
});
|
|
|
|
test('tooltip avoids every badge and the source, not only the closest diagnostic text', () => {
|
|
const data = input({
|
|
blockers: [rect(190, 100, 30, 30), rect(200, 145, 220, 35),
|
|
rect(200, 180, 150, 40), rect(430, 120, 70, 30)],
|
|
});
|
|
const placement = placeDeviceTooltip(data);
|
|
assertFits(placement, data);
|
|
assert.deepEqual(placement, { left: 200, top: 228 });
|
|
});
|
|
|
|
test('tooltip reports no space in a tight card without shrinking the tooltip or moving blockers', () => {
|
|
const blockers = [Object.freeze(rect(75, 20, 80, 45))];
|
|
const data = Object.freeze(input({
|
|
preferred: Object.freeze({ x: 90, y: 40 }),
|
|
size: Object.freeze({ width: 120, height: 70 }),
|
|
bounds: Object.freeze(rect(0, 0, 180, 110)),
|
|
blockers: Object.freeze(blockers),
|
|
}));
|
|
assert.equal(placeDeviceTooltip(data), null);
|
|
assert.deepEqual(blockers[0], rect(75, 20, 80, 45));
|
|
});
|
|
|
|
test('recalculation restores a hidden tooltip after expanding the bounds or removing a badge', () => {
|
|
const data = input({ size: { width: 120, height: 70 }, bounds: rect(0, 0, 180, 110),
|
|
blockers: [rect(75, 20, 80, 45)] });
|
|
assert.equal(placeDeviceTooltip(data), null);
|
|
const expanded = { ...data, bounds: rect(0, 0, 640, 480) };
|
|
assertFits(placeDeviceTooltip(expanded), expanded);
|
|
const cleared = { ...data, blockers: [] };
|
|
assertFits(placeDeviceTooltip(cleared), cleared);
|
|
assert.equal(placeDeviceTooltip(data), null, 'no placement state leaks between calls');
|
|
});
|
|
|
|
test('tooltip clamps all four edges without obstacles and accepts an exact safe-area fit', () => {
|
|
const bounds = rect(20, 30, 300, 200);
|
|
const size = { width: 120, height: 60 };
|
|
for (const [preferred, expected] of [
|
|
[{ x: -500, y: -500 }, { left: 28, top: 38 }],
|
|
[{ x: 1000, y: 1000 }, { left: 192, top: 162 }],
|
|
[{ x: 120, y: 1000 }, { left: 120, top: 162 }],
|
|
[{ x: -500, y: 120 }, { left: 28, top: 120 }],
|
|
]) assert.deepEqual(placeDeviceTooltip(input({ bounds, size, preferred })), expected);
|
|
assert.deepEqual(placeDeviceTooltip(input({ bounds,
|
|
size: { width: 284, height: 184 } })), { left: 28, top: 38 });
|
|
});
|
|
|
|
test('negative origins, subpixels and long tooltip sizes remain in screen coordinates', () => {
|
|
const data = input({
|
|
bounds: rect(-300.5, -100.25, 1100, 360),
|
|
preferred: { x: 500, y: 160.5 },
|
|
size: { width: 800.25, height: 100.5 },
|
|
blockers: [rect(-250, -60, 80, 70)],
|
|
});
|
|
const placement = placeDeviceTooltip(data);
|
|
assert.deepEqual(placement, { left: -8.75, top: 151.25 });
|
|
assertFits(placement, data);
|
|
});
|
|
|
|
test('cross-product search finds an interior pocket unavailable to four directional guesses', () => {
|
|
// Four strips leave only the central 30 x 30 notch. Staying on either of the
|
|
// preferred axes, even at a bounds edge, would incorrectly claim no space.
|
|
const data = input({
|
|
bounds: rect(0, 0, 100, 100), size: { width: 20, height: 20 },
|
|
preferred: { x: 10, y: 10 }, gap: 0, edge: 0,
|
|
blockers: [rect(0, 0, 100, 40), rect(0, 70, 100, 30),
|
|
rect(0, 40, 40, 30), rect(70, 40, 30, 30)],
|
|
});
|
|
const placement = placeDeviceTooltip(data);
|
|
assert.deepEqual(placement, { left: 40, top: 40 });
|
|
assertFits(placement, data);
|
|
assert.equal(placeDeviceTooltip({ ...data, blockers: [...data.blockers, rect(40, 40, 30, 30)] }), null);
|
|
});
|
|
|
|
test('clearance is exact, configurable, and is never rounded back into a badge', () => {
|
|
const data = input({
|
|
size: { width: 40.5, height: 30.25 }, preferred: { x: 150.25, y: 120.5 },
|
|
blockers: [rect(150.25, 120.5, 90.75, 45.5)],
|
|
});
|
|
assert.deepEqual(placeDeviceTooltip(data), { left: 150.25, top: 82.25 });
|
|
assertFits(placeDeviceTooltip(data), data);
|
|
assert.deepEqual(placeDeviceTooltip({ ...data, gap: 0 }), { left: 150.25, top: 90.25 });
|
|
assert.deepEqual(placeDeviceTooltip({ ...data, gap: 12.5 }), { left: 150.25, top: 77.75 });
|
|
});
|
|
|
|
test('equal-distance placements are deterministic regardless of blocker order', () => {
|
|
const data = input({ preferred: { x: 100, y: 100 }, size: { width: 20, height: 20 },
|
|
blockers: [rect(100, 100, 20, 20), rect(300, 300, 20, 20)] });
|
|
assert.deepEqual(placeDeviceTooltip(data), { left: 100, top: 72 });
|
|
assert.deepEqual(placeDeviceTooltip({ ...data, blockers: [...data.blockers].reverse() }),
|
|
placeDeviceTooltip(data));
|
|
});
|
|
|
|
test('empty and off-stage blockers do not invent a collision', () => {
|
|
const data = input({ blockers: [rect(180, 100, 0, 160), rect(180, 160, 180, 0),
|
|
rect(1000, 1000, 400, 400), rect(-1000, -1000, 50, 50)] });
|
|
assert.deepEqual(placeDeviceTooltip(data), { left: 200, top: 150 });
|
|
});
|
|
|
|
test('closest feasible result agrees with exhaustive placement across 200 small layouts', () => {
|
|
let seed = 802;
|
|
const next = (limit) => {
|
|
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
|
return seed % limit;
|
|
};
|
|
for (let scene = 0; scene < 200; scene++) {
|
|
const data = input({ bounds: rect(-5, -4, 32, 28),
|
|
size: { width: 3 + next(10), height: 2 + next(9) },
|
|
preferred: { x: next(50) - 10, y: next(40) - 8 }, gap: next(4), edge: next(4),
|
|
blockers: Array.from({ length: 1 + next(5) }, () =>
|
|
rect(next(40) - 8, next(36) - 8, next(13), next(13))),
|
|
});
|
|
let expected = null;
|
|
let bestDistance = Infinity;
|
|
// All input edges and preferred coordinates are integral, so an integer
|
|
// closest point exists. This oracle scans the area, not obstacle candidates.
|
|
for (let top = data.bounds.top + data.edge;
|
|
top + data.size.height <= data.bounds.bottom - data.edge; top++) {
|
|
for (let left = data.bounds.left + data.edge;
|
|
left + data.size.width <= data.bounds.right - data.edge; left++) {
|
|
if (data.blockers.some((b) => b.width && b.height
|
|
&& left < b.right + data.gap && left + data.size.width + data.gap > b.left
|
|
&& top < b.bottom + data.gap && top + data.size.height + data.gap > b.top)) continue;
|
|
const distance = (left - data.preferred.x) ** 2 + (top - data.preferred.y) ** 2;
|
|
if (distance < bestDistance) {
|
|
expected = { left, top };
|
|
bestDistance = distance;
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(placeDeviceTooltip(data), expected, `layout ${scene}: ${JSON.stringify(data)}`);
|
|
}
|
|
});
|
|
|
|
test('invalid, unmeasurable and oversized inputs fail closed without throwing', () => {
|
|
for (const overrides of [
|
|
{ preferred: { x: NaN, y: 10 } }, { preferred: { x: 10, y: Infinity } },
|
|
{ size: { width: 0, height: 20 } }, { size: { width: -20, height: 20 } },
|
|
{ size: { width: 20, height: NaN } }, { size: { width: 20, height: -1 } },
|
|
{ size: { width: 20, height: Infinity } }, { size: { width: 1000, height: 20 } },
|
|
{ size: { width: 20, height: 1000 } }, { bounds: rect(0, 0, 0, 0) },
|
|
{ bounds: rect(0, 0, -100, 100) }, { bounds: rect(Infinity, 0, 100, 100) },
|
|
{ blockers: [rect(10, 10, -10, 10)] }, { blockers: [rect(10, NaN, 10, 10)] },
|
|
{ gap: -1 }, { gap: Infinity }, { edge: -1 }, { edge: NaN },
|
|
]) assert.equal(placeDeviceTooltip(input(overrides)), null, JSON.stringify(overrides));
|
|
});
|