diff --git a/README.md b/README.md index a5b162c..a186b36 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,31 @@ tiles appear at `GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY.png` (z for now — see `worker/src/render/cpu.rs`) and the frontend's Leaflet viewer picks them up automatically from `GET /api/servers`. +## Running tests + +`worker`'s tests (`cargo test`, in `worker/`) are pure unit tests (greedy mesher, tile +rasterizer, block-color palette) and need nothing running. `api`'s and `frontend`'s (`bun test`, +in each directory) are integration tests against real infra — start it first: + +``` +docker run -d --name mcmapper-test-pg -e POSTGRES_USER=mcmapper -e POSTGRES_PASSWORD=mcmapper -e POSTGRES_DB=mcmapper -p 15432:5432 postgres:17-alpine +docker run -d --name mcmapper-test-redis -p 16379:6379 redis:7-alpine +docker run -d --name mcmapper-test-minio -e MINIO_ROOT_USER=mcmapper -e MINIO_ROOT_PASSWORD=mcmapper-dev-only -p 19000:9000 minio/minio:latest server /data +cd api && DATABASE_URL=postgres://mcmapper:mcmapper@localhost:15432/mcmapper bun run migrate +``` + +then, from `api/` or `frontend/`: + +``` +DATABASE_URL=postgres://mcmapper:mcmapper@localhost:15432/mcmapper \ +REDIS_URL=redis://localhost:16379 \ +MINIO_ENDPOINT=localhost MINIO_PORT=19000 MINIO_ACCESS_KEY=mcmapper MINIO_SECRET_KEY=mcmapper-dev-only \ +bun test +``` + +Each test file creates and tears down its own server row (random token per run) so runs never +collide with each other or with real dev data — see `api/src/test-helpers.ts`. + ## Attribution See `THIRD_PARTY_NOTICES.md`. diff --git a/api/package.json b/api/package.json index de86001..fa559b5 100644 --- a/api/package.json +++ b/api/package.json @@ -7,7 +7,8 @@ "dev": "bun run --watch src/index.ts", "start": "bun run src/index.ts", "migrate": "bun run src/db/migrate.ts", - "seed": "bun run scripts/seed-server.ts" + "seed": "bun run scripts/seed-server.ts", + "test": "bun test" }, "dependencies": { "cookie": "^2.0.1", diff --git a/api/src/index.test.ts b/api/src/index.test.ts new file mode 100644 index 0000000..b1bb5b7 --- /dev/null +++ b/api/src/index.test.ts @@ -0,0 +1,128 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { app } from "./index"; +import { db } from "./db/client"; +import { meshPointers, tilePointers } from "./db/schema"; +import { minio, TILE_BUCKET } from "./minio"; +import { createTestServer, deleteTestServer } from "./test-helpers"; + +// Elysia's `.handle()` drives the app in-process against a plain Request/Response, without +// binding a real port — avoids racing a real running instance for the port (see MCMapper's +// memory notes on stale dev-server processes double-binding a port on Windows) and is faster. +function get(path: string) { + return app.handle(new Request(`http://localhost${path}`)); +} + +describe("GET /health", () => { + test("reports ok", async () => { + const res = await get("/health"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); + }); +}); + +describe("GET /api/servers", () => { + let server: { id: string; token: string }; + + beforeAll(async () => { + server = await createTestServer("servers-route"); + }); + + afterAll(async () => { + await deleteTestServer(server.id); + }); + + test("includes the seeded server without leaking its token", async () => { + const res = await get("/api/servers"); + expect(res.status).toBe(200); + const rows = (await res.json()) as any[]; + const found = rows.find((r) => r.id === server.id); + expect(found).toEqual({ id: server.id, name: "test-servers-route" }); + }); +}); + +describe("GET /api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY", () => { + let server: { id: string; token: string }; + const storageKey = "test-tile-object.png"; + const pngBytes = new Uint8Array([1, 2, 3, 4]); + + beforeAll(async () => { + server = await createTestServer("tiles-route"); + await minio.putObject(TILE_BUCKET, storageKey, Buffer.from(pngBytes)); + await db.insert(tilePointers).values({ + serverId: server.id, + dimension: 0, + zoom: 0, + tileX: 5, + tileZ: -3, // stored as chunkZ; the route negates the requested tileY to get here + storageKey, + contentHash: "test-hash", + }); + }); + + afterAll(async () => { + await deleteTestServer(server.id); + await minio.removeObject(TILE_BUCKET, storageKey).catch(() => {}); + }); + + test("negates tileY back to the stored chunkZ and streams the object", async () => { + // chunkZ = -3, so the frontend's tileY (see public/js/map.js) is 3. + const res = await get(`/api/tiles/${server.id}/0/0/5/3.png`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + const body = new Uint8Array(await res.arrayBuffer()); + expect(body).toEqual(pngBytes); + }); + + test("404s for a tile that hasn't been rendered", async () => { + const res = await get(`/api/tiles/${server.id}/0/0/999/999.png`); + expect(res.status).toBe(404); + }); +}); + +describe("GET /api/meshes/...", () => { + let server: { id: string; token: string }; + const storageKey = "test-mesh-object.bin"; + const meshBytes = new Uint8Array([9, 9, 9]); + + beforeAll(async () => { + server = await createTestServer("meshes-route"); + await minio.putObject(TILE_BUCKET, storageKey, Buffer.from(meshBytes)); + await db.insert(meshPointers).values({ + serverId: server.id, + dimension: 0, + x: 2, + z: 3, + sectionY: 4, + storageKey, + contentHash: "test-hash", + }); + }); + + afterAll(async () => { + await deleteTestServer(server.id); + await minio.removeObject(TILE_BUCKET, storageKey).catch(() => {}); + }); + + test("lists the rendered sections for a chunk", async () => { + const res = await get(`/api/meshes/${server.id}/0/2/3`); + expect(await res.json()).toEqual([4]); + }); + + test("lists nothing for a chunk with no meshes yet", async () => { + const res = await get(`/api/meshes/${server.id}/0/999/999`); + expect(await res.json()).toEqual([]); + }); + + test("serves the mesh binary with the right content type", async () => { + const res = await get(`/api/meshes/${server.id}/0/2/3/4.bin`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/octet-stream"); + const body = new Uint8Array(await res.arrayBuffer()); + expect(body).toEqual(meshBytes); + }); + + test("404s for a section that hasn't been rendered", async () => { + const res = await get(`/api/meshes/${server.id}/0/2/3/15.bin`); + expect(res.status).toBe(404); + }); +}); diff --git a/api/src/index.ts b/api/src/index.ts index 94404b0..45e0f50 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -7,7 +7,7 @@ import { minio, TILE_BUCKET, ensureTileBucket } from "./minio"; await ensureTileBucket(); -const app = new Elysia() +export const app = new Elysia() .get("/health", () => ({ status: "ok" })) .get("/api/servers", async () => { const rows = await db.select({ id: servers.id, name: servers.name }).from(servers); @@ -91,7 +91,12 @@ const app = new Elysia() open: wsGateway.open, message: wsGateway.message, close: wsGateway.close, - }) - .listen(Number(process.env.PORT ?? 3000)); + }); -console.log(`[api] listening on :${app.server?.port}`); +// Only bind a real port when run directly (`bun run src/index.ts`) — tests import `app` and +// drive it via `.handle()` instead, so they don't race for a port with a real running instance +// (see MCMapper's memory notes on stale dev-server processes double-binding a port on Windows). +if (import.meta.main) { + app.listen(Number(process.env.PORT ?? 3000)); + console.log(`[api] listening on :${app.server?.port}`); +} diff --git a/api/src/test-helpers.ts b/api/src/test-helpers.ts new file mode 100644 index 0000000..65a15f6 --- /dev/null +++ b/api/src/test-helpers.ts @@ -0,0 +1,38 @@ +import { eq } from "drizzle-orm"; +import { db } from "./db/client"; +import { servers } from "./db/schema"; + +/** + * Integration tests need real Postgres/Redis/MinIO — see README's "Running tests" section for + * how to start them. Each test file creates its own server row with a random token so runs + * never collide with each other or with dev data, and tears it down (which cascades to any + * chunk_columns/chunk_sections/tile_pointers/mesh_pointers rows via their FK's ON DELETE CASCADE). + */ +export async function createTestServer(namePrefix: string) { + const token = `test-${namePrefix}-${crypto.randomUUID()}`; + const [row] = await db.insert(servers).values({ name: `test-${namePrefix}`, token }).returning(); + return row; +} + +export async function deleteTestServer(serverId: string) { + await db.delete(servers).where(eq(servers.id, serverId)); +} + +/** Minimal stand-in for Elysia's WS connection object — enough for wsGateway.message()/close(). */ +export class FakeSocket { + id = crypto.randomUUID(); + sent: string[] = []; + closed = false; + + send(data: string) { + this.sent.push(data); + } + + close() { + this.closed = true; + } + + lastMessage(): any { + return JSON.parse(this.sent[this.sent.length - 1]); + } +} diff --git a/api/src/ws-gateway.test.ts b/api/src/ws-gateway.test.ts new file mode 100644 index 0000000..cc007d4 --- /dev/null +++ b/api/src/ws-gateway.test.ts @@ -0,0 +1,163 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { and, eq } from "drizzle-orm"; +import { db } from "./db/client"; +import { chunkColumns, chunkSections } from "./db/schema"; +import { redis, DIRTY_CHUNK_STREAM } from "./redis"; +import { wsGateway, chunkOf } from "./ws-gateway"; +import { createTestServer, deleteTestServer, FakeSocket } from "./test-helpers"; + +describe("chunkOf", () => { + test("floors toward negative infinity, matching Math.floor(coord/16)", () => { + expect(chunkOf(0)).toBe(0); + expect(chunkOf(15)).toBe(0); + expect(chunkOf(16)).toBe(1); + expect(chunkOf(-1)).toBe(-1); + expect(chunkOf(-16)).toBe(-1); + expect(chunkOf(-17)).toBe(-2); + }); +}); + +describe("wsGateway.message", () => { + let server: { id: string; token: string }; + + beforeAll(async () => { + server = await createTestServer("ws-gateway"); + }); + + afterAll(async () => { + await deleteTestServer(server.id); + }); + + test("hello with a valid token acknowledges with the server id", async () => { + const socket = new FakeSocket(); + await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token })); + expect(socket.closed).toBe(false); + expect(socket.lastMessage()).toEqual({ type: "hello_ack", ok: true, serverId: server.id }); + }); + + test("hello with an invalid token is rejected and the connection closed", async () => { + const socket = new FakeSocket(); + await wsGateway.message(socket, JSON.stringify({ type: "hello", token: "not-a-real-token" })); + expect(socket.closed).toBe(true); + expect(socket.lastMessage()).toEqual({ type: "hello_ack", ok: false, error: "invalid_token" }); + }); + + test("a message before hello is rejected as not_authenticated", async () => { + const socket = new FakeSocket(); + await wsGateway.message(socket, JSON.stringify({ type: "columns", dimension: 0, columns: [] })); + expect(socket.closed).toBe(true); + expect(socket.lastMessage()).toEqual({ type: "error", error: "not_authenticated" }); + }); + + test("invalid JSON gets an error reply, not a thrown exception", async () => { + const socket = new FakeSocket(); + await wsGateway.message(socket, "{not json"); + expect(socket.lastMessage()).toEqual({ type: "error", error: "invalid_json" }); + }); + + test("columns upserts chunk_columns and marks exactly the touched chunks dirty", async () => { + const socket = new FakeSocket(); + await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token })); + + const streamLenBefore = await redis.xlen(DIRTY_CHUNK_STREAM); + + // Two columns in chunk (0,0), one in chunk (1,0) — should dedupe to exactly 2 dirty events. + await wsGateway.message( + socket, + JSON.stringify({ + type: "columns", + dimension: 0, + columns: [ + { x: 1, z: 1, height: 64, blockId: 2, blockMeta: 0 }, + { x: 2, z: 2, height: 65, blockId: 3, blockMeta: 0 }, + { x: 16, z: 1, height: 70, blockId: 1, blockMeta: 0 }, + ], + }), + ); + + const rows = await db + .select() + .from(chunkColumns) + .where(and(eq(chunkColumns.serverId, server.id), eq(chunkColumns.dimension, 0))); + expect(rows).toHaveLength(3); + const byX = Object.fromEntries(rows.map((r) => [r.x, r])); + expect(byX[1]!.blockId).toBe(2); + expect(byX[1]!.height).toBe(64); + expect(byX[16]!.blockId).toBe(1); + + const streamLenAfter = await redis.xlen(DIRTY_CHUNK_STREAM); + expect(streamLenAfter - streamLenBefore).toBe(2); + }); + + test("a repeated column overwrites rather than duplicating", async () => { + const socket = new FakeSocket(); + await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token })); + + const send = (x: number, blockId: number) => + wsGateway.message( + socket, + JSON.stringify({ + type: "columns", + dimension: 0, + columns: [{ x, z: 100, height: 64, blockId, blockMeta: 0 }], + }), + ); + + await send(50, 2); + await send(50, 3); + + const rows = await db + .select() + .from(chunkColumns) + .where(and(eq(chunkColumns.serverId, server.id), eq(chunkColumns.x, 50), eq(chunkColumns.z, 100))); + expect(rows).toHaveLength(1); + expect(rows[0]!.blockId).toBe(3); + }); + + test("sections upserts chunk_sections and marks the chunk dirty", async () => { + const socket = new FakeSocket(); + await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token })); + + const blocks = Buffer.alloc(8192).toString("base64"); // all-air section is fine for this test + const streamLenBefore = await redis.xlen(DIRTY_CHUNK_STREAM); + + await wsGateway.message( + socket, + JSON.stringify({ + type: "sections", + dimension: 0, + chunkX: 9, + chunkZ: -3, + sections: [{ sectionY: 4, blocks }], + }), + ); + + const rows = await db + .select() + .from(chunkSections) + .where( + and( + eq(chunkSections.serverId, server.id), + eq(chunkSections.x, 9), + eq(chunkSections.z, -3), + eq(chunkSections.sectionY, 4), + ), + ); + expect(rows).toHaveLength(1); + expect(rows[0]!.blocks).toBe(blocks); + + const streamLenAfter = await redis.xlen(DIRTY_CHUNK_STREAM); + expect(streamLenAfter - streamLenBefore).toBe(1); + }); + + test("close() forgets the connection's authentication state", async () => { + const socket = new FakeSocket(); + await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token })); + wsGateway.close(socket); + + const socket2 = new FakeSocket(); + socket2.id = socket.id; // simulate the same connection id being reused post-close + await wsGateway.message(socket2, JSON.stringify({ type: "columns", dimension: 0, columns: [] })); + expect(socket2.lastMessage()).toEqual({ type: "error", error: "not_authenticated" }); + }); +}); diff --git a/api/src/ws-gateway.ts b/api/src/ws-gateway.ts index 7562e89..0f24207 100644 --- a/api/src/ws-gateway.ts +++ b/api/src/ws-gateway.ts @@ -45,7 +45,7 @@ interface ConnState { const connections = new Map(); -function chunkOf(coord: number): number { +export function chunkOf(coord: number): number { return Math.floor(coord / 16); } diff --git a/frontend/package.json b/frontend/package.json index cf99022..0bb4e14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "bun run --watch src/index.ts", "start": "bun run src/index.ts", - "build:css": "tailwindcss -i src/public/css/input.css -o src/public/css/tailwind.css" + "build:css": "tailwindcss -i src/public/css/input.css -o src/public/css/tailwind.css", + "test": "bun test" }, "dependencies": { "elysia": "^1.1.0", diff --git a/frontend/src/index.ts b/frontend/src/index.ts index c3f4d9c..1e4db3f 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -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}`); diff --git a/frontend/src/public/js/mesh-format.js b/frontend/src/public/js/mesh-format.js new file mode 100644 index 0000000..9941156 --- /dev/null +++ b/frontend/src/public/js/mesh-format.js @@ -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 }; +} diff --git a/frontend/src/public/js/mesh-format.test.ts b/frontend/src/public/js/mesh-format.test.ts new file mode 100644 index 0000000..8eb6e1a --- /dev/null +++ b/frontend/src/public/js/mesh-format.test.ts @@ -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); + }); +}); diff --git a/frontend/src/public/js/mesh.js b/frontend/src/public/js/mesh.js index 206477c..5fc23d0 100644 --- a/frontend/src/public/js/mesh.js +++ b/frontend/src/public/js/mesh.js @@ -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; diff --git a/frontend/src/views/scene3d.pug b/frontend/src/views/scene3d.pug index 450bf5c..10e0b78 100644 --- a/frontend/src/views/scene3d.pug +++ b/frontend/src/views/scene3d.pug @@ -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") diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 6da78ad..e71999d 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "bundler", "strict": true, "skipLibCheck": true, + "allowJs": true, "types": ["bun-types", "pug"] } } diff --git a/worker/src/palette.rs b/worker/src/palette.rs index 1c62057..19facdf 100644 --- a/worker/src/palette.rs +++ b/worker/src/palette.rs @@ -78,3 +78,58 @@ fn wool_color(meta: u8) -> [u8; 3] { _ => UNKNOWN_COLOR, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn air_is_the_dedicated_air_color() { + assert_eq!(color_for(0, 0), AIR_COLOR); + } + + #[test] + fn known_block_ignores_meta_unless_the_block_uses_it() { + // Stone (id 1) has no meta variants — any meta should map to the same color. + assert_eq!(color_for(1, 0), color_for(1, 15)); + } + + #[test] + fn oak_log_and_planks_differ() { + assert_ne!(color_for(5, 0), color_for(17, 0)); + } + + #[test] + fn planks_vary_by_meta() { + let oak = color_for(5, 0); + let spruce = color_for(5, 1); + let birch = color_for(5, 2); + assert_ne!(oak, spruce); + assert_ne!(oak, birch); + assert_ne!(spruce, birch); + } + + #[test] + fn wool_all_sixteen_colors_are_distinct() { + let colors: Vec<[u8; 3]> = (0..16).map(|meta| color_for(35, meta)).collect(); + for i in 0..colors.len() { + for j in (i + 1)..colors.len() { + assert_ne!(colors[i], colors[j], "wool meta {i} and {j} share a color"); + } + } + } + + #[test] + fn unmapped_block_id_falls_back_to_unknown_color() { + assert_eq!(color_for(9999, 0), UNKNOWN_COLOR); + } + + #[test] + fn unknown_color_is_distinct_from_every_real_terrain_color() { + // The whole point of UNKNOWN_COLOR is to be visually obvious — assert it doesn't + // silently collide with a real block's color and blend in. + for id in [1u16, 2, 3, 4, 7, 8, 12, 17, 24, 41, 49, 87, 121] { + assert_ne!(color_for(id, 0), UNKNOWN_COLOR, "block {id} accidentally matches UNKNOWN_COLOR"); + } + } +} diff --git a/worker/src/render/cpu.rs b/worker/src/render/cpu.rs index a6746a6..b1f01d2 100644 --- a/worker/src/render/cpu.rs +++ b/worker/src/render/cpu.rs @@ -56,3 +56,64 @@ impl RenderBackend for CpuRenderBackend { Ok(bytes) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::palette::color_for; + + fn decode(bytes: &[u8]) -> RgbImage { + image::load_from_memory(bytes).expect("worker must always produce a decodable PNG").to_rgb8() + } + + #[test] + fn empty_columns_produce_a_full_background_tile() { + let backend = CpuRenderBackend::new(); + let bytes = backend.rasterize_tile(&[]).unwrap(); + let img = decode(&bytes); + assert_eq!(img.dimensions(), (256, 256)); + for pixel in img.pixels() { + assert_eq!(*pixel, Rgb([30, 30, 40])); + } + } + + #[test] + fn a_single_column_paints_exactly_its_upscaled_16x16_block() { + let backend = CpuRenderBackend::new(); + let stone = color_for(1, 0); + let columns = vec![ColumnPixel { local_x: 0, local_z: 0, block_id: 1, block_meta: 0 }]; + let img = decode(&backend.rasterize_tile(&columns).unwrap()); + + // Inside the upscaled region for local (0,0): pixels 0..16 on both axes. + assert_eq!(*img.get_pixel(0, 0), Rgb(stone)); + assert_eq!(*img.get_pixel(15, 15), Rgb(stone)); + // Just outside that region must still be background — proves UPSCALE didn't bleed. + assert_eq!(*img.get_pixel(16, 0), Rgb([30, 30, 40])); + assert_eq!(*img.get_pixel(0, 16), Rgb([30, 30, 40])); + } + + #[test] + fn a_full_grid_paints_every_upscaled_pixel() { + let backend = CpuRenderBackend::new(); + let grass = color_for(2, 0); + let mut columns = Vec::with_capacity(256); + for x in 0..16u8 { + for z in 0..16u8 { + columns.push(ColumnPixel { local_x: x, local_z: z, block_id: 2, block_meta: 0 }); + } + } + let img = decode(&backend.rasterize_tile(&columns).unwrap()); + assert_eq!(img.dimensions(), (256, 256)); + for pixel in img.pixels() { + assert_eq!(*pixel, Rgb(grass)); + } + } + + #[test] + fn out_of_bounds_columns_are_ignored_rather_than_panicking() { + let backend = CpuRenderBackend::new(); + let columns = vec![ColumnPixel { local_x: 200, local_z: 0, block_id: 1, block_meta: 0 }]; + let result = backend.rasterize_tile(&columns); + assert!(result.is_ok()); + } +}