import { afterAll, beforeAll, describe, expect, it } from "vitest"; import fs from "node:fs/promises"; import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import { MODEL_PREVIEWS, STORAGE } from "../src/config"; import { prisma } from "../src/db"; import { writeZip } from "../src/utils/zipWriter"; import { plateAssetHome } from "../src/services/modelAssets"; import { generateModelImage, generateModelPreviewGlb, modelImagePath as cachedImagePath, modelImageState as cachedImageState, modelPreviewGlbPath, } from "../src/services/modelPreviewCache"; // A box as ASCII STL. const v = (x: number, y: number, z: number) => `vertex ${x * 10} ${y * 10} ${z * 10}`; const facet = (a: number[], b: number[], c: number[]) => `facet normal 0 0 0\nouter loop\n${v(a[0], a[1], a[2])}\n${v(b[0], b[1], b[2])}\n${v(c[0], c[1], c[2])}\nendloop\nendfacet`; const quads: [number, number, number][][] = [ [ [0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], ], [ [0, 0, 1], [0, 1, 1], [1, 1, 1], [1, 0, 1], ], [ [0, 0, 0], [0, 0, 1], [1, 0, 1], [1, 0, 0], ], [ [0, 1, 0], [1, 1, 0], [1, 1, 1], [0, 1, 1], ], [ [0, 0, 0], [0, 1, 0], [0, 1, 1], [0, 0, 1], ], [ [1, 0, 0], [1, 0, 1], [1, 1, 1], [1, 1, 0], ], ]; const BOX_STL = `solid box\n${quads.flatMap(([a, b, c, d]) => [facet(a, b, c), facet(a, c, d)]).join("\n")}\nendsolid box\n`; const ONE_TRIANGLE_3MF_XML = ` `; let dir: string; let userId: string; const plateIds: string[] = []; // A render goes in its model's pictures folder, so each plate needs a model. async function newPlateId(label: string): Promise { const name = `image-${label}-${Date.now()}-${plateIds.length}`; const print = await prisma.print.create({ data: { userId, name, nameNormalized: name.toLowerCase() } }); const plate = await prisma.plate.create({ data: { printId: print.id, position: 0, filename: `${label}.stl`, mime: "model/stl", storagePath: `u-${userId}/${name}/${label}.stl`, }, }); plateIds.push(plate.id); return plate.id; } async function file(name: string, content: string): Promise { const target = path.join(dir, name); await fs.writeFile(target, content); return target; } const modelImagePath = async (plateId: string) => cachedImagePath(plateId, await plateAssetHome(plateId)); const modelImageState = async (plateId: string) => cachedImageState(plateId, await plateAssetHome(plateId)); const cacheDir = () => MODEL_PREVIEWS; beforeAll(async () => { await fs.mkdir(cacheDir(), { recursive: true }); dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-image-cache-")); const user = await prisma.user.create({ data: { email: `image-cache-${Date.now()}@example.com`, displayName: "Image cache" }, }); userId = user.id; }); afterAll(async () => { const names = await fs.readdir(cacheDir()); for (const name of names) { if (plateIds.some((id) => name.startsWith(id))) await fs.rm(path.join(cacheDir(), name), { force: true }); } await fs.rm(path.join(STORAGE, `u-${userId}`), { recursive: true, force: true }); await prisma.user.delete({ where: { id: userId } }); await fs.rm(dir, { recursive: true, force: true }); }); describe("model picture cache", () => { it("says generating while queued, then ready, and doesn't redraw an existing picture", async () => { const plateId = await newPlateId("box"); const src = await file("box.stl", BOX_STL); const run = generateModelImage(plateId, src, "box.stl"); expect(await modelImageState(plateId)).toBe("generating"); await run; expect(await modelImageState(plateId)).toBe("ready"); const drawnAt = (await fs.stat(await modelImagePath(plateId))).mtimeMs; await generateModelImage(plateId, src, "box.stl"); expect((await fs.stat(await modelImagePath(plateId))).mtimeMs).toBe(drawnAt); }); it("asks for one picture only once while it's being drawn", async () => { const plateId = await newPlateId("twice"); const src = await file("twice.stl", BOX_STL); await Promise.all([generateModelImage(plateId, src, "twice.stl"), generateModelImage(plateId, src, "twice.stl")]); expect(await modelImageState(plateId)).toBe("ready"); }); it("calls a 3MF layout it can't read unsupported, and doesn't try it again", async () => { const plateId = await newPlateId("elsewhere"); const modelPath = await file("model.model", ONE_TRIANGLE_3MF_XML); const src = path.join(dir, "elsewhere.3mf"); // Valid 3MF (the path comes from _rels/.rels), but not where the parser looks. await writeZip(src, [{ arcname: "3D/model.model", filePath: modelPath }]); await generateModelImage(plateId, src, "elsewhere.3mf"); expect(await modelImageState(plateId)).toBe("unsupported"); const marker = path.join(cacheDir(), `${plateId}.render.v2.error`); const markedAt = (await fs.stat(marker)).mtimeMs; await generateModelImage(plateId, src, "elsewhere.3mf"); expect((await fs.stat(marker)).mtimeMs).toBe(markedAt); }); it("calls a broken file failed rather than drawing nothing", async () => { const plateId = await newPlateId("broken"); await generateModelImage(plateId, await file("broken.stl", "solid empty\nendsolid empty\n"), "broken.stl"); expect(await modelImageState(plateId)).toBe("failed"); }); it("ignores formats it can't draw", async () => { const plateId = await newPlateId("step"); await generateModelImage(plateId, await file("part.step", "ISO-10303-21;"), "part.step"); expect(fsSync.existsSync(await modelImagePath(plateId))).toBe(false); }); it("lets a 3D preview go ahead of pictures already waiting", async () => { const pictures = await Promise.all( [0, 1, 2].map(async (i) => ({ plateId: await newPlateId(`queued${i}`), src: await file(`queued${i}.stl`, BOX_STL), })), ); const modelPath = await file("3dmodel.model", ONE_TRIANGLE_3MF_XML); const fixture = path.join(dir, "one.3mf"); await writeZip(fixture, [{ arcname: "3D/3dmodel.model", filePath: modelPath }]); const glbPlate = await newPlateId("glb"); // The first picture starts at once; the GLB, queued after the other two, still runs before them. const drawing = pictures.map(({ plateId, src }) => generateModelImage(plateId, src, "box.stl")); await generateModelPreviewGlb(glbPlate, fixture); expect(fsSync.existsSync(modelPreviewGlbPath(glbPlate))).toBe(true); expect(fsSync.existsSync(await modelImagePath(pictures[2].plateId))).toBe(false); await Promise.all(drawing); const states = await Promise.all(pictures.map(({ plateId }) => modelImageState(plateId))); expect(states.every((state) => state === "ready")).toBe(true); }); });