| 1 | // canvas.mjs's maths: boxes as frame fractions, clamped to the frame, |
| 2 | // snapped to the thirds and to the other blocks' edges within one per |
| 3 | // cent, moved and resized by handle, nudged by key, and written with |
| 4 | // four decimals. |
| 5 | import test from 'node:test' |
| 6 | import assert from 'node:assert/strict' |
| 7 | import { |
| 8 | clampBox, fixBox, guides, snapMove, snapResize, moveBox, resizeBox, nudge, rectBox, |
| 9 | MIN_SIZE, NUDGE, NUDGE_BIG |
| 10 | } from './canvas.mjs' |
| 11 | |
| 12 | const near = (a, b, eps = 1e-9) => Math.abs(a - b) < eps |
| 13 | const boxNear = (got, want) => { |
| 14 | for (const k of ['x', 'y', 'w', 'h']) assert.ok(near(got[k], want[k]), `${k}: ${got[k]} vs ${want[k]}`) |
| 15 | } |
| 16 | |
| 17 | test('clampBox keeps the box inside the frame and no smaller than the minimum', () => { |
| 18 | assert.deepEqual(clampBox({ x: -0.2, y: 0.5, w: 0.3, h: 0.2 }), { x: 0, y: 0.5, w: 0.3, h: 0.2 }) |
| 19 | assert.deepEqual(clampBox({ x: 0.9, y: 0.95, w: 0.3, h: 0.2 }), { x: 0.7, y: 0.8, w: 0.3, h: 0.2 }) |
| 20 | assert.deepEqual(clampBox({ x: 0.1, y: 0.1, w: 0, h: 5 }), { x: 0.1, y: 0, w: MIN_SIZE, h: 1 }) |
| 21 | }) |
| 22 | |
| 23 | test('fixBox writes four decimals', () => { |
| 24 | assert.deepEqual(fixBox({ x: 0.123456, y: 0.1, w: 0.33333333, h: 0.2 }), { x: 0.1235, y: 0.1, w: 0.3333, h: 0.2 }) |
| 25 | assert.deepEqual(fixBox({ x: 1.2, y: 0, w: 0.5, h: 0.5 }), { x: 0.5, y: 0, w: 0.5, h: 0.5 }) |
| 26 | }) |
| 27 | |
| 28 | test('guides are the thirds plus every other block edge', () => { |
| 29 | const g = guides([{ x: 0.1, y: 0.2, w: 0.3, h: 0.4 }]) |
| 30 | assert.deepEqual(g.x.sort(), [0, 1 / 3, 2 / 3, 1, 0.1, 0.4].sort()) |
| 31 | assert.deepEqual(g.y.sort(), [0, 1 / 3, 2 / 3, 1, 0.2, 0.6000000000000001].sort()) |
| 32 | assert.equal(guides([]).x.length, 4) |
| 33 | }) |
| 34 | |
| 35 | test('snapMove pulls the nearest edge onto a guide within one per cent, and leaves a far one', () => { |
| 36 | const g = guides([{ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }]) |
| 37 | // left edge 0.008 from the frame's left → 0 |
| 38 | boxNear(snapMove({ x: 0.008, y: 0.4, w: 0.2, h: 0.1 }, g), { x: 0, y: 0.4, w: 0.2, h: 0.1 }) |
| 39 | // right edge 0.295 → the other block's left at 0.5 is too far; the third at 1/3 is 0.038 away — no snap |
| 40 | boxNear(snapMove({ x: 0.095, y: 0.4, w: 0.2, h: 0.1 }, g), { x: 0.095, y: 0.4, w: 0.2, h: 0.1 }) |
| 41 | // right edge at 0.495 → snaps to the other block's left edge, moving the whole box |
| 42 | boxNear(snapMove({ x: 0.295, y: 0.4, w: 0.2, h: 0.1 }, g), { x: 0.3, y: 0.4, w: 0.2, h: 0.1 }) |
| 43 | // top edge 0.505 → the other block's top at 0.5 |
| 44 | boxNear(snapMove({ x: 0.1, y: 0.505, w: 0.2, h: 0.1 }, g), { x: 0.1, y: 0.5, w: 0.2, h: 0.1 }) |
| 45 | // the closer of two candidate edges wins: left at 0.009 from 0, right at 0.004 from 1/3 |
| 46 | const both = snapMove({ x: 0.009, y: 0, w: 1 / 3 - 0.005, h: 0.1 }, g) |
| 47 | boxNear(both, { x: 1 / 3 - (1 / 3 - 0.005), y: 0, w: 1 / 3 - 0.005, h: 0.1 }) |
| 48 | }) |
| 49 | |
| 50 | test('snapResize moves only the edges the handle drags', () => { |
| 51 | const g = guides([]) |
| 52 | // east handle: right edge 0.66 → 2/3, left stays |
| 53 | boxNear(snapResize({ x: 0.2, y: 0.2, w: 0.46, h: 0.2 }, 'e', g), { x: 0.2, y: 0.2, w: 2 / 3 - 0.2, h: 0.2 }) |
| 54 | // west handle: left edge 0.338 → 1/3, right edge stays at 0.8 |
| 55 | boxNear(snapResize({ x: 0.338, y: 0.2, w: 0.462, h: 0.2 }, 'w', g), { x: 1 / 3, y: 0.2, w: 0.8 - 1 / 3, h: 0.2 }) |
| 56 | // north-west: both leading edges |
| 57 | boxNear(snapResize({ x: 0.005, y: 0.338, w: 0.3, h: 0.5 }, 'nw', g), { x: 0, y: 1 / 3, w: 0.305, h: 0.5 + 0.338 - 1 / 3 }) |
| 58 | // south handle never touches x |
| 59 | boxNear(snapResize({ x: 0.005, y: 0.2, w: 0.3, h: 0.795 }, 's', g), { x: 0.005, y: 0.2, w: 0.3, h: 0.8 }) |
| 60 | }) |
| 61 | |
| 62 | test('moveBox and resizeBox by handle', () => { |
| 63 | assert.deepEqual(moveBox({ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }, 0.05, -0.05), { x: 0.15000000000000002, y: 0.05, w: 0.2, h: 0.2 }) |
| 64 | boxNear(resizeBox({ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }, 'se', 0.1, 0.1), { x: 0.1, y: 0.1, w: 0.3, h: 0.3 }) |
| 65 | boxNear(resizeBox({ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }, 'nw', 0.05, 0.05), { x: 0.15, y: 0.15, w: 0.15, h: 0.15 }) |
| 66 | boxNear(resizeBox({ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }, 'n', 0, -0.1), { x: 0.1, y: 0, w: 0.2, h: 0.3 }) |
| 67 | boxNear(resizeBox({ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }, 'w', 0.1, 0), { x: 0.2, y: 0.1, w: 0.1, h: 0.2 }) |
| 68 | // A handle dragged past the opposite edge stops at the minimum size. |
| 69 | boxNear(resizeBox({ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }, 'e', -0.5, 0), { x: 0.1, y: 0.1, w: MIN_SIZE, h: 0.2 }) |
| 70 | boxNear(resizeBox({ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }, 'w', 0.5, 0), { x: 0.1 + 0.2 - MIN_SIZE, y: 0.1, w: MIN_SIZE, h: 0.2 }) |
| 71 | }) |
| 72 | |
| 73 | test('nudge: arrows move by half a per cent, Shift by two, Alt resizes', () => { |
| 74 | const b = { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } |
| 75 | boxNear(nudge(b, 'ArrowRight'), { ...b, x: 0.1 + NUDGE }) |
| 76 | boxNear(nudge(b, 'ArrowUp', { shift: true }), { ...b, y: 0.1 - NUDGE_BIG }) |
| 77 | boxNear(nudge(b, 'ArrowDown', { alt: true }), { ...b, h: 0.2 + NUDGE }) |
| 78 | boxNear(nudge(b, 'ArrowLeft', { alt: true, shift: true }), { ...b, w: 0.2 - NUDGE_BIG }) |
| 79 | assert.equal(nudge(b, 'Enter'), null) |
| 80 | }) |
| 81 | |
| 82 | test('rectBox reads a block rectangle against its frame', () => { |
| 83 | const frame = { left: 100, top: 50, width: 800, height: 450 } |
| 84 | boxNear(rectBox(frame, { left: 180, top: 95, width: 400, height: 90 }), { x: 0.1, y: 0.1, w: 0.5, h: 0.2 }) |
| 85 | assert.deepEqual(rectBox({ left: 0, top: 0, width: 0, height: 0 }, { left: 0, top: 0, width: 1, height: 1 }), { x: 0, y: 0, w: 0, h: 0 }) |
| 86 | }) |
| 87 | |