Fail fast on missing terrain assets and recover empty renders

This commit is contained in:
2026-10-04 22:32:11 +05:00
parent 79d0f53a79
commit 763cf8713c
15 changed files with 842 additions and 21 deletions

View File

@@ -0,0 +1,141 @@
import { test, type TestContext } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { resolve, sep } from "node:path";
import { createHash } from "node:crypto";
import { zipSync } from "fflate";
import sharp from "sharp";
import { prepareAssets, assetsRoot } from "../scripts/assets.mjs";
const png = await sharp({
create: { width: 16, height: 16, channels: 3, background: "#719447" },
})
.png()
.toBuffer();
const json = Buffer.from('{"variants":{},"elements":[],"textures":{}}');
const files = {
"blockstates/grass_block.json": json,
"blockstates/stone.json": json,
"models/block/block.json": json,
"models/block/cube.json": json,
"models/block/cube_all.json": json,
"textures/block/grass_block_top.png": png,
"textures/block/dirt.png": png,
"textures/block/stone.png": png,
"textures/block/water_still.png": png,
};
const jar = zipSync(
Object.fromEntries(
Object.entries(files).map(([path, bytes]) => [
`assets/minecraft/${path}`,
bytes,
]),
),
);
const checksum = createHash("sha1").update(jar).digest("hex");
async function isolatedCache(t: TestContext, corruptDownload = false) {
const previous = process.cwd();
const cacheParent = resolve(".cache");
await mkdir(cacheParent, { recursive: true });
const directory = await mkdtemp(`${cacheParent}/asset-tests-`);
process.chdir(directory);
t.after(async () => {
process.chdir(previous);
assert.ok(resolve(directory).startsWith(cacheParent + sep));
await rm(directory, { recursive: true, force: true });
});
const requests: string[] = [];
t.mock.method(globalThis, "fetch", async (input: string | URL | Request) => {
const url = String(input);
requests.push(url);
if (url.includes("version_manifest_v2"))
return Response.json({
versions: [{ id: "1.21.11", url: "https://fixture.invalid/version" }],
});
if (url.endsWith("/version"))
return Response.json({
downloads: {
client: { url: "https://fixture.invalid/client.jar", sha1: checksum },
},
});
if (url.endsWith("/client.jar"))
return new Response(
Buffer.from(corruptDownload ? "incomplete download" : jar),
);
throw new Error(`Unexpected request: ${url}`);
});
return requests;
}
test("missing vanilla cache is prepared completely and subsequent runs work offline", async (t) => {
const requests = await isolatedCache(t);
await prepareAssets();
assert.equal(requests.length, 3);
for (const [path, expected] of Object.entries(files)) {
assert.deepEqual(
await readFile(`${assetsRoot}/assets/minecraft/${path}`),
expected,
);
}
const marker = JSON.parse(
await readFile(`${assetsRoot}/.minegeo-assets.json`, "utf8"),
);
assert.equal(marker.files, Object.keys(files).length);
assert.equal(marker.clientSha1, checksum);
requests.length = 0;
await prepareAssets();
assert.deepEqual(requests, []);
});
test("an interrupted extraction with only a blockstate cannot masquerade as complete", async (t) => {
await isolatedCache(t);
await mkdir(`${assetsRoot}/assets/minecraft/blockstates`, {
recursive: true,
});
await writeFile(
`${assetsRoot}/assets/minecraft/blockstates/grass_block.json`,
json,
);
await prepareAssets();
assert.deepEqual(
await readFile(`${assetsRoot}/assets/minecraft/textures/block/stone.png`),
png,
);
});
test("a complete marker with a missing texture is repaired from the verified cached JAR", async (t) => {
const requests = await isolatedCache(t);
await prepareAssets();
const path = `${assetsRoot}/assets/minecraft/textures/block/stone.png`;
await rm(path);
requests.length = 0;
await prepareAssets();
assert.deepEqual(await readFile(path), png);
assert.equal(requests.filter((url) => url.endsWith("client.jar")).length, 0);
});
test("a corrupted cached client is downloaded again", async (t) => {
const requests = await isolatedCache(t);
await mkdir(".cache/minecraft", { recursive: true });
await writeFile(".cache/minecraft/client-1.21.11.jar", "corrupted");
await prepareAssets();
assert.equal(requests.filter((url) => url.endsWith("client.jar")).length, 1);
assert.equal(
createHash("sha1")
.update(await readFile(".cache/minecraft/client-1.21.11.jar"))
.digest("hex"),
checksum,
);
});
test("a failed checksum never creates a completion marker or saves a downloaded client", async (t) => {
await isolatedCache(t, true);
await assert.rejects(prepareAssets(), /checksum mismatch/);
await assert.rejects(readFile(`${assetsRoot}/.minegeo-assets.json`), {
code: "ENOENT",
});
await assert.rejects(readFile(".cache/minecraft/client-1.21.11.jar"), {
code: "ENOENT",
});
});

