mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
178 lines
8.4 KiB
JavaScript
178 lines
8.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
boxCorners, decorStrokeUnits, decorStyleOf, decorStylePatch, normalizeAngle,
|
|
nudgeDecorShape, resizeDecorBox, resizedBoxTopLeft, snapDecorPoint, validDecorDraft,
|
|
} from '../test-build/editors/decor/geometry.js';
|
|
import { CANVAS_LIMIT, GRID_PITCH, GRID_STEP_N, NORM_W } from '../test-build/space-geometry.js';
|
|
|
|
const close = (actual, expected, epsilon = 1e-9) =>
|
|
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} != ${expected}`);
|
|
|
|
test('canonical physical stroke and legacy render stroke remain visually exact', () => {
|
|
close(decorStrokeUnits({ width_cm: 5 }, 5, 12), 12);
|
|
close(decorStrokeUnits({ width: 3 }, 5, 12), 3);
|
|
const legacy = decorStyleOf({ id: 'r', kind: 'rect', x: 0, y: 0, w: 1, h: 1,
|
|
color: '#123456', width: 3, fill: true }, 5, 12);
|
|
close(legacy.widthCm, 1.25);
|
|
assert.equal(legacy.fillColor, '#123456');
|
|
close(legacy.fillOpacity, 0.25);
|
|
});
|
|
|
|
test('decor style fallbacks and canonical patch stay inside the persisted schema', () => {
|
|
const fallback = {
|
|
color: '#112233', opacity: 0.8, widthCm: 4,
|
|
fill: false, fillColor: '#445566', fillOpacity: 0.3,
|
|
};
|
|
const style = decorStyleOf({ id: 'r', kind: 'rect', x: 0, y: 0, w: 1, h: 1,
|
|
color: 'invalid', opacity: 8, fill: true, fill_color: 'invalid' }, 5, 12, fallback);
|
|
assert.equal(style.color, fallback.color);
|
|
assert.equal(style.opacity, 1);
|
|
assert.equal(style.fillColor, fallback.color);
|
|
assert.equal(style.fillOpacity, 0.25);
|
|
|
|
const high = decorStylePatch({ ...fallback, widthCm: 1e6, opacity: -2,
|
|
fill: true, fillOpacity: 4 }, true);
|
|
assert.equal(high.width_cm, 100);
|
|
assert.equal(high.opacity, 0);
|
|
assert.equal(high.fill_opacity, 1);
|
|
const lowLine = decorStylePatch({ ...fallback, widthCm: -1 }, false);
|
|
assert.equal(lowLine.width_cm, 0.1);
|
|
assert.equal('fill' in lowLine, false);
|
|
});
|
|
|
|
test('angle normalization is stable at wrap boundaries', () => {
|
|
assert.equal(normalizeAngle(0), 0);
|
|
assert.equal(normalizeAngle(180), 180);
|
|
assert.equal(normalizeAngle(181), -179);
|
|
assert.equal(normalizeAngle(-181), 179);
|
|
assert.equal(normalizeAngle(360), 0);
|
|
assert.equal(normalizeAngle(Number.POSITIVE_INFINITY), 0);
|
|
});
|
|
|
|
test('oriented resize preserves ratio by default and Shift-style mode separates axes', () => {
|
|
const original = { x: 10, y: 20, w: 40, h: 20, angle: 30 };
|
|
const proportional = resizeDecorBox(original, 1, 1, 100, 100, true, 5, 5);
|
|
close(proportional.w / proportional.h, 2);
|
|
assert.equal(proportional.w % 5, 0);
|
|
assert.equal(proportional.h % 5, 0);
|
|
const independent = resizeDecorBox(original, 1, 1, 100, 100, false, 5, 5);
|
|
assert.notEqual(independent.w / independent.h, 2);
|
|
assert.equal(independent.w % 5, 0);
|
|
assert.equal(independent.h % 5, 0);
|
|
});
|
|
|
|
test('decor magnet may choose a point or edge but always returns a grid point', () => {
|
|
const grid = ([x, y]) => [Math.round(x / 10) * 10, Math.round(y / 10) * 10];
|
|
const geometry = { points: [[31, 39]], segments: [{ a: [0, 50], b: [100, 50] }] };
|
|
const point = snapDecorPoint([32, 38], geometry, 8, grid);
|
|
assert.equal(point.kind, 'point');
|
|
assert.deepEqual(point.point, [30, 40]);
|
|
const edge = snapDecorPoint([63, 47], geometry, 8, grid);
|
|
assert.equal(edge.kind, 'edge');
|
|
assert.deepEqual(edge.point, [60, 50]);
|
|
|
|
const empty = snapDecorPoint([14, 26], { points: [], segments: [] }, 8, grid);
|
|
assert.deepEqual(empty, { point: [10, 30], target: null, kind: 'grid' });
|
|
const tie = snapDecorPoint([20, 20], {
|
|
points: [[20, 30]], segments: [{ a: [10, 10], b: [30, 10] }],
|
|
}, 10, grid);
|
|
assert.equal(tie.kind, 'point', 'equal-distance point wins deterministically');
|
|
});
|
|
|
|
test('rotated box exposes four rotated content corners around the same centre', () => {
|
|
const corners = boxCorners({ x: 0, y: 0, w: 20, h: 10, angle: 90 });
|
|
assert.deepEqual(corners.map((p) => p.map((v) => Math.round(v))), [[15, -5], [15, 15], [5, 15], [5, -5]]);
|
|
});
|
|
|
|
test('oriented resize caller preserves the derived top-left instead of translating the fixed corner', () => {
|
|
const original = { x: 10, y: 20, w: 40, h: 20, angle: 30 };
|
|
const resized = resizeDecorBox(original, 1, 1, 97, 83, true, 5, 5);
|
|
const fixedBefore = boxCorners(original)[0];
|
|
const fixedAfter = boxCorners(resized)[0];
|
|
close(fixedAfter[0], fixedBefore[0]);
|
|
close(fixedAfter[1], fixedBefore[1]);
|
|
const grid = ([x, y]) => [Math.round(x / 5) * 5, Math.round(y / 5) * 5];
|
|
assert.deepEqual(resizedBoxTopLeft(resized, original.angle, grid), [resized.x, resized.y]);
|
|
assert.deepEqual(resizedBoxTopLeft({ x: 12.2, y: 18.1 }, 0, grid), [10, 20]);
|
|
});
|
|
|
|
test('resize cannot flip across the fixed corner and honours the common grid scale', () => {
|
|
const original = { x: 0, y: 0, w: 40, h: 20, angle: 0 };
|
|
const common = resizeDecorBox(original, 1, 1, 51, 31, true, 10, 10);
|
|
assert.deepEqual([common.w, common.h], [60, 30]);
|
|
assert.equal(common.w % 10, 0);
|
|
assert.equal(common.h % 10, 0);
|
|
|
|
const crossed = resizeDecorBox(original, 1, 1, -100, -100, false, 10, 10);
|
|
assert.deepEqual([crossed.w, crossed.h], [10, 10]);
|
|
assert.deepEqual(boxCorners(crossed)[0], boxCorners(original)[0],
|
|
'opposite corner stays fixed at the minimum-size floor');
|
|
});
|
|
|
|
test('flat rectangle and ellipse drafts are rejected while a straight line is valid', () => {
|
|
assert.equal(validDecorDraft('line', [0, 0], [20, 0], 5), true);
|
|
assert.equal(validDecorDraft('rect', [0, 0], [20, 0], 5), false);
|
|
assert.equal(validDecorDraft('ellipse', [0, 0], [0, 20], 5), false);
|
|
assert.equal(validDecorDraft('rect', [0, 0], [20, 10], 5), true);
|
|
});
|
|
|
|
test('keyboard nudge moves every decor kind by one visible cell without changing other fields', () => {
|
|
const shapes = [
|
|
{ id: 'l', kind: 'line', x1: 0.1013, y1: 0.2, x2: 0.3013, y2: 0.4, color: '#123' },
|
|
{ id: 'r', kind: 'rect', x: 0.1013, y: 0.2, w: 0.2, h: 0.1, angle: 17, fill: true },
|
|
{ id: 'e', kind: 'ellipse', x: 0.1013, y: 0.2, w: 0.2, h: 0.1, angle: -17 },
|
|
{ id: 't', kind: 'text', x: 0.1013, y: 0.2, text: 'Room', size_cm: 12, angle: 45 },
|
|
{ id: 'f', kind: 'furniture', x: 0.1013, y: 0.2, w: 0.2, h: 0.1,
|
|
symbol: 'sofa', flip_h: true, angle: 90 },
|
|
{ id: 'i', kind: 'image', x: 0.1013, y: 0.2, w: 0.2, h: 0.1,
|
|
asset_id: 'abc', flip_v: true, opacity: 0.7 },
|
|
];
|
|
for (const original of shapes) {
|
|
for (const [dx, dy, field, sign] of [
|
|
[-GRID_PITCH, 0, 'x', -1], [GRID_PITCH, 0, 'x', 1],
|
|
[0, -GRID_PITCH, 'y', -1], [0, GRID_PITCH, 'y', 1],
|
|
]) {
|
|
const moved = nudgeDecorShape(original, dx, dy, NORM_W, NORM_W, CANVAS_LIMIT);
|
|
assert.ok(moved, `${original.kind} must move`);
|
|
const before = original.kind === 'line' ? original[`${field}1`] : original[field];
|
|
const after = moved.kind === 'line' ? moved[`${field}1`] : moved[field];
|
|
close(after - before, sign * GRID_STEP_N);
|
|
const restored = structuredClone(moved);
|
|
if (restored.kind === 'line') {
|
|
restored.x1 -= dx / NORM_W; restored.x2 -= dx / NORM_W;
|
|
restored.y1 -= dy / NORM_W; restored.y2 -= dy / NORM_W;
|
|
} else {
|
|
restored.x -= dx / NORM_W; restored.y -= dy / NORM_W;
|
|
}
|
|
assert.deepEqual(restored, original, `${original.kind} changed a non-position field`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('keyboard nudge preserves line geometry and clamps the whole object at canvas bounds', () => {
|
|
const line = { id: 'l', kind: 'line', x1: CANVAS_LIMIT - GRID_STEP_N / 2, y1: 3,
|
|
x2: CANVAS_LIMIT - GRID_STEP_N / 4, y2: 4 };
|
|
const last = nudgeDecorShape(line, GRID_PITCH, 0, NORM_W, NORM_W, CANVAS_LIMIT);
|
|
close(last.x2, CANVAS_LIMIT);
|
|
close(last.x1, line.x1 + GRID_STEP_N / 4);
|
|
close(last.x2 - last.x1, line.x2 - line.x1);
|
|
close(last.y2 - last.y1, line.y2 - line.y1);
|
|
assert.equal(nudgeDecorShape(last, GRID_PITCH, 0, NORM_W, NORM_W, CANVAS_LIMIT), null);
|
|
|
|
const rotatedHalfExtent = Math.SQRT2 * 0.1;
|
|
const rotated = { id: 'r', kind: 'rect',
|
|
x: CANVAS_LIMIT - 0.1 - rotatedHalfExtent - GRID_STEP_N / 2, y: 0,
|
|
w: 0.2, h: 0.2, angle: 45 };
|
|
const corners = boxCorners(rotated);
|
|
const moved = nudgeDecorShape(rotated, GRID_PITCH, 0, NORM_W, NORM_W, CANVAS_LIMIT);
|
|
assert.ok(moved, 'the last step may be shorter than one cell');
|
|
const movedCorners = boxCorners(moved);
|
|
close(Math.max(...movedCorners.map((point) => point[0])), CANVAS_LIMIT);
|
|
for (let i = 0; i < corners.length; i++) {
|
|
close(movedCorners[i][0] - corners[i][0], moved.x - rotated.x);
|
|
close(movedCorners[i][1] - corners[i][1], 0);
|
|
}
|
|
assert.equal(nudgeDecorShape(moved, GRID_PITCH, 0, NORM_W, NORM_W, CANVAS_LIMIT), null);
|
|
});
|