229 lines
7.5 KiB
TypeScript
229 lines
7.5 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import sharp from "sharp";
|
|
import { createClient } from "@supabase/supabase-js";
|
|
import { datasetSchema, faces } from "../shared/protocol";
|
|
import type { Database } from "../shared/database";
|
|
import { processFabricImages } from "../lib/fabric-images";
|
|
import { processFabricJob } from "../lib/fabric-jobs";
|
|
|
|
async function fixture() {
|
|
const map = await sharp({
|
|
create: { width: 32, height: 32, channels: 3, background: "#349d66" },
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
const face = await sharp({
|
|
create: { width: 256, height: 256, channels: 3, background: "#548fda" },
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
const viewpoints = Array.from({ length: 5 }, (_, i) => ({
|
|
id: `p${i}`,
|
|
x: i,
|
|
y: 64,
|
|
z: i,
|
|
}));
|
|
const files = new Map<string, Buffer>([
|
|
["map.png", map],
|
|
...viewpoints.flatMap((p) =>
|
|
faces.map((f) => [`${p.id}/${f}.png`, face] as [string, Buffer]),
|
|
),
|
|
]);
|
|
const manifest = datasetSchema.parse({
|
|
format: "minegeo-dataset/v1",
|
|
minecraftVersion: "26.2",
|
|
dimension: "minecraft:overworld",
|
|
source: "fabric",
|
|
name: "Image worker fixture",
|
|
serverName: "Local",
|
|
bounds: { minX: 0, minZ: 0, maxX: 32, maxZ: 32 },
|
|
north: "-z",
|
|
viewpoints,
|
|
assets: [...files].map(([path, bytes]) => ({
|
|
path,
|
|
bytes: bytes.length,
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
})),
|
|
});
|
|
return { files, manifest };
|
|
}
|
|
|
|
test("mod images become a lossless map, six WebP faces per location, and previews", async () => {
|
|
const { manifest, files } = await fixture();
|
|
const images = new Map<string, Buffer>();
|
|
const progress: number[] = [];
|
|
await processFabricImages(manifest, {
|
|
signal: new AbortController().signal,
|
|
async download(path) {
|
|
return files.get(path)!;
|
|
},
|
|
async save(path, bytes) {
|
|
images.set(path, bytes);
|
|
},
|
|
async progress(value) {
|
|
progress.push(value);
|
|
},
|
|
});
|
|
assert.equal(images.size, 36);
|
|
for (const point of manifest.viewpoints) {
|
|
for (const face of faces) {
|
|
const meta = await sharp(
|
|
images.get(`${point.id}/${face}.webp`),
|
|
).metadata();
|
|
assert.equal(meta.format, "webp");
|
|
assert.equal(meta.width, 256);
|
|
assert.equal(meta.height, 256);
|
|
}
|
|
assert.equal(
|
|
(await sharp(images.get(`${point.id}/preview.webp`)).metadata()).width,
|
|
512,
|
|
);
|
|
}
|
|
assert.deepEqual(
|
|
await sharp(images.get("map.webp")).raw().toBuffer(),
|
|
await sharp(files.get("map.png")).raw().toBuffer(),
|
|
);
|
|
assert.equal(progress.at(-1), 95);
|
|
assert.ok(progress.every((p, i) => i === 0 || p > progress[i - 1]));
|
|
});
|
|
|
|
test("map-only refresh processes only the captured map", async () => {
|
|
const { manifest, files } = await fixture();
|
|
const saved: string[] = [],
|
|
downloaded: string[] = [];
|
|
await processFabricImages(manifest, {
|
|
mapOnly: true,
|
|
signal: new AbortController().signal,
|
|
async download(path) {
|
|
downloaded.push(path);
|
|
return files.get(path)!;
|
|
},
|
|
async save(path) {
|
|
saved.push(path);
|
|
},
|
|
async progress() {},
|
|
});
|
|
assert.deepEqual(downloaded, ["map.png"]);
|
|
assert.deepEqual(saved, ["map.webp"]);
|
|
});
|
|
|
|
test("corrupt assets, non-square faces, and cancelled work cannot complete", async () => {
|
|
const { manifest, files } = await fixture();
|
|
const io = {
|
|
signal: new AbortController().signal,
|
|
async download(path: string) {
|
|
return files.get(path)!;
|
|
},
|
|
async save() {},
|
|
async progress() {},
|
|
};
|
|
await assert.rejects(
|
|
processFabricImages(manifest, {
|
|
...io,
|
|
async download() {
|
|
return Buffer.from("corrupt");
|
|
},
|
|
}),
|
|
/checksum\/size mismatch/,
|
|
);
|
|
const rectangular = await sharp({
|
|
create: { width: 512, height: 256, channels: 3, background: "white" },
|
|
})
|
|
.png()
|
|
.toBuffer();
|
|
files.set("p0/px.png", rectangular);
|
|
const asset = manifest.assets.find((a) => a.path === "p0/px.png")!;
|
|
asset.bytes = rectangular.length;
|
|
asset.sha256 = createHash("sha256").update(rectangular).digest("hex");
|
|
await assert.rejects(
|
|
processFabricImages(manifest, io),
|
|
/must be square PNGs/,
|
|
);
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("Cancelled fixture"));
|
|
await assert.rejects(
|
|
processFabricImages(manifest, { ...io, signal: controller.signal }),
|
|
/Cancelled fixture/,
|
|
);
|
|
});
|
|
|
|
// Exercise the actual Supabase client against a local Storage/PostgREST service.
|
|
// No credentials, game clients, or real user data are involved.
|
|
test("Vercel worker claims the existing queue job, stores images, and completes through the existing RPC", async (t) => {
|
|
const { files, manifest } = await fixture();
|
|
const owner = randomUUID(),
|
|
datasetId = randomUUID();
|
|
let job: Record<string, any> = {
|
|
id: randomUUID(),
|
|
dataset_id: datasetId,
|
|
source_dataset_id: null,
|
|
kind: "upload",
|
|
state: "queued",
|
|
attempts: 0,
|
|
progress: 0,
|
|
lease_until: null,
|
|
};
|
|
let completed = 0;
|
|
const saved = new Map<string, Buffer>();
|
|
const server = createServer(async (req, res) => {
|
|
const url = new URL(req.url!, "http://localhost"),
|
|
chunks = [];
|
|
for await (const chunk of req) chunks.push(chunk);
|
|
const bytes = Buffer.concat(chunks);
|
|
function json(data: unknown) {
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify(data));
|
|
}
|
|
if (url.pathname === "/rest/v1/upload_jobs") {
|
|
if (req.method === "PATCH") {
|
|
const body = JSON.parse(bytes.toString());
|
|
assert.equal(url.searchParams.get("id"), `eq.${job.id}`);
|
|
if (body.lease_owner)
|
|
assert.equal(url.searchParams.get("state"), "eq.queued");
|
|
job = { ...job, ...body };
|
|
}
|
|
return json([job]);
|
|
}
|
|
if (url.pathname === "/rest/v1/datasets")
|
|
return json({ owner_id: owner, source: "fabric" });
|
|
if (url.pathname === "/rest/v1/dataset_inputs") return json({ manifest });
|
|
if (url.pathname === "/rest/v1/rpc/complete_job") {
|
|
const body = JSON.parse(bytes.toString());
|
|
assert.equal(body.p_job, job.id);
|
|
assert.equal(body.p_worker, job.lease_owner);
|
|
assert.equal(body.p_count, 5);
|
|
assert.equal(body.p_cover, "p0");
|
|
assert.equal(saved.size, 36);
|
|
completed++;
|
|
job.state = "complete";
|
|
return json(null);
|
|
}
|
|
const raw = `/storage/v1/object/minegeo-raw/${owner}/${datasetId}/`;
|
|
if (url.pathname.startsWith(raw))
|
|
return res.end(files.get(url.pathname.slice(raw.length)));
|
|
const media = `/storage/v1/object/minegeo-media/${datasetId}/`;
|
|
if (url.pathname.startsWith(media)) {
|
|
assert.equal(req.headers["x-upsert"], "true");
|
|
assert.equal((await sharp(bytes).metadata()).format, "webp");
|
|
saved.set(url.pathname.slice(media.length), bytes);
|
|
return json({ Key: url.pathname, Id: randomUUID() });
|
|
}
|
|
res.statusCode = 404;
|
|
json({ error: url.pathname });
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
t.after(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
|
const client = createClient<Database>(
|
|
`http://127.0.0.1:${(server.address() as any).port}`,
|
|
"local-fixture-only",
|
|
{ auth: { persistSession: false, autoRefreshToken: false } },
|
|
);
|
|
assert.equal(await processFabricJob(client, datasetId), "complete");
|
|
assert.equal(job.state, "complete");
|
|
assert.equal(job.attempts, 1);
|
|
assert.equal(completed, 1);
|
|
});
|