jev-8ball/test/frontend.test.mjs

203 lines
8 KiB
JavaScript

/**
* Unit tests for the hand-rolled 3D layer. These are pure functions, so they run in
* Node with no browser: `npm test`.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { clipPolygonBelow, wrapAnswer } from '../public/js/ball3d.js';
import {
affineFromThreePoints,
clamp,
cross,
dot,
hexToRgb,
mat4LookAt,
mat4Mul,
mat4Perspective,
mat4RotateZ,
mat4Translate,
mixRgb,
normalize,
projectToScreen,
rgbCss,
smoothstep,
transformDir,
transformPoint
} from '../public/js/math3d.js';
import { createPlate, createVessel, hexagon } from '../public/js/meshes.js';
const near = (a, b, tolerance = 1e-5) => Math.abs(a - b) <= tolerance;
const nearVec = (a, b, tolerance = 1e-5) =>
a.every((value, index) => near(value, b[index], tolerance));
test('vector helpers behave', () => {
assert.ok(nearVec(normalize([3, 0, 0]), [1, 0, 0]));
assert.ok(nearVec(normalize([0, 0, 0]), [0, 0, 0]));
assert.equal(dot([1, 2, 3], [4, -5, 6]), 12);
assert.ok(nearVec(cross([1, 0, 0], [0, 1, 0]), [0, 0, 1]));
assert.equal(clamp(5, 0, 1), 1);
assert.equal(smoothstep(0, 1, 0.5), 0.5);
assert.equal(smoothstep(0, 1, -3), 0);
assert.equal(smoothstep(0, 1, 3), 1);
assert.deepEqual(hexToRgb('#10b981'), [16, 185, 129]);
assert.equal(rgbCss([300, -5, 10.4]), 'rgb(255,0,10)');
assert.equal(rgbCss([10, 20, 30], 0.5), 'rgba(10,20,30,0.5)');
assert.ok(nearVec(mixRgb([0, 0, 0], [10, 20, 30], 0.5), [5, 10, 15]));
});
test('matrices compose in the documented order', () => {
const translated = mat4Mul(mat4Translate(1, 2, 3), mat4Translate(10, 20, 30));
assert.ok(nearVec(transformPoint(translated, [0, 0, 0]).slice(0, 3), [11, 22, 33]));
const rotated = mat4RotateZ(Math.PI / 2);
assert.ok(nearVec(transformPoint(rotated, [1, 0, 0]).slice(0, 3), [0, 1, 0], 1e-6));
assert.ok(nearVec(transformDir(rotated, [1, 0, 0]), [0, 1, 0], 1e-6));
});
test('camera projection keeps the vessel inside the viewport', () => {
const view = mat4LookAt([0, 0, 4.3], [0, 0, 0], [0, 1, 0]);
const projection = mat4Mul(mat4Perspective((36 * Math.PI) / 180, 1, 0.1, 40), view);
const size = 344;
const centre = projectToScreen(transformPoint(projection, [0, 0, 0]), size, size);
assert.ok(near(centre.x, size / 2) && near(centre.y, size / 2));
// A point on the vessel's silhouette should land at ~72% of the half-height, which
// is what leaves room for the shake and the cast shadow.
const edge = projectToScreen(transformPoint(projection, [1, 0, 0]), size, size);
const fraction = (edge.x - size / 2) / (size / 2);
assert.ok(fraction > 0.68 && fraction < 0.76, `silhouette fraction was ${fraction}`);
// Far side of the vessel must sort behind the origin for the painter's algorithm,
// and perspective must foreshorten the same height as depth increases.
const back = projectToScreen(transformPoint(projection, [0, 0, -1]), size, size);
assert.ok(back.depth > centre.depth, 'NDC depth must grow with distance');
const nearTop = projectToScreen(transformPoint(projection, [0, 1, 0]), size, size);
const farTop = projectToScreen(transformPoint(projection, [0, 1, -1]), size, size);
assert.ok(nearTop.y < size / 2, 'a raised point must project above the centre row');
assert.ok(farTop.y > nearTop.y, 'the same height must foreshorten when it is further away');
});
test('affine solve maps local points onto screen points', () => {
const matrix = affineFromThreePoints(
{ local: [0, 0], screen: [10, 20] },
{ local: [1, 0], screen: [12, 20] },
{ local: [0, 1], screen: [10, 22] }
);
assert.ok(nearVec(matrix, [2, 0, 0, 2, 10, 20]));
// Degenerate (collinear) inputs must not produce a usable transform.
assert.equal(
affineFromThreePoints(
{ local: [0, 0], screen: [0, 0] },
{ local: [1, 1], screen: [1, 1] },
{ local: [2, 2], screen: [2, 2] }
),
null
);
});
test('hexagon vertices are point-up and evenly spaced', () => {
const points = hexagon(2);
assert.equal(points.length, 6);
assert.ok(nearVec(points[0], [0, 2], 1e-9));
for (const point of points) {
assert.ok(near(Math.hypot(point[0], point[1]), 2));
}
// Neighbouring vertices must be exactly one radius apart on a regular hexagon.
assert.ok(near(Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]), 2));
});
test('vessel mesh is closed, outward-facing, and unit-normalled', () => {
const { mesh, window: opening } = createVessel();
assert.equal(mesh.vertices.length, 18);
assert.equal(mesh.faces.length, 13); // back cap + 6 sides + 6 frame pieces
for (const face of mesh.faces) {
assert.ok(face.indices.length >= 3 && face.indices.length <= 6);
for (const index of face.indices) {
assert.ok(index >= 0 && index < mesh.vertices.length, 'face index out of range');
}
assert.ok(near(Math.hypot(...face.normal), 1), 'normal must be unit length');
}
// Side facets must face away from the axis, caps must face along ±z.
for (const face of mesh.faces) {
if (face.kind === 'side') {
const centre = face.indices
.map((index) => mesh.vertices[index])
.reduce((sum, vertex) => [sum[0] + vertex[0], sum[1] + vertex[1]], [0, 0]);
assert.ok(dot(face.normal, [centre[0], centre[1], 0]) > 0, 'side normal points inward');
}
if (face.kind === 'back') assert.deepEqual(face.normal, [0, 0, -1]);
if (face.kind === 'ring') assert.deepEqual(face.normal, [0, 0, 1]);
}
// The opening is smaller than the body and sits on the front plane.
assert.ok(opening.radius > 0 && opening.radius < 1);
assert.ok(opening.z > 0);
assert.equal(opening.points.length, 6);
});
test('plate mesh exposes an ordered answer face for text projection', () => {
const plate = createPlate();
assert.equal(plate.mesh.vertices.length, 12);
assert.equal(plate.mesh.faces.length, 8); // 8 faces, like the eight-ball
const answer = plate.mesh.faces.find((face) => face.kind === 'answer');
assert.deepEqual(answer.normal, [0, 0, 1]);
assert.equal(answer.indices.length, 6);
// _drawAnswerText pairs face.points[i] with plate.points[i], so vertex order matters.
answer.indices.forEach((index, position) => {
const vertex = plate.mesh.vertices[index];
const point = plate.points[position];
assert.ok(near(vertex[0], point[0]), `x mismatch at ${position}`);
assert.ok(near(vertex[1], point[1]), `y mismatch at ${position}`);
assert.ok(near(vertex[2], plate.thickness / 2));
});
});
test('clipPolygonBelow keeps only the part under the surface line', () => {
const square = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 }
];
const clipped = clipPolygonBelow(square, { x: 0, y: 5 }, { x: 10, y: 5 });
assert.ok(clipped.length >= 3);
for (const point of clipped) {
assert.ok(point.y >= 5 - 1e-9, `point above the surface survived: ${point.y}`);
}
let area = 0;
for (let i = 0; i < clipped.length; i += 1) {
const current = clipped[i];
const next = clipped[(i + 1) % clipped.length];
area += current.x * next.y - next.x * current.y;
}
assert.ok(near(Math.abs(area / 2), 50, 1e-6), `half the square should remain, got ${area / 2}`);
// A line entirely below the polygon leaves the polygon untouched.
assert.equal(clipPolygonBelow(square, { x: 0, y: 50 }, { x: 10, y: 50 }).length, 0);
assert.equal(clipPolygonBelow(square, { x: 0, y: -10 }, { x: 10, y: -10 }).length, 4);
});
test('wrapAnswer breaks verdicts into plate-sized lines', () => {
assert.deepEqual(wrapAnswer('It is certain.'), ['IT IS', 'CERTAIN.']);
assert.deepEqual(wrapAnswer('My sources say no.'), ['MY SOURCES', 'SAY NO.']);
assert.deepEqual(wrapAnswer('Very doubtful.'), ['VERY', 'DOUBTFUL.']);
assert.deepEqual(wrapAnswer('Concentrate and ask again.'), [
'CONCENTRATE',
'AND ASK',
'AGAIN.'
]);
assert.ok(wrapAnswer('').length >= 1);
assert.ok(wrapAnswer(' ').length >= 1);
const long = wrapAnswer('a really extremely unreasonably long prophecy string');
assert.ok(long.length <= 3, 'never more than three lines');
for (const line of long) {
assert.ok(line.length <= 20, `line too long for the plate: ${line}`);
}
});