View File

@@ -0,0 +1,79 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import sharp from "sharp";
import { OpaqueIndex, QuadBuffer } from "../renderer/mesh-buffers.mjs";
import { assertTerrainImage } from "../renderer/image-validation.mjs";
test("opaque sections preserve negative coordinates and do not alias adjacent boundaries", () => {
const index = new OpaqueIndex(
{ minX: -32, minZ: -32, maxX: 32, maxZ: 32 },
-32,
32,
);
const expected = new Set<string>();
for (let x = -32; x <= 32; x++) {
for (let y = -32; y <= 32; y++) {
for (let z = -32; z <= 32; z++) {
if ((x * 3 + y * 7 + z * 11) % 23 === 0) {
index.add(x, y, z);
expected.add(`${x},${y},${z}`);
}
}
}
}
for (let x = -33; x <= 49; x++) {
for (let y = -33; y <= 49; y++) {
for (let z = -33; z <= 49; z++) {
assert.equal(index.has(x, y, z), expected.has(`${x},${y},${z}`));
}
}
}
index.clear();
assert.equal(index.has(0, 0, 0), false);
});
test("geometry batches retain every quad through growth with valid triangle indices and bounds", () => {
const buffer = new QuadBuffer();
const face = {
positions: new Float64Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0]),
uvs: new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
normal: [0, 0, -1],
};
for (let i = 0; i < 4096; i++) buffer.add(face, i - 2048, 64, -1);
assert.equal(buffer.full, true);
assert.throws(() => buffer.add(face, 0, 0, 0), /full/);
const data = buffer.finish();
assert.equal(data.positions.length, 4096 * 12);
assert.equal(data.indices.length, 4096 * 6);
assert.equal(Math.max(...data.indices), 4096 * 4 - 1);
assert.deepEqual(
[...data.indices.slice(-6)],
[16380, 16381, 16382, 16382, 16381, 16383],
);
assert.deepEqual(data.min, [-2048, 64, -1]);
assert.deepEqual(data.max, [2048, 65, -1]);
});
test("sky-only WebP output is rejected instead of becoming a playable draft", async () => {
const sky = await sharp({
create: { width: 128, height: 128, channels: 3, background: "#95c4eb" },
})
.webp({ quality: 85 })
.toBuffer();
await assert.rejects(
assertTerrainImage(sky, "Surface map"),
/contains only sky/,
);
});
test("flat textured terrain is accepted even when its colors are nearly uniform", async () => {
const terrain = await sharp({
create: { width: 128, height: 128, channels: 3, background: "#719447" },
})
.webp()
.toBuffer();
assert.equal(
await assertTerrainImage(terrain, "Panorama p0, downward face"),
64 * 64,
);
});

144
tests/reprocess.test.ts Normal file
View File

