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); });