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