@@ -0,0 +1,144 @@
import { test, type TestContext } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { resolve, sep } from "node:path";
const script = resolve("scripts/reprocess.mjs");
const id = "00000000-0000-4000-8000-000000000001";
const baseDataset = {
id,
source: "fabric",
state: "review",
revision_id: null,
rejection_reason: null,
map_path: "old-map.webp",
cover_path: "old-cover.webp",
panorama_count: 12,
};
const baseJob = {
id: "00000000-0000-4000-8000-000000000002",
state: "complete",
updated_at: "2026-10-04T12:00:00Z",
attempts: 3,
progress: 100,
error: null,
};
async function run(
t: TestContext,
options: {
published?: boolean;
active?: boolean;
jobConflict?: boolean;
} = {},
) {
const dataset = {
...baseDataset,
...(options.published ? { state: "published", revision_id: id } : {}),
};
const job = { ...baseJob, ...(options.active ? { state: "running" } : {}) };
const input = { validated: true, manifest: {} };
const patches: { table: string; body: Record<string, unknown> }[] = [];
const server = createServer(async (req, res) => {
const url = new URL(req.url!, "http://localhost");
const table = url.pathname.split("/").at(-1)!;
const row =
table === "datasets" ? dataset : table === "upload_jobs" ? job : input;
res.setHeader("Content-Type", "application/json");
if (req.method === "GET") {
res.end(JSON.stringify(row));
return;
}
let raw = "";
for await (const chunk of req) raw += chunk;
const body = JSON.parse(raw);
patches.push({ table, body });
if (options.jobConflict && table === "upload_jobs") {
res.end("[]");
return;
}
Object.assign(row, body);
res.end(
url.searchParams.has("select")
? JSON.stringify([{ id: table === "datasets" ? id : job.id }])
: "null",
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const parent = resolve(".cache");
await mkdir(parent, { recursive: true });
const directory = await mkdtemp(`${parent}/reprocess-tests-`);
t.after(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
assert.ok(resolve(directory).startsWith(parent + sep));
await rm(directory, { recursive: true, force: true });
});
const address = server.address();
assert.ok(address && typeof address !== "string");
const result = await new Promise<{ code: number | null; output: string }>(
(resolve, reject) => {
const child = spawn(process.execPath, [script, id], {
cwd: directory,
windowsHide: true,
env: {
...process.env,
NEXT_PUBLIC_SUPABASE_URL: `http://127.0.0.1:${address.port}`,
SUPABASE_SECRET_KEY: "test-only-secret",
},
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (data) => (output += data));
child.stderr.on("data", (data) => (output += data));
child.on("error", reject);
child.on("exit", (code) => resolve({ code, output }));
},
);
return { ...result, dataset, job, input, patches };
}
test("reprocessing keeps the upload and resets unpublished preview and exhausted job metadata", async (t) => {
const result = await run(t);
assert.equal(result.code, 0, result.output);
assert.equal(result.dataset.state, "processing");
assert.equal(result.dataset.map_path, null);
assert.equal(result.dataset.cover_path, null);
assert.equal(result.dataset.panorama_count, 0);
assert.equal(result.input.validated, false);
assert.equal(result.job.state, "queued");
assert.equal(result.job.attempts, 0);
assert.equal(result.job.progress, 0);
assert.equal(result.job.error, null);
assert.ok(
result.patches.every(({ table }) =>
["datasets", "upload_jobs", "dataset_inputs"].includes(table),
),
);
});
test("reprocessing cannot mutate published revisions", async (t) => {
const result = await run(t, { published: true });
assert.notEqual(result.code, 0);
assert.match(result.output, /Published revisions cannot be changed/);
assert.deepEqual(result.patches, []);
});
test("reprocessing cannot take over an active worker job", async (t) => {
const result = await run(t, { active: true });
assert.notEqual(result.code, 0);
assert.match(result.output, /already queued or running/);
assert.deepEqual(result.patches, []);
});
test("a queue conflict restores the previous draft metadata and validation status", async (t) => {
const result = await run(t, { jobConflict: true });
assert.notEqual(result.code, 0);
assert.match(result.output, /Job state changed/);
assert.equal(result.dataset.state, baseDataset.state);
assert.equal(result.dataset.map_path, baseDataset.map_path);
assert.equal(result.dataset.panorama_count, baseDataset.panorama_count);
assert.equal(result.input.validated, true);
assert.equal(result.job.state, "complete");
});