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:
@@ -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 };
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
Reference in New Issue
Block a user