Files
houseplan-card/test/furniture.test.mjs
T
2026-08-30 11:56:35 +03:00

395 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The furniture library (docs/FURNITURE.md): the arithmetic that turns real
// centimetres into canvas units, the wall magnet, the independent resize and
// the generated geometry. Everything here is pure — the card only renders it.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
FURNITURE, FURNITURE_GROUPS, furnitureSymbol, furnitureOfGroup,
furnitureDefaultCm, furniturePathD, furnitureGraphic, furnitureCorners, furnitureResize,
snapFurnitureToWall, resolveFurniturePlacement, furniturePlanScreenScale, furnitureStrokePx,
resizeFurnitureTransform, furnitureRotationAngle, furnitureRenderTransform,
furnitureSignedFieldCm, furnitureSignedFieldValue,
cmToNorm, normToCm, clampFurnSize, clampFurnCm,
FURN_MIN_N, FURN_MIN_CM, FURN_MAX_CM, FURN_WALL_CELLS,
} from '../test-build/furniture.js';
import { roomEdges } from '../test-build/logic.js';
import { NORM_W, GRID_PITCH, GRID_N } from '../test-build/space-geometry.js';
const closeTo = (got, want, tol = 1e-9) =>
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
// ------------------------------- the table ---------------------------------
test('every symbol is well formed: unique id, a known group, positive default size', () => {
const seen = new Set();
for (const s of FURNITURE) {
assert.ok(/^[a-z0-9_]+$/.test(s.id), `bad id ${s.id}`);
assert.ok(!seen.has(s.id), `duplicate id ${s.id}`);
seen.add(s.id);
assert.ok(FURNITURE_GROUPS.includes(s.group), `${s.id}: unknown group ${s.group}`);
assert.ok(s.w > 0 && s.h > 0, `${s.id}: default size must be positive`);
assert.match(s.category, /^[a-z0-9_]+$/, `${s.id}: invalid category`);
assert.ok(furnitureGraphic(s.id)?.d, `${s.id}: nothing to draw`);
if (s.g) {
// Retained primitive art stays in the legacy unit box.
for (const p of s.g) {
const nums = p.slice(1);
for (const v of nums) assert.ok(v >= -0.001 && v <= 1.001, `${s.id}: ${v} outside the unit box`);
}
} else {
assert.deepEqual([s.art.viewW, s.art.viewH], [s.w, s.h], `${s.id}: manifest and SVG viewBox differ`);
}
}
assert.equal(FURNITURE.length, 56);
assert.equal(FURNITURE.filter((s) => s.art).length, 44);
assert.equal(FURNITURE.filter((s) => s.g).length, 12);
});
test('the three groups the owner named are all populated, and every symbol is in exactly one', () => {
for (const g of ['furniture', 'appliance', 'sanitary'])
assert.ok(furnitureOfGroup(g).length > 0, `group ${g} is empty`);
const total = FURNITURE_GROUPS.reduce((n, g) => n + furnitureOfGroup(g).length, 0);
assert.equal(total, FURNITURE.length);
});
test('the owner’s default sizes are the ones stored', () => {
// owner’s list, read as width ALONG the back edge x depth
assert.deepEqual(furnitureDefaultCm('sofa'), { w: 180, h: 90 });
assert.deepEqual(furnitureDefaultCm('bed_double'), { w: 160, h: 200 });
assert.deepEqual(furnitureDefaultCm('bed_single'), { w: 90, h: 200 });
assert.deepEqual(furnitureDefaultCm('table_dining'), { w: 160, h: 90 });
assert.deepEqual(furnitureDefaultCm('toilet'), { w: 40, h: 70 });
assert.deepEqual(furnitureDefaultCm('bathtub'), { w: 170, h: 75 });
assert.deepEqual(furnitureDefaultCm('shower'), { w: 90, h: 90 });
assert.deepEqual(furnitureDefaultCm('sink'), { w: 60, h: 45 });
assert.deepEqual(furnitureDefaultCm('stove'), { w: 60, h: 60 });
assert.deepEqual(furnitureDefaultCm('fridge'), { w: 60, h: 65 });
assert.deepEqual(furnitureDefaultCm('washer'), { w: 60, h: 60 });
assert.deepEqual(furnitureDefaultCm('dishwasher'), { w: 60, h: 60 });
assert.deepEqual(furnitureDefaultCm('wardrobe'), { w: 180, h: 60 });
assert.deepEqual(furnitureDefaultCm('chair'), { w: 50, h: 50 });
assert.deepEqual(furnitureDefaultCm('desk'), { w: 140, h: 70 });
assert.deepEqual(furnitureDefaultCm('sofa_corner_right'), { w: 260, h: 170 });
assert.deepEqual(furnitureDefaultCm('kitchen_sink_double'), { w: 90, h: 50 });
});
test('an unknown symbol is data, not a crash', () => {
assert.equal(furnitureSymbol('no_such_thing'), null);
assert.equal(furnitureSymbol(undefined), null);
assert.deepEqual(furnitureDefaultCm('no_such_thing'), { w: 60, h: 60 });
assert.equal(furniturePathD('no_such_thing', 10, 10), '');
});
// --------------------------- centimetres <-> canvas -------------------------
test('cmToNorm goes through cell_cm: one cell of the grid is cell_cm centimetres', () => {
// a 220 cm sofa on a 5 cm/cell plan is 44 cells; the canvas has GRID_N cells
closeTo(cmToNorm(220, 5), 44 / GRID_N, 1e-12);
// …and the same sofa on a 10 cm/cell plan is HALF the canvas fraction
closeTo(cmToNorm(220, 10), 22 / GRID_N, 1e-12);
// the render-unit form the card actually uses
closeTo(cmToNorm(220, 5) * NORM_W, (220 / 5) * GRID_PITCH, 1e-9);
});
test('normToCm is its inverse, and a missing cell_cm defaults to 5', () => {
for (const cm of [1, 45, 220, 10000]) {
closeTo(normToCm(cmToNorm(cm, 5), 5), cm, 1e-9);
closeTo(normToCm(cmToNorm(cm, 12.5), 12.5), cm, 1e-9);
}
closeTo(cmToNorm(100, 0), cmToNorm(100, 5), 1e-12);
closeTo(cmToNorm(100, NaN), cmToNorm(100, 5), 1e-12);
});
test('sizes are clamped, not trusted', () => {
assert.equal(clampFurnSize(NaN), FURN_MIN_N);
assert.equal(clampFurnSize(-3), FURN_MIN_N);
assert.equal(clampFurnSize(1e9), 5000);
assert.equal(clampFurnCm(0), FURN_MIN_CM);
assert.equal(clampFurnCm(1e9), FURN_MAX_CM);
assert.equal(clampFurnCm(220), 220);
});
// ------------------------------- the drawing --------------------------------
test('designer paths keep their native viewBox; retained paths still scale from the unit box', () => {
const sofa = furnitureGraphic('sofa');
assert.deepEqual([sofa.viewW, sofa.viewH], [180, 90]);
assert.ok(sofa.d.length > 10);
const fridge = furniturePathD('fridge', 60, 65);
assert.ok(fridge.startsWith('M0 0H60V65H0Z'), fridge.slice(0, 40));
// a degenerate box draws nothing rather than NaNs
assert.equal(furniturePathD('sofa', 0, 10), '');
assert.ok(!/NaN/.test(furniturePathD('toilet', 40, 70)));
});
test('every symbol exposes one finite path and a positive native coordinate box', () => {
for (const s of FURNITURE) {
const art = furnitureGraphic(s.id);
assert.ok(art.viewW > 0 && art.viewH > 0, `${s.id}: invalid viewBox`);
assert.ok(art.d.length > 0, `${s.id}: empty path`);
assert.ok(!/NaN|Infinity|undefined/.test(art.d), `${s.id}: ${art.d.slice(0, 60)}`);
}
});
test('furniture stroke follows the outer plan viewBox camera like physical decor', () => {
// 1000 plan units fitted into 500 CSS px: the same 3-unit physical line is
// 1.5 px on screen. Halving the viewBox is a 2x camera zoom and doubles it.
const fit = furniturePlanScreenScale(500, 300, 1000, 600);
const zoom2 = furniturePlanScreenScale(500, 300, 500, 300);
closeTo(fit, 0.5);
closeTo(zoom2, 1);
closeTo(furnitureStrokePx(3, fit), 1.5);
closeTo(furnitureStrokePx(3, zoom2), 3);
closeTo(furnitureStrokePx(3, zoom2) / furnitureStrokePx(3, fit), 2);
});
test('furniture stroke uses the meet scale and stays independent of the local artwork box', () => {
// Width alone would say 0.8; xMidYMid meet is constrained by height at 0.5.
const letterboxed = furniturePlanScreenScale(800, 300, 1000, 600);
closeTo(letterboxed, 0.5);
const stroke = furnitureStrokePx(4, letterboxed);
for (const symbol of ['sofa', 'fridge']) {
assert.ok(furnitureGraphic(symbol), `${symbol}: fixture must render`);
// Designer native viewBox and retained unit-box art receive the same
// physical stroke even if their independent width/depth scales differ.
closeTo(furnitureStrokePx(4, letterboxed), stroke);
}
});
test('furniture stroke layout fallbacks are finite and recover on measured layout', () => {
for (const metrics of [
[0, 300, 1000, 600], [500, NaN, 1000, 600], [500, 300, 0, 600],
[undefined, undefined, undefined, undefined],
]) assert.equal(furniturePlanScreenScale(...metrics), 1);
assert.equal(furnitureStrokePx(NaN, NaN, 2.5), 2.5);
assert.equal(furnitureStrokePx(3, 0), 3);
assert.ok(Number.isFinite(furnitureStrokePx(Infinity, Infinity)));
closeTo(furnitureStrokePx(3, furniturePlanScreenScale(500, 300, 500, 300)), 3);
});
// ------------------------------ the wall magnet -----------------------------
// one 400x300 room, normalised like the config stores it, in render units
const ROOM = { id: 'r', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.4], [0.1, 0.4]] };
const EDGES = roomEdges([ROOM]).map((e) => [e[0] * 1000, e[1] * 1000, e[2] * 1000, e[3] * 1000]);
// -> the box (100,100)-(500,400) in render units
test('the magnet presses the BACK edge onto the wall and turns the piece to it', () => {
// held just inside the TOP wall (y=100), body below it -> angle 0, back on y=100
const s = snapFurnitureToWall(300, 112, 90, EDGES, 30);
assert.ok(s, 'a wall 12 units away must be found');
closeTo(s.angle, 0);
closeTo(s.cy, 100 + 45); // centre = wall + half the depth
closeTo(s.cx, 300);
closeTo(s.dist, 12);
});
test('the same wall from the OTHER side turns the piece round instead of flipping it through', () => {
const s = snapFurnitureToWall(300, 88, 90, EDGES, 30); // above the top wall
closeTo(Math.abs(s.angle), 180);
closeTo(s.cy, 100 - 45);
});
test('a vertical wall gives a right angle and the depth measured sideways', () => {
const left = snapFurnitureToWall(112, 250, 60, EDGES, 30); // inside the x=100 wall
closeTo(Math.abs(left.angle), 90);
closeTo(left.cx, 100 + 30);
closeTo(left.cy, 250);
const right = snapFurnitureToWall(488, 250, 60, EDGES, 30); // inside the x=500 wall
closeTo(right.cx, 500 - 30);
// the two are opposite: the back always looks at its own wall
closeTo(Math.abs(((left.angle - right.angle) % 360 + 360) % 360), 180);
});
test('out of reach there is no magnet at all', () => {
assert.equal(snapFurnitureToWall(300, 250, 90, EDGES, 30), null); // middle of the room
assert.equal(snapFurnitureToWall(300, 112, 90, [], 30), null); // no walls
// …and the threshold is a threshold: 30.1 away with a reach of 30 is a miss
assert.equal(snapFurnitureToWall(300, 130.1, 90, EDGES, 30), null);
assert.ok(snapFurnitureToWall(300, 129.9, 90, EDGES, 30));
});
test('the offset ALONG the wall is quantised to the grid when a step is given', () => {
const step = GRID_PITCH; // one cell
const free = snapFurnitureToWall(303.3, 112, 90, EDGES, 30);
const snapped = snapFurnitureToWall(303.3, 112, 90, EDGES, 30, step);
closeTo(free.cx, 303.3);
// the wall starts at x=100, so a snapped centre sits on 100 + k*step
closeTo(((snapped.cx - 100) / step) % 1, 0, 1e-9);
assert.ok(Math.abs(snapped.cx - 303.3) <= step);
});
test('the default reach is six cells — thirty centimetres on a default plan', () => {
assert.equal(FURN_WALL_CELLS, 6);
closeTo(GRID_PITCH * FURN_WALL_CELLS * (5 / GRID_PITCH), 30); // cells x cell_cm
});
test('preview and commit share one deterministic furniture placement resolver', () => {
const input = {
symbol: 'sofa', widthCm: 180, depthCm: 90,
point: [300, 112], canvasW: 1000, canvasH: 1000,
cellCm: 5, gridPitch: GRID_PITCH, walls: EDGES,
wallReach: 30,
};
const preview = resolveFurniturePlacement(input);
const commit = resolveFurniturePlacement({ ...input });
assert.deepEqual(preview, commit);
assert.equal(preview.symbol, 'sofa');
closeTo((preview.x + preview.w / 2) * 1000, 300, GRID_PITCH);
closeTo((preview.y + preview.h / 2) * 1000, 100 + preview.h * 500);
assert.equal(preview.angle, 0);
});
test('the shared placement resolver supports Shift/free, canvas guards and unknown symbols', () => {
const base = {
symbol: 'sofa', widthCm: 180, depthCm: 90,
point: [300, 112], canvasW: 1000, canvasH: 1000,
cellCm: 5, gridPitch: GRID_PITCH, walls: EDGES,
wallReach: 30,
};
const free = resolveFurniturePlacement({ ...base, free: true });
closeTo((free.x + free.w / 2) * 1000, 300);
closeTo((free.y + free.h / 2) * 1000, 112);
const guarded = resolveFurniturePlacement({ ...base, point: [-1e9, -1e9], free: true });
assert.equal(guarded.x, -5000);
assert.equal(guarded.y, -5000);
assert.equal(resolveFurniturePlacement({ ...base, symbol: 'future_unknown_symbol' }), null);
});
// ------------------------------- the frame ----------------------------------
test('the corners of an unrotated box are the box; a rotated one turns about its centre', () => {
assert.deepEqual(furnitureCorners(100, 200, 40, 20, 0),
[[100, 200], [140, 200], [140, 220], [100, 220]]);
const c = furnitureCorners(0, 0, 40, 20, 90);
// 90 degrees clockwise in SVG coordinates: the NW corner lands top-right
closeTo(c[0][0], 30); closeTo(c[0][1], -10);
closeTo(c[2][0], 10); closeTo(c[2][1], 30);
});
test('a corner drag moves BOTH axes and keeps the opposite corner still', () => {
const orig = { x: 100, y: 100, w: 200, h: 100, angle: 0 };
// pull the SE corner (+1,+1) out to (360, 260): the NW corner must not move
const r = furnitureResize(orig, 1, 1, 360, 260);
closeTo(r.x, 100); closeTo(r.y, 100);
closeTo(r.w, 260); closeTo(r.h, 160);
// …and pulling the NW corner (-1,-1) keeps the SE one
const r2 = furnitureResize(orig, -1, -1, 60, 60);
closeTo(r2.x + r2.w, 300); closeTo(r2.y + r2.h, 200);
closeTo(r2.w, 240); closeTo(r2.h, 140);
});
test('the two axes are INDEPENDENT: the aspect ratio is not preserved', () => {
const orig = { x: 0, y: 0, w: 200, h: 100 };
const r = furnitureResize(orig, 1, 1, 400, 110);
closeTo(r.w, 400);
closeTo(r.h, 110); // a uniform scale would have made this 200
});
test('a step quantises each dimension; without one the drag is exact (Shift)', () => {
const orig = { x: 0, y: 0, w: 200, h: 100 };
const step = GRID_PITCH;
const snapped = furnitureResize(orig, 1, 1, 203.3, 101.1, step);
closeTo((snapped.w / step) % 1, 0, 1e-9);
closeTo((snapped.h / step) % 1, 0, 1e-9);
const free = furnitureResize(orig, 1, 1, 203.3, 101.1, 0);
closeTo(free.w, 203.3); closeTo(free.h, 101.1);
});
test('a resize never collapses below the minimum, however far the corner is dragged back', () => {
const orig = { x: 0, y: 0, w: 200, h: 100 };
const r = furnitureResize(orig, 1, 1, -500, -500, 0, 2);
closeTo(r.w, 2); closeTo(r.h, 2);
});
test('resizing a ROTATED piece works along its own axes and keeps its own fixed corner', () => {
const orig = { x: 0, y: 0, w: 100, h: 50, angle: 90 };
// the fixed corner of an SE drag is the piece's NW corner, in WORLD units
const fixed = furnitureCorners(orig.x, orig.y, orig.w, orig.h, orig.angle)[0];
const r = furnitureResize(orig, 1, 1, fixed[0] + 10, fixed[1] + 200, 0);
const after = furnitureCorners(r.x, r.y, r.w, r.h, orig.angle)[0];
closeTo(after[0], fixed[0], 1e-6);
closeTo(after[1], fixed[1], 1e-6);
// rotated 90 degrees, pulling DOWN in world space grows the piece's WIDTH
closeTo(r.w, 200, 1e-6);
});
test('#383 furniture resize is sub-grid, proportional by default and independent with Shift', () => {
const orig = { x: 10, y: 20, w: 40, h: 20 };
const proportional = resizeFurnitureTransform(orig, 1, 1, 50.37, 40.02, true, 0.1);
closeTo(proportional.w / proportional.h, 2);
assert.ok(Math.abs(proportional.w / GRID_PITCH - Math.round(proportional.w / GRID_PITCH)) > 1e-3,
'continuous resize must not land on the cell lattice by construction');
const independent = resizeFurnitureTransform(orig, 1, 1, 50.37, 40.02, false, 0.1);
closeTo(independent.w, 40.37);
closeTo(independent.h, 20.02);
});
test('#383 edge handles change one local axis and keep the opposite world edge fixed', () => {
const orig = { x: 10, y: 20, w: 40, h: 20, angle: 90 };
const before = furnitureCorners(orig.x, orig.y, orig.w, orig.h, orig.angle);
const right = resizeFurnitureTransform(orig, 1, 0, 30, 90, false, 0.1);
closeTo(right.h, orig.h);
const after = furnitureCorners(right.x, right.y, right.w, right.h, orig.angle);
closeTo((after[0][0] + after[3][0]) / 2, (before[0][0] + before[3][0]) / 2);
closeTo((after[0][1] + after[3][1]) / 2, (before[0][1] + before[3][1]) / 2);
const bottom = resizeFurnitureTransform(orig, 0, 1, -20, 40, false, 0.1);
closeTo(bottom.w, orig.w);
});
test('#383 crossing keeps positive extents, toggles only crossed flips and preserves fixed corner', () => {
const orig = { x: 0, y: 0, w: 40, h: 20, angle: 30, flip_h: true };
const fixed = furnitureCorners(orig.x, orig.y, orig.w, orig.h, orig.angle)[0];
const crossed = resizeFurnitureTransform(orig, 1, 1, fixed[0] - 20, fixed[1] + 5, false, 0.1);
assert.ok(crossed.w > 0 && crossed.h > 0);
assert.equal(crossed.flip_h, undefined, 'crossing horizontal axis toggles existing H flip off');
assert.equal(crossed.flip_v, undefined);
const after = furnitureCorners(crossed.x, crossed.y, crossed.w, crossed.h, orig.angle);
// A crossed active corner becomes the adjacent geometric corner; the fixed
// world point is nevertheless still one of the box corners.
assert.ok(after.some((point) => Math.hypot(point[0] - fixed[0], point[1] - fixed[1]) < 1e-8));
const both = resizeFurnitureTransform({ x: 0, y: 0, w: 40, h: 20 }, 1, 1, -5, -8, false, 0.1);
assert.equal(both.flip_h, true);
assert.equal(both.flip_v, true);
assert.deepEqual([both.w, both.h], [5, 8]);
});
test('#383 furniture rotation is free normally and Shift snaps to 45 degrees', () => {
closeTo(furnitureRotationAngle(10, 20, 33.4, false), 23.4);
assert.equal(furnitureRotationAngle(10, 20, 33.4, true), 45);
assert.equal(furnitureRotationAngle(170, 0, 30, true), 180);
assert.equal(furnitureRotationAngle(0, 0, 22.5, true), 45);
assert.equal(furnitureRotationAngle(0, 0, -22.5, true), -45);
});
test('#383 render transform mirrors inside the same positive box before rotation', () => {
const base = { x: 0.1, y: 0.2, w: 0.3, h: 0.4, angle: 30 };
assert.equal(
furnitureRenderTransform(base, 1000, 500, 100, 50),
'rotate(30 250 200) translate(100 100) scale(3 4)',
);
assert.equal(
furnitureRenderTransform({ ...base, flip_h: true, flip_v: true }, 1000, 500, 100, 50),
'rotate(30 250 200) translate(400 300) scale(-3 -4)',
);
});
test('#383 signed property fields project flags without persisting negative extents', () => {
assert.equal(furnitureSignedFieldValue(180, false, false), '1.8');
assert.equal(furnitureSignedFieldValue(180, true, false), '-1.8');
assert.equal(furnitureSignedFieldValue(30.48, true, true), '-1');
assert.equal(furnitureSignedFieldValue(0.1, false, false), '0.001');
assert.equal(furnitureSignedFieldCm(furnitureSignedFieldValue(0.1, false, false), false, 10000), 0.1);
closeTo(
furnitureSignedFieldCm(furnitureSignedFieldValue(0.1, false, true), true, 10000),
0.1,
0.0001,
);
assert.equal(furnitureSignedFieldCm('-1.8', false, 10000), -180);
assert.equal(furnitureSignedFieldCm('1', true, 10000), 30.48);
for (const invalid of ['', '0', 0, 'wat', Infinity])
assert.equal(furnitureSignedFieldCm(invalid, false, 10000), null);
});