mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
Three owner reports: - The content frame behind the default zoom only looked at rooms, and devices are allowed to stand outside every one of them — a gate sensor by the fence was left outside the opening view. contentBounds() takes the marker positions now, and they count as content even on a space with no rooms at all. - Entering an editor 'strangely shifted' the plan. The stage height was 100dvh minus a hard-coded 118px of header, and the editor header is ~90px taller than that: the whole scene slid down by the difference and its bottom went below the fold. The card now measures where the stage actually starts (HA toolbar, margins and our header included) and gives it the rest of the viewport; the measurement is deferred a frame because setting state straight from a ResizeObserver callback trips the 'undelivered notifications' error, which smoke_dialog_zombie rightly counts as a page error. - Zoom stopped at the base fit. The floor is 0.4× now, and zoomed out the clamp centres the content instead of pinning it to the top-left corner — with the view larger than the plan there is nothing to clamp against. New smoke: smoke_zoom_out.mjs (editor keeps the stage inside the viewport, 0.4 floor, centring, the device-stretched frame); a unit test for the extra points of contentBounds. Not released — the next release goes out after the v1.49.0 audit.
126 lines
5.7 KiB
JavaScript
126 lines
5.7 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos, fitInSquare, contentBounds,
|
|
} from '../test-build/space-geometry.js';
|
|
|
|
const cfg = {
|
|
spaces: [{
|
|
id: 'f1', title: '1st', plan_aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1],
|
|
rooms: [{ id: 'r1', name: 'Room', area: 'a1', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] }],
|
|
}, {
|
|
id: 'yard', title: 'Yard', view_box: [0, 0, 1, 1], rooms: [],
|
|
}],
|
|
markers: [], settings: {},
|
|
};
|
|
|
|
test('spaceModels: the canvas is square; the image is centred by its own ratio', () => {
|
|
const m = spaceModels(cfg);
|
|
assert.equal(m.length, 2);
|
|
const f1 = m[0];
|
|
assert.deepEqual(f1.vb, [0, 0, 1000, 1000]);
|
|
assert.equal(f1.bg.href, '/plans/f1.svg');
|
|
// a plan twice as wide as it is tall: full width, half height, margins above
|
|
// and below — the canvas has no proportions of its own any more (v1.48.0)
|
|
assert.deepEqual(f1.bg, { href: '/plans/f1.svg', x: 0, y: 250, w: 1000, h: 500 });
|
|
assert.deepEqual(f1.rooms[0].poly, [[100, 100], [500, 100], [500, 500], [100, 500]]);
|
|
assert.equal(m[1].bg, null); // no plan_url
|
|
assert.equal(spaceModels(null).length, 0);
|
|
});
|
|
|
|
test('fitInSquare: wide gets top/bottom margins, tall gets side margins', () => {
|
|
assert.deepEqual(fitInSquare(2, 1000), { x: 0, y: 250, w: 1000, h: 500 });
|
|
assert.deepEqual(fitInSquare(0.5, 1000), { x: 250, y: 0, w: 500, h: 1000 });
|
|
assert.deepEqual(fitInSquare(1, 1000), { x: 0, y: 0, w: 1000, h: 1000 });
|
|
// unknown ratio (image not loaded yet, old config): assume square
|
|
for (const bad of [null, undefined, 0, -3, NaN, 'x']) {
|
|
assert.deepEqual(fitInSquare(bad, 1000), { x: 0, y: 0, w: 1000, h: 1000 });
|
|
}
|
|
});
|
|
|
|
test('roomBounds + roomCenter for a polygon', () => {
|
|
const r = spaceModels(cfg)[0].rooms[0];
|
|
assert.deepEqual(roomBounds(r), { x: 100, y: 100, w: 400, h: 400 });
|
|
assert.deepEqual(roomCenter(r), [300, 300]);
|
|
});
|
|
|
|
test('markerPos: saved layout → default grid → space centre', () => {
|
|
const model = spaceModels(cfg)[0];
|
|
const dev = { id: 'd1', space: 'f1', area: 'a1', entities: [] };
|
|
// saved layout (normalized) → render units
|
|
assert.deepEqual(
|
|
markerPos(dev, { d1: { s: 'f1', x: 0.2, y: 0.3 } }, cfg, {}, model),
|
|
{ x: 200, y: 300 }, // 0.2*1000, 0.3*1000
|
|
);
|
|
// default grid position (inside the room)
|
|
const defPos = defaultPositions([dev], model, 2.5);
|
|
assert.ok(defPos.d1);
|
|
assert.deepEqual(markerPos(dev, {}, cfg, defPos, model), defPos.d1);
|
|
const b = roomBounds(model.rooms[0]);
|
|
assert.ok(defPos.d1.x >= b.x && defPos.d1.x <= b.x + b.w && defPos.d1.y >= b.y && defPos.d1.y <= b.y + b.h);
|
|
// no layout, no defPos → space centre
|
|
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 500 });
|
|
});
|
|
|
|
test('labelPos: saved rl_<id> → render units; else room centre', () => {
|
|
const model = spaceModels(cfg)[0];
|
|
const r = model.rooms[0];
|
|
assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 400 });
|
|
assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 300 }); // room centre
|
|
});
|
|
|
|
test('defaultPositions: several devices in one room are spread (declumped, distinct)', () => {
|
|
const model = spaceModels(cfg)[0];
|
|
const devs = [0, 1, 2, 3].map((i) => ({ id: 'd' + i, space: 'f1', area: 'a1', entities: [] }));
|
|
const pos = defaultPositions(devs, model, 2.5);
|
|
assert.equal(Object.keys(pos).length, 4);
|
|
const keys = Object.keys(pos);
|
|
for (let i = 0; i < keys.length; i++)
|
|
for (let j = i + 1; j < keys.length; j++) {
|
|
const a = pos[keys[i]], b = pos[keys[j]];
|
|
assert.ok(Math.hypot(a.x - b.x, a.y - b.y) > 1, 'positions distinct');
|
|
}
|
|
});
|
|
|
|
test('NORM_W is 1000', () => assert.equal(NORM_W, 1000));
|
|
|
|
test('contentBounds: fits what is drawn, with a 5% margin', () => {
|
|
const one = spaceModels({ spaces: [{
|
|
id: 's', view_box: [0, 0, 1, 1],
|
|
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
|
|
}], markers: [] })[0];
|
|
// 200x200 render units in the middle of a 1000x1000 canvas, +5% of 200 each side
|
|
assert.deepEqual(contentBounds(one), { x: 390, y: 390, w: 220, h: 220 });
|
|
|
|
// rectangles count too, and the margin follows the LARGER side
|
|
const rect = spaceModels({ spaces: [{
|
|
id: 's', view_box: [0, 0, 1, 1],
|
|
rooms: [{ id: 'r', x: 0.1, y: 0.4, w: 0.6, h: 0.1 }],
|
|
}], markers: [] })[0];
|
|
const b = contentBounds(rect);
|
|
assert.equal(Math.round(b.w), 660); // 600 + 2 * 5% of 600
|
|
assert.equal(Math.round(b.h), 160); // 100 + the same absolute margin
|
|
assert.equal(Math.round(b.x), 70);
|
|
|
|
// nothing drawn → the caller keeps the whole canvas
|
|
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
|
|
assert.equal(contentBounds(empty), null);
|
|
});
|
|
|
|
test('contentBounds: devices outside every room stretch the frame', () => {
|
|
const one = spaceModels({ spaces: [{
|
|
id: 's', view_box: [0, 0, 1, 1],
|
|
rooms: [{ id: 'r', poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }],
|
|
}], markers: [] })[0];
|
|
// a gate sensor far to the right of the room
|
|
const b = contentBounds(one, 0.05, [[900, 500]]);
|
|
// span 400..900 wide, 400..600 tall; margin 5% of the larger side (500)
|
|
assert.deepEqual(b, { x: 375, y: 375, w: 550, h: 250 });
|
|
// devices alone are content enough — an empty yard with two cameras
|
|
const empty = spaceModels({ spaces: [{ id: 's', view_box: [0, 0, 1, 1], rooms: [] }], markers: [] })[0];
|
|
const only = contentBounds(empty, 0.05, [[100, 100], [300, 200]]);
|
|
assert.equal(Math.round(only.w), 220);
|
|
// and junk coordinates are ignored, not spread across the canvas
|
|
assert.deepEqual(contentBounds(one, 0.05, [[NaN, 5]]), contentBounds(one));
|
|
});
|