166 lines
9.7 KiB
TypeScript
166 lines
9.7 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import path from "node:path";
|
|
|
|
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
|
|
|
test("completes the V1 import-edit-save-restart-reopen-GLB loop", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
await page.goto("/");
|
|
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
|
|
const result = await page.evaluate(async (input) => {
|
|
const [{ WebEngineClient }, { StorageClient }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([
|
|
import("/src/engine-client/WebEngineClient.ts"),
|
|
import("/src/storage/StorageClient.ts"),
|
|
import("/src/three-adapter/viewport.ts"),
|
|
import("/src/three-adapter/offscreen-viewport.ts"),
|
|
]);
|
|
const projectId = `v1-loop-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
const digestGeometry = async (buffers: Array<{ meshId: string; positions: ArrayBuffer; indices: ArrayBuffer }>, meshId: string): Promise<string> => {
|
|
const geometry = buffers.find((candidate) => candidate.meshId === meshId);
|
|
if (!geometry) throw new Error(`missing geometry ${meshId}`);
|
|
const bytes = new Uint8Array(geometry.positions.byteLength + geometry.indices.byteLength);
|
|
bytes.set(new Uint8Array(geometry.positions));
|
|
bytes.set(new Uint8Array(geometry.indices), geometry.positions.byteLength);
|
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
|
};
|
|
const materialByName = (snapshot: { materials: Array<{ name: string; roughness: number; metallic: number; baseColor: number[] }> }, name: string) =>
|
|
snapshot.materials.find((material) => material.name === name);
|
|
|
|
const firstEngine = new WebEngineClient({ timeoutMs: 30_000 });
|
|
const firstStorage = new StorageClient();
|
|
let editedRevision = 0;
|
|
let objectId = "";
|
|
let meshId = "";
|
|
let materialId = "";
|
|
let editedGeometrySha256 = "";
|
|
let storedRevision = 0;
|
|
try {
|
|
const opened = await firstEngine.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
|
const created = await firstEngine.applyCommand({ type: "createPrimitive", primitive: "CUBE", name: "V1LoopCube", location: [2, 0, 0] });
|
|
const object = created.snapshot.nodes.find((node) => node.name === "V1LoopCube");
|
|
if (!object?.dataId) throw new Error("V1 object creation did not return stable object/mesh IDs");
|
|
objectId = object.id;
|
|
meshId = object.dataId;
|
|
await firstEngine.applyCommand({ type: "setObjectTransform", objectId, translation: [2, 1, 0.5], rotationEuler: [0.1, 0.2, 0.3], scale: [1.25, 0.75, 1.5] });
|
|
await firstEngine.applyCommand({ type: "translateMeshVertices", meshId, vertexIndices: [0, 1], offset: [0, 0, 0.25] });
|
|
const slotted = await firstEngine.applyCommand({ type: "addMaterialSlot", objectId, name: "V1LoopMaterial" });
|
|
const material = materialByName(slotted.snapshot, "V1LoopMaterial");
|
|
if (!material) throw new Error("V1 material slot did not create a material");
|
|
materialId = (material as { id?: string }).id ?? "";
|
|
if (!materialId) throw new Error("V1 material has no stable ID");
|
|
const materialEdited = await firstEngine.applyCommand({
|
|
type: "setMaterialPrincipled",
|
|
materialId,
|
|
baseColor: [0.12, 0.34, 0.56, 1],
|
|
roughness: 0.23,
|
|
metallic: 0.67,
|
|
});
|
|
const undone = await firstEngine.applyCommand({ type: "undo" });
|
|
const undoMaterial = materialByName(undone.snapshot, "V1LoopMaterial");
|
|
const redone = await firstEngine.applyCommand({ type: "redo" });
|
|
const redoMaterial = materialByName(redone.snapshot, "V1LoopMaterial");
|
|
if (!undoMaterial || !redoMaterial || undoMaterial.roughness === redoMaterial.roughness) throw new Error("V1 material undo/redo did not change Main state");
|
|
if (materialEdited.snapshot.revision >= redone.snapshot.revision) throw new Error("V1 history revisions did not advance");
|
|
editedRevision = redone.snapshot.revision;
|
|
editedGeometrySha256 = await digestGeometry(redone.geometryBuffers, meshId);
|
|
const unsupported = await firstEngine.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["UNSUPPORTED"] });
|
|
if (unsupported.status !== "BLOCKED" || unsupported.issues[0]?.code !== "SHADER_NODE_UNSUPPORTED") throw new Error("V1 unsupported shader gate drifted");
|
|
const saved = await firstEngine.saveBlend();
|
|
const stored = await firstStorage.saveProject(projectId, editedRevision, saved.slice(0));
|
|
await firstStorage.saveSnapshot(projectId, editedRevision, saved.slice(0));
|
|
storedRevision = stored.revision;
|
|
}
|
|
finally {
|
|
firstEngine.terminate();
|
|
firstStorage.terminate();
|
|
}
|
|
|
|
const restartedStorage = new StorageClient();
|
|
const stored = await restartedStorage.readProject(projectId);
|
|
restartedStorage.terminate();
|
|
const restartedEngine = new WebEngineClient({ timeoutMs: 30_000 });
|
|
const reopened = await restartedEngine.openBlend(stored.buffer);
|
|
restartedEngine.terminate();
|
|
const reopenedObject = reopened.snapshot.nodes.find((node) => node.id === objectId);
|
|
const reopenedMaterial = reopened.snapshot.materials.find((material) => material.id === materialId);
|
|
const reopenedGeometrySha256 = await digestGeometry(reopened.geometryBuffers, meshId);
|
|
if (!reopenedObject || !reopenedMaterial) throw new Error("V1 reopened scene lost edited IDs");
|
|
|
|
const createCanvas = (): HTMLCanvasElement => {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 320;
|
|
canvas.height = 240;
|
|
canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px";
|
|
document.body.append(canvas);
|
|
return canvas;
|
|
};
|
|
const mainCanvas = createCanvas();
|
|
const main = new ViewportRenderer(mainCanvas);
|
|
main.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []);
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const gl = main.renderer.getContext();
|
|
const mainPixels = new Uint8Array(32 * 32 * 4);
|
|
gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, mainPixels);
|
|
const mainVisible = Array.from({ length: 32 * 32 }, (_, index) => mainPixels[index * 4] + mainPixels[index * 4 + 1] + mainPixels[index * 4 + 2] > 30).filter(Boolean).length;
|
|
main.dispose();
|
|
mainCanvas.remove();
|
|
|
|
const offscreenCanvas = createCanvas();
|
|
const offscreen = new OffscreenViewportRenderer(offscreenCanvas);
|
|
offscreen.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []);
|
|
const deadline = performance.now() + 30_000;
|
|
while (Number(offscreenCanvas.dataset.rendererPixels ?? 0) === 0 && !offscreenCanvas.dataset.rendererError) {
|
|
if (performance.now() > deadline) throw new Error("V1 Offscreen viewport timed out");
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
const offscreenVisible = Number(offscreenCanvas.dataset.rendererPixels ?? 0);
|
|
const offscreenError = offscreenCanvas.dataset.rendererError;
|
|
offscreen.dispose();
|
|
offscreenCanvas.remove();
|
|
|
|
const glb = await new Promise<{ ok: boolean; byteLength: number; roundTrip?: { compatible: boolean; mismatches: string[] }; pbrSummary?: { baseColorFactor?: number[]; roughnessFactor?: number; metallicFactor?: number }; error?: string }>((resolve, reject) => {
|
|
const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" });
|
|
worker.onmessage = (event) => { worker.terminate(); resolve(event.data); };
|
|
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
|
const transfer: Transferable[] = [];
|
|
for (const geometry of reopened.geometryBuffers) {
|
|
for (const value of Object.values(geometry)) if (value instanceof ArrayBuffer) transfer.push(value);
|
|
}
|
|
worker.postMessage({ snapshot: reopened.snapshot, geometryBuffers: reopened.geometryBuffers, assetBuffers: [] }, transfer);
|
|
});
|
|
return {
|
|
editedRevision,
|
|
storedRevision,
|
|
persistedRevision: stored.revision,
|
|
reopenedRevision: reopened.snapshot.revision,
|
|
objectTransform: reopenedObject.transform,
|
|
material: reopenedMaterial,
|
|
geometry: [editedGeometrySha256, reopenedGeometrySha256],
|
|
viewport: { mainVisible, offscreenVisible, offscreenError },
|
|
glb,
|
|
};
|
|
}, new Uint8Array(bytes));
|
|
|
|
expect(result.editedRevision).toBeGreaterThan(1);
|
|
expect(result.storedRevision).toBe(result.editedRevision);
|
|
expect(result.persistedRevision).toBe(result.editedRevision);
|
|
expect(result.reopenedRevision).toBe(1);
|
|
expect(result.objectTransform.translation).toEqual([2, 1, 0.5]);
|
|
expect(result.objectTransform.scale).toEqual([1.25, 0.75, 1.5]);
|
|
[0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.material.baseColor[index]).toBeCloseTo(value, 6));
|
|
expect(result.material.roughness).toBeCloseTo(0.23);
|
|
expect(result.material.metallic).toBeCloseTo(0.67);
|
|
expect(result.geometry[0]).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(result.geometry[1]).toBe(result.geometry[0]);
|
|
expect(result.viewport.mainVisible).toBeGreaterThan(0);
|
|
expect(result.viewport.offscreenVisible).toBeGreaterThan(0);
|
|
expect(result.viewport.offscreenError).toBeUndefined();
|
|
expect(result.glb.ok).toBe(true);
|
|
expect(result.glb.byteLength).toBeGreaterThan(1_000);
|
|
expect(result.glb.roundTrip).toEqual({ compatible: true, mismatches: [] });
|
|
[0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.glb.pbrSummary?.baseColorFactor?.[index]).toBeCloseTo(value, 6));
|
|
expect(result.glb.pbrSummary?.roughnessFactor).toBeCloseTo(0.23);
|
|
expect(result.glb.pbrSummary?.metallicFactor).toBeCloseTo(0.67);
|
|
});
|