Add test coverage retrofit for Phase 1/2 (worker, api, frontend)

Requested after Phase 2: from here on, MCMapper development follows
TDD (test-first) — this retrofits the pieces already built before that
request landed.

worker: unit tests for the tile rasterizer (background fill, exact
upscaled-block boundaries, full-grid painting, out-of-bounds columns) and
the block-color palette (distinctness checks, including that the
"unmapped block" placeholder never accidentally collides with a real
block's color). 16 tests total alongside the existing mesher tests.

api: wired up `bun test`. Unit tests for chunkOf's coordinate math.
Integration tests (real Postgres/Redis/MinIO, see README's new "Running
tests" section) for wsGateway.message() — auth accept/reject, upsert +
dedup on columns/sections, not-authenticated/invalid-JSON handling — and
for the tile/mesh/servers HTTP routes, driven through Elysia's in-process
`.handle()` rather than a bound port (sidesteps the stale dev-server
port-collision issue hit repeatedly this session). index.ts now exports
`app` and only calls `.listen()` when run directly, specifically so tests
can drive it this way.

frontend: extracted mesh.js's binary-format parser into its own ESM
module (mesh-format.js) so it's unit-testable without a browser/Babylon;
mesh.js now imports it. Tests build a buffer independently of the parser
(mirroring worker's encoder layout) so a mismatch in either direction —
Rust producer or JS consumer drifting — would be caught.
This commit is contained in:
2026-08-08 16:39:27 +02:00
parent 5ed4d32a56
commit dc7185c15e
16 changed files with 623 additions and 38 deletions
+1
View File
@@ -14,6 +14,7 @@ const app = new Elysia()
.get("/css/tailwind.css", () => Bun.file(join(import.meta.dir, "public/css/tailwind.css")))
.get("/js/map.js", () => Bun.file(join(import.meta.dir, "public/js/map.js")))
.get("/js/mesh.js", () => Bun.file(join(import.meta.dir, "public/js/mesh.js")))
.get("/js/mesh-format.js", () => Bun.file(join(import.meta.dir, "public/js/mesh-format.js")))
.listen(Number(process.env.PORT ?? 3001));
console.log(`[frontend] listening on :${app.server?.port}`);
+32
View File
@@ -0,0 +1,32 @@
// Binary mesh format written by worker/src/mesh.rs's MeshBuffers::encode():
// u32 vertexCount, u32 indexCount,
// f32[vertexCount*3] positions, f32[vertexCount*3] normals, f32[vertexCount*3] colors,
// u32[indexCount] indices — all little-endian.
//
// Pulled into its own module (rather than living inline in mesh.js) so it can be unit tested
// without a browser/Babylon — see mesh-format.test.ts.
export function parseMeshBuffer(buf) {
const view = new DataView(buf);
const vertexCount = view.getUint32(0, true);
const indexCount = view.getUint32(4, true);
let offset = 8;
const positions = new Float32Array(buf, offset, vertexCount * 3);
offset += vertexCount * 3 * 4;
const normals = new Float32Array(buf, offset, vertexCount * 3);
offset += vertexCount * 3 * 4;
const rgb = new Float32Array(buf, offset, vertexCount * 3);
offset += vertexCount * 3 * 4;
const indices = new Uint32Array(buf, offset, indexCount);
// Babylon's VertexData.colors wants RGBA.
const colors = new Float32Array(vertexCount * 4);
for (let i = 0; i < vertexCount; i++) {
colors[i * 4] = rgb[i * 3];
colors[i * 4 + 1] = rgb[i * 3 + 1];
colors[i * 4 + 2] = rgb[i * 3 + 2];
colors[i * 4 + 3] = 1;
}
return { positions, normals, colors, indices };
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, test, expect } from "bun:test";
import { parseMeshBuffer } from "./mesh-format";
// Builds a buffer matching worker/src/mesh.rs's MeshBuffers::encode() layout, independent of
// parseMeshBuffer itself, so these tests catch a mismatch in either direction (Rust producer
// drifting from JS consumer, or vice versa) rather than just testing the parser against its own
// assumptions.
function buildMeshBuffer(positions: number[][], normals: number[][], colors: number[][], indices: number[]): ArrayBuffer {
const vertexCount = positions.length;
const indexCount = indices.length;
const buf = new ArrayBuffer(8 + vertexCount * 36 + indexCount * 4);
const view = new DataView(buf);
view.setUint32(0, vertexCount, true);
view.setUint32(4, indexCount, true);
let offset = 8;
for (const [x, y, z] of positions) {
view.setFloat32(offset, x, true);
view.setFloat32(offset + 4, y, true);
view.setFloat32(offset + 8, z, true);
offset += 12;
}
for (const [x, y, z] of normals) {
view.setFloat32(offset, x, true);
view.setFloat32(offset + 4, y, true);
view.setFloat32(offset + 8, z, true);
offset += 12;
}
for (const [r, g, b] of colors) {
view.setFloat32(offset, r, true);
view.setFloat32(offset + 4, g, true);
view.setFloat32(offset + 8, b, true);
offset += 12;
}
for (const i of indices) {
view.setUint32(offset, i, true);
offset += 4;
}
return buf;
}
describe("parseMeshBuffer", () => {
test("parses an empty mesh", () => {
const buf = buildMeshBuffer([], [], [], []);
const { positions, normals, colors, indices } = parseMeshBuffer(buf);
expect(positions.length).toBe(0);
expect(normals.length).toBe(0);
expect(colors.length).toBe(0);
expect(indices.length).toBe(0);
});
test("parses positions, normals, and indices unchanged", () => {
const buf = buildMeshBuffer(
[
[0, 0, 0],
[16, 0, 0],
[16, 16, 0],
],
[
[0, 1, 0],
[0, 1, 0],
[0, 1, 0],
],
[
[1, 0, 0],
[1, 0, 0],
[1, 0, 0],
],
[0, 1, 2],
);
const { positions, normals, indices } = parseMeshBuffer(buf);
expect(Array.from(positions)).toEqual([0, 0, 0, 16, 0, 0, 16, 16, 0]);
expect(Array.from(normals)).toEqual([0, 1, 0, 0, 1, 0, 0, 1, 0]);
expect(Array.from(indices)).toEqual([0, 1, 2]);
});
test("expands RGB colors to RGBA with alpha 1", () => {
const buf = buildMeshBuffer([[0, 0, 0]], [[0, 1, 0]], [[0.5, 0.25, 0.75]], [0]);
const { colors } = parseMeshBuffer(buf);
expect(Array.from(colors)).toEqual([0.5, 0.25, 0.75, 1]);
});
test("colors array length is 4x vertex count, not 3x", () => {
const buf = buildMeshBuffer(
[
[0, 0, 0],
[1, 1, 1],
],
[
[0, 1, 0],
[0, 1, 0],
],
[
[1, 0, 0],
[0, 1, 0],
],
[0, 1],
);
const { colors, positions } = parseMeshBuffer(buf);
expect(colors.length).toBe((positions.length / 3) * 4);
});
});
+2 -30
View File
@@ -2,39 +2,11 @@
// at startup — no camera-based dynamic streaming/culling yet, that's a natural follow-up once
// there's a reason to care about performance at scale. Dimension is hardcoded to 0 (overworld),
// matching the 2D map's assumption (see public/js/map.js).
import { parseMeshBuffer } from "./mesh-format.js";
const DIMENSION = 0;
const CHUNK_RADIUS = 2; // (2*2+1)^2 = 25 chunks
// Binary mesh format written by worker/src/mesh.rs's MeshBuffers::encode():
// u32 vertexCount, u32 indexCount,
// f32[vertexCount*3] positions, f32[vertexCount*3] normals, f32[vertexCount*3] colors,
// u32[indexCount] indices — all little-endian.
function parseMeshBuffer(buf) {
const view = new DataView(buf);
const vertexCount = view.getUint32(0, true);
const indexCount = view.getUint32(4, true);
let offset = 8;
const positions = new Float32Array(buf, offset, vertexCount * 3);
offset += vertexCount * 3 * 4;
const normals = new Float32Array(buf, offset, vertexCount * 3);
offset += vertexCount * 3 * 4;
const rgb = new Float32Array(buf, offset, vertexCount * 3);
offset += vertexCount * 3 * 4;
const indices = new Uint32Array(buf, offset, indexCount);
// Babylon's VertexData.colors wants RGBA.
const colors = new Float32Array(vertexCount * 4);
for (let i = 0; i < vertexCount; i++) {
colors[i * 4] = rgb[i * 3];
colors[i * 4 + 1] = rgb[i * 3 + 1];
colors[i * 4 + 2] = rgb[i * 3 + 2];
colors[i * 4 + 3] = 1;
}
return { positions, normals, colors, indices };
}
async function loadSectionMesh(scene, serverId, chunkX, chunkZ, sectionY) {
const res = await fetch(`/api/meshes/${serverId}/${DIMENSION}/${chunkX}/${chunkZ}/${sectionY}.bin`);
if (!res.ok) return;
+1 -1
View File
@@ -15,4 +15,4 @@ html(lang="en")
a.text-sm.text-neutral-400.underline(href="/") back to 2D map
span#status.text-sm.text-neutral-500 loading…
canvas#renderCanvas.flex-1
script(src="/js/mesh.js")
script(type="module" src="/js/mesh.js")