Advance M8-M11 parity workflows
This commit is contained in:
81
web/tests/e2e/compositor-node-golden.spec.ts
Normal file
81
web/tests/e2e/compositor-node-golden.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-07/compositor-node-golden.json"), "utf8")) as {
|
||||
width: number;
|
||||
height: number;
|
||||
maxAbsoluteError: number;
|
||||
allowlist: string[];
|
||||
fixture: { path: string };
|
||||
cases: Array<{ scene: string; newNode: string; nodeTypes: string[]; pixel: number[]; float32Sha256: string }>;
|
||||
};
|
||||
const fixture = fs.readFileSync(path.join(root, golden.fixture.path));
|
||||
|
||||
test("M11-07 gives every allowlisted compositor node an independent CPU/WebGPU golden", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const [{ WebEngineClient }, cpu, gpu] = await Promise.all([
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/compositor/CompositorExecutor.ts"),
|
||||
import("/src/compositor/CompositorWebGPU.ts"),
|
||||
]);
|
||||
const hash = async (data: Float32Array): Promise<string> => Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength))),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const device = await gpu.requestCompositorWebGPUDevice();
|
||||
try {
|
||||
const opened = await client.openBlend(Uint8Array.from(input.bytes).buffer);
|
||||
const cases = [];
|
||||
for (const expected of input.cases) {
|
||||
const scene = opened.snapshot.scenes.find((candidate) => candidate.name === expected.scene);
|
||||
if (!scene?.compositorGraph) throw new Error(`Missing compositor graph ${expected.scene}`);
|
||||
const plan = cpu.compileCompositorWebGPUPlan(scene.compositorGraph);
|
||||
const cpuResult = cpu.executeCompositorGraph(scene.compositorGraph, new Map(), { width: input.width, height: input.height }).composite;
|
||||
const gpuResult = await gpu.executeCompositorGraphWebGPU(device, scene.compositorGraph, input.width, input.height);
|
||||
let maxAbsoluteError = 0;
|
||||
for (let index = 0; index < cpuResult.data.length; index++) maxAbsoluteError = Math.max(maxAbsoluteError, Math.abs(cpuResult.data[index] - gpuResult.data[index]));
|
||||
cases.push({
|
||||
scene: expected.scene,
|
||||
newNode: expected.newNode,
|
||||
instructionTypes: plan.instructions.map((instruction) => instruction.type),
|
||||
cpuPixel: Array.from(cpuResult.data.slice(0, 4)),
|
||||
gpuPixel: Array.from(gpuResult.data.slice(0, 4)),
|
||||
cpuSha256: await hash(cpuResult.data),
|
||||
gpuSha256: await hash(gpuResult.data),
|
||||
maxAbsoluteError,
|
||||
});
|
||||
}
|
||||
const base = opened.snapshot.scenes.find((candidate) => candidate.name === input.cases[0].scene)?.compositorGraph;
|
||||
if (!base) throw new Error("Missing base compositor graph");
|
||||
let blockedCode = "";
|
||||
try {
|
||||
cpu.compileCompositorWebGPUPlan({ ...base, nodes: [...base.nodes, { id: "blocked:viewer", type: "VIEWER", name: "Blocked Viewer", properties: {} }] });
|
||||
}
|
||||
catch (error) { blockedCode = error instanceof cpu.CompositorValidationError ? error.code : String(error); }
|
||||
return { allowlist: cpu.COMPOSITOR_WEBGPU_NODE_ALLOWLIST, cases, blockedCode };
|
||||
}
|
||||
finally {
|
||||
device.destroy();
|
||||
client.terminate();
|
||||
}
|
||||
}, { bytes: Array.from(fixture), cases: golden.cases, width: golden.width, height: golden.height });
|
||||
|
||||
expect(result.allowlist).toEqual(golden.allowlist);
|
||||
expect(result.blockedCode).toBe("COMPOSITOR_NODE_UNSUPPORTED");
|
||||
for (const [index, candidate] of result.cases.entries()) {
|
||||
const expected = golden.cases[index];
|
||||
expect(candidate.scene).toBe(expected.scene);
|
||||
expect(candidate.newNode).toBe(expected.newNode);
|
||||
expect(candidate.instructionTypes).toEqual(expected.nodeTypes);
|
||||
expect(candidate.cpuPixel).toEqual(expected.pixel);
|
||||
expect(candidate.gpuPixel).toEqual(expected.pixel);
|
||||
expect(candidate.cpuSha256).toBe(expected.float32Sha256);
|
||||
expect(candidate.gpuSha256).toBe(expected.float32Sha256);
|
||||
expect(candidate.maxAbsoluteError).toBeLessThanOrEqual(golden.maxAbsoluteError);
|
||||
}
|
||||
});
|
||||
63
web/tests/e2e/compositor-unsupported-gate.spec.ts
Normal file
63
web/tests/e2e/compositor-unsupported-gate.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-08/compositor-unsupported-gate.json"), "utf8")) as {
|
||||
fixture: { path: string; sha256: string };
|
||||
scene: string;
|
||||
unsupportedNode: { name: string; type: string; blenderType: string };
|
||||
expectedErrorCode: string;
|
||||
expectedRevisionDelta: number;
|
||||
};
|
||||
const fixture = fs.readFileSync(path.join(root, golden.fixture.path));
|
||||
|
||||
test("M11-08 keeps the Blender graph intact while blocking unsupported compositor execution", async ({ page }) => {
|
||||
expect(crypto.createHash("sha256").update(fixture).digest("hex")).toBe(golden.fixture.sha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const [{ WebEngineClient }, compositor] = await Promise.all([
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/compositor/CompositorExecutor.ts"),
|
||||
]);
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
await client.init();
|
||||
const opened = await client.openBlend(input.bytes.buffer);
|
||||
const scene = opened.snapshot.scenes.find((candidate) => candidate.name === input.scene);
|
||||
if (!scene?.compositorGraph) throw new Error(`Missing compositor graph ${input.scene}`);
|
||||
const beforeGraph = JSON.stringify(scene.compositorGraph);
|
||||
const beforeRevision = opened.snapshot.revision;
|
||||
const unsupported = scene.compositorGraph.nodes.find((node) => node.type === input.unsupportedNode.type);
|
||||
let gateCode = "";
|
||||
let executeCode = "";
|
||||
let cachedCode = "";
|
||||
try { gateCode = compositor.gateCompositorGraph(scene.compositorGraph, new Set()).issues[0]?.code ?? ""; } catch (error) { gateCode = String(error); }
|
||||
try { compositor.executeCompositorGraph(scene.compositorGraph, new Map(), { width: 2, height: 2 }); }
|
||||
catch (error) { executeCode = error instanceof compositor.CompositorValidationError ? error.code : String(error); }
|
||||
try {
|
||||
await compositor.executeCompositorGraphCached(scene.compositorGraph, new Map(), new compositor.CompositorFrameCache(512), { frame: 1, width: 2, height: 2 });
|
||||
}
|
||||
catch (error) { cachedCode = error instanceof compositor.CompositorValidationError ? error.code : String(error); }
|
||||
const after = await client.snapshot();
|
||||
client.terminate();
|
||||
const afterScene = after.snapshot.scenes.find((candidate) => candidate.name === input.scene);
|
||||
return {
|
||||
beforeGraph,
|
||||
afterGraph: JSON.stringify(afterScene?.compositorGraph),
|
||||
beforeRevision,
|
||||
afterRevision: after.snapshot.revision,
|
||||
gateCode,
|
||||
executeCode,
|
||||
cachedCode,
|
||||
unsupported: unsupported && { name: unsupported.name, type: unsupported.type, blenderType: unsupported.blenderType },
|
||||
};
|
||||
}, { bytes: new Uint8Array(fixture), scene: golden.scene, unsupportedNode: golden.unsupportedNode });
|
||||
|
||||
expect(result.gateCode).toBe(golden.expectedErrorCode);
|
||||
expect(result.executeCode).toBe(golden.expectedErrorCode);
|
||||
expect(result.cachedCode).toBe(golden.expectedErrorCode);
|
||||
expect(result.unsupported).toEqual(golden.unsupportedNode);
|
||||
expect(result.afterGraph).toBe(result.beforeGraph);
|
||||
expect(result.afterRevision - result.beforeRevision).toBe(golden.expectedRevisionDelta);
|
||||
});
|
||||
23
web/tests/e2e/curve-topology-contract.spec.ts
Normal file
23
web/tests/e2e/curve-topology-contract.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M9-04 freezes Curve topology operators and budgets before Main or UI exposure", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/curve-topology-contract-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage(null);
|
||||
}));
|
||||
expect(result).toMatchObject({
|
||||
operatorCount: 14,
|
||||
blockedCount: 13,
|
||||
readyOperators: ["TOGGLE_CYCLIC"],
|
||||
sourceAuthority: "blender-5.2.0/source/blender/editors/curve/curve_ops.cc",
|
||||
atomicMainTransaction: true,
|
||||
unknownCode: "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED",
|
||||
staleCode: "REVISION_CONFLICT",
|
||||
budgetCode: "NON_MESH_DATA_BUDGET_EXCEEDED",
|
||||
stage: "ONE_VERIFIED_OPERATOR",
|
||||
});
|
||||
expect(result.accepted).toMatchObject({ operator: "ADD_SPLINE", outputSplineCount: 2, outputPointCount: 4 });
|
||||
});
|
||||
59
web/tests/e2e/curve-topology-operator.spec.ts
Normal file
59
web/tests/e2e/curve-topology-operator.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend");
|
||||
const golden = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M9-05/curve-toggle-cyclic.json"), "utf8"));
|
||||
|
||||
test("M9-05 exposes TOGGLE_CYCLIC only after Main, undo, save and Blender golden verification", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const fixtureBytes = fs.readFileSync(fixture);
|
||||
expect(crypto.createHash("sha256").update(fixtureBytes).digest("hex")).toBe(golden.fixtureSha256);
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(fixture);
|
||||
const curveRow = page.locator('[data-data-id="curve:WebCurveData"]');
|
||||
await expect(curveRow).toBeVisible();
|
||||
await curveRow.click();
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
const editor = page.getByTestId("curve-topology-editor");
|
||||
const cyclic = page.getByRole("checkbox", { name: "Curve cyclic U" });
|
||||
await expect(editor).toHaveAttribute("data-ready-operators", "TOGGLE_CYCLIC");
|
||||
await expect(editor).toHaveAttribute("data-spline-types", golden.splineTypes.join(","));
|
||||
await expect(editor).toHaveAttribute("data-point-count", String(golden.splinePointCounts.reduce((sum: number, count: number) => sum + count, 0)));
|
||||
await expect(editor).toHaveAttribute("data-cyclic-u", golden.beforeCyclicU.join(","));
|
||||
await expect(cyclic).not.toBeChecked();
|
||||
const selectedRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
|
||||
await cyclic.click();
|
||||
await expect(editor).toHaveAttribute("data-cyclic-u", golden.afterCyclicU.join(","));
|
||||
await expect(cyclic).toBeChecked();
|
||||
await expect(app).toHaveAttribute("data-dirty", "true");
|
||||
const toggledRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
expect(toggledRevision).toBe(selectedRevision + 1);
|
||||
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(editor).toHaveAttribute("data-cyclic-u", golden.beforeCyclicU.join(","));
|
||||
const undoRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
expect(undoRevision).toBe(toggledRevision + 1);
|
||||
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(editor).toHaveAttribute("data-cyclic-u", golden.afterCyclicU.join(","));
|
||||
const redoRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
expect(redoRevision).toBe(undoRevision + 1);
|
||||
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
await page.getByRole("button", { name: "关闭项目" }).click();
|
||||
await expect(editor).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "恢复项目" }).click();
|
||||
await expect(curveRow).toBeVisible();
|
||||
await curveRow.click();
|
||||
await expect(editor).toHaveAttribute("data-cyclic-u", golden.reopenedCyclicU.join(","));
|
||||
await expect(cyclic).toBeChecked();
|
||||
});
|
||||
87
web/tests/e2e/diagnostic-report.spec.ts
Normal file
87
web/tests/e2e/diagnostic-report.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
const materialBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
|
||||
|
||||
test("M7-17 keeps Worker detail out of the UI and exports it in a diagnostics report", async ({ page }) => {
|
||||
await page.goto("/?worker-fault=engine");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator("main.blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible();
|
||||
const revision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
|
||||
await page.getByTestId("inject-worker-crash").click();
|
||||
await expect(app).toHaveAttribute("data-worker-fault-code", "WORKER_TERMINATED");
|
||||
await expect(app).toHaveAttribute("data-last-diagnostic-code", "ENGINE_WORKER_TERMINATED");
|
||||
await expect(page.getByTestId("engine-status")).toHaveText("Engine: Worker stopped; project remains available");
|
||||
await expect(page.locator("body")).not.toContainText("WORKER_CRASH_INJECTED");
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByTestId("export-diagnostics").click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe("blender-web-diagnostics.json");
|
||||
const reportPath = await download.path();
|
||||
expect(reportPath).not.toBeNull();
|
||||
const report = JSON.parse(await fs.readFile(reportPath!, "utf8"));
|
||||
|
||||
expect(report).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
product: "Web Blender Modeler V1",
|
||||
runtime: {
|
||||
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/$/),
|
||||
userAgent: expect.any(String),
|
||||
language: expect.any(String),
|
||||
crossOriginIsolated: true,
|
||||
},
|
||||
project: { projectId: "basic_scene", revision },
|
||||
});
|
||||
expect(report.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(report.entries).toHaveLength(1);
|
||||
expect(report.entries[0]).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
sequence: 1,
|
||||
area: "ENGINE",
|
||||
code: "ENGINE_WORKER_TERMINATED",
|
||||
summary: "Engine: Worker stopped; project remains available",
|
||||
sourceCode: "WORKER_TERMINATED",
|
||||
context: { projectId: "basic_scene", revision },
|
||||
});
|
||||
expect(report.entries[0].detail).toContain("WORKER_CRASH_INJECTED");
|
||||
});
|
||||
|
||||
test("M7-17 records open and import details behind stable user messages", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator("main.blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(materialBlend);
|
||||
await expect(page.getByText("AttributeMeshObject", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByTestId("blend-file-input").setInputFiles({
|
||||
name: "invalid.blend",
|
||||
mimeType: "application/octet-stream",
|
||||
buffer: Buffer.from([0x42, 0x41, 0x44]),
|
||||
});
|
||||
await expect(page.getByTestId("engine-status")).toHaveText("Engine: .blend open failed");
|
||||
await expect(app).toHaveAttribute("data-last-diagnostic-code", "BLEND_OPEN_FAILED");
|
||||
|
||||
await page.locator("label.file-button input[type=file]").setInputFiles({
|
||||
name: "invalid.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from("not-a-png"),
|
||||
});
|
||||
await expect(page.getByTestId("engine-status")).toHaveText("Image import failed");
|
||||
await expect(app).toHaveAttribute("data-last-diagnostic-code", "IMAGE_IMPORT_FAILED");
|
||||
await expect(app).toHaveAttribute("data-diagnostic-count", "2");
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByTestId("export-diagnostics").click();
|
||||
const reportPath = await (await downloadPromise).path();
|
||||
const report = JSON.parse(await fs.readFile(reportPath!, "utf8"));
|
||||
expect(report.entries.map((entry: { code: string }) => entry.code)).toEqual(["BLEND_OPEN_FAILED", "IMAGE_IMPORT_FAILED"]);
|
||||
expect(report.entries.every((entry: { detail: string }) => entry.detail.length > 0)).toBe(true);
|
||||
expect(report.entries[0].summary).toBe("Engine: .blend open failed");
|
||||
expect(report.entries[1].summary).toBe("Image import failed");
|
||||
});
|
||||
34
web/tests/e2e/editing-domain-recovery.spec.ts
Normal file
34
web/tests/e2e/editing-domain-recovery.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixtures = {
|
||||
CURVE: path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend"),
|
||||
GREASE_PENCIL: path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend"),
|
||||
PAINT: path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend"),
|
||||
};
|
||||
|
||||
test("M9-14 recovers Curve, Grease Pencil and Paint after Worker, OOM and GPU release faults", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.goto("/");
|
||||
const input = Object.fromEntries(Object.entries(fixtures).map(([domain, file]) => [domain, Array.from(fs.readFileSync(file))]));
|
||||
const reports = await page.evaluate(async (bytes) => {
|
||||
const { runEditingDomainRecoverySuite } = await import("/src/testing/editing-domain-recovery.ts");
|
||||
const buffers = Object.fromEntries(Object.entries(bytes).map(([domain, value]) => [domain, Uint8Array.from(value as number[]).buffer]));
|
||||
return runEditingDomainRecoverySuite(buffers);
|
||||
}, input);
|
||||
|
||||
expect(reports.map((report) => report.domain)).toEqual(["CURVE", "GREASE_PENCIL", "PAINT"]);
|
||||
for (const report of reports) {
|
||||
expect(report.schemaVersion).toBe(1);
|
||||
expect(report.workerRestart).toMatchObject({ status: "RECOVERED", workerGeneration: 2, temporaryResourcesAfter: 0 });
|
||||
expect(report.workerRestart.hashAfter).toBe(report.baseline.identityHash);
|
||||
expect(report.oom).toMatchObject({ status: "RECOVERED", faultPoint: "GPU_GEOMETRY_UPLOAD", code: "GPU_GEOMETRY_BUDGET_EXCEEDED", temporaryResourcesAfter: 0 });
|
||||
expect(report.oom.hashAfter).toBe(report.baseline.identityHash);
|
||||
expect(report.gpuRelease).toMatchObject({ status: "RECOVERED", backend: "WEBGL2", releaseCount: 1, reinitCount: 1 });
|
||||
expect(report.gpuRelease.disposedResources).toBeGreaterThan(0);
|
||||
expect(report.gpuRelease.visiblePixels).toBeGreaterThan(0);
|
||||
expect(report.smallScene).toMatchObject({ status: "RECOVERED", identityHash: report.baseline.identityHash, revision: report.baseline.revision, objectCount: report.baseline.objectCount });
|
||||
expect(report.smallScene.visiblePixels).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
239
web/tests/e2e/editing-soak.spec.ts
Normal file
239
web/tests/e2e/editing-soak.spec.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const REQUIRED_DURATION_MS = 30 * 60 * 1_000;
|
||||
const configuredDurationMs = Number.parseInt(process.env.EDITING_SOAK_DURATION_MS ?? String(REQUIRED_DURATION_MS), 10);
|
||||
const allowShort = process.env.EDITING_SOAK_ALLOW_SHORT === "1";
|
||||
const reportPath = process.env.EDITING_SOAK_REPORT ? path.resolve(process.cwd(), process.env.EDITING_SOAK_REPORT) : null;
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
interface StoredProjectSample {
|
||||
revision: number;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
snapshotCount: number;
|
||||
}
|
||||
|
||||
interface ResourceSample {
|
||||
cycle: number;
|
||||
label: string;
|
||||
elapsedMs: number;
|
||||
jsHeapBytes: number;
|
||||
storageUsageBytes: number;
|
||||
storageQuotaBytes: number;
|
||||
}
|
||||
|
||||
async function readStoredProject(page: Page): Promise<StoredProjectSample> {
|
||||
return page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
try {
|
||||
const [project, snapshots] = await Promise.all([
|
||||
storage.readProject("basic_scene"),
|
||||
storage.listSnapshots("basic_scene"),
|
||||
]);
|
||||
return { revision: project.revision, sha256: project.sha256, bytes: project.bytes, snapshotCount: snapshots.snapshots.length };
|
||||
}
|
||||
finally {
|
||||
storage.terminate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("M7-18 keeps high-frequency retained snapshots within a bounded origin budget", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
const projectId = `snapshot-soak-${Date.now()}`;
|
||||
const payload = new Uint8Array(256 * 1024);
|
||||
payload.fill(0x5a);
|
||||
const baseline = (await navigator.storage.estimate()).usage ?? 0;
|
||||
for (let revision = 1; revision <= 128; revision++) {
|
||||
payload[0] = revision & 0xff;
|
||||
await storage.saveSnapshot(projectId, revision, payload.slice().buffer, 5, 2 * 1024 * 1024);
|
||||
}
|
||||
const snapshots = await storage.listSnapshots(projectId);
|
||||
const latest = await storage.readSnapshot(projectId, 128);
|
||||
const final = (await navigator.storage.estimate()).usage ?? 0;
|
||||
storage.terminate();
|
||||
return {
|
||||
growthBytes: final - baseline,
|
||||
revisions: snapshots.snapshots.map((snapshot) => snapshot.revision),
|
||||
latestByte: new Uint8Array(latest.buffer)[0],
|
||||
};
|
||||
});
|
||||
expect(result.revisions).toEqual([128, 127, 126, 125, 124]);
|
||||
expect(result.latestByte).toBe(128);
|
||||
expect(result.growthBytes).toBeLessThanOrEqual(4 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test("M7-18 sustains editing, autosave and OPFS reopen for 30 minutes", async ({ page }, testInfo) => {
|
||||
if (!Number.isSafeInteger(configuredDurationMs) || configuredDurationMs <= 0) throw new Error("EDITING_SOAK_DURATION_INVALID");
|
||||
if (!allowShort && configuredDurationMs < REQUIRED_DURATION_MS) throw new Error("EDITING_SOAK_DURATION_BELOW_30_MINUTES");
|
||||
test.setTimeout(configuredDurationMs + 5 * 60 * 1_000);
|
||||
|
||||
const pageErrors: string[] = [];
|
||||
const resourceSamples: ResourceSample[] = [];
|
||||
const revisionSamples: number[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
let downloadCount = 0;
|
||||
page.on("download", () => { downloadCount += 1; });
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
let soakStarted = 0;
|
||||
let actualDurationMs = 0;
|
||||
let cycles = 0;
|
||||
let autosaves = 0;
|
||||
let reopens = 0;
|
||||
let initialRevision = 0;
|
||||
let finalStored: StoredProjectSample | null = null;
|
||||
let failure: string | null = null;
|
||||
|
||||
const app = page.locator("main.blender-app");
|
||||
const sampleResources = async (label: string): Promise<void> => {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
let jsHeapBytes = 0;
|
||||
try {
|
||||
await cdp.send("HeapProfiler.collectGarbage");
|
||||
await cdp.send("Performance.enable");
|
||||
const metrics = await cdp.send("Performance.getMetrics") as { metrics: Array<{ name: string; value: number }> };
|
||||
jsHeapBytes = metrics.metrics.find((metric) => metric.name === "JSHeapUsedSize")?.value ?? 0;
|
||||
}
|
||||
finally {
|
||||
await cdp.detach();
|
||||
}
|
||||
const storage = await page.evaluate(async () => {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
return { usage: estimate.usage ?? 0, quota: estimate.quota ?? 0 };
|
||||
});
|
||||
resourceSamples.push({ cycle: cycles, label, elapsedMs: soakStarted ? Date.now() - soakStarted : 0, jsHeapBytes, storageUsageBytes: storage.usage, storageQuotaBytes: storage.quota });
|
||||
};
|
||||
|
||||
const reopen = async (expected: StoredProjectSample): Promise<void> => {
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const recent = page.getByTestId("recent-projects");
|
||||
await expect(recent.locator('option[value="basic_scene"]')).toHaveCount(1, { timeout: 20_000 });
|
||||
await recent.selectOption("basic_scene");
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3", { timeout: 30_000 });
|
||||
await expect(app).toHaveAttribute("data-project-id", "basic_scene");
|
||||
await expect(app).toHaveAttribute("data-current-main-revision", String(expected.revision));
|
||||
await expect(app).toHaveAttribute("data-committed-main-revision", String(expected.revision));
|
||||
await expect(app).toHaveAttribute("data-save-committed-hash", expected.sha256);
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
await expect(app).toHaveAttribute("data-diagnostic-count", "0");
|
||||
await expect(app).not.toHaveAttribute("data-worker-fault-code", /.+/);
|
||||
reopens += 1;
|
||||
};
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3", { timeout: 30_000 });
|
||||
const firstDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await firstDownload;
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
finalStored = await readStoredProject(page);
|
||||
initialRevision = finalStored.revision;
|
||||
revisionSamples.push(initialRevision);
|
||||
await sampleResources("baseline");
|
||||
|
||||
soakStarted = Date.now();
|
||||
const reopenEveryCycles = allowShort ? 3 : 12;
|
||||
const cyclePauseMs = allowShort ? 100 : 2_000;
|
||||
while (Date.now() - soakStarted < configuredDurationMs) {
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
|
||||
await expect(app).toHaveAttribute("data-dirty", "true");
|
||||
await page.getByRole("button", { name: "删除对象" }).click();
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3");
|
||||
await expect(app).toHaveAttribute("data-dirty", "false", { timeout: 15_000 });
|
||||
await expect(app).toHaveAttribute("data-save-transaction-status", "SUCCEEDED");
|
||||
const currentRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
const committedRevision = Number(await app.getAttribute("data-committed-main-revision"));
|
||||
expect(currentRevision).toBe(committedRevision);
|
||||
expect(currentRevision).toBeGreaterThan(revisionSamples.at(-1) ?? 0);
|
||||
revisionSamples.push(currentRevision);
|
||||
finalStored = await readStoredProject(page);
|
||||
expect(finalStored.revision).toBe(currentRevision);
|
||||
expect(finalStored.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(finalStored.snapshotCount).toBeLessThanOrEqual(5);
|
||||
await expect(app).toHaveAttribute("data-save-committed-hash", finalStored.sha256);
|
||||
await expect(app).toHaveAttribute("data-diagnostic-count", "0");
|
||||
cycles += 1;
|
||||
autosaves += 1;
|
||||
|
||||
if (cycles % reopenEveryCycles === 0) {
|
||||
await reopen(finalStored);
|
||||
await sampleResources(`reopen-${reopens}`);
|
||||
console.log(`M7-18 soak progress elapsedMs=${Date.now() - soakStarted} cycles=${cycles} reopens=${reopens} revision=${finalStored.revision}`);
|
||||
}
|
||||
if (cyclePauseMs > 0) await page.waitForTimeout(cyclePauseMs);
|
||||
}
|
||||
actualDurationMs = Date.now() - soakStarted;
|
||||
finalStored = await readStoredProject(page);
|
||||
await reopen(finalStored);
|
||||
await sampleResources("final");
|
||||
|
||||
const baseline = resourceSamples[0];
|
||||
const final = resourceSamples.at(-1)!;
|
||||
const heapGrowthBytes = final.jsHeapBytes - baseline.jsHeapBytes;
|
||||
const storageGrowthBytes = final.storageUsageBytes - baseline.storageUsageBytes;
|
||||
expect(actualDurationMs).toBeGreaterThanOrEqual(configuredDurationMs);
|
||||
expect(cycles).toBeGreaterThan(allowShort ? 0 : 100);
|
||||
expect(reopens).toBeGreaterThanOrEqual(allowShort ? 1 : 20);
|
||||
expect(autosaves).toBe(cycles);
|
||||
expect(finalStored.revision).toBeGreaterThanOrEqual(initialRevision + cycles * 2);
|
||||
expect(finalStored.snapshotCount).toBeLessThanOrEqual(5);
|
||||
expect(downloadCount).toBe(1);
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(heapGrowthBytes).toBeLessThanOrEqual(64 * 1024 * 1024);
|
||||
expect(storageGrowthBytes).toBeLessThanOrEqual(16 * 1024 * 1024);
|
||||
}
|
||||
catch (error) {
|
||||
failure = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
if (soakStarted && actualDurationMs === 0) actualDurationMs = Date.now() - soakStarted;
|
||||
const baseline = resourceSamples[0];
|
||||
const final = resourceSamples.at(-1);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
status: failure ? "FAILED" : "READY",
|
||||
profile: allowShort ? "DEBUG" : "FORMAL",
|
||||
requiredDurationMs: REQUIRED_DURATION_MS,
|
||||
configuredDurationMs,
|
||||
actualDurationMs,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
cycles,
|
||||
autosaves,
|
||||
reopens,
|
||||
initialRevision,
|
||||
finalRevision: finalStored?.revision ?? 0,
|
||||
finalSha256: finalStored?.sha256 ?? null,
|
||||
finalBytes: finalStored?.bytes ?? 0,
|
||||
finalSnapshotCount: finalStored?.snapshotCount ?? 0,
|
||||
downloadCount,
|
||||
pageErrors,
|
||||
failure,
|
||||
limits: { maxHeapGrowthBytes: 64 * 1024 * 1024, maxStorageGrowthBytes: 16 * 1024 * 1024, maxSnapshots: 5 },
|
||||
observed: {
|
||||
heapGrowthBytes: baseline && final ? final.jsHeapBytes - baseline.jsHeapBytes : null,
|
||||
storageGrowthBytes: baseline && final ? final.storageUsageBytes - baseline.storageUsageBytes : null,
|
||||
},
|
||||
resourceSamples,
|
||||
};
|
||||
const body = `${JSON.stringify(report, null, 2)}\n`;
|
||||
await testInfo.attach("editing-soak-report", { body, contentType: "application/json" });
|
||||
if (reportPath) {
|
||||
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
||||
await fs.writeFile(reportPath, body);
|
||||
}
|
||||
}
|
||||
});
|
||||
218
web/tests/e2e/external-vfont.spec.ts
Normal file
218
web/tests/e2e/external-vfont.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fontPath = path.resolve(import.meta.dirname, "../../../blender-5.2.0/release/datafiles/bfont.pfb");
|
||||
const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend");
|
||||
|
||||
test("M9-01 validates a real external font in Chromium before any storage or Main call", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const bytes = fs.readFileSync(fontPath);
|
||||
const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
const result = await page.evaluate(({ bytes, sha256 }) => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/external-vfont-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
const data = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
worker.postMessage({ data, sha256 }, [data]);
|
||||
}), { bytes: new Uint8Array(bytes), sha256 });
|
||||
expect(result.metadata).toMatchObject({ schemaVersion: 1, sourcePath: "//fonts/browser-bfont.pfb", format: "PFB", byteLength: 25181, sha256 });
|
||||
expect(result.copied).toBe(true);
|
||||
expect(result.spoofCode).toBe("NON_MESH_BINARY_INVALID");
|
||||
expect(result.stage).toBe("VALIDATED_BEFORE_STORAGE_OR_MAIN");
|
||||
});
|
||||
|
||||
test("M9-02 commits the verified font to OPFS before creating a packed Main VFont", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const fontBytes = fs.readFileSync(fontPath);
|
||||
const blendBytes = fs.readFileSync(nonMeshBlend);
|
||||
const sha256 = crypto.createHash("sha256").update(fontBytes).digest("hex");
|
||||
const result = await page.evaluate(async ({ fontBytes, blendBytes, sha256 }) => {
|
||||
const [{ StorageClient }, { WebEngineClient }, { importExternalVFontIntoMain }] = await Promise.all([
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/fonts/external-vfont-import.ts"),
|
||||
]);
|
||||
const projectId = "m9-vfont-browser";
|
||||
const storage = new StorageClient();
|
||||
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const events: string[] = [];
|
||||
await engine.init();
|
||||
const blend = blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength) as ArrayBuffer;
|
||||
const opened = await engine.openBlend(blend);
|
||||
const storagePort = {
|
||||
putAsset: async (...args: Parameters<StorageClient["putAsset"]>) => {
|
||||
events.push("storage:start");
|
||||
const stored = await storage.putAsset(...args);
|
||||
events.push("storage:committed");
|
||||
return stored;
|
||||
},
|
||||
};
|
||||
const enginePort = {
|
||||
applyCommand: async (...args: Parameters<WebEngineClient["applyCommand"]>) => {
|
||||
events.push("main:start");
|
||||
const applied = await engine.applyCommand(...args);
|
||||
events.push("main:committed");
|
||||
return applied;
|
||||
},
|
||||
};
|
||||
const data = fontBytes.buffer.slice(fontBytes.byteOffset, fontBytes.byteOffset + fontBytes.byteLength) as ArrayBuffer;
|
||||
const imported = await importExternalVFontIntoMain({
|
||||
projectId,
|
||||
request: { sourcePath: "//fonts/m9-browser-bfont.pfb", mimeType: "application/x-font-type1", byteLength: data.byteLength, sha256, data },
|
||||
storage: storagePort,
|
||||
engine: enginePort,
|
||||
});
|
||||
const restored = await storage.readAsset(projectId, sha256);
|
||||
let failedMainCalls = 0;
|
||||
let failureCode = "";
|
||||
try {
|
||||
await importExternalVFontIntoMain({
|
||||
projectId,
|
||||
request: { sourcePath: "//fonts/m9-storage-failure.pfb", mimeType: "application/x-font-type1", byteLength: restored.data.byteLength, sha256, data: restored.data.slice(0) },
|
||||
storage: { putAsset: async () => { throw new Error("STORAGE_TRANSACTION: injected before asset commit"); } },
|
||||
engine: { applyCommand: async () => { failedMainCalls += 1; throw new Error("unexpected Main call"); } },
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
failureCode = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const listed = await storage.listAssets(projectId);
|
||||
storage.terminate();
|
||||
engine.terminate();
|
||||
return {
|
||||
beforeCount: opened.snapshot.vfonts?.length ?? 0,
|
||||
afterCount: imported.snapshot.vfonts?.length ?? 0,
|
||||
events,
|
||||
asset: imported.asset,
|
||||
vfont: imported.vfont,
|
||||
restoredHash: restored.asset.sha256,
|
||||
restoredBytes: restored.data.byteLength,
|
||||
listedCount: listed.assets.length,
|
||||
failedMainCalls,
|
||||
failureCode,
|
||||
};
|
||||
}, { fontBytes: new Uint8Array(fontBytes), blendBytes: new Uint8Array(blendBytes), sha256 });
|
||||
expect(result.events).toEqual(["storage:start", "storage:committed", "main:start", "main:committed"]);
|
||||
expect(result.afterCount).toBe(result.beforeCount + 1);
|
||||
expect(result.asset).toMatchObject({ projectId: "m9-vfont-browser", sha256, bytes: fontBytes.byteLength, persisted: true });
|
||||
expect(result.asset.path).toBe(`projects/m9-vfont-browser/assets/sha256/${sha256.slice(0, 2)}/${sha256}`);
|
||||
expect(result.vfont).toMatchObject({ name: "m9-browser-bfont", sourcePath: "//fonts/m9-browser-bfont.pfb", builtin: false, packed: true });
|
||||
expect(result.restoredHash).toBe(sha256);
|
||||
expect(result.restoredBytes).toBe(fontBytes.byteLength);
|
||||
expect(result.listedCount).toBe(1);
|
||||
expect(result.failedMainCalls).toBe(0);
|
||||
expect(result.failureCode).toContain("STORAGE_TRANSACTION");
|
||||
});
|
||||
|
||||
test("M9-03 replaces, undoes, saves and reopens a packed font with missing-asset closure", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const fontBytes = fs.readFileSync(fontPath);
|
||||
const blendBytes = fs.readFileSync(nonMeshBlend);
|
||||
const sha256 = crypto.createHash("sha256").update(fontBytes).digest("hex");
|
||||
const result = await page.evaluate(async ({ fontBytes, blendBytes, sha256 }) => {
|
||||
const [{ StorageClient }, { WebEngineClient }, fontImport] = await Promise.all([
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/fonts/external-vfont-import.ts"),
|
||||
]);
|
||||
const projectId = "m9-vfont-roundtrip";
|
||||
const storage = new StorageClient();
|
||||
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await engine.init();
|
||||
const opened = await engine.openBlend(blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength));
|
||||
const fontData = opened.snapshot.nonMeshData?.find((candidate) => candidate.type === "FONT" && candidate.fontLinks);
|
||||
if (!fontData?.fontLinks) throw new Error("font fixture has no style links");
|
||||
const originalLinks = { ...fontData.fontLinks };
|
||||
const imported = await fontImport.importExternalVFontIntoMain({
|
||||
projectId,
|
||||
request: {
|
||||
sourcePath: "//fonts/m9-roundtrip-bfont.pfb",
|
||||
mimeType: "application/x-font-type1",
|
||||
byteLength: fontBytes.byteLength,
|
||||
sha256,
|
||||
data: fontBytes.buffer.slice(fontBytes.byteOffset, fontBytes.byteOffset + fontBytes.byteLength),
|
||||
},
|
||||
storage,
|
||||
engine,
|
||||
});
|
||||
const replaced = await fontImport.replaceExternalVFontStyleInMain({
|
||||
projectId,
|
||||
sha256,
|
||||
dataId: fontData.id,
|
||||
vfontId: imported.vfont.id,
|
||||
style: "regular",
|
||||
snapshot: imported.snapshot,
|
||||
storage,
|
||||
engine,
|
||||
});
|
||||
const undone = await engine.applyCommand({ type: "undo" });
|
||||
const redone = await engine.applyCommand({ type: "redo" });
|
||||
const undoLinks = undone.snapshot.nonMeshData?.find((candidate) => candidate.id === fontData.id)?.fontLinks;
|
||||
const redoLinks = redone.snapshot.nonMeshData?.find((candidate) => candidate.id === fontData.id)?.fontLinks;
|
||||
const savedBlend = await engine.saveBlend();
|
||||
await storage.saveProject(projectId, redone.snapshot.revision, savedBlend);
|
||||
|
||||
const root = await navigator.storage.getDirectory();
|
||||
let assetDirectory = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", sha256.slice(0, 2)]) {
|
||||
assetDirectory = await assetDirectory.getDirectoryHandle(segment);
|
||||
}
|
||||
await assetDirectory.removeEntry(sha256);
|
||||
let missingCode = "";
|
||||
let missingMainCalls = 0;
|
||||
try {
|
||||
await fontImport.replaceExternalVFontStyleInMain({
|
||||
projectId,
|
||||
sha256,
|
||||
dataId: fontData.id,
|
||||
vfontId: imported.vfont.id,
|
||||
style: "bold",
|
||||
snapshot: redone.snapshot,
|
||||
storage,
|
||||
engine: { applyCommand: async () => { missingMainCalls += 1; throw new Error("unexpected Main call"); } },
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
missingCode = (error as { code?: string }).code ?? "";
|
||||
}
|
||||
engine.terminate();
|
||||
|
||||
const persisted = await storage.readProject(projectId);
|
||||
storage.terminate();
|
||||
const reopenedEngine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await reopenedEngine.init();
|
||||
const reopened = await reopenedEngine.openBlend(persisted.buffer);
|
||||
reopenedEngine.terminate();
|
||||
const reopenedData = reopened.snapshot.nonMeshData?.find((candidate) => candidate.id === fontData.id);
|
||||
const reopenedVFont = reopened.snapshot.vfonts?.find((candidate) => candidate.id === imported.vfont.id);
|
||||
return {
|
||||
originalLinks,
|
||||
replacementLinks: replaced.links,
|
||||
undoLinks,
|
||||
redoLinks,
|
||||
importedVFont: imported.vfont,
|
||||
reopenedLinks: reopenedData?.fontLinks,
|
||||
reopenedVFont,
|
||||
missingCode,
|
||||
missingMainCalls,
|
||||
};
|
||||
}, { fontBytes: new Uint8Array(fontBytes), blendBytes: new Uint8Array(blendBytes), sha256 });
|
||||
expect(result.replacementLinks.regular).toBe(result.importedVFont.id);
|
||||
expect(result.undoLinks).toEqual(result.originalLinks);
|
||||
expect(result.redoLinks).toEqual(result.replacementLinks);
|
||||
expect(result.missingCode).toBe("NON_MESH_RESOURCE_MISSING");
|
||||
expect(result.missingMainCalls).toBe(0);
|
||||
expect(result.reopenedLinks).toEqual(result.replacementLinks);
|
||||
expect(result.reopenedVFont).toMatchObject({
|
||||
id: result.importedVFont.id,
|
||||
sourcePath: "//fonts/m9-roundtrip-bfont.pfb",
|
||||
builtin: false,
|
||||
packed: true,
|
||||
packedByteLength: fontBytes.byteLength,
|
||||
sha256,
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,8 @@ test("M7-03 cancels a large streamed open before WebEngine and preserves the cur
|
||||
const actualTotal = Number(element?.getAttribute("data-total-bytes"));
|
||||
const cancel = document.querySelector<HTMLButtonElement>('button[aria-label="取消打开"]');
|
||||
if (bytesRead <= 0 || bytesRead >= expectedTotal || !cancel) return false;
|
||||
cancel.click();
|
||||
cancel.focus();
|
||||
cancel.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
|
||||
return { bytesRead, totalBytes: actualTotal };
|
||||
}, totalBytes, { polling: "raf", timeout: 10_000 });
|
||||
const observed = await observation.jsonValue() as { bytesRead: number; totalBytes: number };
|
||||
|
||||
67
web/tests/e2e/geometry-node-allowlist.spec.ts
Normal file
67
web/tests/e2e/geometry-node-allowlist.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(
|
||||
path.join(root, "tests/golden/M10-02/geometry-node-allowlist.json"),
|
||||
"utf8",
|
||||
));
|
||||
const blendBytes = fs.readFileSync(path.join(root, expected.fixture));
|
||||
|
||||
test("M10-02 blocks non-allowlisted Main nodes without replacing the preserved graph", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
expect((await import("node:crypto")).createHash("sha256").update(blendBytes).digest("hex"))
|
||||
.toBe(expected.fixtureSha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ bytes, expected }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await client.init();
|
||||
const opened = await client.openBlend(source);
|
||||
const supported = opened.snapshot.geometryNodeGraphs?.find((graph) => graph.name === expected.supportedGraph);
|
||||
const unsupported = opened.snapshot.geometryNodeGraphs?.find((graph) => graph.name === expected.unsupportedGraph);
|
||||
const meshId = opened.snapshot.meshes[0]?.id ?? "mesh:missing";
|
||||
if (!supported || !unsupported) throw new Error("Geometry Node allowlist fixture graphs are missing");
|
||||
const baseline = JSON.stringify(opened.snapshot.geometryNodeGraphs);
|
||||
const revision = opened.snapshot.revision;
|
||||
const unsupportedTypes = unsupported.nodes
|
||||
.filter((node) => !expected.allowlist.includes(node.type))
|
||||
.map((node) => node.type);
|
||||
const attempt = async (graph: typeof supported) => {
|
||||
try {
|
||||
await client.applyCommand({ type: "setGeometryNodeGraph", meshId, graph });
|
||||
return null;
|
||||
}
|
||||
catch (error) {
|
||||
return error as { code?: string; message?: string };
|
||||
}
|
||||
};
|
||||
const unsupportedError = await attempt(unsupported);
|
||||
const afterUnsupported = await client.snapshot();
|
||||
const supportedError = await attempt(supported);
|
||||
const afterSupported = await client.snapshot();
|
||||
client.terminate();
|
||||
return {
|
||||
unsupportedTypes,
|
||||
unsupportedErrorCode: unsupportedError?.code,
|
||||
supportedErrorCode: supportedError?.code,
|
||||
unsupportedPreserved: JSON.stringify(afterUnsupported.snapshot.geometryNodeGraphs) === baseline,
|
||||
supportedPreserved: JSON.stringify(afterSupported.snapshot.geometryNodeGraphs) === baseline,
|
||||
revisions: [revision, afterUnsupported.snapshot.revision, afterSupported.snapshot.revision],
|
||||
graphHashes: [unsupported.graphHash, afterUnsupported.snapshot.geometryNodeGraphs?.find((graph) => graph.name === expected.unsupportedGraph)?.graphHash],
|
||||
};
|
||||
}, { bytes: new Uint8Array(blendBytes), expected });
|
||||
|
||||
expect(result).toEqual({
|
||||
unsupportedTypes: expected.unsupportedNodeTypes,
|
||||
unsupportedErrorCode: expected.unsupportedErrorCode,
|
||||
supportedErrorCode: expected.evaluatorUnavailableErrorCode,
|
||||
unsupportedPreserved: true,
|
||||
supportedPreserved: true,
|
||||
revisions: [result.revisions[0], result.revisions[0], result.revisions[0]],
|
||||
graphHashes: [result.graphHashes[0], result.graphHashes[0]],
|
||||
});
|
||||
expect(result.graphHashes[0]).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
164
web/tests/e2e/geometry-node-evaluator-golden.spec.ts
Normal file
164
web/tests/e2e/geometry-node-evaluator-golden.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
interface GoldenMesh {
|
||||
attributes: Record<string, {
|
||||
domain: "POINT";
|
||||
dataType: "FLOAT";
|
||||
values: number[];
|
||||
}>;
|
||||
bounds: { min: number[]; max: number[] };
|
||||
indices: number[];
|
||||
positions: number[];
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
}
|
||||
|
||||
interface GoldenCase {
|
||||
graph: string;
|
||||
mesh: GoldenMesh;
|
||||
name: string;
|
||||
nodeTypes: string[];
|
||||
}
|
||||
|
||||
interface Golden {
|
||||
allowlist: string[];
|
||||
cases: GoldenCase[];
|
||||
fixture: string;
|
||||
fixtureSha256: string;
|
||||
nodeCoverage: Record<string, string[]>;
|
||||
schemaVersion: number;
|
||||
tolerance: {
|
||||
boundsError: number;
|
||||
maxAttributeError: number;
|
||||
maxPositionError: number;
|
||||
rmsPositionError: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface EvaluatedMesh {
|
||||
attributes?: GoldenMesh["attributes"];
|
||||
indices: number[];
|
||||
modifiers: Array<{ status: string }>;
|
||||
objectId: string;
|
||||
positions: number[];
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
}
|
||||
|
||||
interface EvaluationResult {
|
||||
graphs: Array<{ name: string; nodes: Array<{ type: string }> }>;
|
||||
report: { engine: string; status: string; meshes: EvaluatedMesh[] };
|
||||
}
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(
|
||||
path.join(root, "tests/golden/M10-03/geometry-node-evaluator.json"),
|
||||
"utf8",
|
||||
)) as Golden;
|
||||
const blendBytes = fs.readFileSync(path.join(root, golden.fixture));
|
||||
|
||||
function errorMetrics(expected: number[], actual: number[]) {
|
||||
expect(actual).toHaveLength(expected.length);
|
||||
const errors = actual.map((value, index) => value - expected[index]);
|
||||
return {
|
||||
maximum: Math.max(0, ...errors.map((value) => Math.abs(value))),
|
||||
rms: errors.length === 0 ? 0 : Math.sqrt(
|
||||
errors.reduce((sum, value) => sum + value * value, 0) / errors.length,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function bounds(positions: number[]) {
|
||||
if (positions.length === 0) return { min: [0, 0, 0], max: [0, 0, 0] };
|
||||
const result = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] };
|
||||
for (let index = 0; index < positions.length; index += 3) {
|
||||
for (let axis = 0; axis < 3; axis++) {
|
||||
result.min[axis] = Math.min(result.min[axis], positions[index + axis]);
|
||||
result.max[axis] = Math.max(result.max[axis], positions[index + axis]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function verifyEvaluation(result: EvaluationResult) {
|
||||
expect(result.report.engine).toBe("BlenderDepsgraph");
|
||||
expect(result.report.status).toBe("EVALUATED");
|
||||
const graphs = new Map(result.graphs.map((graph) => [graph.name, graph]));
|
||||
const meshes = new Map(result.report.meshes.map((mesh) => [mesh.objectId, mesh]));
|
||||
|
||||
for (const expectedCase of golden.cases) {
|
||||
const graph = graphs.get(expectedCase.graph);
|
||||
expect(graph, `${expectedCase.name} graph`).toBeDefined();
|
||||
expect(graph?.nodes.map((node) => node.type).sort(), `${expectedCase.name} node inventory`)
|
||||
.toEqual([...expectedCase.nodeTypes].sort());
|
||||
|
||||
const actual = meshes.get(`object:${expectedCase.name}`);
|
||||
expect(actual, `${expectedCase.name} evaluated mesh`).toBeDefined();
|
||||
if (!actual) continue;
|
||||
expect(actual.vertexCount, `${expectedCase.name} vertex count`).toBe(expectedCase.mesh.vertexCount);
|
||||
expect(actual.triangleCount, `${expectedCase.name} triangle count`).toBe(expectedCase.mesh.triangleCount);
|
||||
expect(actual.indices, `${expectedCase.name} topology`).toEqual(expectedCase.mesh.indices);
|
||||
expect(actual.modifiers).toHaveLength(1);
|
||||
expect(actual.modifiers[0].status, `${expectedCase.name} modifier status`).toBe("EVALUATED");
|
||||
|
||||
const positionError = errorMetrics(expectedCase.mesh.positions, actual.positions);
|
||||
expect(positionError.maximum, `${expectedCase.name} maximum position error`)
|
||||
.toBeLessThanOrEqual(golden.tolerance.maxPositionError);
|
||||
expect(positionError.rms, `${expectedCase.name} RMS position error`)
|
||||
.toBeLessThanOrEqual(golden.tolerance.rmsPositionError);
|
||||
|
||||
const actualBounds = bounds(actual.positions);
|
||||
expect(errorMetrics(expectedCase.mesh.bounds.min, actualBounds.min).maximum,
|
||||
`${expectedCase.name} minimum bounds error`).toBeLessThanOrEqual(golden.tolerance.boundsError);
|
||||
expect(errorMetrics(expectedCase.mesh.bounds.max, actualBounds.max).maximum,
|
||||
`${expectedCase.name} maximum bounds error`).toBeLessThanOrEqual(golden.tolerance.boundsError);
|
||||
|
||||
expect(Object.keys(actual.attributes ?? {}).sort(), `${expectedCase.name} attribute inventory`)
|
||||
.toEqual(Object.keys(expectedCase.mesh.attributes).sort());
|
||||
for (const [name, expectedAttribute] of Object.entries(expectedCase.mesh.attributes)) {
|
||||
const actualAttribute = actual.attributes?.[name];
|
||||
expect(actualAttribute, `${expectedCase.name}/${name}`).toBeDefined();
|
||||
expect(actualAttribute?.domain).toBe(expectedAttribute.domain);
|
||||
expect(actualAttribute?.dataType).toBe(expectedAttribute.dataType);
|
||||
expect(errorMetrics(expectedAttribute.values, actualAttribute?.values ?? []).maximum,
|
||||
`${expectedCase.name}/${name} value error`)
|
||||
.toBeLessThanOrEqual(golden.tolerance.maxAttributeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("M10-03 matches every allowlisted Geometry Node against Blender 5.2 desktop goldens", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
expect(golden.schemaVersion).toBe(1);
|
||||
expect(crypto.createHash("sha256").update(blendBytes).digest("hex")).toBe(golden.fixtureSha256);
|
||||
expect(Object.keys(golden.nodeCoverage).sort()).toEqual([...golden.allowlist].sort());
|
||||
expect(golden.allowlist).toHaveLength(16);
|
||||
|
||||
await page.goto("/");
|
||||
const evaluations = await page.evaluate(async ({ bytes }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const first = new WebEngineClient({ timeoutMs: 90_000 });
|
||||
await first.init();
|
||||
const opened = await first.openBlend(source);
|
||||
const firstReport = await first.evaluateDepsgraph();
|
||||
const saved = await first.saveBlend();
|
||||
const initial = { graphs: opened.snapshot.geometryNodeGraphs ?? [], report: firstReport.depsgraph };
|
||||
first.terminate();
|
||||
|
||||
const second = new WebEngineClient({ timeoutMs: 90_000 });
|
||||
await second.init();
|
||||
const reopened = await second.openBlend(saved);
|
||||
const secondReport = await second.evaluateDepsgraph();
|
||||
const restored = { graphs: reopened.snapshot.geometryNodeGraphs ?? [], report: secondReport.depsgraph };
|
||||
second.terminate();
|
||||
return [initial, restored];
|
||||
}, { bytes: new Uint8Array(blendBytes) }) as EvaluationResult[];
|
||||
|
||||
expect(evaluations).toHaveLength(2);
|
||||
verifyEvaluation(evaluations[0]);
|
||||
verifyEvaluation(evaluations[1]);
|
||||
});
|
||||
108
web/tests/e2e/geometry-node-field-budget.spec.ts
Normal file
108
web/tests/e2e/geometry-node-field-budget.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
GEOMETRY_NODE_FIELD_BUDGET,
|
||||
parseGeometryNodeFieldMaterializationBatch,
|
||||
type GeometryNodeDomainCardinalityIR,
|
||||
} from "../../protocol/geometry-nodes";
|
||||
import { parseDepsgraphEvaluation, type DepsgraphEvaluationIR } from "../../protocol/depsgraph";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const fixture = fs.readFileSync(path.join(root, "tests/files/web/geometry_node_allowlist_evaluator.blend"));
|
||||
|
||||
test("M10-04 binds field conversion budgets to native mesh domain cardinality", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ bytes }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const client = new WebEngineClient({ timeoutMs: 90_000 });
|
||||
await client.init();
|
||||
await client.openBlend(source);
|
||||
const evaluated = await client.evaluateDepsgraph();
|
||||
client.terminate();
|
||||
const mesh = evaluated.depsgraph.meshes.find((candidate) =>
|
||||
candidate.objectId === "object:M10GN_StoreAttribute");
|
||||
if (!mesh?.domainCardinality) throw new Error("Store Named Attribute domain cardinality is missing");
|
||||
return {
|
||||
depsgraph: evaluated.depsgraph,
|
||||
domainCardinality: mesh.domainCardinality,
|
||||
fieldMaterializations: mesh.fieldMaterializations ?? [],
|
||||
attributes: mesh.attributes ?? {},
|
||||
};
|
||||
}, { bytes: new Uint8Array(fixture) });
|
||||
|
||||
expect(result.domainCardinality).toEqual({
|
||||
POINT: 8,
|
||||
EDGE: 12,
|
||||
FACE: 6,
|
||||
CORNER: 24,
|
||||
CURVE: 0,
|
||||
INSTANCE: 0,
|
||||
LAYER: 0,
|
||||
});
|
||||
expect(result.fieldMaterializations).toEqual([{
|
||||
schemaVersion: 1,
|
||||
fieldId: "attribute:m10_value",
|
||||
domain: "POINT",
|
||||
dataType: "FLOAT",
|
||||
elementCount: 8,
|
||||
scalarValueCount: 8,
|
||||
materializedByteLength: 32,
|
||||
transport: "JSON",
|
||||
}]);
|
||||
expect(result.attributes.m10_value.values).toHaveLength(8);
|
||||
expect(result.attributes.m10_value.values.every((value: number) => value === 0.375)).toBe(true);
|
||||
|
||||
const rejects = (mutate: (candidate: DepsgraphEvaluationIR) => void): void => {
|
||||
const candidate = structuredClone(result.depsgraph);
|
||||
mutate(candidate);
|
||||
expect(() => parseDepsgraphEvaluation(candidate)).toThrow();
|
||||
};
|
||||
rejects((candidate) => {
|
||||
const target = candidate.meshes.find((entry) => entry.objectId === "object:M10GN_StoreAttribute")!;
|
||||
target.domainCardinality!.POINT += 1;
|
||||
});
|
||||
rejects((candidate) => {
|
||||
const target = candidate.meshes.find((entry) => entry.objectId === "object:M10GN_StoreAttribute")!;
|
||||
target.fieldMaterializations![0].materializedByteLength += 4;
|
||||
});
|
||||
rejects((candidate) => {
|
||||
const target = candidate.meshes.find((entry) => entry.objectId === "object:M10GN_StoreAttribute")!;
|
||||
(target.fieldMaterializations![0] as unknown as Record<string, unknown>).values = [0];
|
||||
});
|
||||
|
||||
const common = {
|
||||
schemaVersion: 1 as const,
|
||||
graphId: "node-group:M10GN_StoreAttributeGraph",
|
||||
graphHash: "a".repeat(64),
|
||||
revision: 1,
|
||||
transport: "JSON" as const,
|
||||
domainCardinality: result.domainCardinality as GeometryNodeDomainCardinalityIR,
|
||||
};
|
||||
const batch = parseGeometryNodeFieldMaterializationBatch([
|
||||
{
|
||||
...common,
|
||||
fieldId: "field:point-to-corner",
|
||||
sourceDomain: "POINT",
|
||||
targetDomain: "CORNER",
|
||||
dataType: "FLOAT",
|
||||
},
|
||||
{
|
||||
...common,
|
||||
fieldId: "field:constant-offset",
|
||||
sourceDomain: "CONSTANT",
|
||||
targetDomain: "POINT",
|
||||
dataType: "VECTOR",
|
||||
},
|
||||
]);
|
||||
expect(batch).toMatchObject({
|
||||
fieldCount: 2,
|
||||
domainConversionCount: 1,
|
||||
materializedElementCount: 32,
|
||||
materializedByteLength: 192,
|
||||
});
|
||||
expect(batch.fields.every((field) => field.scalarValueCount <=
|
||||
GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField)).toBe(true);
|
||||
});
|
||||
57
web/tests/e2e/geometry-node-main-reader.spec.ts
Normal file
57
web/tests/e2e/geometry-node-main-reader.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const geometryNodesBlend = path.resolve(
|
||||
import.meta.dirname,
|
||||
"../../../tests/files/web/modifier_geometry_nodes_scene.blend",
|
||||
);
|
||||
|
||||
test("M10-01 production Worker preserves Main Geometry Node topology across save and reopen", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const blendBytes = fs.readFileSync(geometryNodesBlend);
|
||||
const result = await page.evaluate(async ({ blendBytes }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const source = blendBytes.buffer.slice(
|
||||
blendBytes.byteOffset,
|
||||
blendBytes.byteOffset + blendBytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
const first = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await first.init();
|
||||
const opened = await first.openBlend(source);
|
||||
const initial = opened.snapshot.geometryNodeGraphs ?? [];
|
||||
const saved = await first.saveBlend();
|
||||
first.terminate();
|
||||
|
||||
const second = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await second.init();
|
||||
const reopened = await second.openBlend(saved);
|
||||
const restored = reopened.snapshot.geometryNodeGraphs ?? [];
|
||||
second.terminate();
|
||||
return {
|
||||
graphNames: initial.map((graph) => graph.name),
|
||||
nodeCount: initial.reduce((count, graph) => count + graph.nodes.length, 0),
|
||||
linkCount: initial.reduce((count, graph) => count + graph.links.length, 0),
|
||||
defaultCount: initial.reduce((count, graph) => count + graph.nodes.reduce(
|
||||
(nodeCount, node) => nodeCount + node.sockets.filter((socket) => socket.defaultValue !== undefined).length,
|
||||
0,
|
||||
), 0),
|
||||
unsupportedPreserved: initial.some((graph) => graph.nodes.some((node) => node.type === "GeometryNodeSimulationOutput")),
|
||||
stable: JSON.stringify(initial) === JSON.stringify(restored),
|
||||
hashes: initial.map((graph) => graph.graphHash),
|
||||
};
|
||||
}, { blendBytes: new Uint8Array(blendBytes) });
|
||||
|
||||
expect(result).toEqual({
|
||||
graphNames: ["WebGeometryNodes", "WebGeometryNodesSetPosition", "WebGeometryNodesSimulation"],
|
||||
nodeCount: 10,
|
||||
linkCount: 7,
|
||||
defaultCount: 9,
|
||||
unsupportedPreserved: true,
|
||||
stable: true,
|
||||
hashes: result.hashes,
|
||||
});
|
||||
expect(result.hashes).toHaveLength(3);
|
||||
expect(result.hashes.every((hash) => /^[0-9a-f]{64}$/.test(hash ?? ""))).toBe(true);
|
||||
});
|
||||
37
web/tests/e2e/grease-pencil-marquee.spec.ts
Normal file
37
web/tests/e2e/grease-pencil-marquee.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend");
|
||||
const expectedPointIds = Array.from({ length: 4 }, (_, index) => `grease-pencil-point:GreasePencilData:0:0:${index}`);
|
||||
const expectedStrokeId = "grease-pencil-stroke:GreasePencilData:0:0";
|
||||
|
||||
for (const offscreen of [false, true]) {
|
||||
test(`M9-06 marquee selects only the current drawing stable IDs in ${offscreen ? "OffscreenCanvas" : "main-thread"} Chromium`, async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto(offscreen ? "/?offscreen=1" : "/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(fixture);
|
||||
await page.getByText("GreasePencilObject", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Object Mode" }).click();
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
const revision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
await page.getByRole("button", { name: "Grease Pencil 框选工具" }).click();
|
||||
const surface = page.getByTestId("grease-pencil-marquee-surface");
|
||||
await expect(surface).toHaveAttribute("data-drawing-id", "grease-pencil-drawing:GreasePencilData:0");
|
||||
const bounds = await surface.boundingBox();
|
||||
if (!bounds) throw new Error("Grease Pencil marquee surface has no bounds");
|
||||
await page.mouse.move(bounds.x + 3, bounds.y + 3);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width - 3, bounds.y + bounds.height - 3, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
|
||||
const canvas = page.locator("canvas.viewport-canvas");
|
||||
await expect(canvas).toHaveAttribute("data-grease-pencil-marquee-count", "4", { timeout: 20_000 });
|
||||
await expect(canvas).toHaveAttribute("data-grease-pencil-marquee-drawing-id", "grease-pencil-drawing:GreasePencilData:0");
|
||||
await expect(canvas).toHaveAttribute("data-grease-pencil-marquee-stroke-ids", expectedStrokeId);
|
||||
await expect.poll(async () => (await app.getAttribute("data-selected-grease-pencil-point-ids"))?.split(",").filter(Boolean).sort()).toEqual(expectedPointIds);
|
||||
expect(Number(await app.getAttribute("data-current-main-revision"))).toBe(revision);
|
||||
await expect(canvas).toHaveAttribute("data-renderer-backend", offscreen ? "offscreen-worker" : "webgl-pbr");
|
||||
});
|
||||
}
|
||||
65
web/tests/e2e/grease-pencil-reorder.spec.ts
Normal file
65
web/tests/e2e/grease-pencil-reorder.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend");
|
||||
const golden = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, "../../../tests/golden/M9-08/grease-pencil-reorder.json"), "utf8"));
|
||||
|
||||
test("M9-08 reorders Grease Pencil layers and frames through Main undo, save and reopen", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
expect(crypto.createHash("sha256").update(fs.readFileSync(fixture)).digest("hex")).toBe(golden.fixtureSha256);
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(fixture);
|
||||
await page.getByText("GreasePencilObject", { exact: true }).click();
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
const editor = page.getByTestId("grease-pencil-editor");
|
||||
const revision = async () => Number(await app.getAttribute("data-current-main-revision"));
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.source.layerOrder.join(","));
|
||||
await editor.getByLabel("Grease Pencil new layer name").fill("Web Drafts");
|
||||
await editor.getByRole("button", { name: "Add Layer" }).click();
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.beforeReorder.layerOrder.join(","));
|
||||
await editor.getByLabel("Grease Pencil layer", { exact: true }).selectOption({ label: "Web Drafts" });
|
||||
await editor.getByRole("button", { name: "Add Frame" }).click();
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-frames", "1");
|
||||
const drawingId = await editor.getAttribute("data-selected-layer-drawing-ids");
|
||||
expect(drawingId).toMatch(/^grease-pencil-drawing:GreasePencilData:\d+$/);
|
||||
|
||||
const beforeLayerMove = await revision();
|
||||
await editor.getByRole("button", { name: "Move layer down" }).click();
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.afterReorder.layerOrder.join(","));
|
||||
expect(await revision()).toBe(beforeLayerMove + 1);
|
||||
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.beforeReorder.layerOrder.join(","));
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.afterReorder.layerOrder.join(","));
|
||||
|
||||
const beforeFrameMove = await revision();
|
||||
await editor.getByLabel("Grease Pencil target frame").fill("12");
|
||||
await editor.getByRole("button", { name: "Move Grease Pencil frame" }).click();
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.afterReorder.framesByLayer["Web Drafts"].join(","));
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-drawing-ids", drawingId!);
|
||||
expect(await revision()).toBe(beforeFrameMove + 1);
|
||||
|
||||
await page.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.beforeReorder.framesByLayer["Web Drafts"].join(","));
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-drawing-ids", drawingId!);
|
||||
await page.getByRole("button", { name: "重做" }).click();
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.afterReorder.framesByLayer["Web Drafts"].join(","));
|
||||
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
await page.getByRole("button", { name: "关闭项目" }).click();
|
||||
await expect(editor).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "恢复项目" }).click();
|
||||
await page.getByText("GreasePencilObject", { exact: true }).click();
|
||||
await expect(editor).toHaveAttribute("data-layer-order", golden.reopened.layerOrder.join(","));
|
||||
await editor.getByLabel("Grease Pencil layer", { exact: true }).selectOption({ label: "Web Drafts" });
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-frames", golden.reopened.framesByLayer["Web Drafts"].join(","));
|
||||
await expect(editor).toHaveAttribute("data-selected-layer-drawing-ids", drawingId!);
|
||||
});
|
||||
50
web/tests/e2e/grease-pencil-selection.spec.ts
Normal file
50
web/tests/e2e/grease-pencil-selection.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = path.resolve(import.meta.dirname, "../../../tests/files/web/modifier_grease_pencil_scene.blend");
|
||||
const expectedPointIds = Array.from({ length: 4 }, (_, index) => `grease-pencil-point:GreasePencilData:0:0:${index}`);
|
||||
|
||||
for (const offscreen of [false, true]) {
|
||||
test(`M9-07 shares one Grease Pencil selection revision between 2D canvas and ${offscreen ? "OffscreenCanvas" : "main-thread"} 3D viewport`, async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto(offscreen ? "/?offscreen=1" : "/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(fixture);
|
||||
await page.getByText("GreasePencilObject", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Object Mode" }).click();
|
||||
|
||||
const app = page.locator(".blender-app");
|
||||
const viewport = page.locator("canvas.viewport-canvas");
|
||||
const canvas = page.getByTestId("grease-pencil-canvas-2d");
|
||||
await expect(canvas).toHaveAttribute("data-selection-revision", "0");
|
||||
const mainRevision = Number(await app.getAttribute("data-current-main-revision"));
|
||||
const first = await canvas.evaluate((element) => JSON.parse(element.dataset.pointLayout ?? "[]")[0] as { pointId: string; x: number; y: number });
|
||||
await canvas.scrollIntoViewIfNeeded();
|
||||
const bounds = await canvas.boundingBox();
|
||||
if (!bounds || !first) throw new Error("Grease Pencil 2D canvas point layout is unavailable");
|
||||
await page.mouse.click(bounds.x + (first.x / 280) * bounds.width, bounds.y + (first.y / 150) * bounds.height);
|
||||
|
||||
await expect(app).toHaveAttribute("data-grease-pencil-selection-revision", "1");
|
||||
await expect(app).toHaveAttribute("data-grease-pencil-selection-source", "CANVAS_2D");
|
||||
await expect(canvas).toHaveAttribute("data-selected-point-ids", first.pointId);
|
||||
await expect(viewport).toHaveAttribute("data-grease-pencil-selection-revision", "1", { timeout: 20_000 });
|
||||
await expect(viewport).toHaveAttribute("data-grease-pencil-selection-point-ids", first.pointId);
|
||||
|
||||
await page.getByRole("button", { name: "Grease Pencil 框选工具" }).click();
|
||||
const marquee = page.getByTestId("grease-pencil-marquee-surface");
|
||||
const marqueeBounds = await marquee.boundingBox();
|
||||
if (!marqueeBounds) throw new Error("Grease Pencil 3D marquee surface is unavailable");
|
||||
await page.mouse.move(marqueeBounds.x + 3, marqueeBounds.y + 3);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(marqueeBounds.x + marqueeBounds.width - 3, marqueeBounds.y + marqueeBounds.height - 3, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(app).toHaveAttribute("data-grease-pencil-selection-revision", "2", { timeout: 20_000 });
|
||||
await expect(app).toHaveAttribute("data-grease-pencil-selection-source", "VIEWPORT_3D");
|
||||
await expect.poll(async () => (await canvas.getAttribute("data-selected-point-ids"))?.split(",").filter(Boolean).sort()).toEqual(expectedPointIds);
|
||||
await expect(canvas).toHaveAttribute("data-selection-revision", "2");
|
||||
await expect(viewport).toHaveAttribute("data-grease-pencil-selection-revision", "2", { timeout: 20_000 });
|
||||
expect(Number(await app.getAttribute("data-current-main-revision"))).toBe(mainRevision);
|
||||
await expect(viewport).toHaveAttribute("data-renderer-backend", offscreen ? "offscreen-worker" : "webgl-pbr");
|
||||
});
|
||||
}
|
||||
100
web/tests/e2e/keyboard-accessibility.spec.ts
Normal file
100
web/tests/e2e/keyboard-accessibility.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
const app = (page: Page) => page.locator("main.blender-app");
|
||||
|
||||
async function focusWithKeyboard(page: Page, target: Locator, backwards = false): Promise<void> {
|
||||
for (let index = 0; index < 80; index += 1) {
|
||||
if (await target.evaluate((element) => element === document.activeElement)) return;
|
||||
await page.keyboard.press(backwards ? "Shift+Tab" : "Tab");
|
||||
}
|
||||
throw new Error(`Keyboard focus did not reach ${await target.getAttribute("aria-label") ?? await target.textContent() ?? "target"}`);
|
||||
}
|
||||
|
||||
async function expectVisibleKeyboardFocus(target: Locator): Promise<void> {
|
||||
await expect(target).toBeFocused();
|
||||
await expect.poll(() => target.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return style.outlineStyle !== "none" && Number.parseFloat(style.outlineWidth) >= 2;
|
||||
})).toBe(true);
|
||||
}
|
||||
|
||||
async function expectNoSeriousAccessibilityViolations(page: Page, checkpoint: string): Promise<void> {
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
|
||||
.analyze();
|
||||
const violations = results.violations
|
||||
.filter((violation) => violation.impact === "critical" || violation.impact === "serious")
|
||||
.map((violation) => ({
|
||||
id: violation.id,
|
||||
impact: violation.impact,
|
||||
help: violation.help,
|
||||
targets: violation.nodes.map((node) => node.target.join(" ")),
|
||||
}));
|
||||
expect(violations, `${checkpoint}: ${JSON.stringify(violations, null, 2)}`).toEqual([]);
|
||||
}
|
||||
|
||||
test("M7-16 completes the P0 project loop without pointer input and passes the accessibility gate", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await expectNoSeriousAccessibilityViolations(page, "empty project");
|
||||
|
||||
const fileMenu = page.getByRole("button", { name: "文件", exact: true });
|
||||
await focusWithKeyboard(page, fileMenu);
|
||||
await expectVisibleKeyboardFocus(fileMenu);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("menu", { name: "文件" })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "打开" })).toBeFocused();
|
||||
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.keyboard.press("Enter");
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(basicBlend);
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(app(page)).toHaveAttribute("data-user-action-open-status", "SUCCEEDED");
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 3");
|
||||
|
||||
await page.keyboard.press("F3");
|
||||
const search = page.getByRole("textbox", { name: "搜索操作" });
|
||||
await expectVisibleKeyboardFocus(search);
|
||||
await page.keyboard.insertText("Add Cube");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4");
|
||||
await expect(app(page)).toHaveAttribute("data-dirty", "true");
|
||||
|
||||
const blendDownload = page.waitForEvent("download");
|
||||
await page.keyboard.press("Control+s");
|
||||
await expect((await blendDownload).suggestedFilename()).toBe("blender-web.blend");
|
||||
await expect(app(page)).toHaveAttribute("data-user-action-save-status", "SUCCEEDED");
|
||||
await expect(app(page)).toHaveAttribute("data-dirty", "false");
|
||||
|
||||
await page.keyboard.press("F3");
|
||||
await expect(search).toBeFocused();
|
||||
await page.keyboard.insertText("Close Project");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 0");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const recentProjects = page.getByRole("combobox", { name: "最近项目" });
|
||||
await focusWithKeyboard(page, recentProjects);
|
||||
await expectVisibleKeyboardFocus(recentProjects);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("scene-stats")).toContainText("Objects 4", { timeout: 30_000 });
|
||||
await expect(app(page)).toHaveAttribute("data-dirty", "false");
|
||||
await expectNoSeriousAccessibilityViolations(page, "reopened project");
|
||||
|
||||
const renderMenu = page.getByRole("button", { name: "渲染", exact: true });
|
||||
await focusWithKeyboard(page, renderMenu, true);
|
||||
await expectVisibleKeyboardFocus(renderMenu);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("menuitem", { name: "导出 GLB" })).toBeFocused();
|
||||
const glbDownload = page.waitForEvent("download");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect((await glbDownload).suggestedFilename()).toBe("blender-web.glb");
|
||||
await expect(app(page)).toHaveAttribute("data-user-action-export-status", "SUCCEEDED");
|
||||
});
|
||||
143
web/tests/e2e/lighting-field-roundtrip.spec.ts
Normal file
143
web/tests/e2e/lighting-field-roundtrip.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const blend = fs.readFileSync(path.join(root, "tests/files/web/basic_scene.blend"));
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-02/lighting-field-roundtrip.json"), "utf8"));
|
||||
|
||||
function expectClose(actual: unknown, reference: unknown): void {
|
||||
if (typeof reference === "number") {
|
||||
expect(actual).toBeCloseTo(reference, 5);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(reference)) {
|
||||
expect(Array.isArray(actual)).toBe(true);
|
||||
expect(actual).toHaveLength(reference.length);
|
||||
reference.forEach((value, index) => expectClose((actual as unknown[])[index], value));
|
||||
return;
|
||||
}
|
||||
if (reference && typeof reference === "object") {
|
||||
expect(actual && typeof actual === "object").toBe(true);
|
||||
for (const [field, value] of Object.entries(reference)) {
|
||||
expectClose((actual as Record<string, unknown>)[field], value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
expect(actual).toEqual(reference);
|
||||
}
|
||||
|
||||
test("M11-02 edits, undoes, redoes, reopens and maps supported lighting fields", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const { configurePBRCamera, configurePBRLight, createPBRLight } = await import("/src/three-adapter/pbr.ts");
|
||||
const { Object3D, PerspectiveCamera } = await import("/src/vendor/three/three.module.js");
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const reopened = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
try {
|
||||
const opened = await client.openBlend(Uint8Array.from(input).buffer);
|
||||
const camera = opened.snapshot.cameras.find((item) => item.id === "camera:Camera.001")!;
|
||||
const light = opened.snapshot.lights.find((item) => item.id === "light:Area")!;
|
||||
const world = opened.snapshot.worlds.find((item) => item.id === "world:World")!;
|
||||
const revisions = [opened.snapshot.revision];
|
||||
|
||||
const cameraEdit = await client.applyCommand({ type: "setCameraProperties", dataId: camera.id, properties: {
|
||||
lensMm: 35, sensorWidthMm: 32, sensorHeightMm: 18, sensorFit: 2, shift: [0.1, -0.2],
|
||||
near: 0.2, far: 500, orthoScale: 8,
|
||||
depthOfField: { enabled: true, focusDistance: 4.5, apertureFStop: 1.8, apertureBlades: 7, apertureRotation: 0.25, apertureRatio: 1.2 },
|
||||
} });
|
||||
revisions.push(cameraEdit.snapshot.revision);
|
||||
const cameraUndo = await client.applyCommand({ type: "undo" });
|
||||
revisions.push(cameraUndo.snapshot.revision);
|
||||
const cameraRedo = await client.applyCommand({ type: "redo" });
|
||||
revisions.push(cameraRedo.snapshot.revision);
|
||||
|
||||
const lightEdit = await client.applyCommand({ type: "setLightProperties", dataId: light.id, properties: {
|
||||
color: [0.25, 0.5, 0.75], energy: 400, exposure: 1, temperature: 5000,
|
||||
useTemperature: true, castsShadow: false, radius: 0.3, spotAngle: 1.1,
|
||||
spotBlend: 0.25, areaSize: 3, areaSizeY: 2, areaSpread: 2.4, sunAngle: 0.1,
|
||||
} });
|
||||
revisions.push(lightEdit.snapshot.revision);
|
||||
const lightUndo = await client.applyCommand({ type: "undo" });
|
||||
revisions.push(lightUndo.snapshot.revision);
|
||||
const lightRedo = await client.applyCommand({ type: "redo" });
|
||||
revisions.push(lightRedo.snapshot.revision);
|
||||
|
||||
const worldEdit = await client.applyCommand({ type: "setWorldProperties", dataId: world.id, properties: {
|
||||
color: [0.1, 0.2, 0.3], exposure: 0.5,
|
||||
mist: { enabled: true, type: "LINEAR", start: 2, depth: 50, intensity: 0.2, height: 3 },
|
||||
} });
|
||||
revisions.push(worldEdit.snapshot.revision);
|
||||
const worldUndo = await client.applyCommand({ type: "undo" });
|
||||
revisions.push(worldUndo.snapshot.revision);
|
||||
const worldRedo = await client.applyCommand({ type: "redo" });
|
||||
revisions.push(worldRedo.snapshot.revision);
|
||||
|
||||
const saved = await client.saveBlend();
|
||||
const reopenedResult = await reopened.openBlend(saved);
|
||||
const reopenedCamera = reopenedResult.snapshot.cameras.find((item) => item.id === camera.id)!;
|
||||
const reopenedLight = reopenedResult.snapshot.lights.find((item) => item.id === light.id)!;
|
||||
const reopenedWorld = reopenedResult.snapshot.worlds.find((item) => item.id === world.id)!;
|
||||
const mappedCamera = new PerspectiveCamera();
|
||||
configurePBRCamera(mappedCamera, reopenedCamera);
|
||||
const horizontalCamera = new PerspectiveCamera();
|
||||
configurePBRCamera(horizontalCamera, { ...reopenedCamera, sensorFit: 1 });
|
||||
const spot = createPBRLight({ ...reopenedLight, lightType: 2 });
|
||||
const area = createPBRLight({ ...reopenedLight, lightType: 4 });
|
||||
const lightNode = reopenedResult.snapshot.nodes.find((item) => item.dataId === light.id)!;
|
||||
configurePBRLight(spot, lightNode, new Object3D());
|
||||
return {
|
||||
revisions,
|
||||
cameraUndoLens: cameraUndo.snapshot.cameras.find((item) => item.id === camera.id)!.lensMm,
|
||||
cameraRedoLens: cameraRedo.snapshot.cameras.find((item) => item.id === camera.id)!.lensMm,
|
||||
lightUndoEnergy: lightUndo.snapshot.lights.find((item) => item.id === light.id)!.energy,
|
||||
lightRedoEnergy: lightRedo.snapshot.lights.find((item) => item.id === light.id)!.energy,
|
||||
worldUndoColor: worldUndo.snapshot.worlds.find((item) => item.id === world.id)!.color,
|
||||
worldRedoColor: worldRedo.snapshot.worlds.find((item) => item.id === world.id)!.color,
|
||||
camera: reopenedCamera,
|
||||
light: reopenedLight,
|
||||
world: reopenedWorld,
|
||||
viewport: {
|
||||
camera: { fov: mappedCamera.fov, horizontalFov: horizontalCamera.fov, near: mappedCamera.near, far: mappedCamera.far, filmGauge: mappedCamera.filmGauge, filmOffset: mappedCamera.filmOffset },
|
||||
spot: { color: spot.color.toArray(), intensity: spot.intensity, angle: "angle" in spot ? spot.angle : 0, penumbra: "penumbra" in spot ? spot.penumbra : 0, castShadow: spot.castShadow },
|
||||
area: { intensity: area.intensity, width: "width" in area ? area.width : 0, height: "height" in area ? area.height : 0 },
|
||||
worldColor: reopenedWorld.color,
|
||||
},
|
||||
};
|
||||
}
|
||||
finally {
|
||||
client.terminate();
|
||||
reopened.terminate();
|
||||
}
|
||||
}, Array.from(blend));
|
||||
|
||||
expect(result.revisions.every((revision, index) => index === 0 || revision === result.revisions[index - 1] + 1)).toBe(true);
|
||||
expect(result.cameraUndoLens).toBe(50);
|
||||
expect(result.cameraRedoLens).toBe(expected.camera.lensMm);
|
||||
expect(result.lightUndoEnergy).toBe(800);
|
||||
expect(result.lightRedoEnergy).toBe(expected.light.energy);
|
||||
expect(result.worldUndoColor).not.toEqual(expected.world.color);
|
||||
expectClose(result.worldRedoColor, expected.world.color);
|
||||
for (const [field, value] of Object.entries(expected.camera).filter(([field]) => !field.startsWith("viewport"))) {
|
||||
expectClose(result.camera[field as keyof typeof result.camera], value);
|
||||
}
|
||||
for (const [field, value] of Object.entries(expected.light).filter(([field]) => !field.startsWith("viewport"))) {
|
||||
expectClose(result.light[field as keyof typeof result.light], value);
|
||||
}
|
||||
expectClose(result.world, expected.world);
|
||||
expect(result.viewport.camera.fov).toBeCloseTo(expected.camera.viewportFov, 5);
|
||||
expect(result.viewport.camera.horizontalFov).toBeCloseTo(expected.camera.viewportHorizontalFov, 5);
|
||||
expectClose(result.viewport.camera.near, expected.camera.near);
|
||||
expectClose(result.viewport.camera.far, expected.camera.far);
|
||||
expectClose(result.viewport.camera.filmGauge, expected.camera.viewportFilmGauge);
|
||||
expect(result.viewport.camera.filmOffset).toBeCloseTo(expected.camera.viewportFilmOffset, 6);
|
||||
expectClose(result.viewport.spot.intensity, expected.light.viewportIntensity);
|
||||
expectClose(result.viewport.spot.color, expected.light.viewportColor);
|
||||
expectClose(result.viewport.spot.angle, expected.light.spotAngle);
|
||||
expectClose(result.viewport.spot.penumbra, expected.light.spotBlend);
|
||||
expect(result.viewport.spot.castShadow).toBe(false);
|
||||
expectClose(result.viewport.area, { intensity: expected.light.viewportIntensity, width: expected.light.areaSize, height: expected.light.areaSizeY });
|
||||
expectClose(result.viewport.worldColor, expected.world.color);
|
||||
});
|
||||
@@ -183,7 +183,20 @@ test("indexes, seeks, cancels and reopens a bounded one-million-frame media time
|
||||
const restartedDisposed = await restarted.dispose();
|
||||
restarted.terminate();
|
||||
|
||||
const codec = gateSequencerCodec("video/mp4", new Set(["image/png", "audio/wav"]));
|
||||
const codecRequest = {
|
||||
schemaVersion: 1 as const,
|
||||
stripType: "MOVIE" as const,
|
||||
mimeType: "video/mp4",
|
||||
byteLength: 1,
|
||||
sourceSha256: "0".repeat(64),
|
||||
};
|
||||
const codec = gateSequencerCodec(codecRequest, {
|
||||
...codecRequest,
|
||||
status: "BLOCKED",
|
||||
backend: null,
|
||||
reason: "RUNTIME_UNAVAILABLE",
|
||||
decoded: null,
|
||||
});
|
||||
const runtime = sequencerRuntimeCapabilities();
|
||||
let corruptManifestCode = "";
|
||||
try { parseLongMediaSessionManifest({ ...manifest, assets: [{ ...manifest.assets[0], sha256: "bad" }, manifest.assets[1]] }); }
|
||||
|
||||
47
web/tests/e2e/m10-domain-browser-gates.spec.ts
Normal file
47
web/tests/e2e/m10-domain-browser-gates.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-15/domain-browser-gates.json"), "utf8"));
|
||||
|
||||
test("M10-15 gates GN, Shader, NLA and Simulation performance, OOM and malicious inputs", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const results = await page.evaluate(async () => {
|
||||
const domains = ["GN", "SHADER", "NLA", "SIMULATION"] as const;
|
||||
const run = (domain: typeof domains[number]) => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/m10-domain-gate-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event: MessageEvent<{ ok: boolean; result?: Record<string, unknown>; error?: string }>) => {
|
||||
worker.terminate();
|
||||
if (event.data.ok && event.data.result) resolve(event.data.result);
|
||||
else reject(new Error(event.data.error ?? `${domain} gate failed`));
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({ domain });
|
||||
});
|
||||
return Promise.all(domains.map(run));
|
||||
}) as Array<{
|
||||
domain: string;
|
||||
performanceMs: number;
|
||||
workUnits: number;
|
||||
oomCode: string;
|
||||
maliciousCode: string;
|
||||
recovered: boolean;
|
||||
performanceStatus: string;
|
||||
oomPreventedBeforeAllocation: boolean;
|
||||
}>;
|
||||
|
||||
expect(results.map((result) => result.domain)).toEqual(["GN", "SHADER", "NLA", "SIMULATION"]);
|
||||
for (const result of results) {
|
||||
const gate = expected.domains[result.domain];
|
||||
expect(result.workUnits).toBe(gate.workUnits);
|
||||
expect(result.performanceMs).toBeLessThan(gate.maximumMs);
|
||||
expect(result.performanceStatus).toBe(gate.performanceStatus);
|
||||
expect(result.oomCode).toBe(gate.oomCode);
|
||||
expect(result.maliciousCode).toBe(gate.maliciousCode);
|
||||
expect(result.oomPreventedBeforeAllocation).toBe(true);
|
||||
expect(result.recovered).toBe(true);
|
||||
}
|
||||
console.log("m10-domain-browser-gates", JSON.stringify(results));
|
||||
});
|
||||
101
web/tests/e2e/nanovdb-main-thread.spec.ts
Normal file
101
web/tests/e2e/nanovdb-main-thread.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-11 main-thread WebGPU loads a missing page and renders deterministic pixels", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const [viewport, renderer, { NanoVDBFloat32Sampler }] = await Promise.all([
|
||||
import("/src/volume/nanovdb-viewport.ts"),
|
||||
import("/src/render/nanovdb-volume-renderer.ts"),
|
||||
import("/src/volume/nanovdb-float32.ts"),
|
||||
]);
|
||||
const manifest = await viewport.loadNanoVDBViewportAsset("main-thread-fixture", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal);
|
||||
const density = manifest.manifest.grids.find((grid) => grid.name === manifest.manifest.material.densityGrid);
|
||||
if (!density || !manifest.manifest.gpu.float32TreeLayout) throw new Error("NANOVDB_MANIFEST_INVALID: main-thread fixture is incomplete");
|
||||
const response = await fetch("/__vdb_fixture__/bundle", {
|
||||
cache: "no-store",
|
||||
headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` },
|
||||
});
|
||||
if (response.status !== 206) throw new Error("NANOVDB_STREAM_INCOMPLETE: main-thread fixture range was not served");
|
||||
const payload = await response.arrayBuffer();
|
||||
const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.manifest.gpu.float32TreeLayout);
|
||||
const coordinate = [0, 0, 0] as const;
|
||||
const leafByteOffset = sampler.leafByteOffset(coordinate);
|
||||
if (leafByteOffset === null) throw new Error("NANOVDB_GRID_UNSUPPORTED: main-thread fixture has no leaf");
|
||||
|
||||
const pageByteLength = 256 * 1024;
|
||||
const pageCount = Math.ceil(payload.byteLength / pageByteLength);
|
||||
const leafPageId = Math.floor(leafByteOffset / pageByteLength);
|
||||
if (leafPageId <= 0 || leafPageId >= pageCount) throw new Error("NANOVDB_GRID_UNSUPPORTED: main-thread leaf page is not pageable");
|
||||
const session = new renderer.NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(pageCount * pageByteLength, 6);
|
||||
const grid = renderer.createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, pageCount * pageByteLength);
|
||||
for (let pageId = 0; pageId < pageCount; pageId++) {
|
||||
if (pageId === leafPageId) continue;
|
||||
grid.uploadPage(pageId, payload.slice(pageId * pageByteLength, Math.min(payload.byteLength, (pageId + 1) * pageByteLength)));
|
||||
}
|
||||
|
||||
const feedbackBuffer = renderer.createNanoVDBPageFeedbackGPUBuffer(device, 4);
|
||||
const beforeLoad = await renderer.sampleNanoVDBFloat32WebGPU(device, grid, [coordinate], feedbackBuffer);
|
||||
const missing = await renderer.readNanoVDBPageFeedbackGPUBuffer(device, feedbackBuffer, 4, pageCount, 17);
|
||||
const pageSource = async (pageId: number, signal: AbortSignal): Promise<ArrayBuffer> => {
|
||||
if (signal.aborted) throw new DOMException("main-thread page request cancelled", "AbortError");
|
||||
const start = density.byteOffset + pageId * pageByteLength;
|
||||
const end = Math.min(density.byteOffset + density.byteLength, start + pageByteLength) - 1;
|
||||
const pageResponse = await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${start}-${end}` }, signal });
|
||||
if (pageResponse.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: page range returned ${pageResponse.status}`);
|
||||
return pageResponse.arrayBuffer();
|
||||
};
|
||||
const loadedPages: number[] = [];
|
||||
const page = await pageSource(missing.pageIds[0], new AbortController().signal);
|
||||
const frames: Array<() => void> = [];
|
||||
let redraws = 0;
|
||||
const scheduler = new renderer.NanoVDBProgressiveRedrawScheduler((callback) => frames.push(callback), () => { redraws++; });
|
||||
const uploader = new renderer.NanoVDBProgressivePageUploader(grid, scheduler);
|
||||
loadedPages.push(missing.pageIds[0]);
|
||||
const upload = uploader.upload(missing.pageIds[0], page.slice(0));
|
||||
const queuedBeforeRedraw = frames.length;
|
||||
frames.shift()?.();
|
||||
|
||||
const afterLoad = await renderer.sampleNanoVDBFloat32WebGPU(device, grid, [coordinate]);
|
||||
const material = { ...manifest.manifest.material, interpolation: "LINEAR" as const };
|
||||
const pixelsA = await renderer.renderNanoVDBFloat32WebGPU(device, grid, density, material, 64, 64);
|
||||
const pixelsB = await renderer.renderNanoVDBFloat32WebGPU(device, grid, density, material, 64, 64);
|
||||
const digest = async (pixels: Uint8Array): Promise<string> => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", pixels))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
const visible = (pixels: Uint8Array): number => { let count = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 24) count++; return count; };
|
||||
const output = {
|
||||
pageCount,
|
||||
leafPageId,
|
||||
beforeLoad,
|
||||
missing,
|
||||
loadedPages,
|
||||
upload,
|
||||
queuedBeforeRedraw,
|
||||
redraws,
|
||||
afterLoad,
|
||||
pixels: { width: 64, height: 64, bytes: pixelsA.byteLength, visible: visible(pixelsA), sha256A: await digest(pixelsA), sha256B: await digest(pixelsB) },
|
||||
scheduler: scheduler.stats(),
|
||||
};
|
||||
scheduler.dispose();
|
||||
grid.dispose();
|
||||
feedbackBuffer.destroy();
|
||||
session.dispose();
|
||||
return output;
|
||||
});
|
||||
|
||||
expect(result.pageCount).toBeGreaterThan(1);
|
||||
expect(result.leafPageId).toBeGreaterThan(0);
|
||||
expect(result.beforeLoad[0].valid).toBe(true);
|
||||
expect(result.missing.status).toBe("READY");
|
||||
expect(result.missing.pageIds).toEqual([result.leafPageId]);
|
||||
expect(result.loadedPages).toEqual([result.leafPageId]);
|
||||
expect(result.upload).toEqual({ pageId: result.leafPageId, redrawScheduled: true });
|
||||
expect(result.queuedBeforeRedraw).toBe(1);
|
||||
expect(result.redraws).toBe(1);
|
||||
expect(result.afterLoad[0].valid).toBe(true);
|
||||
expect(result.pixels).toMatchObject({ width: 64, height: 64, bytes: 64 * 64 * 4 });
|
||||
expect(result.pixels.visible).toBeGreaterThan(0);
|
||||
expect(result.pixels.sha256A).toBe("87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c");
|
||||
expect(result.pixels.sha256A).toBe(result.pixels.sha256B);
|
||||
expect(result.scheduler).toMatchObject({ pending: false, scheduledCount: 1, redrawCount: 1, capped: false, errorCode: null });
|
||||
});
|
||||
25
web/tests/e2e/nanovdb-offscreen-page-feedback.spec.ts
Normal file
25
web/tests/e2e/nanovdb-offscreen-page-feedback.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-12 Offscreen Worker repeats the main-thread NanoVDB page sequence and pixels", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-offscreen-page-feedback-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.pageCount).toBeGreaterThan(1);
|
||||
expect(result.leafPageId).toBeGreaterThan(0);
|
||||
expect(result.beforeLoad[0].valid).toBe(true);
|
||||
expect(result.missing.status).toBe("READY");
|
||||
expect(result.missing.pageIds).toEqual([result.leafPageId]);
|
||||
expect(result.upload).toEqual({ pageId: result.leafPageId, redrawScheduled: true });
|
||||
expect(result.queuedBeforeRedraw).toBe(1);
|
||||
expect(result.redraws).toBe(1);
|
||||
expect(result.afterLoad[0].valid).toBe(true);
|
||||
expect(result.pixels.bytes).toBe(64 * 64 * 4);
|
||||
expect(result.pixels.sha256A).toBe("87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c");
|
||||
expect(result.pixels.sha256B).toBe(result.pixels.sha256A);
|
||||
expect(result.scheduler).toMatchObject({ pending: false, scheduledCount: 1, redrawCount: 1, capped: false, errorCode: null });
|
||||
});
|
||||
25
web/tests/e2e/nanovdb-opfs-restart.spec.ts
Normal file
25
web/tests/e2e/nanovdb-opfs-restart.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-14 Worker restart restores the OPFS manifest without trusting resident pages", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const run = (action: "prepare" | "reopen", state?: unknown): Promise<any> => page.evaluate(({ action, state }) => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-opfs-restart-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({ action, state });
|
||||
}), { action, state });
|
||||
const prepared = await run("prepare");
|
||||
const reopened = await run("reopen", {
|
||||
projectId: prepared.projectId,
|
||||
bundleSha256: prepared.bundleSha256,
|
||||
claimedResidentPages: prepared.claimedResidentPages,
|
||||
});
|
||||
expect(prepared.residentBeforeRestart).toEqual([0]);
|
||||
expect(reopened.manifestSha256).toBe(prepared.bundleSha256);
|
||||
expect(reopened.claimedResidentPages).toEqual([0]);
|
||||
expect(reopened.residentBeforeRestore).toEqual([]);
|
||||
expect(reopened.residentAfterRestore).toEqual([0]);
|
||||
expect(reopened.pageBytes).toBe(64 * 1024);
|
||||
expect(reopened.tamperedError).toContain("NANOVDB_HASH_MISMATCH");
|
||||
expect(reopened.residentAfterTamperedRestore).toEqual([]);
|
||||
});
|
||||
197
web/tests/e2e/nanovdb-page-feedback.spec.ts
Normal file
197
web/tests/e2e/nanovdb-page-feedback.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-02 through M8-10 gates, pins and bounded redraws manifest-backed page faults", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-page-feedback-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => {
|
||||
worker.terminate();
|
||||
event.data.error ? reject(new Error(event.data.error)) : resolve(event.data);
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
|
||||
expect(result.leafByteOffset).toBeGreaterThan(0);
|
||||
expect(result.leafPageId).toBeGreaterThan(0);
|
||||
expect(result.leafPageId).toBeLessThan(result.pageCount);
|
||||
expect(result.manifestPageBytes).toBe(4 * 1024 * 1024);
|
||||
expect(result.leafSampleCount).toBe(256);
|
||||
expect(result.leafSamplesFallback).toBe(true);
|
||||
expect(result.leafResult).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 17,
|
||||
attemptedCount: 1,
|
||||
gpuStoredCount: 1,
|
||||
uniqueCount: 1,
|
||||
pageIds: [result.leafPageId],
|
||||
status: "READY",
|
||||
errorCode: null,
|
||||
});
|
||||
expect(result.leafGuard).toEqual(result.guardWords);
|
||||
expect(result.staleDispatch).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 17,
|
||||
currentRenderRevision: 18,
|
||||
status: "STALE",
|
||||
requestedPageIds: [],
|
||||
requestedCount: 0,
|
||||
errorCode: "REVISION_CONFLICT",
|
||||
});
|
||||
expect(result.stalePageIoCount).toBe(0);
|
||||
expect(result.currentDispatch).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 17,
|
||||
currentRenderRevision: 17,
|
||||
status: "ACCEPTED",
|
||||
requestedPageIds: [result.leafPageId],
|
||||
requestedCount: 1,
|
||||
errorCode: null,
|
||||
});
|
||||
expect(result.pageIoRequests).toEqual([{ pageId: result.leafPageId, renderRevision: 17 }]);
|
||||
expect(result.manifestFeedbackResult).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 19,
|
||||
attemptedCount: 1,
|
||||
gpuStoredCount: 1,
|
||||
uniqueCount: 1,
|
||||
pageIds: [0],
|
||||
status: "READY",
|
||||
errorCode: null,
|
||||
});
|
||||
expect(result.manifestWordsZero).toBe(true);
|
||||
expect(result.manifestStaleDispatch).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 19,
|
||||
currentRenderRevision: 20,
|
||||
status: "STALE",
|
||||
requestedPageIds: [],
|
||||
requestedCount: 0,
|
||||
errorCode: "REVISION_CONFLICT",
|
||||
});
|
||||
expect(result.staleManifestRangeCount).toBe(0);
|
||||
expect(result.manifestDispatch).toEqual({
|
||||
schemaVersion: 1,
|
||||
renderRevision: 19,
|
||||
currentRenderRevision: 19,
|
||||
status: "ACCEPTED",
|
||||
requestedPageIds: [0],
|
||||
requestedCount: 1,
|
||||
errorCode: null,
|
||||
});
|
||||
expect(result.manifestPageIoRequests).toEqual([{ pageId: 0, renderRevision: 19 }]);
|
||||
expect(result.manifestRanges.length).toBeGreaterThan(0);
|
||||
expect(result.manifestRanges).toEqual(result.declaredManifestRanges);
|
||||
expect(result.manifestRanges.every((range: { sha256: string }) => /^[a-f0-9]{64}$/.test(range.sha256))).toBe(true);
|
||||
expect(result.manifestPageMatchesPayload).toBe(true);
|
||||
expect(result.coalesced.rangeCalls).toBe(1);
|
||||
expect(result.coalesced.underlyingAborts).toBe(0);
|
||||
expect(result.coalesced.beforeCancel).toEqual({ pendingPages: 1, subscribers: 2, pageIds: [0] });
|
||||
expect(result.coalesced.afterFirstCancel).toEqual({ pendingPages: 1, subscribers: 1, pageIds: [0] });
|
||||
expect(result.coalesced.afterResolve).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] });
|
||||
expect(result.coalesced.outcomes[0]).toEqual({ status: "REJECTED", name: "AbortError" });
|
||||
expect(result.coalesced.outcomes[1].status).toBe("RESOLVED");
|
||||
expect(result.coalesced.consumers).toEqual(["second:0"]);
|
||||
expect(result.lastCancel.rangeCalls).toBe(1);
|
||||
expect(result.lastCancel.underlyingAborts).toBe(1);
|
||||
expect(result.lastCancel.afterFirst).toEqual({
|
||||
stats: { pendingPages: 1, subscribers: 1, pageIds: [0] },
|
||||
underlyingAborts: 0,
|
||||
});
|
||||
expect(result.lastCancel.afterLast).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] });
|
||||
expect(result.lastCancel.outcomes).toEqual([
|
||||
{ status: "REJECTED", name: "AbortError" },
|
||||
{ status: "REJECTED", name: "AbortError" },
|
||||
]);
|
||||
expect(result.tamperedPage).toEqual({
|
||||
rangeCalls: 1,
|
||||
residentWrites: 0,
|
||||
errorCode: "NANOVDB_HASH_MISMATCH",
|
||||
errorMessage: expect.stringContaining("NANOVDB_HASH_MISMATCH"),
|
||||
cache: {
|
||||
residentPageCount: 0,
|
||||
residentBytes: 0,
|
||||
residentVirtualPages: [],
|
||||
coordinator: { pendingPages: 0, subscribers: 0, pageIds: [] },
|
||||
},
|
||||
});
|
||||
expect(result.framePin).toEqual({
|
||||
pinnedPage0: true,
|
||||
pinnedMissingPage: false,
|
||||
afterPinnedEviction: {
|
||||
residentVirtualPages: [0, 2],
|
||||
evictionCount: 1,
|
||||
page0: true,
|
||||
page1: false,
|
||||
page2: true,
|
||||
},
|
||||
allPinnedError: expect.stringContaining("NANOVDB_GPU_BUDGET_EXCEEDED: all resident NanoVDB pages are pinned"),
|
||||
afterAllPinned: {
|
||||
residentVirtualPages: [0, 2],
|
||||
evictionCount: 1,
|
||||
page3: false,
|
||||
},
|
||||
afterNextFrame: {
|
||||
residentVirtualPages: [2, 3],
|
||||
evictionCount: 2,
|
||||
page0: false,
|
||||
page2: true,
|
||||
page3: true,
|
||||
},
|
||||
pageTable: [0xffffffff, 0xffffffff, 1, 0],
|
||||
});
|
||||
expect(result.progressiveRedraw).toEqual({
|
||||
firstUpload: { pageId: 0, redrawScheduled: true },
|
||||
secondUpload: { pageId: 1, redrawScheduled: false },
|
||||
failedUpload: expect.stringContaining("NANOVDB_STREAM_INCOMPLETE"),
|
||||
beforeFirst: {
|
||||
queuedFrames: 1,
|
||||
callbacks: 0,
|
||||
stats: { pending: true, scheduledCount: 1, redrawCount: 0, maxRedraws: 32, capped: false, errorCode: null },
|
||||
},
|
||||
afterFirst: {
|
||||
queuedFrames: 0,
|
||||
callbacks: 1,
|
||||
stats: { pending: false, scheduledCount: 1, redrawCount: 1, maxRedraws: 32, capped: false, errorCode: null },
|
||||
},
|
||||
thirdUpload: { pageId: 2, redrawScheduled: true },
|
||||
beforeSecond: {
|
||||
queuedFrames: 1,
|
||||
callbacks: 1,
|
||||
stats: { pending: true, scheduledCount: 2, redrawCount: 1, maxRedraws: 32, capped: false, errorCode: null },
|
||||
},
|
||||
afterSecond: {
|
||||
queuedFrames: 0,
|
||||
callbacks: 2,
|
||||
stats: { pending: false, scheduledCount: 2, redrawCount: 2, maxRedraws: 32, capped: false, errorCode: null },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.progressiveRedrawCap).toEqual({
|
||||
cappedRedraw: {
|
||||
uploads: [
|
||||
{ pageId: 0, redrawScheduled: true },
|
||||
{ pageId: 0, redrawScheduled: true },
|
||||
{ pageId: 0, redrawScheduled: false },
|
||||
{ pageId: 0, redrawScheduled: false },
|
||||
],
|
||||
callbacks: 2,
|
||||
queuedFrames: 0,
|
||||
stats: { pending: false, scheduledCount: 2, redrawCount: 2, maxRedraws: 2, capped: true, errorCode: "NANOVDB_PROGRESSIVE_REDRAW_LIMIT" },
|
||||
},
|
||||
afterCappedReset: { pending: false, scheduledCount: 0, redrawCount: 0, maxRedraws: 2, capped: false, errorCode: null },
|
||||
});
|
||||
|
||||
expect(result.missingWordsZero).toBe(true);
|
||||
expect(result.overflowResult.status).toBe("OVERFLOW");
|
||||
expect(result.overflowResult.renderRevision).toBe(18);
|
||||
expect(result.overflowResult.errorCode).toBe("NANOVDB_PAGE_FEEDBACK_OVERFLOW");
|
||||
expect(result.overflowResult.attemptedCount).toBeGreaterThan(2);
|
||||
expect(result.overflowResult.gpuStoredCount).toBe(2);
|
||||
expect(result.overflowResult.uniqueCount).toBe(2);
|
||||
expect(new Set(result.overflowResult.pageIds).size).toBe(2);
|
||||
expect(result.overflowResult.pageIds).toEqual([...result.overflowResult.pageIds].sort((left, right) => left - right));
|
||||
expect(result.overflowResult.pageIds.every((pageId: number) => result.missingPageIds.includes(pageId))).toBe(true);
|
||||
expect(result.overflowGuard).toEqual(result.guardWords);
|
||||
});
|
||||
20
web/tests/e2e/nanovdb-page-resume.spec.ts
Normal file
20
web/tests/e2e/nanovdb-page-resume.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-13 resumes a feedback page from the exact interrupted byte offset", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-page-resume-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); event.data.error ? reject(new Error(event.data.error)) : resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.dispatch).toMatchObject({ status: "ACCEPTED", renderRevision: 17, requestedPageIds: [0], requestedCount: 1, errorCode: null });
|
||||
expect(result.ranges.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.ranges[0]).toMatch(/^bytes=\d+-\d+$/);
|
||||
expect(result.resumedRangeStart).toBe(Number(result.ranges[0].match(/^bytes=(\d+)-/)?.[1]) + 4096);
|
||||
expect(result.ifRanges[0]).toBe("");
|
||||
expect(result.ifRanges[1]).toMatch(/^"vdb-/);
|
||||
expect(result.consumedBytes).toBe(result.expectedBytes);
|
||||
expect(result.consumedSha256).toBe(result.expectedSha256);
|
||||
expect(result.coordinator).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] });
|
||||
});
|
||||
88
web/tests/e2e/nanovdb-render-golden.spec.ts
Normal file
88
web/tests/e2e/nanovdb-render-golden.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { compareNanoVDBRenderGolden, type NanoVDBRenderGoldenThresholdsIR } from "../../protocol/nanovdb-render-golden";
|
||||
|
||||
const goldenRoot = path.resolve(import.meta.dirname, "../../../tests/golden/M8-19");
|
||||
const goldenManifest = JSON.parse(fs.readFileSync(path.join(goldenRoot, "manifest.json"), "utf8")) as {
|
||||
schemaVersion: number;
|
||||
source: { sha256: string };
|
||||
renderContract: {
|
||||
shaderSemanticVersion: string;
|
||||
width: number;
|
||||
height: number;
|
||||
axes: Array<"X" | "Y" | "Z">;
|
||||
thresholds: NanoVDBRenderGoldenThresholdsIR;
|
||||
};
|
||||
images: Array<{ axis: "X" | "Y" | "Z"; file: string; sha256: string }>;
|
||||
};
|
||||
const referenceImages = Object.fromEntries(goldenManifest.images.map((image) => [image.axis, new Uint8Array(fs.readFileSync(path.join(goldenRoot, image.file))) ])) as Record<"X" | "Y" | "Z", Uint8Array>;
|
||||
|
||||
test("M8-19 compares desktop OpenVDB with main-thread and Offscreen WebGPU goldens", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.goto("/");
|
||||
const main = await page.evaluate(async () => {
|
||||
const [viewport, renderer] = await Promise.all([
|
||||
import("/src/volume/nanovdb-viewport.ts"),
|
||||
import("/src/render/nanovdb-volume-renderer.ts"),
|
||||
]);
|
||||
const asset = await viewport.loadNanoVDBViewportAsset("m8-19-main", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal);
|
||||
const density = asset.manifest.grids.find((grid) => grid.name === asset.manifest.material.densityGrid);
|
||||
const payload = asset.grids.find((grid) => grid.name === density?.name)?.data;
|
||||
if (!density || !payload) throw new Error("NANOVDB_STREAM_INCOMPLETE: M8-19 density payload is missing");
|
||||
const session = new renderer.NanoVDBWebGPUDeviceSession();
|
||||
const device = await session.open(payload.byteLength, 4);
|
||||
const uploaded = renderer.uploadNanoVDBFloat32Grid(device, payload);
|
||||
const material = { ...asset.manifest.material, temperatureGrid: undefined, colorGrid: undefined, emissionGrid: undefined, interpolation: "LINEAR" as const };
|
||||
const images: Record<string, number[]> = {};
|
||||
const hashes: Record<string, string> = {};
|
||||
for (const axis of ["X", "Y", "Z"] as const) {
|
||||
const pixels = await renderer.renderNanoVDBFloat32WebGPU(device, uploaded, density, material, 64, 64, {}, axis);
|
||||
images[axis] = Array.from(pixels);
|
||||
hashes[axis] = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", pixels))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
uploaded.dispose();
|
||||
session.dispose();
|
||||
return { sourceSha256: asset.manifest.sourceSha256, shaderSemanticVersion: asset.manifest.gpu.shaderSemanticVersion, hashes, images };
|
||||
});
|
||||
const offscreen = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-render-golden-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => {
|
||||
worker.terminate();
|
||||
if (event.data.error) { reject(new Error(event.data.error)); return; }
|
||||
resolve({
|
||||
...event.data,
|
||||
images: Object.fromEntries(Object.entries(event.data.images).map(([axis, buffer]) => [axis, Array.from(new Uint8Array(buffer as ArrayBuffer))])),
|
||||
});
|
||||
};
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
|
||||
expect(goldenManifest.schemaVersion).toBe(1);
|
||||
expect(main.sourceSha256).toBe(goldenManifest.source.sha256);
|
||||
expect(offscreen.sourceSha256).toBe(goldenManifest.source.sha256);
|
||||
expect(main.shaderSemanticVersion).toBe(goldenManifest.renderContract.shaderSemanticVersion);
|
||||
expect(offscreen.shaderSemanticVersion).toBe(goldenManifest.renderContract.shaderSemanticVersion);
|
||||
const report: Record<string, unknown> = {};
|
||||
for (const image of goldenManifest.images) {
|
||||
const reference = referenceImages[image.axis];
|
||||
const mainPixels = Uint8Array.from(main.images[image.axis]);
|
||||
const offscreenPixels = Uint8Array.from(offscreen.images[image.axis]);
|
||||
const mainComparison = compareNanoVDBRenderGolden(reference, mainPixels, goldenManifest.renderContract.thresholds);
|
||||
const offscreenComparison = compareNanoVDBRenderGolden(reference, offscreenPixels, goldenManifest.renderContract.thresholds);
|
||||
const backendComparison = compareNanoVDBRenderGolden(mainPixels, offscreenPixels, {
|
||||
maxChannelError: 0,
|
||||
meanAbsoluteError: 0,
|
||||
rmsError: 0,
|
||||
alphaCoverageDeltaRatio: 0,
|
||||
});
|
||||
expect(main.hashes[image.axis]).toBe(image.sha256);
|
||||
expect(offscreen.hashes[image.axis]).toBe(image.sha256);
|
||||
expect(mainComparison.status).toBe("READY");
|
||||
expect(offscreenComparison.status).toBe("READY");
|
||||
expect(backendComparison.status).toBe("READY");
|
||||
report[image.axis] = { main: mainComparison, offscreen: offscreenComparison, backend: backendComparison, sha256: image.sha256 };
|
||||
}
|
||||
console.log("nanovdb-render-golden", JSON.stringify(report));
|
||||
});
|
||||
53
web/tests/e2e/nanovdb-sparse-performance.spec.ts
Normal file
53
web/tests/e2e/nanovdb-sparse-performance.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M8-17 streams a 64 MiB sparse bundle with bounded paging and cancellation", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/nanovdb-sparse-performance-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => {
|
||||
worker.terminate();
|
||||
event.data.error ? reject(new Error(event.data.error)) : resolve(event.data);
|
||||
};
|
||||
worker.onerror = (event) => {
|
||||
worker.terminate();
|
||||
reject(new Error(event.message));
|
||||
};
|
||||
worker.postMessage({});
|
||||
}));
|
||||
|
||||
console.log("nanovdb-sparse-performance", JSON.stringify({
|
||||
bundleBytes: result.bundleBytes,
|
||||
requestedPageIds: result.requestedPageIds,
|
||||
loadedPageCount: result.loadedPageCount,
|
||||
transferredBytes: result.transferredBytes,
|
||||
peakRangeBytes: result.peakRangeBytes,
|
||||
firstPageMs: result.firstPageMs,
|
||||
successElapsedMs: result.successElapsedMs,
|
||||
cancel: result.cancel,
|
||||
}));
|
||||
|
||||
const mib = 1024 * 1024;
|
||||
expect(result.bundleBytes).toBe(64 * mib);
|
||||
expect(result.chunkBytes).toBe(4 * mib);
|
||||
expect(result.pageBytes).toBe(256 * 1024);
|
||||
expect(result.requestedPageIds).toEqual([0, 16, 128, 192]);
|
||||
expect(result.loadedPageCount).toBe(result.requestedPageIds.length);
|
||||
expect(result.transferredBytes).toBe(4 * result.chunkBytes);
|
||||
expect(result.rangeCalls).toBe(result.requestedPageIds.length);
|
||||
expect(result.peakRangeBytes).toBe(result.chunkBytes);
|
||||
expect(result.firstPageMs).toBeLessThan(10_000);
|
||||
expect(result.successElapsedMs).toBeLessThan(30_000);
|
||||
expect(result.successCoordinatorStats).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] });
|
||||
|
||||
expect(result.cancel.status).toBe("CANCELLED");
|
||||
expect(result.cancel.errorName).toBe("AbortError");
|
||||
expect(result.cancel.consumerCalls).toBe(0);
|
||||
expect(result.cancel.rangeCalls).toBe(1);
|
||||
expect(result.cancel.abortedRequests).toBe(1);
|
||||
expect(result.cancel.transferredBytes).toBeGreaterThan(0);
|
||||
expect(result.cancel.transferredBytes).toBeLessThan(result.chunkBytes);
|
||||
expect(result.cancel.peakRangeBytes).toBe(result.chunkBytes);
|
||||
expect(result.cancel.cancelLatencyMs).toBeLessThan(2_000);
|
||||
expect(result.cancel.coordinatorStats).toEqual({ pendingPages: 0, subscribers: 0, pageIds: [] });
|
||||
});
|
||||
156
web/tests/e2e/nanovdb-volume-roundtrip.spec.ts
Normal file
156
web/tests/e2e/nanovdb-volume-roundtrip.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const nonMeshBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/nonmesh_scene.blend");
|
||||
|
||||
test("M8-18 saves Volume Main and its NanoVDB binding, then reopens both production viewports", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await page.goto("/");
|
||||
const blend = fs.readFileSync(nonMeshBlend);
|
||||
const result = await page.evaluate(async ({ blendBytes }) => {
|
||||
const [engineModule, storageModule, volumeModule, opfsModule, viewportModule, offscreenModule] = await Promise.all([
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
import("/src/volume/nanovdb-viewport.ts"),
|
||||
import("/src/volume/nanovdb-opfs.ts"),
|
||||
import("/src/three-adapter/viewport.ts"),
|
||||
import("/src/three-adapter/offscreen-viewport.ts"),
|
||||
]);
|
||||
const { WebEngineClient } = engineModule;
|
||||
const { StorageClient } = storageModule;
|
||||
const {
|
||||
loadAndCommitNanoVDBViewportAsset,
|
||||
reopenNanoVDBViewportAssetFromOPFS,
|
||||
} = volumeModule;
|
||||
const { listVDBProjectBindings, pruneNanoVDBOPFS } = opfsModule;
|
||||
const { ViewportRenderer } = viewportModule;
|
||||
const { OffscreenViewportRenderer } = offscreenModule;
|
||||
const projectId = "m8-volume-roundtrip";
|
||||
const mainSourcePath = "//volumes/generated-smoke.vdb";
|
||||
const bindingSourcePath = "volumes/generated-smoke.vdb";
|
||||
const data = blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength) as ArrayBuffer;
|
||||
const digest = async (value: ArrayBuffer): Promise<string> => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", value)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
const waitFor = async (condition: () => boolean, timeoutMs = 120_000): Promise<void> => {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (performance.now() > deadline) throw new Error("M8-18 viewport volume timeout");
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
};
|
||||
const renderViewport = async (
|
||||
backend: "main" | "offscreen",
|
||||
snapshot: any,
|
||||
geometryBuffers: any[],
|
||||
nonMeshGeometryBuffers: any[],
|
||||
asset: any,
|
||||
): Promise<any> => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px;z-index:10000";
|
||||
document.body.append(canvas);
|
||||
const renderer: any = backend === "main" ? new ViewportRenderer(canvas) : new OffscreenViewportRenderer(canvas);
|
||||
renderer.setSnapshot(snapshot, geometryBuffers, nonMeshGeometryBuffers);
|
||||
renderer.setVolumeAssets([asset]);
|
||||
await waitFor(() => ["ready", "blocked"].includes(canvas.dataset.volumeStatus ?? ""));
|
||||
if (canvas.dataset.volumeStatus !== "ready") throw new Error(`${backend} volume ${canvas.dataset.volumeErrorCode ?? "blocked"}`);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const visible = backend === "offscreen"
|
||||
? Number(canvas.dataset.rendererPixels ?? 0)
|
||||
: (() => {
|
||||
const gl = renderer.renderer.getContext();
|
||||
const pixels = new Uint8Array(64 * 64 * 4);
|
||||
gl.readPixels(Math.max(0, Math.floor((gl.drawingBufferWidth - 64) / 2)), Math.max(0, Math.floor((gl.drawingBufferHeight - 64) / 2)), 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
return Array.from({ length: 64 * 64 }, (_, index) => pixels[index * 4 + 3] > 0 && pixels[index * 4] + pixels[index * 4 + 1] + pixels[index * 4 + 2] > 40).filter(Boolean).length;
|
||||
})();
|
||||
let volumeObjects = 0;
|
||||
if (backend === "main") renderer.scene.traverse((object: any) => { if (object.userData.nanoVDBVolume) volumeObjects += 1; });
|
||||
const status = { backend, volumeStatus: canvas.dataset.volumeStatus, volumeCount: Number(canvas.dataset.volumeCount), volumeObjects, visible };
|
||||
renderer.dispose();
|
||||
canvas.remove();
|
||||
return status;
|
||||
};
|
||||
|
||||
await pruneNanoVDBOPFS(projectId, 0);
|
||||
const firstEngine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await firstEngine.init();
|
||||
const opened = await firstEngine.openBlend(data.slice(0));
|
||||
const volume = opened.snapshot.nonMeshData.find((candidate: any) => candidate.type === "VOLUME");
|
||||
if (!volume) throw new Error("M8-18 nonmesh fixture has no Volume Main data");
|
||||
const properties = { displayDensity: 1.75, interpolation: "NEAREST" as const, stepSize: 0.125, velocityGrid: "velocity", velocityScale: 1.5 };
|
||||
const changed = await firstEngine.applyCommand({ type: "setVolumeProperties", dataId: volume.id, sourcePath: mainSourcePath, ...properties });
|
||||
const saved = await firstEngine.saveBlend();
|
||||
const savedHash = await digest(saved);
|
||||
const firstVolume = changed.snapshot.nonMeshData.find((candidate: any) => candidate.id === volume.id);
|
||||
if (!firstVolume || firstVolume.sourcePath !== mainSourcePath) throw new Error("M8-18 Volume Main source path did not commit");
|
||||
const storage = new StorageClient();
|
||||
const savedProject = await storage.saveProject(projectId, 7, saved.slice(0));
|
||||
storage.terminate();
|
||||
firstEngine.terminate();
|
||||
|
||||
const committedStorage = new StorageClient();
|
||||
const persisted = await committedStorage.readProject(projectId);
|
||||
committedStorage.terminate();
|
||||
const committedAsset = await loadAndCommitNanoVDBViewportAsset(
|
||||
volume.id,
|
||||
bindingSourcePath,
|
||||
"/volumes/generated-smoke.nanovdb.json",
|
||||
"/volumes/generated-smoke.nvdb",
|
||||
{ projectId, sourceBlendSha256: savedHash },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const bindings = await listVDBProjectBindings(projectId);
|
||||
const binding = bindings.find((candidate: any) => candidate.bundleSha256 === committedAsset.manifest.bundleSha256);
|
||||
if (!binding) throw new Error("M8-18 VDB binding was not discoverable after commit");
|
||||
|
||||
const reopenedEngine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await reopenedEngine.init();
|
||||
const reopened = await reopenedEngine.openBlend(persisted.buffer.slice(0));
|
||||
const reopenedVolume = reopened.snapshot.nonMeshData.find((candidate: any) => candidate.id === volume.id);
|
||||
if (!reopenedVolume) throw new Error("M8-18 reopened Main has no Volume data");
|
||||
const reopenedAsset = await reopenNanoVDBViewportAssetFromOPFS(
|
||||
volume.id,
|
||||
bindingSourcePath,
|
||||
{ projectId, sourceBlendSha256: savedHash },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const assetHashes = async (asset: any): Promise<string[]> => Promise.all(asset.grids.map((grid: any) => digest(grid.data)));
|
||||
const committedHashes = await assetHashes(committedAsset);
|
||||
const reopenedHashes = await assetHashes(reopenedAsset);
|
||||
const mainViewport = await renderViewport("main", reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? [], reopenedAsset);
|
||||
const offscreenViewport = await renderViewport("offscreen", reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? [], reopenedAsset);
|
||||
const reopenedBytesHash = await digest(persisted.buffer);
|
||||
reopenedEngine.terminate();
|
||||
await pruneNanoVDBOPFS(projectId, 0);
|
||||
return {
|
||||
project: { revision: savedProject.revision, bytes: savedProject.bytes, persisted: savedProject.persisted, backend: savedProject.backend, hash: savedProject.sha256 },
|
||||
savedHash,
|
||||
reopenedStorage: { revision: persisted.revision, bytes: persisted.bytes, sha256: persisted.sha256, backend: persisted.backend, recovered: persisted.recovered, bytesHash: reopenedBytesHash },
|
||||
main: { id: firstVolume.id, sourcePath: firstVolume.sourcePath, properties: firstVolume.volumeProperties },
|
||||
reopenedMain: { id: reopenedVolume.id, sourcePath: reopenedVolume.sourcePath, properties: reopenedVolume.volumeProperties, revision: reopened.snapshot.revision },
|
||||
binding: { projectId: binding.projectId, sourcePath: binding.sourcePath, sourceBlendSha256: binding.sourceBlendSha256, bundleSha256: binding.bundleSha256, manifestSha256: binding.manifestSha256 },
|
||||
asset: { dataId: reopenedAsset.dataId, bundleSha256: reopenedAsset.manifest.bundleSha256, committedHashes, reopenedHashes, gridCount: reopenedAsset.grids.length },
|
||||
viewports: { main: mainViewport, offscreen: offscreenViewport },
|
||||
};
|
||||
}, { blendBytes: new Uint8Array(blend) });
|
||||
|
||||
console.log("nanovdb-volume-roundtrip", JSON.stringify({
|
||||
project: result.project,
|
||||
savedHash: result.savedHash,
|
||||
reopenedStorage: result.reopenedStorage,
|
||||
binding: result.binding,
|
||||
asset: result.asset,
|
||||
viewports: result.viewports,
|
||||
}));
|
||||
expect(result.project).toMatchObject({ revision: 7, persisted: true, backend: "opfs", hash: result.savedHash });
|
||||
expect(result.project.bytes).toBeGreaterThan(0);
|
||||
expect(result.reopenedStorage).toMatchObject({ revision: 7, sha256: result.savedHash, backend: "opfs", recovered: false, bytesHash: result.savedHash });
|
||||
expect(result.main).toMatchObject({ id: result.reopenedMain.id, sourcePath: "//volumes/generated-smoke.vdb", properties: { displayDensity: 1.75, interpolation: "NEAREST", stepSize: 0.125, velocityGrid: "velocity", velocityScale: 1.5 } });
|
||||
expect(result.reopenedMain).toMatchObject(result.main);
|
||||
expect(result.binding).toMatchObject({ projectId: "m8-volume-roundtrip", sourcePath: "volumes/generated-smoke.vdb", sourceBlendSha256: result.savedHash });
|
||||
expect(result.asset).toMatchObject({ dataId: result.main.id, committedHashes: result.asset.reopenedHashes });
|
||||
expect(result.asset.gridCount).toBeGreaterThan(0);
|
||||
expect(result.viewports.main).toMatchObject({ backend: "main", volumeStatus: "ready", volumeCount: 1, volumeObjects: 1 });
|
||||
expect(result.viewports.main.visible).toBeGreaterThan(50);
|
||||
expect(result.viewports.offscreen).toMatchObject({ backend: "offscreen", volumeStatus: "ready", volumeCount: 1 });
|
||||
expect(result.viewports.offscreen.visible).toBeGreaterThan(10);
|
||||
});
|
||||
42
web/tests/e2e/nla-evaluation-golden.spec.ts
Normal file
42
web/tests/e2e/nla-evaluation-golden.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-11/nla-evaluation.json"), "utf8"));
|
||||
const blendBytes = fs.readFileSync(path.join(root, golden.fixture));
|
||||
|
||||
test("M10-11 evaluates NLA track, strip and time mapping through the production Worker", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ bytes, expected }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await client.init();
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const opened = await client.openBlend(source);
|
||||
const initial = JSON.stringify({ nlaTracks: opened.snapshot.nlaTracks, animations: opened.snapshot.animations });
|
||||
const samples = [];
|
||||
for (const frame of expected.frames) {
|
||||
await client.applyCommand({ type: "setFrame", frame: frame.frame });
|
||||
const report = await client.evaluateDepsgraph();
|
||||
const mesh = report.depsgraph.meshes.find((candidate) => candidate.objectId === `object:${expected.object}`);
|
||||
samples.push({ frame: frame.frame, status: report.depsgraph.status, worldMatrix: mesh?.worldMatrix ?? null });
|
||||
}
|
||||
const after = await client.snapshot();
|
||||
client.terminate();
|
||||
return { snapshot: opened.snapshot, initial, after: JSON.stringify({ nlaTracks: after.snapshot.nlaTracks, animations: after.snapshot.animations }), samples };
|
||||
}, { bytes: new Uint8Array(blendBytes), expected: golden });
|
||||
|
||||
expect(result.snapshot.nlaTracks).toHaveLength(golden.tracks.length);
|
||||
expect(result.snapshot.nlaTracks[0].strips).toHaveLength(golden.tracks[0].strips.length);
|
||||
expect(result.after).toBe(result.initial);
|
||||
expect(result.samples).toHaveLength(golden.frames.length);
|
||||
for (const [index, sample] of result.samples.entries()) {
|
||||
expect(sample.status, `frame ${sample.frame}`).toBe("EVALUATED");
|
||||
expect(sample.worldMatrix, `frame ${sample.frame}`).not.toBeNull();
|
||||
const maximumError = Math.max(...sample.worldMatrix.map((value: number, matrixIndex: number) =>
|
||||
Math.abs(value - golden.frames[index].worldMatrix[matrixIndex])));
|
||||
expect(maximumError, `frame ${sample.frame} matrix error`).toBeLessThanOrEqual(golden.tolerance.maxMatrixError);
|
||||
}
|
||||
});
|
||||
100
web/tests/e2e/nla-operator.spec.ts
Normal file
100
web/tests/e2e/nla-operator.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const fixture = fs.readFileSync(path.resolve(
|
||||
import.meta.dirname,
|
||||
"../../../tests/files/web/nla_time_mapping_scene.blend",
|
||||
));
|
||||
|
||||
test("M10-12 moves one NLA strip through Main, undo, redo and save/reopen", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ bytes }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const first = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await first.init();
|
||||
const opened = await first.openBlend(source);
|
||||
const track = opened.snapshot.nlaTracks?.find((candidate) => candidate.name === "M10 Time Mapping");
|
||||
const strip = track?.strips.find((candidate) => candidate.id === "M10 Scaled Clip");
|
||||
if (!track || !strip) throw new Error("NLA move fixture is incomplete");
|
||||
const initialRevision = opened.snapshot.revision;
|
||||
const initialTracks = JSON.stringify(opened.snapshot.nlaTracks);
|
||||
|
||||
let staleCode = "";
|
||||
try {
|
||||
await first.applyCommand({
|
||||
type: "moveNLAStrip",
|
||||
objectId: track.ownerId,
|
||||
trackId: track.id,
|
||||
stripId: strip.id,
|
||||
frameStart: 5,
|
||||
baseRevision: initialRevision - 1,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
staleCode = (error as { code?: string }).code ?? "";
|
||||
}
|
||||
const afterStale = await first.snapshot();
|
||||
|
||||
const moved = await first.applyCommand({
|
||||
type: "moveNLAStrip",
|
||||
objectId: track.ownerId,
|
||||
trackId: track.id,
|
||||
stripId: strip.id,
|
||||
frameStart: 5,
|
||||
baseRevision: initialRevision,
|
||||
});
|
||||
const movedStrip = moved.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name)
|
||||
?.strips.find((candidate) => candidate.id === strip.id);
|
||||
|
||||
const undone = await first.applyCommand({ type: "undo" });
|
||||
const undoneStrip = undone.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name)
|
||||
?.strips.find((candidate) => candidate.id === strip.id);
|
||||
const redone = await first.applyCommand({ type: "redo" });
|
||||
const redoneStrip = redone.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name)
|
||||
?.strips.find((candidate) => candidate.id === strip.id);
|
||||
const saved = await first.saveBlend();
|
||||
first.terminate();
|
||||
|
||||
const second = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await second.init();
|
||||
const reopened = await second.openBlend(saved);
|
||||
const reopenedStrip = reopened.snapshot.nlaTracks?.find((candidate) => candidate.name === track.name)
|
||||
?.strips.find((candidate) => candidate.id === strip.id);
|
||||
await second.applyCommand({ type: "setFrame", frame: 10 });
|
||||
const report = await second.evaluateDepsgraph();
|
||||
const mesh = report.depsgraph.meshes.find((candidate) => candidate.objectId === track.ownerId);
|
||||
second.terminate();
|
||||
|
||||
return {
|
||||
staleCode,
|
||||
stalePreserved: JSON.stringify(afterStale.snapshot.nlaTracks) === initialTracks,
|
||||
revisions: [initialRevision, moved.snapshot.revision, undone.snapshot.revision, redone.snapshot.revision],
|
||||
delta: [moved.delta.baseRevision, moved.delta.nextRevision],
|
||||
moved: movedStrip ? [movedStrip.frameStart, movedStrip.frameEnd] : null,
|
||||
undone: undoneStrip ? [undoneStrip.frameStart, undoneStrip.frameEnd] : null,
|
||||
redone: redoneStrip ? [redoneStrip.frameStart, redoneStrip.frameEnd] : null,
|
||||
reopened: reopenedStrip ? [reopenedStrip.frameStart, reopenedStrip.frameEnd] : null,
|
||||
evaluatedX: mesh?.worldMatrix[3] ?? null,
|
||||
evaluationStatus: report.depsgraph.status,
|
||||
};
|
||||
}, { bytes: new Uint8Array(fixture) });
|
||||
|
||||
expect(result.staleCode).toBe("REVISION_CONFLICT");
|
||||
expect(result.stalePreserved).toBe(true);
|
||||
expect(result.revisions).toEqual([
|
||||
result.revisions[0],
|
||||
result.revisions[0] + 1,
|
||||
result.revisions[0] + 2,
|
||||
result.revisions[0] + 3,
|
||||
]);
|
||||
expect(result.delta).toEqual([result.revisions[0], result.revisions[0] + 1]);
|
||||
expect(result.moved).toEqual([5, 25]);
|
||||
expect(result.undone).toEqual([20, 40]);
|
||||
expect(result.redone).toEqual([5, 25]);
|
||||
expect(result.reopened).toEqual([5, 25]);
|
||||
expect(result.evaluationStatus).toBe("EVALUATED");
|
||||
expect(result.evaluatedX).toBeCloseTo(2.5, 5);
|
||||
});
|
||||
@@ -75,9 +75,17 @@ test("recovers deterministically from WASM, OPFS, GPU and NanoVDB allocation fau
|
||||
|
||||
const nanoVdb = result.reports.find((report) => report.scenario === "NANOVDB_RESIDENT")!;
|
||||
expect(nanoVdb.faults[0]).toMatchObject({ point: "NANOVDB_PAGE_TABLE", code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_PAGE_TABLE" });
|
||||
expect(nanoVdb.memory.releasedBytes).toBe(64 * 1024);
|
||||
expect(nanoVdb.state.temporaryResourcesPeak).toBe(1);
|
||||
expect(nanoVdb.checks).toEqual(expect.arrayContaining(["resident-buffer-destroyed-once", "same-device-recovers", "lru-eviction-recovers"]));
|
||||
expect(nanoVdb.faults[1]).toMatchObject({ point: "NANOVDB_FEEDBACK_BUFFER", code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_FEEDBACK_BUFFER" });
|
||||
expect(nanoVdb.memory.releasedBytes).toBe(2 * 64 * 1024 + 8 + 32);
|
||||
expect(nanoVdb.state.temporaryResourcesPeak).toBe(3);
|
||||
expect(nanoVdb.checks).toEqual(expect.arrayContaining([
|
||||
"resident-buffer-destroyed-once",
|
||||
"page-table-destroyed-once",
|
||||
"feedback-buffer-destroyed-once",
|
||||
"resource-group-dispose-idempotent",
|
||||
"same-device-recovers",
|
||||
"lru-eviction-recovers",
|
||||
]));
|
||||
|
||||
console.log("oom-recovery", JSON.stringify(result.reports.map((report) => ({
|
||||
scenario: report.scenario,
|
||||
|
||||
116
web/tests/e2e/paint-depth-visibility.spec.ts
Normal file
116
web/tests/e2e/paint-depth-visibility.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M9-09 derives the same occlusion set from real main-thread and Offscreen GPU depth passes", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { ViewportRenderer } = await import("/src/three-adapter/viewport.ts");
|
||||
const { OffscreenViewportRenderer } = await import("/src/three-adapter/offscreen-viewport.ts");
|
||||
|
||||
const normalize = (value: number[]) => {
|
||||
const length = Math.hypot(...value);
|
||||
return value.map((component) => component / length);
|
||||
};
|
||||
const cross = (left: number[], right: number[]) => [
|
||||
left[1] * right[2] - left[2] * right[1],
|
||||
left[2] * right[0] - left[0] * right[2],
|
||||
left[0] * right[1] - left[1] * right[0],
|
||||
];
|
||||
const add = (...values: number[][]) => values[0].map((_, axis) => values.reduce((sum, value) => sum + value[axis], 0));
|
||||
const scale = (value: number[], factor: number) => value.map((component) => component * factor);
|
||||
const camera = [
|
||||
7 * Math.cos(0.55) * Math.cos(-Math.PI / 4),
|
||||
7 * Math.cos(0.55) * Math.sin(-Math.PI / 4),
|
||||
7 * Math.sin(0.55),
|
||||
];
|
||||
const direction = normalize(camera.map((component) => -component));
|
||||
const horizontal = normalize(cross(direction, [0, 0, 1]));
|
||||
const vertical = normalize(cross(direction, horizontal));
|
||||
const front = [
|
||||
add(scale(horizontal, -2), scale(vertical, -2)),
|
||||
add(scale(horizontal, -2), scale(vertical, 2)),
|
||||
add(scale(horizontal, 2), scale(vertical, 2)),
|
||||
add(scale(horizontal, 2), scale(vertical, -2)),
|
||||
];
|
||||
const behind = scale(direction, 1.5);
|
||||
const back = [
|
||||
add(behind, scale(horizontal, -0.12), scale(vertical, -0.08)),
|
||||
add(behind, scale(horizontal, 0.12), scale(vertical, -0.08)),
|
||||
add(behind, scale(vertical, 0.12)),
|
||||
];
|
||||
const toBlender = (value: number[]) => [value[0], -value[2], value[1]];
|
||||
const positions = new Float32Array([...front, ...back].flatMap(toBlender));
|
||||
const indices = new Uint32Array([0, 1, 2, 0, 2, 3, 4, 6, 5]);
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
const snapshot = {
|
||||
schemaVersion: 1 as const,
|
||||
revision: 9,
|
||||
sceneId: "scene:paint-depth",
|
||||
source: { kind: "mock" as const },
|
||||
coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 },
|
||||
nodes: [{
|
||||
id: "object:PaintDepth",
|
||||
name: "PaintDepth",
|
||||
type: "MESH" as const,
|
||||
parentId: null,
|
||||
dataId: "mesh:PaintDepth",
|
||||
visible: true,
|
||||
selectable: true,
|
||||
localMatrix: identity,
|
||||
worldMatrix: identity,
|
||||
transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 },
|
||||
}],
|
||||
meshes: [{ id: "mesh:PaintDepth", name: "PaintDepth", vertexCount: 7, edgeCount: 0, faceCount: 3, cornerCount: 9, triangleCount: 3, geometryStatus: "binary" as const }],
|
||||
materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [],
|
||||
activeObjectId: "object:PaintDepth",
|
||||
frame: { current: 1, start: 1, end: 1 },
|
||||
};
|
||||
const geometry = {
|
||||
schemaVersion: 1 as const,
|
||||
meshId: "mesh:PaintDepth",
|
||||
byteLength: positions.byteLength + indices.byteLength,
|
||||
positions: positions.buffer,
|
||||
indices: indices.buffer,
|
||||
};
|
||||
const request = { schemaVersion: 1 as const, objectId: "object:PaintDepth", meshId: "mesh:PaintDepth", revision: 9, vertexIndices: [0, 1, 2, 3, 4, 5, 6] };
|
||||
|
||||
const createCanvas = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.width = "320px";
|
||||
canvas.style.height = "240px";
|
||||
document.body.append(canvas);
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const mainCanvas = createCanvas();
|
||||
const mainRenderer = new ViewportRenderer(mainCanvas);
|
||||
mainRenderer.setSnapshot(snapshot, [geometry]);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const main = await mainRenderer.samplePaintVisibility(request);
|
||||
mainRenderer.dispose();
|
||||
mainCanvas.remove();
|
||||
|
||||
const offscreenCanvas = createCanvas();
|
||||
const offscreenRenderer = new OffscreenViewportRenderer(offscreenCanvas);
|
||||
offscreenRenderer.setSnapshot(snapshot, [geometry]);
|
||||
let stale = "";
|
||||
try { await offscreenRenderer.samplePaintVisibility({ ...request, revision: 8 }); }
|
||||
catch (error) { stale = error instanceof Error ? error.message : String(error); }
|
||||
const offscreen = await offscreenRenderer.samplePaintVisibility(request);
|
||||
offscreenRenderer.dispose();
|
||||
offscreenCanvas.remove();
|
||||
|
||||
return { main, offscreen, stale };
|
||||
});
|
||||
|
||||
expect(result.main.backend).toBe("MAIN_THREAD_WEBGL2");
|
||||
expect(result.offscreen.backend).toBe("OFFSCREEN_WEBGL2");
|
||||
expect(result.main.source).toBe("GPU_RGBA_DEPTH_READBACK");
|
||||
expect(result.main.occluderPixelCount).toBeGreaterThan(0);
|
||||
expect(result.offscreen.occluderPixelCount).toBeGreaterThan(0);
|
||||
expect(result.main.depthReadbackBytes).toBe(result.main.width * result.main.height * 4);
|
||||
expect(result.offscreen.depthReadbackBytes).toBe(result.offscreen.width * result.offscreen.height * 4);
|
||||
expect(result.main.visibleVertexIndices).toEqual([0, 1, 2, 3]);
|
||||
expect(result.offscreen.visibleVertexIndices).toEqual(result.main.visibleVertexIndices);
|
||||
expect(result.stale).toContain("REVISION_CONFLICT");
|
||||
});
|
||||
96
web/tests/e2e/paint-pbvh-capability.spec.ts
Normal file
96
web/tests/e2e/paint-pbvh-capability.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { paintPBVHBrushInventory } from "../../protocol/paint-pbvh-capability";
|
||||
|
||||
const attributeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
|
||||
|
||||
test("M9-13 production Worker blocks the full PBVH brush inventory without changing Main", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const blendBytes = fs.readFileSync(attributeBlend);
|
||||
const result = await page.evaluate(async ({ blendBytes, inventory }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await engine.init();
|
||||
const opened = await engine.openBlend(blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength));
|
||||
const object = opened.snapshot.nodes.find((candidate) => candidate.id === opened.snapshot.activeObjectId && candidate.type === "MESH");
|
||||
if (!object?.dataId) throw new Error("PBVH fixture has no active Mesh object");
|
||||
const before = await engine.snapshot();
|
||||
const gates = [];
|
||||
for (const entry of inventory) {
|
||||
gates.push(await engine.queryPaintPBVHCapability({
|
||||
schemaVersion: 1,
|
||||
operation: "PBVH_BRUSH",
|
||||
domain: entry.domain,
|
||||
brush: entry.brush,
|
||||
objectId: object.id,
|
||||
meshId: object.dataId,
|
||||
baseRevision: before.snapshot.revision,
|
||||
}));
|
||||
}
|
||||
const stale = await engine.queryPaintPBVHCapability({
|
||||
schemaVersion: 1,
|
||||
operation: "PBVH_BRUSH",
|
||||
domain: "WEIGHT",
|
||||
brush: "DRAW",
|
||||
objectId: object.id,
|
||||
meshId: object.dataId,
|
||||
baseRevision: before.snapshot.revision + 1,
|
||||
});
|
||||
let malformed: { code?: string; severity?: string; recoverable?: boolean } = {};
|
||||
try {
|
||||
await engine.queryPaintPBVHCapability({
|
||||
schemaVersion: 1,
|
||||
operation: "PBVH_BRUSH",
|
||||
domain: "WEIGHT",
|
||||
brush: "DRAW",
|
||||
objectId: object.id,
|
||||
meshId: object.dataId,
|
||||
baseRevision: before.snapshot.revision,
|
||||
proxySuccess: true,
|
||||
} as never);
|
||||
}
|
||||
catch (error) {
|
||||
malformed = error as typeof malformed;
|
||||
}
|
||||
const after = await engine.snapshot();
|
||||
engine.terminate();
|
||||
return {
|
||||
count: gates.length,
|
||||
statuses: [...new Set(gates.map((gate) => gate.status))],
|
||||
taskIds: [...new Set(gates.map((gate) => gate.taskId))],
|
||||
issueCodes: [...new Set(gates.flatMap((gate) => gate.issues.map((issue) => issue.code)))],
|
||||
capabilities: new Set(gates.map((gate) => gate.capability)).size,
|
||||
staleCode: stale.issues[0]?.code,
|
||||
malformed,
|
||||
beforeRevision: before.snapshot.revision,
|
||||
afterRevision: after.snapshot.revision,
|
||||
beforeHandles: before.status.liveHandles,
|
||||
afterHandles: after.status.liveHandles,
|
||||
beforeBytes: before.status.allocatedBytes,
|
||||
afterBytes: after.status.allocatedBytes,
|
||||
};
|
||||
}, { blendBytes: new Uint8Array(blendBytes), inventory: paintPBVHBrushInventory() });
|
||||
|
||||
expect(result).toEqual({
|
||||
count: 46,
|
||||
statuses: ["BLOCKED"],
|
||||
taskIds: ["N-017"],
|
||||
issueCodes: ["PAINT_PBVH_UNAVAILABLE"],
|
||||
capabilities: 46,
|
||||
staleCode: "REVISION_CONFLICT",
|
||||
malformed: {
|
||||
code: "PAINT_SCHEMA_INVALID",
|
||||
severity: "error",
|
||||
recoverable: true,
|
||||
message: "PBVH capability request contains unsupported field proxySuccess",
|
||||
},
|
||||
beforeRevision: result.beforeRevision,
|
||||
afterRevision: result.beforeRevision,
|
||||
beforeHandles: result.beforeHandles,
|
||||
afterHandles: result.beforeHandles,
|
||||
beforeBytes: result.beforeBytes,
|
||||
afterBytes: result.beforeBytes,
|
||||
});
|
||||
});
|
||||
76
web/tests/e2e/paint-stroke-session.spec.ts
Normal file
76
web/tests/e2e/paint-stroke-session.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const attributeBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/attribute_scene.blend");
|
||||
|
||||
test("M9-10 commits pointer chunks as one Main revision and one undo step", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const blendBytes = fs.readFileSync(attributeBlend);
|
||||
const result = await page.evaluate(async ({ blendBytes }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await engine.init();
|
||||
const opened = await engine.openBlend(blendBytes.buffer.slice(blendBytes.byteOffset, blendBytes.byteOffset + blendBytes.byteLength));
|
||||
const mesh = opened.snapshot.meshes.find((candidate) => candidate.id === "mesh:AttributeMesh");
|
||||
if (!mesh || mesh.vertexCount < 4) throw new Error("paint fixture is missing its vertex domain");
|
||||
const pointerSessionId = "paint-pointer:chromium-main-1";
|
||||
const session = {
|
||||
schemaVersion: 1 as const,
|
||||
pointerSessionId,
|
||||
baseRevision: opened.snapshot.revision,
|
||||
target: { mode: "VERTEX_COLOR" as const, meshId: mesh.id, attributeName: "M9StrokeColor", domain: "POINT" as const },
|
||||
};
|
||||
const started = await engine.beginPaintStroke(session);
|
||||
const first = await engine.appendPaintStrokeChunk({ schemaVersion: 1, pointerSessionId, baseRevision: session.baseRevision, chunkIndex: 0, indices: [0, 1], values: [1, 0, 0, 1, 0, 1, 0, 1] });
|
||||
const during = await engine.snapshot();
|
||||
const second = await engine.appendPaintStrokeChunk({ schemaVersion: 1, pointerSessionId, baseRevision: session.baseRevision, chunkIndex: 1, indices: [1, 2, 3], values: [0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1] });
|
||||
const committed = await engine.commitPaintStroke({ schemaVersion: 1, pointerSessionId, baseRevision: session.baseRevision, expectedChunkCount: 2 });
|
||||
const committedMesh = committed.snapshot.meshes.find((candidate) => candidate.id === mesh.id);
|
||||
const undone = await engine.applyCommand({ type: "undo" });
|
||||
const undoneMesh = undone.snapshot.meshes.find((candidate) => candidate.id === mesh.id);
|
||||
let secondUndoCode = "";
|
||||
try { await engine.applyCommand({ type: "undo" }); }
|
||||
catch (error) { secondUndoCode = (error as { code?: string }).code ?? String(error); }
|
||||
const redone = await engine.applyCommand({ type: "redo" });
|
||||
const redoneMesh = redone.snapshot.meshes.find((candidate) => candidate.id === mesh.id);
|
||||
|
||||
const cancelId = "paint-pointer:chromium-cancel-2";
|
||||
const cancelBase = redone.snapshot.revision;
|
||||
await engine.beginPaintStroke({ ...session, pointerSessionId: cancelId, baseRevision: cancelBase });
|
||||
await engine.appendPaintStrokeChunk({ schemaVersion: 1, pointerSessionId: cancelId, baseRevision: cancelBase, chunkIndex: 0, indices: [0], values: [0, 0, 0, 1] });
|
||||
const cancelled = await engine.cancelPaintStroke({ schemaVersion: 1, pointerSessionId: cancelId, baseRevision: cancelBase });
|
||||
const afterCancel = await engine.snapshot();
|
||||
engine.terminate();
|
||||
const hasAttribute = (candidate: typeof mesh | undefined) => candidate?.attributes?.some((attribute) => attribute.name === "M9StrokeColor" && attribute.domain === "POINT") ?? false;
|
||||
return {
|
||||
started,
|
||||
first,
|
||||
second,
|
||||
duringRevision: during.snapshot.revision,
|
||||
committedRevision: committed.snapshot.revision,
|
||||
committedReceipt: committed.paintStrokeSession,
|
||||
committedAttribute: hasAttribute(committedMesh),
|
||||
undoneAttribute: hasAttribute(undoneMesh),
|
||||
redoneAttribute: hasAttribute(redoneMesh),
|
||||
secondUndoCode,
|
||||
cancelled,
|
||||
cancelBase,
|
||||
afterCancelRevision: afterCancel.snapshot.revision,
|
||||
};
|
||||
}, { blendBytes: new Uint8Array(blendBytes) });
|
||||
|
||||
expect(result.started).toMatchObject({ state: "OPEN", chunkCount: 0 });
|
||||
expect(result.first).toMatchObject({ state: "OPEN", chunkCount: 1, receivedEntryCount: 2, uniqueEntryCount: 2 });
|
||||
expect(result.second).toMatchObject({ state: "OPEN", chunkCount: 2, receivedEntryCount: 5, uniqueEntryCount: 4 });
|
||||
expect(result.duringRevision).toBe(result.started.baseRevision);
|
||||
expect(result.committedRevision).toBe(result.started.baseRevision + 1);
|
||||
expect(result.committedReceipt).toMatchObject({ state: "COMMITTED", chunkCount: 2, receivedEntryCount: 5, uniqueEntryCount: 4, committedRevision: result.committedRevision });
|
||||
expect(result.committedAttribute).toBe(true);
|
||||
expect(result.undoneAttribute).toBe(false);
|
||||
expect(result.redoneAttribute).toBe(true);
|
||||
expect(result.secondUndoCode).toBe("INVALID_ARGUMENT");
|
||||
expect(result.cancelled.state).toBe("CANCELLED");
|
||||
expect(result.afterCancelRevision).toBe(result.cancelBase);
|
||||
});
|
||||
30
web/tests/e2e/physics-cache-family.spec.ts
Normal file
30
web/tests/e2e/physics-cache-family.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-14/physics-cache-family.json"), "utf8"));
|
||||
|
||||
test("M10-14 verifies every Physics family cache before playback", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/physics-cache-family-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
})) as {
|
||||
verified: Array<{ family: string; source: string; bytes: number; frames: number }>;
|
||||
sourceMismatch: string;
|
||||
payloadMismatch: string;
|
||||
versionMismatch: string;
|
||||
budgetExceeded: string;
|
||||
};
|
||||
expect(result.verified.map((entry) => entry.family)).toEqual(expected.families);
|
||||
expect(result.verified.map((entry) => entry.source)).toEqual(expected.sources);
|
||||
expect(result.verified.every((entry) => entry.bytes === expected.byteLength)).toBe(true);
|
||||
expect(result.verified.every((entry) => entry.frames === expected.frameCount)).toBe(true);
|
||||
expect(result.sourceMismatch).toBe(expected.sourceMismatch);
|
||||
expect(result.payloadMismatch).toBe(expected.payloadMismatch);
|
||||
expect(result.versionMismatch).toBe(expected.versionMismatch);
|
||||
expect(result.budgetExceeded).toBe(expected.budgetExceeded);
|
||||
});
|
||||
34
web/tests/e2e/physics-solver-probe.spec.ts
Normal file
34
web/tests/e2e/physics-solver-probe.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-13/physics-solver-probe.json"), "utf8"));
|
||||
|
||||
test("M10-13 probes each Physics solver family and keeps failures bake-only", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/physics-solver-probe-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event: MessageEvent<Record<string, unknown>>) => { worker.terminate(); resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
})) as {
|
||||
familyOrder: string[];
|
||||
defaultProbe: string[];
|
||||
probes: Record<string, string>;
|
||||
routes: Record<string, string>;
|
||||
localGate: { status: string; issues: number };
|
||||
fallbackGate: { status: string; code: string; message: string };
|
||||
};
|
||||
expect(result.familyOrder).toEqual(expected.familyOrder);
|
||||
expect(result.defaultProbe).toEqual(expected.familyOrder.map(() => expected.defaultProbe));
|
||||
expect(result.probes).toEqual(expected.probes);
|
||||
expect(result.routes.RIGID_BODY).toBe(expected.localRoute);
|
||||
for (const family of expected.familyOrder.filter((family: string) => family !== "RIGID_BODY")) {
|
||||
expect(result.routes[family]).toBe(expected.fallbackRoute);
|
||||
}
|
||||
expect(result.localGate).toEqual({ status: "READY", issues: 0 });
|
||||
expect(result.fallbackGate.status).toBe("BLOCKED");
|
||||
expect(result.fallbackGate.code).toBe(expected.fallbackErrorCode);
|
||||
expect(result.fallbackGate.message).toContain("desktop/server bake");
|
||||
});
|
||||
198
web/tests/e2e/recent-projects-recovery.spec.ts
Normal file
198
web/tests/e2e/recent-projects-recovery.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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("M7-09 persists recent projects and restores UI state after a damaged-index Worker restart", async ({ page }) => {
|
||||
await page.goto("/?worker-fault=storage");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator(".blender-app");
|
||||
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
|
||||
await page.getByRole("button", { name: "Modeling" }).click();
|
||||
await page.getByRole("slider", { name: "当前帧" }).fill("12");
|
||||
await expect(app).toHaveAttribute("data-current-frame", "12");
|
||||
await page.locator('.tree-row.child').filter({ hasText: "Camera" }).click();
|
||||
await page.locator('.tree-row.child').filter({ hasText: "Cube" }).click({ modifiers: ["Shift"] });
|
||||
const selectedBefore = await app.getAttribute("data-selected-object-ids");
|
||||
const revisionBefore = await app.getAttribute("data-current-main-revision");
|
||||
expect(selectedBefore?.split(",")).toHaveLength(2);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("blender-web-metadata", 7);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const transaction = database.transaction("setting", "readwrite");
|
||||
transaction.objectStore("setting").put({ id: "recent-projects:v1", value: { schemaVersion: 1, projects: [{ broken: true }] } });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
database.close();
|
||||
});
|
||||
|
||||
await page.getByTestId("inject-worker-crash").click();
|
||||
await expect(app).toHaveAttribute("data-worker-fault-source", "storage");
|
||||
await page.getByTestId("restart-and-recover").click();
|
||||
await expect(app).toHaveAttribute("data-worker-recovery-status", "SUCCEEDED", { timeout: 30_000 });
|
||||
await expect(app).toHaveAttribute("data-workspace", "Modeling");
|
||||
await expect(app).toHaveAttribute("data-current-frame", "12");
|
||||
await expect(app).toHaveAttribute("data-selected-object-ids", selectedBefore ?? "");
|
||||
await expect(app).toHaveAttribute("data-current-main-revision", revisionBefore ?? "");
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
await expect(app).toHaveAttribute("data-recent-project-quarantined", "1");
|
||||
const persistedIndex = await page.evaluate(async () => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("blender-web-metadata", 7);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const transaction = database.transaction("setting", "readonly");
|
||||
const row = await new Promise<{ value?: { projects?: Array<{ projectId?: string }> } } | undefined>((resolve, reject) => {
|
||||
const request = transaction.objectStore("setting").get("recent-projects:v1");
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
database.close();
|
||||
return row?.value?.projects?.map((project) => project.projectId) ?? [];
|
||||
});
|
||||
expect(persistedIndex).toEqual(["basic_scene"]);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
await page.getByTestId("recent-projects").selectOption("basic_scene");
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
await expect(app).toHaveAttribute("data-project-id", "basic_scene");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("Recovery:");
|
||||
});
|
||||
|
||||
test("M7-09 StorageClient keeps deterministic recent projects across Worker instances", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const base = {
|
||||
schemaVersion: 1 as const,
|
||||
revision: 1,
|
||||
bytes: 8,
|
||||
sha256: "a".repeat(64),
|
||||
updatedAt: "2026-08-15T12:00:00.000Z",
|
||||
backend: "unknown" as const,
|
||||
};
|
||||
const first = new StorageClient();
|
||||
await Promise.all([
|
||||
first.touchRecentProject({ ...base, projectId: "older", displayName: "Older", lastOpenedAt: "2026-08-15T11:00:00.000Z" }),
|
||||
first.touchRecentProject({ ...base, projectId: "newer", displayName: "Newer", lastOpenedAt: "2026-08-15T13:00:00.000Z" }),
|
||||
first.touchRecentProject({ ...base, projectId: "older", displayName: "Older latest", revision: 2, lastOpenedAt: "2026-08-15T14:00:00.000Z" }),
|
||||
]);
|
||||
first.terminate();
|
||||
const restarted = new StorageClient();
|
||||
const listed = await restarted.listRecentProjects();
|
||||
restarted.terminate();
|
||||
return listed;
|
||||
});
|
||||
|
||||
expect(result.quarantined).toBe(0);
|
||||
expect(result.projects.map(({ projectId, revision }) => [projectId, revision])).toEqual([["older", 2], ["newer", 1]]);
|
||||
});
|
||||
|
||||
test("M7-10 isolates a missing recent project and removes only its reference", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
await storage.touchRecentProject({
|
||||
schemaVersion: 1,
|
||||
projectId: "missing_recent",
|
||||
displayName: "Missing recent.blend",
|
||||
revision: 1,
|
||||
bytes: 8,
|
||||
sha256: "a".repeat(64),
|
||||
updatedAt: "2026-08-15T12:00:00.000Z",
|
||||
lastOpenedAt: "2026-08-15T12:00:00.000Z",
|
||||
backend: "opfs",
|
||||
});
|
||||
storage.terminate();
|
||||
});
|
||||
const app = page.locator(".blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-count", "1");
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-ids", "missing_recent");
|
||||
await expect(page.getByTestId("recent-project-repair-banner")).toContainText("项目内容缺失");
|
||||
await expect(page.locator('[data-testid="recent-project-repair-banner"] [data-issue-code="MISSING"]')).toBeVisible();
|
||||
|
||||
await page.getByTestId("remove-recent-project-missing_recent").click();
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-count", "0");
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
await expect(page.getByTestId("recent-project-repair-banner")).toHaveCount(0);
|
||||
const listed = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
const result = await storage.listRecentProjects();
|
||||
storage.terminate();
|
||||
return result;
|
||||
});
|
||||
expect(listed.projects.map(({ projectId }) => projectId)).toEqual(["basic_scene"]);
|
||||
expect(listed.issues).toEqual([]);
|
||||
});
|
||||
|
||||
test("M7-10 isolates a hash-mismatched entry while retaining other recent projects", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator(".blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "basic_scene");
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("blender-web-metadata", 7);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const transaction = database.transaction("setting", "readwrite");
|
||||
const store = transaction.objectStore("setting");
|
||||
const row = await new Promise<{ value?: { projects?: Array<Record<string, unknown>> } } | undefined>((resolve, reject) => {
|
||||
const request = store.get("recent-projects:v1");
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const projects = (row?.value?.projects ?? []).map((project) => project.projectId === "basic_scene" ? { ...project, sha256: "f".repeat(64) } : project);
|
||||
store.put({ id: "recent-projects:v1", value: { schemaVersion: 1, projects } });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
database.close();
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-count", "1");
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-ids", "basic_scene");
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "");
|
||||
await expect(page.locator('[data-testid="recent-project-repair-banner"] [data-issue-code="HASH_MISMATCH"]')).toBeVisible();
|
||||
|
||||
await page.getByTestId("remove-recent-project-basic_scene").click();
|
||||
await expect(app).toHaveAttribute("data-recent-project-invalid-count", "0");
|
||||
await expect(app).toHaveAttribute("data-recent-project-ids", "");
|
||||
await expect(page.getByText("Cube", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
103
web/tests/e2e/render-reference.spec.ts
Normal file
103
web/tests/e2e/render-reference.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const goldenRoot = path.join(root, "tests/golden/M11-04");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(goldenRoot, "manifest.json"), "utf8"));
|
||||
const blend = fs.readFileSync(path.join(root, manifest.source.fixture));
|
||||
const referencePng = fs.readFileSync(path.join(goldenRoot, manifest.reference.file));
|
||||
|
||||
async function decodeAndCompare(page: import("@playwright/test").Page, reference: Buffer, actual: Buffer) {
|
||||
return page.evaluate(async ({ referenceBytes, actualBytes, thresholds }) => {
|
||||
const { compareRenderImages } = await import("/src/three-adapter/render-image-comparison.ts");
|
||||
const decode = async (bytes: number[]) => {
|
||||
const bitmap = await createImageBitmap(new Blob([Uint8Array.from(bytes)], { type: "image/png" }), {
|
||||
colorSpaceConversion: "none",
|
||||
premultiplyAlpha: "none",
|
||||
});
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("2D decode context is unavailable");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const pixels = new Uint8Array(context.getImageData(0, 0, bitmap.width, bitmap.height).data);
|
||||
const result = { width: bitmap.width, height: bitmap.height, pixels };
|
||||
bitmap.close();
|
||||
return result;
|
||||
};
|
||||
const [referenceFrame, actualFrame] = await Promise.all([decode(referenceBytes), decode(actualBytes)]);
|
||||
if (referenceFrame.width !== actualFrame.width || referenceFrame.height !== actualFrame.height) {
|
||||
throw new Error("render frame dimensions differ");
|
||||
}
|
||||
return compareRenderImages(
|
||||
referenceFrame.pixels,
|
||||
actualFrame.pixels,
|
||||
referenceFrame.width,
|
||||
referenceFrame.height,
|
||||
thresholds,
|
||||
);
|
||||
}, { referenceBytes: Array.from(reference), actualBytes: Array.from(actual), thresholds: manifest.thresholds });
|
||||
}
|
||||
|
||||
for (const offscreen of [false, true]) {
|
||||
test(`M11-04 ${offscreen ? "Offscreen" : "main-thread"} production render matches the Blender reference metrics`, async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
await page.evaluate(async ({ input, useOffscreen }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const { ViewportRenderer } = await import("/src/three-adapter/viewport.ts");
|
||||
const { OffscreenViewportRenderer } = await import("/src/three-adapter/offscreen-viewport.ts");
|
||||
document.body.replaceChildren();
|
||||
document.body.style.margin = "0";
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = "m11-reference-canvas";
|
||||
canvas.style.width = "256px";
|
||||
canvas.style.height = "256px";
|
||||
document.body.append(canvas);
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const opened = await client.openBlend(Uint8Array.from(input).buffer);
|
||||
opened.snapshot.activeObjectId = null;
|
||||
const renderer = useOffscreen ? new OffscreenViewportRenderer(canvas) : new ViewportRenderer(canvas);
|
||||
renderer.setSnapshot(opened.snapshot, opened.geometryBuffers, opened.nonMeshGeometryBuffers ?? []);
|
||||
for (let attempt = 0; attempt < 300; attempt++) {
|
||||
if (!useOffscreen || (Number(canvas.dataset.rendererPixels) > 0 && canvas.dataset.renderBudgetStatus === "ready")) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
(globalThis as typeof globalThis & { __m11Reference?: { renderer: { dispose(): void }; client: { terminate(): void } } }).__m11Reference = { renderer, client };
|
||||
}, { input: Array.from(blend), useOffscreen: offscreen });
|
||||
|
||||
const canvas = page.locator("#m11-reference-canvas");
|
||||
await expect(canvas).toHaveAttribute("data-render-budget-status", "ready");
|
||||
const actualPng = await canvas.screenshot({ animations: "disabled" });
|
||||
const report = await decodeAndCompare(page, referencePng, actualPng);
|
||||
console.log(`m11-render-reference-${offscreen ? "offscreen" : "main"}`, JSON.stringify(report));
|
||||
expect(report.status).toBe("READY");
|
||||
expect(report.errorCode).toBeNull();
|
||||
expect(report.checks.every((item) => item.passed)).toBe(true);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const state = (globalThis as typeof globalThis & { __m11Reference?: { renderer: { dispose(): void }; client: { terminate(): void } } }).__m11Reference;
|
||||
state?.renderer.dispose();
|
||||
state?.client.terminate();
|
||||
delete (globalThis as typeof globalThis & { __m11Reference?: unknown }).__m11Reference;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("M11-04 rejects a non-empty frame with the wrong composition", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const wrong = await page.evaluate(async () => {
|
||||
const canvas = new OffscreenCanvas(256, 256);
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.fillStyle = "rgb(58,58,58)";
|
||||
context.fillRect(0, 0, 256, 256);
|
||||
context.fillStyle = "rgb(255,0,0)";
|
||||
context.fillRect(0, 0, 32, 32);
|
||||
return Array.from(new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer()));
|
||||
});
|
||||
const report = await decodeAndCompare(page, referencePng, Buffer.from(wrong));
|
||||
expect(report.status).toBe("BLOCKED");
|
||||
expect(report.errorCode).toBe("RENDER_REFERENCE_MISMATCH");
|
||||
expect(report.checks.some((item) => !item.passed)).toBe(true);
|
||||
});
|
||||
187
web/tests/e2e/render-resource-budget.spec.ts
Normal file
187
web/tests/e2e/render-resource-budget.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const blend = fs.readFileSync(path.join(root, "tests/files/web/basic_scene.blend"));
|
||||
const texturePng = fs.readFileSync(path.join(root, "tests/files/web/resources/udim_1001.png"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-03/render-resource-budget.json"), "utf8"));
|
||||
|
||||
for (const offscreen of [false, true]) {
|
||||
test(`M11-03 ${offscreen ? "Offscreen" : "main-thread"} renderer enforces light, shadow and texture budgets`, async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ input, useOffscreen, requestedLights }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const { ViewportRenderer } = await import("/src/three-adapter/viewport.ts");
|
||||
const { OffscreenViewportRenderer } = await import("/src/three-adapter/offscreen-viewport.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.width = "256px";
|
||||
canvas.style.height = "256px";
|
||||
document.body.append(canvas);
|
||||
const renderer = useOffscreen ? new OffscreenViewportRenderer(canvas) : new ViewportRenderer(canvas);
|
||||
const waitFor = async (attribute: string): Promise<string> => {
|
||||
for (let attempt = 0; attempt < 300; attempt++) {
|
||||
const value = canvas.getAttribute(attribute);
|
||||
if (value) return value;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`${attribute} was not published`);
|
||||
};
|
||||
try {
|
||||
const opened = await client.openBlend(Uint8Array.from(input).buffer);
|
||||
const snapshot = structuredClone(opened.snapshot);
|
||||
const sourceNode = snapshot.nodes.find((node) => node.type === "LIGHT")!;
|
||||
const sourceLight = snapshot.lights.find((light) => light.id === sourceNode.dataId)!;
|
||||
snapshot.nodes = [
|
||||
...snapshot.nodes.filter((node) => node.type !== "LIGHT"),
|
||||
...Array.from({ length: requestedLights }, (_, index) => ({
|
||||
...sourceNode,
|
||||
id: `object:M11BudgetLight${index.toString().padStart(2, "0")}`,
|
||||
name: `M11BudgetLight${index.toString().padStart(2, "0")}`,
|
||||
dataId: `light:M11BudgetLight${index.toString().padStart(2, "0")}`,
|
||||
visible: true,
|
||||
})),
|
||||
];
|
||||
snapshot.lights = Array.from({ length: requestedLights }, (_, index) => ({
|
||||
...sourceLight,
|
||||
id: `light:M11BudgetLight${index.toString().padStart(2, "0")}`,
|
||||
name: `M11BudgetLight${index.toString().padStart(2, "0")}`,
|
||||
lightType: 2,
|
||||
castsShadow: true,
|
||||
}));
|
||||
renderer.setSnapshot(snapshot, [], []);
|
||||
await waitFor("data-render-budget-status");
|
||||
|
||||
const textures = Array.from({ length: 257 }, (_, index) => ({
|
||||
schemaVersion: 1 as const,
|
||||
assetId: `asset:m11-budget-${index}`,
|
||||
imageId: `image:m11-budget-${index}`,
|
||||
mimeType: "image/png",
|
||||
width: 1,
|
||||
height: 1,
|
||||
usage: "BASE_COLOR" as const,
|
||||
colorSpace: "SRGB" as const,
|
||||
sha256: "0".repeat(64),
|
||||
byteLength: 1,
|
||||
data: Uint8Array.of(index & 0xff).buffer,
|
||||
}));
|
||||
renderer.setTextureAssets(textures);
|
||||
await waitFor("data-texture-budget-status");
|
||||
|
||||
let actualLights: number | null = null;
|
||||
let actualShadows: number | null = null;
|
||||
if (!useOffscreen) {
|
||||
actualLights = 0;
|
||||
actualShadows = 0;
|
||||
(renderer as InstanceType<typeof ViewportRenderer>).scene.traverse((object) => {
|
||||
if (!("isLight" in object) || !object.isLight) return;
|
||||
actualLights! += 1;
|
||||
if ("castShadow" in object && object.castShadow) actualShadows! += 1;
|
||||
});
|
||||
}
|
||||
return {
|
||||
render: {
|
||||
backend: canvas.dataset.renderBudgetBackend,
|
||||
status: canvas.dataset.renderBudgetStatus,
|
||||
code: canvas.dataset.renderBudgetCode,
|
||||
requestedLights: Number(canvas.dataset.renderBudgetLights),
|
||||
renderedLights: Number(canvas.dataset.renderBudgetRenderedLights),
|
||||
droppedLights: Number(canvas.dataset.renderBudgetDroppedLights),
|
||||
requestedShadows: Number(canvas.dataset.renderBudgetShadows),
|
||||
renderedShadows: Number(canvas.dataset.renderBudgetRenderedShadows),
|
||||
blockedShadows: Number(canvas.dataset.renderBudgetBlockedShadows),
|
||||
dimension: Number(canvas.dataset.renderBudgetShadowMapDimension),
|
||||
},
|
||||
texture: {
|
||||
status: canvas.dataset.textureBudgetStatus,
|
||||
code: canvas.dataset.textureBudgetCode,
|
||||
assets: Number(canvas.dataset.textureBudgetAssets),
|
||||
loaded: Number(canvas.dataset.textureLoaded),
|
||||
bytes: Number(canvas.dataset.textureBytes),
|
||||
},
|
||||
actualLights,
|
||||
actualShadows,
|
||||
};
|
||||
}
|
||||
finally {
|
||||
renderer.dispose();
|
||||
client.terminate();
|
||||
canvas.remove();
|
||||
}
|
||||
}, { input: Array.from(blend), useOffscreen: offscreen, requestedLights: golden.overflow.requestedLights });
|
||||
|
||||
expect(result.render).toEqual({
|
||||
backend: "THREE_WEBGL2",
|
||||
status: "blocked",
|
||||
code: "GPU_LIGHT_BUDGET_EXCEEDED",
|
||||
requestedLights: golden.overflow.requestedLights,
|
||||
renderedLights: golden.overflow.renderedLights,
|
||||
droppedLights: golden.overflow.droppedLights,
|
||||
requestedShadows: golden.overflow.requestedShadowMaps,
|
||||
renderedShadows: golden.overflow.renderedShadowMaps,
|
||||
blockedShadows: golden.overflow.blockedShadowMaps,
|
||||
dimension: golden.webgl2.shadowMapDimension,
|
||||
});
|
||||
expect(result.texture).toEqual({
|
||||
status: "blocked",
|
||||
code: "GPU_TEXTURE_BUDGET_EXCEEDED",
|
||||
assets: 257,
|
||||
loaded: 0,
|
||||
bytes: 0,
|
||||
});
|
||||
if (!offscreen) {
|
||||
expect(result.actualLights).toBe(golden.webgl2.maxLights);
|
||||
expect(result.actualShadows).toBe(golden.webgl2.maxShadowMaps);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("M11-03 keeps the previous texture set when an aggregate batch is over budget", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { GPUTextureStore } = await import("/src/three-adapter/texture-assets.ts");
|
||||
const store = new GPUTextureStore("THREE_WEBGL2");
|
||||
try {
|
||||
const data = Uint8Array.from(input).buffer;
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
const kept = {
|
||||
schemaVersion: 1 as const,
|
||||
assetId: "asset:kept",
|
||||
imageId: "image:kept",
|
||||
mimeType: "image/png",
|
||||
width: 8,
|
||||
height: 8,
|
||||
usage: "BASE_COLOR",
|
||||
colorSpace: "SRGB",
|
||||
byteLength: data.byteLength,
|
||||
sha256: [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""),
|
||||
data,
|
||||
} as const;
|
||||
const accepted = await store.upload([kept]);
|
||||
const before = store.getAsset("image:kept", "BASE_COLOR");
|
||||
const overBudget = Array.from({ length: 257 }, (_, index) => ({
|
||||
...kept,
|
||||
assetId: `asset:overflow:${index}`,
|
||||
imageId: `image:overflow:${index}`,
|
||||
data: kept.data.slice(0),
|
||||
}));
|
||||
const blocked = await store.upload(overBudget);
|
||||
const after = store.getAsset("image:kept", "BASE_COLOR");
|
||||
return {
|
||||
accepted: [accepted.loaded, accepted.rejected, accepted.budget.status],
|
||||
blocked: [blocked.loaded, blocked.rejected, blocked.budget.status, blocked.budget.requestedAssets, blocked.errorCodes[0]],
|
||||
retained: before === after && after?.sha256 === kept.sha256,
|
||||
revision: store.getRevision(),
|
||||
};
|
||||
}
|
||||
finally {
|
||||
store.dispose();
|
||||
}
|
||||
}, Array.from(texturePng));
|
||||
expect(result.accepted).toEqual([1, 0, "READY"]);
|
||||
expect(result.blocked).toEqual([0, 257, "BLOCKED", 258, "GPU_TEXTURE_BUDGET_EXCEEDED"]);
|
||||
expect(result.retained).toBe(true);
|
||||
expect(result.revision).toBe(2);
|
||||
});
|
||||
43
web/tests/e2e/render-routing.spec.ts
Normal file
43
web/tests/e2e/render-routing.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const blend = fs.readFileSync(path.join(root, "tests/files/web/m11_render_reference.blend"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-05/render-routing.json"), "utf8"));
|
||||
|
||||
test("M11-05 exposes fail-closed render routing for Web, server and hardware capabilities", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { routeRenderExecution } = await import("/src/three-adapter/render-routing.ts");
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
try {
|
||||
const opened = await client.openBlend(Uint8Array.from(input).buffer);
|
||||
const sourceRenderEngine = opened.snapshot.scenes[0]?.renderEngine;
|
||||
if (sourceRenderEngine !== "BLENDER_EEVEE" && sourceRenderEngine !== "BLENDER_EEVEE_NEXT") throw new Error(`Unexpected fixture render engine: ${sourceRenderEngine}`);
|
||||
const base = { schemaVersion: 1 as const, renderEngine: sourceRenderEngine, backend: "WEBGL2" as const, complexity: "BOUNDED" as const };
|
||||
const bounded = routeRenderExecution(base);
|
||||
const cycles = routeRenderExecution({ ...base, renderEngine: "BLENDER_CYCLES", backend: "CYCLES" });
|
||||
const complex = routeRenderExecution({ ...base, backend: "EEVEE_COMPLEX", complexity: "COMPLEX" });
|
||||
const hardware = routeRenderExecution({ ...base, hardwareBackend: "OPTIX" });
|
||||
const availableServer = routeRenderExecution({ ...base, renderEngine: "BLENDER_CYCLES", backend: "CYCLES" }, { serverRenderAvailable: true });
|
||||
const unavailableGpu = routeRenderExecution({ ...base, backend: "WEBGPU" });
|
||||
const unknownEngine = routeRenderExecution({ ...base, renderEngine: "UNKNOWN_ENGINE" });
|
||||
return { sourceRenderEngine, bounded, cycles, complex, hardware, availableServer, unavailableGpu, unknownEngine };
|
||||
}
|
||||
finally { client.terminate(); }
|
||||
}, Array.from(blend));
|
||||
|
||||
expect(result.sourceRenderEngine).toBe("BLENDER_EEVEE");
|
||||
expect(result.bounded).toMatchObject(golden.boundedEevee);
|
||||
for (const serverResult of [result.cycles, result.complex, result.hardware]) {
|
||||
expect(serverResult).toMatchObject({ target: "SERVER_JOB", status: "BLOCKED" });
|
||||
expect(serverResult.issues[0].code).toBe("SERVER_JOB_UNAVAILABLE");
|
||||
}
|
||||
expect(result.availableServer).toMatchObject({ target: golden.cycles.target, ...golden.cycles.withEndpoint });
|
||||
expect(result.unavailableGpu).toMatchObject({ target: golden.webgpu.target, status: golden.webgpu.status });
|
||||
expect(result.unavailableGpu.issues[0].code).toBe(golden.webgpu.code);
|
||||
expect(result.unknownEngine).toMatchObject({ target: golden.unknownEngine.target, status: golden.unknownEngine.status, capability: golden.unknownEngine.capability });
|
||||
expect(result.unknownEngine.issues[0].code).toBe(golden.unknownEngine.code);
|
||||
});
|
||||
50
web/tests/e2e/responsive-layout.spec.ts
Normal file
50
web/tests/e2e/responsive-layout.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const viewports = [
|
||||
{ name: "desktop-1440x900", width: 1440, height: 900 },
|
||||
{ name: "desktop-1280x720", width: 1280, height: 720 },
|
||||
{ name: "mobile-narrow", width: 360, height: 640 },
|
||||
{ name: "mobile-extra-narrow", width: 320, height: 568 },
|
||||
] as const;
|
||||
|
||||
test.describe("M7-14 responsive layout", () => {
|
||||
for (const viewport of viewports) {
|
||||
test(`${viewport.name} keeps controls inside their layout bands`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
|
||||
const report = await page.evaluate(() => {
|
||||
const visible = (element: Element): element is HTMLElement => {
|
||||
const node = element as HTMLElement;
|
||||
const style = getComputedStyle(node);
|
||||
return style.display !== "none" && style.visibility !== "hidden" && node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0;
|
||||
};
|
||||
const bands = [".topbar", ".workspace-toolbar", ".storage-budget-panel", ".status-bar"]
|
||||
.map((selector) => document.querySelector<HTMLElement>(selector))
|
||||
.filter((element): element is HTMLElement => Boolean(element) && visible(element));
|
||||
const bandViolations = bands.flatMap((band) => {
|
||||
const bandRect = band.getBoundingClientRect();
|
||||
const horizontalConstrained = !["auto", "scroll", "hidden"].includes(getComputedStyle(band).overflowX);
|
||||
return [...band.querySelectorAll<HTMLElement>("button, input, select, output, span")]
|
||||
.filter(visible)
|
||||
.filter((control) => {
|
||||
const rect = control.getBoundingClientRect();
|
||||
return rect.top < bandRect.top - 1 || rect.bottom > bandRect.bottom + 1 || (horizontalConstrained && (rect.left < bandRect.left - 1 || rect.right > bandRect.right + 1));
|
||||
})
|
||||
.map((control) => `${band.className}:${control.textContent?.trim() || control.getAttribute("aria-label") || control.tagName}`);
|
||||
});
|
||||
const textOverflow = [...document.querySelectorAll<HTMLElement>(".topbar button, .topbar select, .workspace-toolbar button, .workspace-toolbar > span, .storage-budget-panel span, .storage-budget-panel output, .editor-header button")]
|
||||
.filter(visible)
|
||||
.filter((element) => element.scrollWidth > element.clientWidth + 1)
|
||||
.map((element) => element.textContent?.trim() || element.getAttribute("aria-label") || element.tagName);
|
||||
const root = document.documentElement;
|
||||
return { bandViolations, textOverflow, rootScrollWidth: root.scrollWidth, viewportWidth: window.innerWidth };
|
||||
});
|
||||
|
||||
expect(report.bandViolations, JSON.stringify(report)).toEqual([]);
|
||||
expect(report.textOverflow, JSON.stringify(report)).toEqual([]);
|
||||
expect(report.rootScrollWidth, JSON.stringify(report)).toBeLessThanOrEqual(report.viewportWidth);
|
||||
});
|
||||
}
|
||||
});
|
||||
72
web/tests/e2e/sequencer-audio-recovery.spec.ts
Normal file
72
web/tests/e2e/sequencer-audio-recovery.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-13/sequencer-audio-recovery.json"), "utf8")) as {
|
||||
outputGain: number;
|
||||
lifecycle: Array<[string, string, boolean, number, string | null]>;
|
||||
missingDevice: [string, string, string];
|
||||
};
|
||||
|
||||
test("M11-13 suspends, resumes, mutes, recovers, and closes a Chromium AudioContext", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate((outputGain) => {
|
||||
const button = document.createElement("button");
|
||||
button.id = "m11-audio-user-gesture";
|
||||
button.textContent = "Start audio test";
|
||||
document.body.append(button);
|
||||
const result = new Promise((resolve, reject) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
const { SequencerAudioSession } = await import("/src/sequencer/SequencerAudioSession.ts");
|
||||
const summarize = (report: import("/src/sequencer/SequencerAudioSession.ts").SequencerAudioSessionReportIR) => [
|
||||
report.contextState,
|
||||
report.outputState,
|
||||
report.muted,
|
||||
report.outputGain,
|
||||
report.issueCode,
|
||||
];
|
||||
const supported = "AudioContext" in globalThis;
|
||||
const session = new SequencerAudioSession({ outputGain });
|
||||
const initialized = await session.initialize();
|
||||
const lifecycle = [];
|
||||
lifecycle.push(summarize(await session.suspend()));
|
||||
lifecycle.push(summarize(session.setMuted(true)));
|
||||
lifecycle.push(summarize(await session.resume()));
|
||||
lifecycle.push(summarize(session.setMuted(false)));
|
||||
lifecycle.push(summarize(await session.suspend()));
|
||||
lifecycle.push(summarize(await session.resume()));
|
||||
lifecycle.push(summarize(await session.close()));
|
||||
|
||||
const missingSession = new SequencerAudioSession({ scope: {}, outputGain });
|
||||
const missing = await missingSession.initialize();
|
||||
const missingDevice = [missing.contextState, missing.outputState, missing.issueCode];
|
||||
const missingClosed = await missingSession.close();
|
||||
resolve({ supported, initialized, lifecycle, missingDevice, missingClosed });
|
||||
}
|
||||
catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
(globalThis as typeof globalThis & { __m11AudioResult?: Promise<unknown> }).__m11AudioResult = result;
|
||||
}, golden.outputGain);
|
||||
|
||||
await page.locator("#m11-audio-user-gesture").click();
|
||||
const result = await page.evaluate(() =>
|
||||
(globalThis as typeof globalThis & { __m11AudioResult: Promise<unknown> }).__m11AudioResult) as {
|
||||
supported: boolean;
|
||||
initialized: { contextState: string; issueCode: string | null };
|
||||
lifecycle: Array<[string, string, boolean, number, string | null]>;
|
||||
missingDevice: [string, string, string];
|
||||
missingClosed: { contextState: string; outputState: string; outputGain: number };
|
||||
};
|
||||
|
||||
expect(result.supported).toBe(true);
|
||||
expect(["RUNNING", "SUSPENDED"]).toContain(result.initialized.contextState);
|
||||
expect(result.initialized.issueCode).toBeNull();
|
||||
expect(result.lifecycle).toEqual(golden.lifecycle);
|
||||
expect(result.missingDevice).toEqual(golden.missingDevice);
|
||||
expect(result.missingClosed).toMatchObject({ contextState: "CLOSED", outputState: "SILENT", outputGain: 0 });
|
||||
});
|
||||
84
web/tests/e2e/sequencer-codec-probe.spec.ts
Normal file
84
web/tests/e2e/sequencer-codec-probe.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-09/sequencer-codec-probe.json"), "utf8")) as {
|
||||
assets: Array<{
|
||||
stripType: "IMAGE" | "SOUND" | "MOVIE";
|
||||
mimeType: string;
|
||||
path: string;
|
||||
sourcePath: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
backend: string;
|
||||
decoded: Record<string, number>;
|
||||
}>;
|
||||
movieGenerator: { path: string; sha256: string };
|
||||
blockedCode: string;
|
||||
};
|
||||
|
||||
test("M11-09 probes IMAGE, SOUND and MOVIE bytes at runtime without extension guessing", async ({ page }) => {
|
||||
const assets = golden.assets.map((asset) => {
|
||||
const bytes = fs.readFileSync(path.join(root, asset.path));
|
||||
expect(bytes.byteLength).toBe(asset.byteLength);
|
||||
expect(crypto.createHash("sha256").update(bytes).digest("hex")).toBe(asset.sha256);
|
||||
return { ...asset, bytes: new Uint8Array(bytes) };
|
||||
});
|
||||
expect(crypto.createHash("sha256").update(fs.readFileSync(path.join(root, golden.movieGenerator.path))).digest("hex"))
|
||||
.toBe(golden.movieGenerator.sha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ assets }) => {
|
||||
const [runtime, protocol] = await Promise.all([
|
||||
import("/src/sequencer/SequencerCodecProbe.ts"),
|
||||
import("/src/sequencer/SequencerTimeline.ts"),
|
||||
]);
|
||||
const ready = [];
|
||||
for (const asset of assets) {
|
||||
const request = runtime.createSequencerCodecProbeRequest(asset.stripType, asset.mimeType, asset.byteLength, asset.sha256);
|
||||
const receipt = await runtime.probeSequencerCodec(request, asset.bytes.buffer);
|
||||
const gate = protocol.gateSequencerCodec(request, receipt);
|
||||
ready.push({ sourcePath: asset.sourcePath, receipt, gate: gate.status });
|
||||
}
|
||||
|
||||
const source = assets[0];
|
||||
const corrupted = source.bytes.slice();
|
||||
corrupted.fill(0, 0, Math.min(32, corrupted.length));
|
||||
const corruptedHash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", corrupted.buffer)),
|
||||
(byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
const corruptRequest = runtime.createSequencerCodecProbeRequest("IMAGE", "image/png", corrupted.byteLength, corruptedHash);
|
||||
const corruptReceipt = await runtime.probeSequencerCodec(corruptRequest, corrupted.buffer);
|
||||
const mismatchRequest = runtime.createSequencerCodecProbeRequest("IMAGE", "image/png", source.byteLength, source.sha256);
|
||||
const mismatchReceipt = await runtime.probeSequencerCodec(mismatchRequest, corrupted.buffer);
|
||||
const spoofRequest = runtime.createSequencerCodecProbeRequest("MOVIE", "video/mp4", source.byteLength, source.sha256);
|
||||
const spoofReceipt = await runtime.probeSequencerCodec(spoofRequest, source.bytes.buffer);
|
||||
const forgedReceipt = { ...ready[0].receipt, sourceSha256: "f".repeat(64) };
|
||||
const forgedGate = protocol.gateSequencerCodec(mismatchRequest, forgedReceipt);
|
||||
return {
|
||||
ready,
|
||||
corrupt: { status: corruptReceipt.status, reason: corruptReceipt.reason },
|
||||
mismatch: { status: mismatchReceipt.status, reason: mismatchReceipt.reason },
|
||||
spoof: { status: spoofReceipt.status, reason: spoofReceipt.reason },
|
||||
forged: { status: forgedGate.status, code: forgedGate.issues[0]?.code },
|
||||
};
|
||||
}, { assets });
|
||||
|
||||
expect(result.ready.map((item) => ({
|
||||
sourcePath: item.sourcePath,
|
||||
status: item.receipt.status,
|
||||
backend: item.receipt.backend,
|
||||
decoded: item.receipt.decoded,
|
||||
gate: item.gate,
|
||||
}))).toEqual(golden.assets.map((asset) => ({
|
||||
sourcePath: asset.sourcePath,
|
||||
status: "READY",
|
||||
backend: asset.backend,
|
||||
decoded: asset.decoded,
|
||||
gate: "READY",
|
||||
})));
|
||||
expect(result.corrupt).toEqual({ status: "BLOCKED", reason: "DECODE_FAILED" });
|
||||
expect(result.mismatch).toEqual({ status: "BLOCKED", reason: "SOURCE_IDENTITY_MISMATCH" });
|
||||
expect(result.spoof.status).toBe("BLOCKED");
|
||||
expect(result.forged).toEqual({ status: "BLOCKED", code: golden.blockedCode });
|
||||
});
|
||||
94
web/tests/e2e/sequencer-final-export.spec.ts
Normal file
94
web/tests/e2e/sequencer-final-export.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const blend = fs.readFileSync(path.join(root, "tests/files/web/sequencer_scene.blend"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-12/sequencer-final-export.json"), "utf8")) as {
|
||||
sourceBlendSha256: string;
|
||||
settingsSha256: string;
|
||||
requestSha256: string;
|
||||
};
|
||||
|
||||
test("M11-12 routes a real Main timeline to server export even when VideoEncoder exists", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const [{ WebEngineClient }, finalExport] = await Promise.all([
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/sequencer/SequencerFinalExport.ts"),
|
||||
]);
|
||||
const source = Uint8Array.from(input).buffer;
|
||||
const sourceBlendSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", source)), (byte) =>
|
||||
byte.toString(16).padStart(2, "0")).join("");
|
||||
const client = new WebEngineClient({ timeoutMs: 20_000 });
|
||||
try {
|
||||
const opened = await client.openBlend(source);
|
||||
const timeline = opened.snapshot.scenes.find((scene) => scene.name === "SequencerScene")?.sequencerTimeline;
|
||||
if (!timeline) throw new Error("Real Sequencer Main timeline is missing");
|
||||
const request = {
|
||||
schemaVersion: 1 as const,
|
||||
timelineId: timeline.id,
|
||||
timelineRevision: timeline.revision,
|
||||
sourceBlendSha256,
|
||||
frameStart: timeline.frameStart,
|
||||
frameEnd: timeline.frameEnd,
|
||||
fpsNumerator: timeline.fpsNumerator,
|
||||
fpsDenominator: timeline.fpsDenominator,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
container: "MPEG4" as const,
|
||||
videoCodec: "H264" as const,
|
||||
audioCodec: "AAC" as const,
|
||||
};
|
||||
const withoutServer = await finalExport.routeSequencerFinalExportInBrowser(request, false, {});
|
||||
const serverWithoutEncoder = await finalExport.routeSequencerFinalExportInBrowser(request, true, {});
|
||||
const serverWithEncoder = await finalExport.routeSequencerFinalExportInBrowser(
|
||||
request,
|
||||
true,
|
||||
{ VideoEncoder: class TestVideoEncoder {} },
|
||||
);
|
||||
const runtime = await finalExport.routeSequencerFinalExportInBrowser(request, true);
|
||||
return { timeline, sourceBlendSha256, withoutServer, serverWithoutEncoder, serverWithEncoder, runtime };
|
||||
}
|
||||
finally {
|
||||
client.terminate();
|
||||
}
|
||||
}, Array.from(blend));
|
||||
|
||||
expect(result.timeline).toMatchObject({
|
||||
id: "sequencer:scene:SequencerScene",
|
||||
revision: 1,
|
||||
frameStart: 1,
|
||||
frameEnd: 250,
|
||||
fpsNumerator: 24000,
|
||||
fpsDenominator: 1001,
|
||||
});
|
||||
expect(result.sourceBlendSha256).toBe(golden.sourceBlendSha256);
|
||||
expect(result.withoutServer).toMatchObject({
|
||||
requestSha256: golden.requestSha256,
|
||||
settingsSha256: golden.settingsSha256,
|
||||
route: "SERVER_EXPORT",
|
||||
status: "BLOCKED",
|
||||
code: "SEQUENCER_EXPORT_SERVER_UNAVAILABLE",
|
||||
localEncoding: "BLOCKED",
|
||||
browserVideoEncoderDetected: false,
|
||||
});
|
||||
expect(result.serverWithoutEncoder).toMatchObject({
|
||||
requestSha256: golden.requestSha256,
|
||||
settingsSha256: golden.settingsSha256,
|
||||
route: "SERVER_EXPORT",
|
||||
status: "SERVER_EXPORT_REQUIRED",
|
||||
code: null,
|
||||
localEncoding: "BLOCKED",
|
||||
browserVideoEncoderDetected: false,
|
||||
});
|
||||
expect(result.serverWithEncoder).toMatchObject({
|
||||
...result.serverWithoutEncoder,
|
||||
browserVideoEncoderDetected: true,
|
||||
});
|
||||
expect(result.runtime).toMatchObject({
|
||||
route: "SERVER_EXPORT",
|
||||
status: "SERVER_EXPORT_REQUIRED",
|
||||
localEncoding: "BLOCKED",
|
||||
});
|
||||
});
|
||||
132
web/tests/e2e/sequencer-media-cache.spec.ts
Normal file
132
web/tests/e2e/sequencer-media-cache.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const codecGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-09/sequencer-codec-probe.json"), "utf8")) as {
|
||||
assets: Array<{
|
||||
stripType: "IMAGE" | "SOUND" | "MOVIE";
|
||||
mimeType: string;
|
||||
path: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
decoded: Record<string, number>;
|
||||
}>;
|
||||
};
|
||||
const cacheGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-10/sequencer-media-cache.json"), "utf8")) as {
|
||||
sourceSha256: string;
|
||||
profile: { kind: "MOVIE_RGBA8_FRAME"; width: number; height: number; colorSpace: "SRGB8"; alphaMode: "STRAIGHT" };
|
||||
identitySha256: string;
|
||||
proxyByteLength: number;
|
||||
};
|
||||
|
||||
test("M11-10 binds a real movie proxy cache to source hash and runtime decode capability", async ({ page }) => {
|
||||
const movie = codecGolden.assets.find((asset) => asset.stripType === "MOVIE")!;
|
||||
const bytes = fs.readFileSync(path.join(root, movie.path));
|
||||
expect(crypto.createHash("sha256").update(bytes).digest("hex")).toBe(cacheGolden.sourceSha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ movie, profile, bytes }) => {
|
||||
const [probeModule, cacheModule, storageModule] = await Promise.all([
|
||||
import("/src/sequencer/SequencerCodecProbe.ts"),
|
||||
import("/src/sequencer/SequencerMediaProxyCache.ts"),
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
]);
|
||||
const request = probeModule.createSequencerCodecProbeRequest("MOVIE", movie.mimeType, movie.byteLength, movie.sha256);
|
||||
const sourceData = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
const capability = await probeModule.probeSequencerCodec(request, sourceData.slice(0));
|
||||
const generated = await cacheModule.generateInitialSequencerMovieProxyFrame(request, capability, profile, sourceData.slice(0));
|
||||
const cache = new cacheModule.SequencerMediaProxyCache(generated.data.byteLength);
|
||||
const firstKey = await cache.put(generated.manifest, generated.data.slice(0), request, capability);
|
||||
const hit = await cache.get(request, capability, profile, 0);
|
||||
const secondManifest = await import("/src/sequencer/SequencerTimeline.ts").then((module) =>
|
||||
module.createSequencerMediaCacheManifest(request, capability, profile, 1, generated.data.slice(0)));
|
||||
const secondKey = await cache.put(secondManifest, generated.data.slice(0), request, capability);
|
||||
const evicted = await cache.get(request, capability, profile, 0);
|
||||
|
||||
const projectId = `m11-10-${Date.now()}`;
|
||||
const writer = new storageModule.StorageClient();
|
||||
const storedPayload = await writer.putAsset(
|
||||
projectId,
|
||||
generated.data.slice(0),
|
||||
"application/vnd.blender.sequencer-proxy-rgba8",
|
||||
"cache/sequencer/proxy-frame-0.rgba8",
|
||||
);
|
||||
const manifestData = new TextEncoder().encode(JSON.stringify(generated.manifest)).buffer as ArrayBuffer;
|
||||
const storedManifest = await writer.putAsset(
|
||||
projectId,
|
||||
manifestData,
|
||||
"application/vnd.blender.sequencer-proxy-cache+json",
|
||||
"cache/sequencer/proxy-frame-0.json",
|
||||
);
|
||||
writer.terminate();
|
||||
|
||||
const reader = new storageModule.StorageClient();
|
||||
const [reopenedPayload, reopenedManifestAsset] = await Promise.all([
|
||||
reader.readAsset(projectId, storedPayload.sha256),
|
||||
reader.readAsset(projectId, storedManifest.sha256),
|
||||
]);
|
||||
reader.terminate();
|
||||
const reopenedManifest = JSON.parse(new TextDecoder().decode(reopenedManifestAsset.data));
|
||||
const verified = await import("/src/sequencer/SequencerTimeline.ts").then((module) =>
|
||||
module.verifySequencerMediaCacheEntry(reopenedManifest, reopenedPayload.data, request, capability));
|
||||
|
||||
const changedSource = { ...request, sourceSha256: "e".repeat(64) };
|
||||
const changedSourceCapability = { ...capability, sourceSha256: changedSource.sourceSha256 };
|
||||
const changedCapability = {
|
||||
...capability,
|
||||
decoded: { ...capability.decoded!, durationMicros: capability.decoded!.durationMicros! + 1 },
|
||||
};
|
||||
const corruptPayload = reopenedPayload.data.slice(0);
|
||||
new Uint8Array(corruptPayload)[0] ^= 0xff;
|
||||
const code = async (operation: () => Promise<unknown>): Promise<string> => {
|
||||
try { await operation(); return "unexpected-success"; }
|
||||
catch (error) { return (error as { code?: string }).code ?? String(error); }
|
||||
};
|
||||
const protocol = await import("/src/sequencer/SequencerTimeline.ts");
|
||||
const errors = {
|
||||
source: await code(() => protocol.verifySequencerMediaCacheEntry(reopenedManifest, reopenedPayload.data, changedSource, changedSourceCapability)),
|
||||
capability: await code(() => protocol.verifySequencerMediaCacheEntry(reopenedManifest, reopenedPayload.data, request, changedCapability)),
|
||||
payload: await code(() => protocol.verifySequencerMediaCacheEntry(reopenedManifest, corruptPayload, request, capability)),
|
||||
sourceBytes: await code(() => cacheModule.generateInitialSequencerMovieProxyFrame(request, capability, profile, new ArrayBuffer(request.byteLength))),
|
||||
};
|
||||
const statsBeforeClear = cache.stats();
|
||||
const releasedBytes = cache.clear();
|
||||
return {
|
||||
capability,
|
||||
manifest: generated.manifest,
|
||||
firstKey,
|
||||
secondKey,
|
||||
hitBytes: hit?.data.byteLength,
|
||||
evicted: evicted === undefined,
|
||||
statsBeforeClear,
|
||||
releasedBytes,
|
||||
statsAfterClear: cache.stats(),
|
||||
storedPayloadSha256: storedPayload.sha256,
|
||||
verifiedIdentity: verified.identitySha256,
|
||||
errors,
|
||||
};
|
||||
}, { movie, profile: cacheGolden.profile, bytes: new Uint8Array(bytes) });
|
||||
|
||||
expect(result.capability).toMatchObject({ status: "READY", backend: "HTML_MEDIA", decoded: movie.decoded });
|
||||
expect(result.manifest).toMatchObject({
|
||||
identitySha256: cacheGolden.identitySha256,
|
||||
payloadByteLength: cacheGolden.proxyByteLength,
|
||||
profile: cacheGolden.profile,
|
||||
});
|
||||
expect(result.manifest.payloadSha256).toBe(result.storedPayloadSha256);
|
||||
expect(result.verifiedIdentity).toBe(cacheGolden.identitySha256);
|
||||
expect(result.firstKey).toBe(`sequencer-media-cache:v1:${cacheGolden.identitySha256}`);
|
||||
expect(result.secondKey).not.toBe(result.firstKey);
|
||||
expect(result.hitBytes).toBe(cacheGolden.proxyByteLength);
|
||||
expect(result.evicted).toBe(true);
|
||||
expect(result.statsBeforeClear).toMatchObject({ entries: 1, bytes: cacheGolden.proxyByteLength, hits: 1, misses: 1, evictions: 1 });
|
||||
expect(result.releasedBytes).toBe(cacheGolden.proxyByteLength);
|
||||
expect(result.statsAfterClear).toMatchObject({ entries: 0, bytes: 0 });
|
||||
expect(result.errors).toEqual({
|
||||
source: "SEQUENCER_CACHE_SOURCE_MISMATCH",
|
||||
capability: "SEQUENCER_CACHE_CAPABILITY_MISMATCH",
|
||||
payload: "SEQUENCER_CACHE_HASH_MISMATCH",
|
||||
sourceBytes: "SEQUENCER_CACHE_SOURCE_MISMATCH",
|
||||
});
|
||||
});
|
||||
94
web/tests/e2e/sequencer-media-revision.spec.ts
Normal file
94
web/tests/e2e/sequencer-media-revision.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const codecGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-09/sequencer-codec-probe.json"), "utf8")) as {
|
||||
assets: Array<{ stripType: "IMAGE" | "SOUND" | "MOVIE"; mimeType: string; path: string; byteLength: number; sha256: string }>;
|
||||
};
|
||||
const revisionGolden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-11/sequencer-media-revision.json"), "utf8")) as {
|
||||
sourceSha256: string;
|
||||
decisions: Array<[string, number, "PUBLISH" | "STALE", string | null]>;
|
||||
published: string[];
|
||||
cacheWrites: string[];
|
||||
};
|
||||
|
||||
test("M11-11 gates late seek, scrub and real decode results before publish or cache", async ({ page }) => {
|
||||
const movie = codecGolden.assets.find((asset) => asset.stripType === "MOVIE")!;
|
||||
const bytes = fs.readFileSync(path.join(root, movie.path));
|
||||
expect(crypto.createHash("sha256").update(bytes).digest("hex")).toBe(revisionGolden.sourceSha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ movie, bytes }) => {
|
||||
const [probeModule, revisionModule] = await Promise.all([
|
||||
import("/src/sequencer/SequencerCodecProbe.ts"),
|
||||
import("/src/sequencer/SequencerMediaRevisionGate.ts"),
|
||||
]);
|
||||
const gate = new revisionModule.SequencerMediaRevisionGate("sequencer:main", 7);
|
||||
const published: string[] = [];
|
||||
const cacheWrites: string[] = [];
|
||||
const decisions: Array<[string, number, string, string | null]> = [];
|
||||
const complete = (
|
||||
request: import("/src/sequencer/SequencerMediaRevisionGate.ts").SequencerMediaRevisionRequestIR,
|
||||
payloadSha256 = movie.sha256,
|
||||
) => ({ ...request, status: "COMPLETED" as const, sourceFrame: request.frame, payloadSha256 });
|
||||
const resolve = (
|
||||
request: import("/src/sequencer/SequencerMediaRevisionGate.ts").SequencerMediaRevisionRequestIR,
|
||||
response = complete(request),
|
||||
) => {
|
||||
const decision = gate.resolve(request, response, (accepted) => {
|
||||
const label = `${accepted.operation}@${accepted.requestRevision}`;
|
||||
published.push(label);
|
||||
if (accepted.operation === "DECODE") cacheWrites.push(label);
|
||||
});
|
||||
decisions.push([decision.operation, decision.requestRevision, decision.status, decision.code]);
|
||||
return decision;
|
||||
};
|
||||
|
||||
const oldSeek = gate.begin("SEEK", 10);
|
||||
const oldSeekResult = new Promise<ReturnType<typeof complete>>((resolveResult) =>
|
||||
setTimeout(() => resolveResult(complete(oldSeek)), 30));
|
||||
const currentScrub = gate.begin("SCRUB", 20);
|
||||
resolve(currentScrub);
|
||||
resolve(oldSeek, await oldSeekResult);
|
||||
|
||||
const oldDecode = gate.begin("DECODE", 30);
|
||||
const request = probeModule.createSequencerCodecProbeRequest("MOVIE", movie.mimeType, movie.byteLength, movie.sha256);
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
const lateDecode = new Promise<Awaited<ReturnType<typeof probeModule.probeSequencerCodec>>>((resolveReceipt) =>
|
||||
setTimeout(() => { void probeModule.probeSequencerCodec(request, source.slice(0)).then(resolveReceipt); }, 20));
|
||||
const replacedState = gate.replaceTimeline("sequencer:main", 8);
|
||||
const lateReceipt = await lateDecode;
|
||||
resolve(oldDecode, complete(oldDecode, lateReceipt.sourceSha256));
|
||||
|
||||
const currentDecode = gate.begin("DECODE", 40);
|
||||
const currentReceipt = await probeModule.probeSequencerCodec(request, source.slice(0));
|
||||
resolve(currentDecode, complete(currentDecode, currentReceipt.sourceSha256));
|
||||
|
||||
const forgedSeek = gate.begin("SEEK", 50);
|
||||
resolve(forgedSeek, { ...complete(forgedSeek), requestId: "media:forged" });
|
||||
|
||||
let monotonicCode = "";
|
||||
try { gate.replaceTimeline("sequencer:main", 8); }
|
||||
catch (error) { monotonicCode = (error as { code?: string }).code ?? String(error); }
|
||||
return {
|
||||
decisions,
|
||||
published,
|
||||
cacheWrites,
|
||||
replacedState,
|
||||
finalState: gate.state(),
|
||||
lateReceipt: { status: lateReceipt.status, backend: lateReceipt.backend },
|
||||
currentReceipt: { status: currentReceipt.status, backend: currentReceipt.backend },
|
||||
monotonicCode,
|
||||
};
|
||||
}, { movie, bytes: new Uint8Array(bytes) });
|
||||
|
||||
expect(result.decisions).toEqual(revisionGolden.decisions);
|
||||
expect(result.published).toEqual(revisionGolden.published);
|
||||
expect(result.cacheWrites).toEqual(revisionGolden.cacheWrites);
|
||||
expect(result.replacedState).toEqual({ schemaVersion: 1, timelineId: "sequencer:main", timelineRevision: 8, latestRequestRevision: 4 });
|
||||
expect(result.finalState).toEqual({ schemaVersion: 1, timelineId: "sequencer:main", timelineRevision: 8, latestRequestRevision: 6 });
|
||||
expect(result.lateReceipt).toEqual({ status: "READY", backend: "HTML_MEDIA" });
|
||||
expect(result.currentReceipt).toEqual({ status: "READY", backend: "HTML_MEDIA" });
|
||||
expect(result.monotonicCode).toBe("REVISION_CONFLICT");
|
||||
});
|
||||
30
web/tests/e2e/shader-capability-block.spec.ts
Normal file
30
web/tests/e2e/shader-capability-block.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-10/shader-capability-block.json"), "utf8"));
|
||||
|
||||
test("M10-10 returns a stable block for every unknown Shader node", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
await client.init();
|
||||
const first = await client.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["VORONOI", "CUSTOM_OSL"] });
|
||||
const second = await client.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["CUSTOM_OSL", "VORONOI"] });
|
||||
client.terminate();
|
||||
return {
|
||||
first: { taskId: first.taskId, capability: first.capability, status: first.status, code: first.issues[0]?.code, recoverable: first.issues[0]?.recoverable, message: first.issues[0]?.message },
|
||||
second: { taskId: second.taskId, capability: second.capability, status: second.status, code: second.issues[0]?.code, recoverable: second.issues[0]?.recoverable, message: second.issues[0]?.message },
|
||||
};
|
||||
});
|
||||
expect(result.first.taskId).toBe("PBR-012");
|
||||
expect(result.first.capability).toBe(expected.capability);
|
||||
expect(result.first.status).toBe(expected.status);
|
||||
expect(result.first.code).toBe(expected.errorCode);
|
||||
expect(result.first.recoverable).toBe(expected.recoverable);
|
||||
expect(result.first.message).toContain("VORONOI");
|
||||
expect(result.first.message).toContain("CUSTOM_OSL");
|
||||
expect(result.second).toEqual(result.first);
|
||||
});
|
||||
48
web/tests/e2e/shader-compile-key.spec.ts
Normal file
48
web/tests/e2e/shader-compile-key.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-08/shader-compile-key.json"), "utf8"));
|
||||
|
||||
test("M10-08 compileKey changes with texture identity and color space", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createPBRMaterial } = await import("/src/three-adapter/pbr.ts");
|
||||
const source = {
|
||||
id: "material:key",
|
||||
name: "Key",
|
||||
baseColor: [0.2, 0.3, 0.4, 1] as [number, number, number, number],
|
||||
roughness: 0.5,
|
||||
metallic: 0,
|
||||
emissionColor: [0, 0, 0, 1] as [number, number, number, number],
|
||||
alpha: 1,
|
||||
ior: 1.45,
|
||||
nodes: [
|
||||
{ id: "image", type: "IMAGE_TEXTURE" as const, name: "Image", imageId: "image:key" },
|
||||
{ id: "normal", type: "NORMAL_MAP" as const, name: "Normal" },
|
||||
{ id: "principled", type: "PRINCIPLED" as const, name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT" as const, name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" },
|
||||
{ fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
};
|
||||
const identity = { assetId: "asset:key-v1", sha256: "d".repeat(64), colorSpace: "NON_COLOR" as const };
|
||||
const first = createPBRMaterial(source, false, { imageIds: new Set(["image:key"]), textureIdentities: new Map([["image:key", identity]]) });
|
||||
const changed = createPBRMaterial(source, false, { imageIds: new Set(["image:key"]), textureIdentities: new Map([["image:key", { ...identity, sha256: "e".repeat(64) }]]) });
|
||||
const linear = createPBRMaterial(source, false, { imageIds: new Set(["image:key"]), textureIdentities: new Map([["image:key", { ...identity, colorSpace: "LINEAR" as const }]]) });
|
||||
const firstReport = first.userData.shaderCompile;
|
||||
const changedReport = changed.userData.shaderCompile;
|
||||
const linearReport = linear.userData.shaderCompile;
|
||||
first.dispose(); changed.dispose(); linear.dispose();
|
||||
return { status: firstReport.status, key: firstReport.compileKey, changed: changedReport.compileKey, linear: linearReport.compileKey };
|
||||
});
|
||||
expect(result.status).toBe("COMPILED");
|
||||
expect(result.key).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.changed).not.toBe(result.key);
|
||||
expect(result.linear).not.toBe(result.key);
|
||||
expect(expected.keyDigest).toBe("sha256");
|
||||
});
|
||||
157
web/tests/e2e/shader-compile.spec.ts
Normal file
157
web/tests/e2e/shader-compile.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-07/shader-compile.json"), "utf8"));
|
||||
const blendBytes = fs.readFileSync(path.join(root, expected.fixture));
|
||||
|
||||
function sockets() {
|
||||
return {
|
||||
rgb: [{ id: "color", name: "Color", direction: "OUTPUT", dataType: "COLOR", defaultValue: [0.15, 0.25, 0.35, 1] }],
|
||||
value: [{ id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE", defaultValue: 0.2 }],
|
||||
math: [
|
||||
{ id: "a", name: "Value", direction: "INPUT", dataType: "VALUE", defaultValue: 0 },
|
||||
{ id: "b", name: "Value_001", direction: "INPUT", dataType: "VALUE", defaultValue: 0 },
|
||||
{ id: "value", name: "Value", direction: "OUTPUT", dataType: "VALUE" },
|
||||
],
|
||||
principled: [
|
||||
{ id: "base", name: "Base Color", direction: "INPUT", dataType: "COLOR", defaultValue: [0.2, 0.3, 0.4, 1] },
|
||||
{ id: "roughness", name: "Roughness", direction: "INPUT", dataType: "VALUE", defaultValue: 0.5 },
|
||||
{ id: "bsdf", name: "BSDF", direction: "OUTPUT", dataType: "SHADER" },
|
||||
],
|
||||
output: [{ id: "surface", name: "Surface", direction: "INPUT", dataType: "SHADER" }],
|
||||
};
|
||||
}
|
||||
|
||||
test("M10-07 produces a bounded compile report from the real Main Shader graph", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
expect(createHash("sha256").update(blendBytes).digest("hex")).toBe(expected.fixtureSha256);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async ({ bytes, socketSet }) => {
|
||||
const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts");
|
||||
const client = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
await client.init();
|
||||
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
const opened = await client.openBlend(source);
|
||||
const material = opened.snapshot.materials[0];
|
||||
if (!material) throw new Error("basic fixture has no editable material");
|
||||
const graph = {
|
||||
schemaVersion: 1 as const,
|
||||
id: "shader:m10-07",
|
||||
materialId: material.id,
|
||||
outputNodeId: "output",
|
||||
nodes: [
|
||||
{ id: "rgb", type: "RGB" as const, name: "RGB", sockets: socketSet.rgb },
|
||||
{ id: "a", type: "VALUE" as const, name: "A", sockets: socketSet.value },
|
||||
{ id: "b", type: "VALUE" as const, name: "B", sockets: [{ ...socketSet.value[0], defaultValue: 0.22 }] },
|
||||
{ id: "math", type: "MATH" as const, name: "Add", properties: { operation: "ADD" as const }, sockets: socketSet.math },
|
||||
{ id: "principled", type: "PRINCIPLED" as const, name: "Principled", sockets: socketSet.principled },
|
||||
{ id: "output", type: "MATERIAL_OUTPUT" as const, name: "Output", sockets: socketSet.output },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "rgb", fromSocketId: "color", toNodeId: "principled", toSocketId: "base" },
|
||||
{ fromNodeId: "a", fromSocketId: "value", toNodeId: "math", toSocketId: "a" },
|
||||
{ fromNodeId: "b", fromSocketId: "value", toNodeId: "math", toSocketId: "b" },
|
||||
{ fromNodeId: "math", fromSocketId: "value", toNodeId: "principled", toSocketId: "roughness" },
|
||||
{ fromNodeId: "principled", fromSocketId: "bsdf", toNodeId: "output", toSocketId: "surface" },
|
||||
],
|
||||
};
|
||||
const applied = await client.applyCommand({ type: "setShaderGraph", materialId: material.id, graph });
|
||||
const report = applied.shaderCompile;
|
||||
const after = await client.snapshot();
|
||||
client.terminate();
|
||||
return {
|
||||
openedHash: material.shaderGraphHash,
|
||||
status: report?.status,
|
||||
taskId: report?.taskId,
|
||||
backend: report?.backend,
|
||||
graphHash: report?.graphHash,
|
||||
roughness: report?.material?.roughness,
|
||||
nodeTypes: report?.compiledNodeTypes,
|
||||
revisionDelta: after.snapshot.revision - opened.snapshot.revision,
|
||||
};
|
||||
}, { bytes: new Uint8Array(blendBytes), socketSet: sockets() });
|
||||
|
||||
expect(result.status).toBe("COMPILED");
|
||||
expect(result.openedHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.taskId).toBe("M10-07");
|
||||
expect(result.backend).toBe(expected.backend);
|
||||
expect(result.graphHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.roughness).toBeCloseTo(expected.expectedMathResult, 5);
|
||||
expect(result.nodeTypes).toEqual(expect.arrayContaining(expected.allowlist.filter((type: string) => !["IMAGE_TEXTURE", "NORMAL_MAP"].includes(type))));
|
||||
expect(result.revisionDelta).toBe(1);
|
||||
});
|
||||
|
||||
test("M10-07 shared viewport material path compiles Image/Normal/Math bindings", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createPBRMaterial } = await import("/src/three-adapter/pbr.ts");
|
||||
const material = createPBRMaterial({
|
||||
id: "material:viewport",
|
||||
name: "Viewport",
|
||||
baseColor: [0.8, 0.8, 0.8, 1],
|
||||
roughness: 0.5,
|
||||
metallic: 0,
|
||||
emissionColor: [0, 0, 0, 1],
|
||||
alpha: 1,
|
||||
ior: 1.45,
|
||||
nodes: [
|
||||
{ id: "a", type: "VALUE", name: "A", defaultValue: [0.2] },
|
||||
{ id: "b", type: "VALUE", name: "B", defaultValue: [0.22] },
|
||||
{ id: "math", type: "MATH", name: "Add", properties: { operation: "ADD" } },
|
||||
{ id: "image", type: "IMAGE_TEXTURE", name: "Image", imageId: "image:normal" },
|
||||
{ id: "normal", type: "NORMAL_MAP", name: "Normal" },
|
||||
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT", name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
|
||||
{ fromNodeId: "b", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
|
||||
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
|
||||
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" },
|
||||
{ fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
});
|
||||
const compile = material.userData.shaderCompile;
|
||||
const report = {
|
||||
status: compile.status,
|
||||
graphHash: compile.graphHash,
|
||||
roughness: material.roughness,
|
||||
textures: compile.textureBindings,
|
||||
};
|
||||
material.dispose();
|
||||
return report;
|
||||
});
|
||||
expect(result.status).toBe("COMPILED");
|
||||
expect(result.graphHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.roughness).toBeCloseTo(expected.expectedMathResult, 5);
|
||||
expect(result.textures).toEqual([{ imageId: "image:normal", usage: "NORMAL" }]);
|
||||
});
|
||||
|
||||
test("M10-07 compiler blocks a non-declared node without mutating the input graph", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createPBRMaterial } = await import("/src/three-adapter/pbr.ts");
|
||||
const material = {
|
||||
id: "material:blocked",
|
||||
name: "Blocked",
|
||||
baseColor: [1, 1, 1, 1],
|
||||
roughness: 0.5,
|
||||
metallic: 0,
|
||||
emissionColor: [0, 0, 0, 1],
|
||||
alpha: 1,
|
||||
ior: 1.45,
|
||||
nodes: [{ id: "mix", type: "UNSUPPORTED", name: "Mix" }, { id: "output", type: "OUTPUT", name: "Output" }],
|
||||
links: [],
|
||||
} as const;
|
||||
const before = JSON.stringify(material);
|
||||
const threeMaterial = createPBRMaterial(material);
|
||||
const report = threeMaterial.userData.shaderCompile;
|
||||
threeMaterial.dispose();
|
||||
return { status: report.status, code: report.issues[0]?.code, preserved: JSON.stringify(material) === before };
|
||||
});
|
||||
expect(result).toEqual({ status: "BLOCKED", code: expected.unsupportedNodeCode, preserved: true });
|
||||
});
|
||||
56
web/tests/e2e/shader-pipeline.spec.ts
Normal file
56
web/tests/e2e/shader-pipeline.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M10-09/shader-pipeline.json"), "utf8"));
|
||||
|
||||
test("M10-09 keeps the previous material pipeline after compile failure", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { PBRMaterialPipeline } = await import("/src/three-adapter/pbr.ts");
|
||||
const valid = {
|
||||
id: "material:pipeline",
|
||||
name: "Pipeline",
|
||||
baseColor: [0.2, 0.3, 0.4, 1] as [number, number, number, number],
|
||||
roughness: 0.5,
|
||||
metallic: 0,
|
||||
emissionColor: [0, 0, 0, 1] as [number, number, number, number],
|
||||
alpha: 1,
|
||||
ior: 1.45,
|
||||
nodes: [
|
||||
{ id: "principled", type: "PRINCIPLED" as const, name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT" as const, name: "Output" },
|
||||
],
|
||||
links: [{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" }],
|
||||
};
|
||||
const invalid = { ...valid, nodes: [{ id: "mix", type: "UNSUPPORTED" as const, name: "Mix" }, ...valid.nodes] };
|
||||
const pipeline = new PBRMaterialPipeline();
|
||||
const first = pipeline.update(valid);
|
||||
const failed = pipeline.update(invalid);
|
||||
const preserved = failed.material === first.material;
|
||||
const failureReport = first.material.userData.shaderCompileFailure;
|
||||
const replacement = pipeline.update({ ...valid, baseColor: [0.8, 0.2, 0.1, 1] });
|
||||
const replaced = replacement.material !== first.material;
|
||||
const oldDisposed = first.material.userData.shaderCompile?.status !== "COMPILED";
|
||||
pipeline.dispose();
|
||||
return {
|
||||
first: first.report?.status,
|
||||
failure: failed.report?.status,
|
||||
preserved,
|
||||
failureCode: failureReport?.issues?.[0]?.code,
|
||||
replaced,
|
||||
replacedOnSuccess: replacement.replaced,
|
||||
oldPipelineStillCompiled: !oldDisposed,
|
||||
};
|
||||
});
|
||||
expect(result).toEqual({
|
||||
first: "COMPILED",
|
||||
failure: expected.failureStatus,
|
||||
preserved: expected.preservedPipeline,
|
||||
failureCode: "SHADER_NODE_UNSUPPORTED",
|
||||
replaced: true,
|
||||
replacedOnSuccess: expected.replacedOnSuccess,
|
||||
oldPipelineStillCompiled: expected.failureDoesNotDisposePrevious,
|
||||
});
|
||||
});
|
||||
115
web/tests/e2e/simulation-cache-identity.spec.ts
Normal file
115
web/tests/e2e/simulation-cache-identity.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M10-05 isolates Simulation caches by committed graph/source/revision identity", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const digest = async (data: ArrayBuffer): Promise<string> => Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", data)),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const revisionHash = async (binding: {
|
||||
graphId: string; graphHash: string; sourceBlendSha256: string; sourceRevision: number;
|
||||
inputHash: string; blenderVersion: string; frameStart: number; frameEnd: number;
|
||||
}): Promise<string> => digest(new TextEncoder().encode(JSON.stringify([
|
||||
"blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash,
|
||||
binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash,
|
||||
binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd),
|
||||
])).buffer);
|
||||
const errorCode = async (operation: () => Promise<unknown>): Promise<string> => {
|
||||
try { await operation(); return ""; }
|
||||
catch (error) { return String((error as Error & { code?: string }).code ?? ""); }
|
||||
};
|
||||
|
||||
const projectId = `m10-05-${Date.now()}`;
|
||||
const sourceOne = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 1]).buffer;
|
||||
const sourceTwo = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 2]).buffer;
|
||||
const payload = Uint8Array.from([11, 12, 13, 14]).buffer;
|
||||
const frameHash = await digest(payload);
|
||||
const common = {
|
||||
graphId: "node-group:M10SimulationIdentity",
|
||||
graphHash: await digest(Uint8Array.from([21]).buffer),
|
||||
inputHash: await digest(Uint8Array.from([22]).buffer),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: 1,
|
||||
};
|
||||
const makeManifest = async (source: ArrayBuffer, sourceRevision: number) => {
|
||||
const binding = { ...common, sourceBlendSha256: await digest(source), sourceRevision };
|
||||
return {
|
||||
schemaVersion: 2 as const,
|
||||
...binding,
|
||||
revisionHash: await revisionHash(binding),
|
||||
cacheSha256: frameHash,
|
||||
byteLength: payload.byteLength,
|
||||
frames: [{ frame: 1, byteOffset: 0, byteLength: payload.byteLength, sha256: frameHash }],
|
||||
};
|
||||
};
|
||||
|
||||
const firstManifest = await makeManifest(sourceOne, 7);
|
||||
const first = new StorageClient();
|
||||
await first.saveProject(projectId, 7, sourceOne.slice(0));
|
||||
const storedOne = await first.putSimulationCache(projectId, firstManifest, payload.slice(0));
|
||||
const forgedGraphCode = await errorCode(() => first.putSimulationCache(
|
||||
projectId,
|
||||
{ ...firstManifest, graphHash: "0".repeat(64) },
|
||||
payload.slice(0),
|
||||
));
|
||||
const forgedSourceBinding = { ...firstManifest, sourceBlendSha256: "1".repeat(64) };
|
||||
const forgedSourceManifest = {
|
||||
...forgedSourceBinding,
|
||||
revisionHash: await revisionHash(forgedSourceBinding),
|
||||
};
|
||||
const forgedSourceCode = await errorCode(() => first.putSimulationCache(
|
||||
projectId,
|
||||
forgedSourceManifest,
|
||||
payload.slice(0),
|
||||
));
|
||||
first.terminate();
|
||||
|
||||
const restarted = new StorageClient();
|
||||
await restarted.prepareSimulationCachePlayback(projectId, storedOne.cacheKey);
|
||||
const recovered = await restarted.readSimulationCacheFrame(projectId, storedOne.cacheKey, 1);
|
||||
const listedBefore = await restarted.listSimulationCaches(projectId);
|
||||
await restarted.saveProject(projectId, 8, sourceTwo.slice(0));
|
||||
const staleReadCode = await errorCode(() => restarted.readSimulationCache(projectId, storedOne.cacheKey));
|
||||
const listedAfter = await restarted.listSimulationCaches(projectId);
|
||||
const stalePutCode = await errorCode(() => restarted.putSimulationCache(
|
||||
projectId,
|
||||
firstManifest,
|
||||
payload.slice(0),
|
||||
));
|
||||
|
||||
const secondManifest = await makeManifest(sourceTwo, 8);
|
||||
const storedTwo = await restarted.putSimulationCache(projectId, secondManifest, payload.slice(0));
|
||||
const listedCurrent = await restarted.listSimulationCaches(projectId);
|
||||
restarted.terminate();
|
||||
return {
|
||||
firstKey: storedOne.cacheKey,
|
||||
secondKey: storedTwo.cacheKey,
|
||||
recoveredBytes: Array.from(new Uint8Array(recovered.data)),
|
||||
listedBefore: listedBefore.caches.map((cache) => cache.cacheKey),
|
||||
listedAfter: listedAfter.caches.map((cache) => cache.cacheKey),
|
||||
listedCurrent: listedCurrent.caches.map((cache) => cache.cacheKey),
|
||||
forgedGraphCode,
|
||||
forgedSourceCode,
|
||||
staleReadCode,
|
||||
stalePutCode,
|
||||
sourceRevision: storedTwo.manifest.sourceRevision,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.firstKey).toMatch(/^sim2-[a-f0-9]{64}$/);
|
||||
expect(result.secondKey).toMatch(/^sim2-[a-f0-9]{64}$/);
|
||||
expect(result.secondKey).not.toBe(result.firstKey);
|
||||
expect(result.recoveredBytes).toEqual([11, 12, 13, 14]);
|
||||
expect(result.listedBefore).toEqual([result.firstKey]);
|
||||
expect(result.listedAfter).toEqual([]);
|
||||
expect(result.listedCurrent).toEqual([result.secondKey]);
|
||||
expect(result.forgedGraphCode).toBe("SIMULATION_CACHE_REVISION_MISMATCH");
|
||||
expect(result.forgedSourceCode).toBe("SIMULATION_CACHE_HASH_MISMATCH");
|
||||
expect(result.staleReadCode).toBe("SIMULATION_CACHE_REVISION_MISMATCH");
|
||||
expect(result.stalePutCode).toBe("SIMULATION_CACHE_REVISION_MISMATCH");
|
||||
expect(result.sourceRevision).toBe(8);
|
||||
});
|
||||
211
web/tests/e2e/simulation-cache-lifecycle.spec.ts
Normal file
211
web/tests/e2e/simulation-cache-lifecycle.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M10-06 gates playback on verification and enforces cancel, LRU, restart and corruption quarantine", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const { BrowserTransformCachePlaybackSession } = await import("/src/simulation/BrowserTransformCachePlayback.ts");
|
||||
const { upgradeStorageSchema } = await import("/src/storage/migrations.ts");
|
||||
const digest = async (data: ArrayBuffer): Promise<string> => Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", data)),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const errorCode = async (operation: () => Promise<unknown>): Promise<string> => {
|
||||
try { await operation(); return ""; }
|
||||
catch (error) {
|
||||
const code = (error as Error & { code?: unknown }).code;
|
||||
return typeof code === "string" ? code : (error as Error).name;
|
||||
}
|
||||
};
|
||||
const projectId = `m10-06-${Date.now()}`;
|
||||
const migrationDatabase = `m10-06-migration-${Date.now()}`;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open(migrationDatabase, 6);
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore("migration", { keyPath: "id" });
|
||||
request.result.createObjectStore("simulation_manifest", { keyPath: "id" });
|
||||
};
|
||||
request.onsuccess = () => { request.result.close(); resolve(); };
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const migration = await new Promise<{ version: number; stores: string[]; record?: { version: number } }>((resolve, reject) => {
|
||||
const request = indexedDB.open(migrationDatabase, 7);
|
||||
request.onupgradeneeded = (event) => upgradeStorageSchema(request.result, request.transaction!, (event as IDBVersionChangeEvent).oldVersion);
|
||||
request.onsuccess = () => {
|
||||
const database = request.result;
|
||||
const transaction = database.transaction("migration", "readonly");
|
||||
const record = transaction.objectStore("migration").get("schema-7");
|
||||
record.onsuccess = () => resolve({ version: database.version, stores: [...database.objectStoreNames], record: record.result as { version: number } | undefined });
|
||||
record.onerror = () => reject(record.error);
|
||||
transaction.oncomplete = () => database.close();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
indexedDB.deleteDatabase(migrationDatabase);
|
||||
const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 6]).buffer;
|
||||
const sourceBlendSha256 = await digest(sourceBlend);
|
||||
const makeManifest = async (name: string, payload: ArrayBuffer, frameCount = 1) => {
|
||||
const frameBytes = payload.byteLength / frameCount;
|
||||
const binding = {
|
||||
graphId: `node-group:${name}`,
|
||||
graphHash: await digest(new TextEncoder().encode(`graph:${name}`).buffer),
|
||||
sourceBlendSha256,
|
||||
sourceRevision: 6,
|
||||
inputHash: await digest(new TextEncoder().encode(`input:${name}`).buffer),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: frameCount,
|
||||
};
|
||||
const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([
|
||||
"blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash,
|
||||
binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash,
|
||||
binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd),
|
||||
])).buffer);
|
||||
const frames = await Promise.all(Array.from({ length: frameCount }, async (_, index) => ({
|
||||
frame: index + 1,
|
||||
byteOffset: index * frameBytes,
|
||||
byteLength: frameBytes,
|
||||
sha256: await digest(payload.slice(index * frameBytes, (index + 1) * frameBytes)),
|
||||
})));
|
||||
return {
|
||||
schemaVersion: 2 as const,
|
||||
...binding,
|
||||
revisionHash,
|
||||
cacheSha256: await digest(payload),
|
||||
byteLength: payload.byteLength,
|
||||
frames,
|
||||
};
|
||||
};
|
||||
|
||||
const first = new StorageClient();
|
||||
await first.saveProject(projectId, 6, sourceBlend.slice(0));
|
||||
const payloadA = Uint8Array.from([1, 2, 3, 4]).buffer;
|
||||
const manifestA = await makeManifest("lru-a", payloadA);
|
||||
const storedA = await first.putSimulationCache(projectId, manifestA, payloadA.slice(0));
|
||||
first.terminate();
|
||||
|
||||
const restarted = new StorageClient();
|
||||
const notReadyAfterRestart = await errorCode(() => restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1));
|
||||
const prepared = await restarted.prepareSimulationCachePlayback(projectId, storedA.cacheKey);
|
||||
const recovered = await restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1);
|
||||
let cancelledPublishedFrames = 0;
|
||||
const playback = new BrowserTransformCachePlaybackSession({
|
||||
schemaVersion: 1 as const,
|
||||
revision: 6,
|
||||
sceneId: "scene:M10-06",
|
||||
source: { kind: "mock" as const },
|
||||
coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 },
|
||||
activeObjectId: null,
|
||||
frame: { current: 1, start: 1, end: 1 },
|
||||
nodes: [], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [],
|
||||
}, {
|
||||
frameStart: 1,
|
||||
frameEnd: 1,
|
||||
readFrame: async (frame, signal) => (await restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, frame, signal)).data,
|
||||
}, () => { cancelledPublishedFrames += 1; });
|
||||
const cancellingPlayback = playback.play();
|
||||
playback.cancel();
|
||||
const playbackCancellation = await cancellingPlayback;
|
||||
const pendingAfterPlaybackCancel = restarted.getPendingRequestCount();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const payloadB = Uint8Array.from([5, 6, 7, 8]).buffer;
|
||||
const manifestB = await makeManifest("lru-b", payloadB);
|
||||
const storedB = await restarted.putSimulationCache(projectId, manifestB, payloadB.slice(0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const payloadC = Uint8Array.from([9, 10, 11, 12]).buffer;
|
||||
const manifestC = await makeManifest("lru-c", payloadC);
|
||||
const storedC = await restarted.putSimulationCache(projectId, manifestC, payloadC.slice(0));
|
||||
const activePrune = await restarted.pruneSimulationCaches(projectId, 4);
|
||||
const afterActivePrune = await restarted.listSimulationCaches(projectId);
|
||||
await restarted.releaseSimulationCachePlayback(projectId, storedA.cacheKey);
|
||||
const releasedCode = await errorCode(() => restarted.readSimulationCacheFrame(projectId, storedA.cacheKey, 1));
|
||||
const finalPrune = await restarted.pruneSimulationCaches(projectId, 0);
|
||||
|
||||
const cancelledPayload = new ArrayBuffer(256 * 32);
|
||||
const cancelledBytes = new Uint8Array(cancelledPayload);
|
||||
for (let index = 0; index < cancelledBytes.length; index += 1) cancelledBytes[index] = index % 251;
|
||||
const cancelledManifest = await makeManifest("cancelled", cancelledPayload, 256);
|
||||
const controller = new AbortController();
|
||||
const cancelledWrite = restarted.putSimulationCache(projectId, cancelledManifest, cancelledPayload, controller.signal);
|
||||
controller.abort();
|
||||
const cancelledCode = await errorCode(() => cancelledWrite);
|
||||
const afterCancel = await restarted.listSimulationCaches(projectId);
|
||||
const assetsAfterCancel = await restarted.listAssets(projectId);
|
||||
|
||||
const corruptPayload = Uint8Array.from([31, 32, 33, 34]).buffer;
|
||||
const corruptManifest = await makeManifest("corrupt", corruptPayload);
|
||||
const corruptStored = await restarted.putSimulationCache(projectId, corruptManifest, corruptPayload.slice(0));
|
||||
restarted.terminate();
|
||||
const pathSegments = corruptStored.path.split("/");
|
||||
const fileName = pathSegments.pop()!;
|
||||
let directory = await navigator.storage.getDirectory();
|
||||
for (const segment of pathSegments) directory = await directory.getDirectoryHandle(segment);
|
||||
const handle = await directory.getFileHandle(fileName);
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(Uint8Array.from([99, 98, 97, 96]));
|
||||
await writable.close();
|
||||
|
||||
const quarantineReader = new StorageClient();
|
||||
const corruptNotReady = await errorCode(() => quarantineReader.readSimulationCacheFrame(projectId, corruptStored.cacheKey, 1));
|
||||
const corruptionCode = await errorCode(() => quarantineReader.prepareSimulationCachePlayback(projectId, corruptStored.cacheKey));
|
||||
const afterCorruption = await quarantineReader.listSimulationCaches(projectId);
|
||||
const info = await quarantineReader.info();
|
||||
const pending = quarantineReader.getPendingRequestCount();
|
||||
quarantineReader.terminate();
|
||||
|
||||
return {
|
||||
notReadyAfterRestart,
|
||||
verifiedAt: prepared.verifiedAt,
|
||||
recovered: Array.from(new Uint8Array(recovered.data)),
|
||||
playbackCancellation,
|
||||
cancelledPublishedFrames,
|
||||
pendingAfterPlaybackCancel,
|
||||
activePrune,
|
||||
activeKeys: afterActivePrune.caches.map((cache) => cache.cacheKey),
|
||||
expectedActiveKey: storedA.cacheKey,
|
||||
evictedKeys: [storedB.cacheKey, storedC.cacheKey].sort(),
|
||||
releasedCode,
|
||||
finalPrune,
|
||||
cancelledCode,
|
||||
cancelledPublished: afterCancel.caches.some((cache) => cache.cacheKey === `sim2-${cancelledManifest.revisionHash}`),
|
||||
cancelledAssetPresent: assetsAfterCancel.assets.some((asset) => asset.sha256 === cancelledManifest.cacheSha256),
|
||||
corruptNotReady,
|
||||
corruptionCode,
|
||||
cachesAfterCorruption: afterCorruption.caches.length,
|
||||
quarantined: afterCorruption.quarantined,
|
||||
issues: afterCorruption.issues,
|
||||
schemaVersion: info.schemaVersion,
|
||||
stores: info.stores,
|
||||
pending,
|
||||
migration,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.notReadyAfterRestart).toBe("SIMULATION_CACHE_NOT_READY");
|
||||
expect(result.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(result.recovered).toEqual([1, 2, 3, 4]);
|
||||
expect(result.playbackCancellation).toEqual({ status: "CANCELLED", appliedFrames: 0, lastFrame: null });
|
||||
expect(result.cancelledPublishedFrames).toBe(0);
|
||||
expect(result.pendingAfterPlaybackCancel).toBe(0);
|
||||
expect(result.activePrune).toMatchObject({ beforeBytes: 12, remainingBytes: 4, removedBytes: 8, removed: 2, budgetSatisfied: true });
|
||||
expect(result.activePrune.cacheKeys.sort()).toEqual(result.evictedKeys);
|
||||
expect(result.activePrune.protectedCacheKeys).toContain(result.expectedActiveKey);
|
||||
expect(result.activeKeys).toEqual([result.expectedActiveKey]);
|
||||
expect(result.releasedCode).toBe("SIMULATION_CACHE_NOT_READY");
|
||||
expect(result.finalPrune).toMatchObject({ beforeBytes: 4, remainingBytes: 0, removedBytes: 4, removed: 1, budgetSatisfied: true });
|
||||
expect(result.cancelledCode).toBe("AbortError");
|
||||
expect(result.cancelledPublished).toBe(false);
|
||||
expect(result.cancelledAssetPresent).toBe(false);
|
||||
expect(result.corruptNotReady).toBe("SIMULATION_CACHE_NOT_READY");
|
||||
expect(result.corruptionCode).toBe("SIMULATION_CACHE_HASH_MISMATCH");
|
||||
expect(result.cachesAfterCorruption).toBe(0);
|
||||
expect(result.quarantined).toBe(1);
|
||||
expect(result.issues).toEqual([expect.objectContaining({ code: "SIMULATION_CACHE_HASH_MISMATCH" })]);
|
||||
expect(result.schemaVersion).toBe(7);
|
||||
expect(result.stores).toContain("simulation_quarantine");
|
||||
expect(result.pending).toBe(0);
|
||||
expect(result.migration).toMatchObject({ version: 7, record: { version: 7 } });
|
||||
expect(result.migration.stores).toContain("simulation_quarantine");
|
||||
});
|
||||
@@ -35,16 +35,26 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async
|
||||
})));
|
||||
const sourceBlend = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, 0x45, 0x52]).buffer;
|
||||
const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer);
|
||||
const manifest = {
|
||||
schemaVersion: 1 as const,
|
||||
const binding = {
|
||||
graphId: "geometry-node-tree:simulation-performance",
|
||||
graphHash: fixedHash,
|
||||
sourceBlendSha256: await digest(sourceBlend),
|
||||
sourceRevision: 1,
|
||||
inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer),
|
||||
cacheSha256: await digest(payload),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: frameCount,
|
||||
};
|
||||
const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([
|
||||
"blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash,
|
||||
binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash,
|
||||
binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd),
|
||||
])).buffer);
|
||||
const manifest = {
|
||||
schemaVersion: 2 as const,
|
||||
...binding,
|
||||
revisionHash,
|
||||
cacheSha256: await digest(payload),
|
||||
byteLength: payload.byteLength,
|
||||
frames,
|
||||
};
|
||||
@@ -58,6 +68,7 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async
|
||||
const storedAt = performance.now();
|
||||
|
||||
const reader = new StorageClient();
|
||||
await reader.prepareSimulationCachePlayback(projectId, stored.cacheKey);
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
const scene = {
|
||||
schemaVersion: 1 as const,
|
||||
@@ -77,7 +88,7 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async
|
||||
frameEnd: frameCount,
|
||||
readFrame: async (frame, signal) => {
|
||||
if (signal.aborted) throw new DOMException("Playback aborted", "AbortError");
|
||||
const read = await reader.readSimulationCacheFrame(projectId, stored.cacheKey, frame);
|
||||
const read = await reader.readSimulationCacheFrame(projectId, stored.cacheKey, frame, signal);
|
||||
if (signal.aborted) throw new DOMException("Playback aborted", "AbortError");
|
||||
return read.data;
|
||||
},
|
||||
@@ -86,10 +97,12 @@ test("meets the Chromium OPFS Simulation cache playback performance gate", async
|
||||
lastTranslation = preview.nodes[0].transform.translation[0];
|
||||
});
|
||||
const playbackResult = await playback.play();
|
||||
await reader.releaseSimulationCachePlayback(projectId, stored.cacheKey);
|
||||
const finished = performance.now();
|
||||
reader.terminate();
|
||||
const readerPendingAfterTerminate = reader.getPendingRequestCount();
|
||||
const recovery = new StorageClient();
|
||||
await recovery.prepareSimulationCachePlayback(projectId, stored.cacheKey);
|
||||
const recovered = await recovery.readSimulationCacheFrame(projectId, stored.cacheKey, frameCount);
|
||||
recovery.terminate();
|
||||
const recoveryPendingAfterTerminate = recovery.getPendingRequestCount();
|
||||
|
||||
@@ -87,8 +87,8 @@ test("migrates IndexedDB metadata and creates a validated OPFS project layout",
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({ requestId: infoId, command: { type: "info" } });
|
||||
}));
|
||||
expect(result.schemaVersion).toBe(6);
|
||||
expect(result.stores).toEqual(expect.arrayContaining(["project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "migration"]));
|
||||
expect(result.schemaVersion).toBe(7);
|
||||
expect(result.stores).toEqual(expect.arrayContaining(["project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "simulation_quarantine", "migration"]));
|
||||
expect(result.projectPath).toBe("projects/layout-e2e/scene.blend");
|
||||
});
|
||||
|
||||
@@ -454,8 +454,8 @@ test("renders a bounded previous/next N-016 Grease Pencil onion-skin preview", a
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createGreasePencilObject } = await import("/src/three-adapter/grease-pencil.ts");
|
||||
const point = (x: number) => ({ position: [x, 0, 0] as [number, number, number], radius: 0.1, opacity: 1, vertexColor: [0.2, 0.4, 0.8, 1] as [number, number, number, number] });
|
||||
const drawing = (id: string, x: number) => ({ id, strokeCount: 1, pointCount: 2, strokes: [{ cyclic: false, pointCount: 2, materialIndex: 0, points: [point(x), point(x + 1)] }] });
|
||||
const point = (drawingId: string, x: number, index: number) => ({ id: `grease-pencil-point:onion:${drawingId}:${index}`, position: [x, 0, 0] as [number, number, number], radius: 0.1, opacity: 1, vertexColor: [0.2, 0.4, 0.8, 1] as [number, number, number, number] });
|
||||
const drawing = (id: string, x: number) => ({ id, strokeCount: 1, pointCount: 2, strokes: [{ id: `grease-pencil-stroke:onion:${id}`, cyclic: false, pointCount: 2, materialIndex: 0, points: [point(id, x, 0), point(id, x + 1, 1)] }] });
|
||||
const object = createGreasePencilObject({
|
||||
id: "grease-pencil:onion",
|
||||
name: "Onion",
|
||||
@@ -464,7 +464,8 @@ test("renders a bounded previous/next N-016 Grease Pencil onion-skin preview", a
|
||||
frameCount: 3,
|
||||
strokeCount: 3,
|
||||
pointCount: 6,
|
||||
layers: [{ id: "layer:1", name: "Lines", visible: true, locked: false, opacity: 1, onionSkinning: true, frames: [
|
||||
activeLayerId: "grease-pencil-layer:onion:1",
|
||||
layers: [{ id: "grease-pencil-layer:onion:1", name: "Lines", visible: true, locked: false, opacity: 1, onionSkinning: true, frames: [
|
||||
{ frame: 1, drawing: drawing("drawing:1", -2) },
|
||||
{ frame: 5, drawing: drawing("drawing:5", 0) },
|
||||
{ frame: 9, drawing: drawing("drawing:9", 2) },
|
||||
@@ -531,7 +532,7 @@ test("raycasts and highlights N-016 Grease Pencil points with stable drawing ide
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
worker.postMessage({});
|
||||
}));
|
||||
expect(result.hit).toEqual({ dataId: "grease-pencil:Viewport", layerId: "grease-pencil-layer:Viewport", frame: 1, strokeIndex: 0, pointIndex: 1 });
|
||||
expect(result.hit).toEqual({ dataId: "grease-pencil:Viewport", layerId: "grease-pencil-layer:Viewport", frame: 1, drawingId: "grease-pencil-drawing:Viewport", strokeId: "grease-pencil-stroke:Viewport:0:0", pointId: "grease-pencil-point:Viewport:0:0:1", strokeIndex: 0, pointIndex: 1 });
|
||||
expect(result.selectedColor).toEqual([expect.closeTo(1), expect.closeTo(0.38), expect.closeTo(0.08)]);
|
||||
expect(result.preview).toEqual([[0.5, 2, -1], [0.5, 2, -1]]);
|
||||
expect(result.restored).toEqual([[0, 0, -0], [0, 0, -0]]);
|
||||
@@ -849,7 +850,7 @@ test("executes the bounded N-020 CPU compositor and preserves unsupported nodes
|
||||
expect(result.cache).toEqual([false, true, false, true, true, 0.25, 2, 256]);
|
||||
});
|
||||
|
||||
test("executes the N-020 Exposure and Invert chain read from a real Blender 5.2 graph", async ({ page }) => {
|
||||
test("blocks the N-020 Main graph when it also preserves an unsupported Blender node", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const bytes = await import("node:fs").then((fs) => fs.readFileSync(compositorBlend));
|
||||
const result = await page.evaluate(async (input) => {
|
||||
@@ -860,19 +861,24 @@ test("executes the N-020 Exposure and Invert chain read from a real Blender 5.2
|
||||
const opened = await client.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
||||
const scene = opened.snapshot.scenes.find((candidate) => candidate.name === "CompositorScene");
|
||||
if (!scene?.compositorGraph) throw new Error("Real compositor graph is missing");
|
||||
const execution = executeCompositorGraph(scene.compositorGraph, new Map(), { width: 1, height: 1 });
|
||||
const before = JSON.stringify(scene.compositorGraph);
|
||||
let executionCode = "";
|
||||
try { executeCompositorGraph(scene.compositorGraph, new Map(), { width: 1, height: 1 }); }
|
||||
catch (error) { executionCode = (error as { code?: string }).code ?? String(error); }
|
||||
return {
|
||||
status: scene.compositorStatus,
|
||||
pixel: Array.from(execution.composite.data),
|
||||
evaluated: execution.evaluatedNodeIds.map((id) => scene.compositorGraph!.nodes.find((node) => node.id === id)?.name),
|
||||
executionCode,
|
||||
preserved: JSON.stringify(scene.compositorGraph) === before,
|
||||
unsupported: scene.compositorGraph.nodes.find((node) => node.type === "UNSUPPORTED")?.blenderType,
|
||||
gate: gateCompositorGraph(scene.compositorGraph, new Set()).issues[0]?.code,
|
||||
};
|
||||
}
|
||||
finally { client.terminate(); }
|
||||
}, new Uint8Array(bytes));
|
||||
expect(result.status).toBe("AVAILABLE");
|
||||
expect(result.pixel).toEqual([0.75, 0.5, 0, 0.75]);
|
||||
expect(result.evaluated).toEqual(["WebConstantColor", "WebExposure", "WebInvert", "WebComposite"]);
|
||||
expect(result.executionCode).toBe("COMPOSITOR_NODE_UNSUPPORTED");
|
||||
expect(result.preserved).toBe(true);
|
||||
expect(result.unsupported).toBe("CompositorNodeGlare");
|
||||
expect(result.gate).toBe("COMPOSITOR_NODE_UNSUPPORTED");
|
||||
});
|
||||
|
||||
@@ -1121,8 +1127,7 @@ for (const offscreen of [false, true]) test(`previews an N-015 Curve handle drag
|
||||
const { PerspectiveCamera, Vector3 } = await import("/src/vendor/three/three.module.js");
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const camera = new PerspectiveCamera(45, bounds.width / bounds.height, 0.01, 1000);
|
||||
if (input.offscreen) camera.position.set(7 * Math.cos(0.55) * Math.cos(-Math.PI / 4), 7 * Math.cos(0.55) * Math.sin(-Math.PI / 4), 7 * Math.sin(0.55));
|
||||
else camera.position.set(4.5, -4.5, 3.5);
|
||||
camera.position.set(4.219781, -4.219781, 3.658811);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.updateMatrixWorld(true);
|
||||
camera.updateProjectionMatrix();
|
||||
@@ -1383,7 +1388,15 @@ test("recovers NanoVDB paging from network, Worker and WebGPU device faults", as
|
||||
expect(gpu.paging.pageCount).toBeGreaterThan(1);
|
||||
expect(gpu.paging.residentPageCount).toBe(gpu.paging.pageCount);
|
||||
expect(gpu.deviceLoss.recoveredGeneration).toBe(gpu.deviceLoss.firstGeneration + 1);
|
||||
expect(gpu.deviceLoss.recoveredDemandWords).toEqual(gpu.demandPaging.expectedWords.slice(0, 2));
|
||||
expect(gpu.deviceLoss.residentBeforeReplay).toEqual([]);
|
||||
expect(gpu.deviceLoss.visiblePageIds).toEqual([0, 1, 2]);
|
||||
expect(gpu.deviceLoss.replayedPageIds).toEqual([0, 1]);
|
||||
expect(gpu.deviceLoss.skippedPageIds).toEqual([2]);
|
||||
expect(gpu.deviceLoss.recoveredResidentPages).toEqual([0, 1]);
|
||||
expect(gpu.deviceLoss.recoveredResidentPages).not.toContain(gpu.deviceLoss.oldNonVisiblePage);
|
||||
expect(gpu.deviceLoss.recoveredPageTable.slice(0, 2)).toEqual([0, 1]);
|
||||
expect(gpu.deviceLoss.recoveredPageTable.slice(2).every((slot: number) => slot === 0xffffffff)).toBe(true);
|
||||
expect(gpu.deviceLoss.recoveredDemandWords).toEqual(gpu.deviceLoss.expectedDemandWords);
|
||||
expect(gpu.samplesStable).toBe(true);
|
||||
|
||||
const interrupted = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
@@ -1549,7 +1562,7 @@ test("retains bounded snapshots and returns a validated operation replay plan",
|
||||
await client.appendOperation("op-r5", projectId, 5, { type: "setFrame", frame: 5 });
|
||||
await client.appendOperation("op-r4", projectId, 4, { type: "setFrame", frame: 4 });
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("blender-web-metadata", 6);
|
||||
const request = indexedDB.open("blender-web-metadata", 7);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
@@ -1559,18 +1572,50 @@ test("retains bounded snapshots and returns a validated operation replay plan",
|
||||
db.close();
|
||||
const snapshots = await client.listSnapshots(projectId);
|
||||
const latest = await client.readSnapshot(projectId, snapshots.snapshots[0].revision);
|
||||
const snapshotRows = await new Promise<Array<{ backend?: string; buffer?: ArrayBuffer }>>((resolve, reject) => {
|
||||
const request = indexedDB.open("blender-web-metadata", 7);
|
||||
request.onsuccess = () => {
|
||||
const snapshotDb = request.result;
|
||||
const transaction = snapshotDb.transaction("snapshot", "readonly");
|
||||
const rows = transaction.objectStore("snapshot").getAll();
|
||||
rows.onsuccess = () => resolve((rows.result as Array<{ projectId: string; backend?: string; buffer?: ArrayBuffer }>).filter((row) => row.projectId === projectId));
|
||||
rows.onerror = () => reject(rows.error);
|
||||
transaction.oncomplete = () => snapshotDb.close();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const projects = await root.getDirectoryHandle("projects");
|
||||
const project = await projects.getDirectoryHandle(projectId);
|
||||
const snapshotDirectory = await project.getDirectoryHandle("snapshots");
|
||||
const snapshotFiles: string[] = [];
|
||||
for await (const [name, handle] of (snapshotDirectory as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries()) {
|
||||
if (handle.kind === "file") snapshotFiles.push(name);
|
||||
}
|
||||
const replay = await client.listOperations(projectId, 3);
|
||||
const pruned = await client.pruneOperations(projectId, 4);
|
||||
client.terminate();
|
||||
return {
|
||||
snapshotRevisions: snapshots.snapshots.map((item) => item.revision),
|
||||
latest: Array.from(new Uint8Array(latest.buffer)),
|
||||
snapshotBackends: snapshotRows.map((row) => row.backend).sort(),
|
||||
inlineSnapshotBuffers: snapshotRows.filter((row) => row.buffer).length,
|
||||
snapshotFiles: snapshotFiles.sort(),
|
||||
replayRevisions: replay.operations.map((item) => item.revision),
|
||||
quarantined: replay.quarantined,
|
||||
pruned: pruned.removed,
|
||||
};
|
||||
});
|
||||
expect(result).toEqual({ snapshotRevisions: [4, 3], latest: [4, 5], replayRevisions: [4, 5], quarantined: 1, pruned: 2 });
|
||||
expect(result).toEqual({
|
||||
snapshotRevisions: [4, 3],
|
||||
latest: [4, 5],
|
||||
snapshotBackends: ["opfs", "opfs"],
|
||||
inlineSnapshotBuffers: 0,
|
||||
snapshotFiles: ["3.blend", "4.blend"],
|
||||
replayRevisions: [4, 5],
|
||||
quarantined: 1,
|
||||
pruned: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("reports quota exhaustion without replacing the committed project", async ({ page }) => {
|
||||
@@ -1654,16 +1699,26 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
payloadBytes.set(new Uint8Array(frameTwo), frameOne.byteLength);
|
||||
const payload = payloadBytes.buffer;
|
||||
const fixedHash = await digest(Uint8Array.from([1, 2, 3]).buffer);
|
||||
const manifest = {
|
||||
schemaVersion: 1 as const,
|
||||
const binding = {
|
||||
graphId: "geometry-node-tree:simulation-e2e",
|
||||
graphHash: fixedHash,
|
||||
sourceBlendSha256: await digest(sourceBlend),
|
||||
sourceRevision: 1,
|
||||
inputHash: await digest(Uint8Array.from([4, 5, 6]).buffer),
|
||||
cacheSha256: await digest(payload),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: 2,
|
||||
};
|
||||
const revisionHash = await digest(new TextEncoder().encode(JSON.stringify([
|
||||
"blender-web-simulation-cache-revision-v2", binding.graphId, binding.graphHash,
|
||||
binding.sourceBlendSha256, String(binding.sourceRevision), binding.inputHash,
|
||||
binding.blenderVersion, String(binding.frameStart), String(binding.frameEnd),
|
||||
])).buffer);
|
||||
const manifest = {
|
||||
schemaVersion: 2 as const,
|
||||
...binding,
|
||||
revisionHash,
|
||||
cacheSha256: await digest(payload),
|
||||
byteLength: payload.byteLength,
|
||||
frames: [
|
||||
{ frame: 1, byteOffset: 0, byteLength: frameOne.byteLength, sha256: await digest(frameOne) },
|
||||
@@ -1683,7 +1738,7 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
const scene = { schemaVersion: 1 as const, revision: 1, sceneId: "scene:Cache", source: { kind: "mock" as const }, coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 }, activeObjectId: "object:CacheTarget", frame: { current: 1, start: 1, end: 2 }, nodes: [{ id: "object:CacheTarget", name: "CacheTarget", type: "MESH" as const, parentId: null, dataId: "mesh:CacheTarget", visible: true, selectable: true, localMatrix: identity, worldMatrix: identity, transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 } }], meshes: [], materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [], scenes: [] };
|
||||
let publishedFrame = 0;
|
||||
let publishedTranslation: number[] = [];
|
||||
const playback = new BrowserTransformCachePlaybackSession(scene, { frameStart: 1, frameEnd: 2, readFrame: async (frame) => (await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, frame)).data }, (preview) => { publishedFrame = preview.frame.current; publishedTranslation = preview.nodes[0].transform.translation; });
|
||||
const playback = new BrowserTransformCachePlaybackSession(scene, { frameStart: 1, frameEnd: 2, readFrame: async (frame, signal) => (await restarted.readSimulationCacheFrame(projectId, stored.cacheKey, frame, signal)).data }, (preview) => { publishedFrame = preview.frame.current; publishedTranslation = preview.nodes[0].transform.translation; });
|
||||
await playback.seek(2);
|
||||
let missingFrameCode = "";
|
||||
try {
|
||||
@@ -1715,7 +1770,7 @@ test("persists and revalidates content-addressed Simulation caches across Worker
|
||||
corruptCode,
|
||||
};
|
||||
});
|
||||
expect(result.cacheKey).toMatch(/^[a-f0-9]{16}-[a-f0-9]{16}-[a-f0-9]{16}-1-2$/);
|
||||
expect(result.cacheKey).toMatch(/^sim2-[a-f0-9]{64}$/);
|
||||
expect(result.path).toMatch(/^projects\/simulation-e2e-[0-9]+\/assets\/sha256\/[a-f0-9]{2}\/[a-f0-9]{64}$/);
|
||||
expect(result.listed).toContain(result.cacheKey);
|
||||
expect(result.bytes).toBe(176);
|
||||
|
||||
42
web/tests/e2e/storage-budget.spec.ts
Normal file
42
web/tests/e2e/storage-budget.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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("M7-11 reports project, snapshot, LOD, media and VDB byte categories", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator(".blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
await expect(app).toHaveAttribute("data-project-id", "basic_scene");
|
||||
|
||||
const budget = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
await storage.putAsset("basic_scene", new Uint8Array(7).buffer, "image/png", "media/test.png");
|
||||
await storage.putAsset("basic_scene", new Uint8Array(17).buffer, "application/x-blender-simulation-cache", "cache/simulation.bin");
|
||||
await storage.saveLOD("basic_scene", "budget-lod", new Uint8Array(13).buffer);
|
||||
const result = await storage.getBudget("basic_scene");
|
||||
storage.terminate();
|
||||
return result;
|
||||
});
|
||||
expect(budget.projectBytes).toBeGreaterThan(0);
|
||||
expect(budget.snapshotBytes).toBeGreaterThan(0);
|
||||
expect(budget.lodBytes).toBe(13);
|
||||
expect(budget.mediaBytes).toBe(7);
|
||||
expect(budget.vdbBytes).toBe(17);
|
||||
expect(budget.totalBytes).toBe(budget.projectBytes + budget.snapshotBytes + 13 + 7 + 17);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const panel = page.getByTestId("storage-budget-panel");
|
||||
await expect(panel).toHaveAttribute("data-project-id", "basic_scene");
|
||||
await expect(panel.locator('[data-category="LOD"]')).toHaveAttribute("data-bytes", "13");
|
||||
await expect(panel.locator('[data-category="媒体"]')).toHaveAttribute("data-bytes", "7");
|
||||
await expect(panel.locator('[data-category="VDB"]')).toHaveAttribute("data-bytes", "17");
|
||||
await expect(panel).toHaveAttribute("data-total-bytes", String(budget.totalBytes));
|
||||
});
|
||||
55
web/tests/e2e/storage-cleanup.spec.ts
Normal file
55
web/tests/e2e/storage-cleanup.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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("M7-12 removes only one project's unreferenced content-addressed assets", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await download;
|
||||
|
||||
const orphanA = "a".repeat(64);
|
||||
const orphanB = "b".repeat(64);
|
||||
const referenced = await page.evaluate(async ({ orphanA, orphanB }) => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const storage = new StorageClient();
|
||||
const keptA = await storage.putAsset("basic_scene", new Uint8Array(5).buffer, "image/png", "media/kept.png");
|
||||
await storage.putAsset("cleanup-other", new Uint8Array(6).buffer, "image/png", "media/other.png");
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const writeOrphan = async (projectId: string, hash: string, size: number) => {
|
||||
let current = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", hash.slice(0, 2)]) current = await current.getDirectoryHandle(segment, { create: true });
|
||||
const handle = await current.getFileHandle(hash, { create: true });
|
||||
const writer = await handle.createWritable();
|
||||
await writer.write(new Uint8Array(size));
|
||||
await writer.close();
|
||||
};
|
||||
await writeOrphan("basic_scene", orphanA, 19);
|
||||
await writeOrphan("cleanup-other", orphanB, 23);
|
||||
storage.terminate();
|
||||
return { keptA: keptA.sha256, orphanA, orphanB };
|
||||
}, { orphanA, orphanB });
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
await page.getByTestId("cleanup-project-assets").click();
|
||||
await expect(page.locator(".status-bar")).toContainText("removed 1 orphan asset(s), 19 bytes");
|
||||
const files = await page.evaluate(async ({ orphanA, orphanB, keptA }) => {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const read = async (projectId: string, hash: string) => {
|
||||
try {
|
||||
let current = root;
|
||||
for (const segment of ["projects", projectId, "assets", "sha256", hash.slice(0, 2)]) current = await current.getDirectoryHandle(segment);
|
||||
await current.getFileHandle(hash);
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
};
|
||||
return { orphanA: await read("basic_scene", orphanA), orphanB: await read("cleanup-other", orphanB), keptA: await read("basic_scene", keptA) };
|
||||
}, referenced);
|
||||
expect(files).toEqual({ orphanA: false, orphanB: true, keptA: true });
|
||||
});
|
||||
146
web/tests/e2e/texture-paint-asset.spec.ts
Normal file
146
web/tests/e2e/texture-paint-asset.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M9-11 atomically publishes packed and UDIM dirty tiles after verified OPFS writes", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { StorageClient } = await import("/src/storage/StorageClient.ts");
|
||||
const projectId = "m9-texture-paint-atomic";
|
||||
const hash = async (bytes: Uint8Array | ArrayBuffer) => {
|
||||
const data = bytes instanceof Uint8Array ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : bytes;
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
const encode = async (pixels: Uint8Array) => {
|
||||
const canvas = new OffscreenCanvas(2, 2);
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.putImageData(new ImageData(new Uint8ClampedArray(pixels), 2, 2), 0, 0);
|
||||
return (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer();
|
||||
};
|
||||
const decode = async (data: ArrayBuffer) => {
|
||||
const bitmap = await createImageBitmap(new Blob([data], { type: "image/png" }));
|
||||
const canvas = new OffscreenCanvas(2, 2);
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true })!;
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
return new Uint8Array(context.getImageData(0, 0, 2, 2).data);
|
||||
};
|
||||
const commit = async ({
|
||||
client,
|
||||
textureAssetId,
|
||||
kind,
|
||||
tile,
|
||||
revision,
|
||||
sourcePath,
|
||||
baseAssetSha256,
|
||||
basePixels,
|
||||
offset,
|
||||
dirty,
|
||||
faultAt,
|
||||
}: {
|
||||
client: InstanceType<typeof StorageClient>;
|
||||
textureAssetId: string;
|
||||
kind: "PACKED" | "UDIM";
|
||||
tile: number;
|
||||
revision: number;
|
||||
sourcePath: string;
|
||||
baseAssetSha256: string;
|
||||
basePixels: Uint8Array;
|
||||
offset: number;
|
||||
dirty: number[];
|
||||
faultAt?: "after-asset-write";
|
||||
}) => {
|
||||
const next = basePixels.slice();
|
||||
next.set(dirty, offset);
|
||||
return client.commitTexturePaintTile({
|
||||
schemaVersion: 1,
|
||||
target: {
|
||||
schemaVersion: 1,
|
||||
projectId,
|
||||
imageId: "image:M9Paint",
|
||||
textureAssetId,
|
||||
kind,
|
||||
tile,
|
||||
revision,
|
||||
width: 2,
|
||||
height: 2,
|
||||
mimeType: "image/png",
|
||||
colorSpace: "SRGB",
|
||||
sourcePath,
|
||||
baseAssetSha256,
|
||||
},
|
||||
patch: {
|
||||
schemaVersion: 1,
|
||||
textureAssetId,
|
||||
tile,
|
||||
revision,
|
||||
width: 2,
|
||||
height: 2,
|
||||
format: "RGBA8",
|
||||
colorSpace: "SRGB",
|
||||
baseSha256: await hash(basePixels),
|
||||
resultSha256: await hash(next),
|
||||
byteOffset: offset,
|
||||
bytes: new Uint8Array(dirty),
|
||||
},
|
||||
faultAt,
|
||||
});
|
||||
};
|
||||
|
||||
const opaqueBlack = new Uint8Array(Array.from({ length: 4 }, () => [0, 0, 0, 255]).flat());
|
||||
const client = new StorageClient();
|
||||
const packedBase = await client.putAsset(projectId, await encode(opaqueBlack), "image/png", "textures/packed.png");
|
||||
const packedId = "image:M9Paint:packed";
|
||||
const packedFirstPixels = opaqueBlack.slice();
|
||||
packedFirstPixels.set([255, 0, 0, 255], 0);
|
||||
const packedFirst = await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 4, sourcePath: "textures/packed.png", baseAssetSha256: packedBase.sha256, basePixels: opaqueBlack, offset: 0, dirty: [255, 0, 0, 255] });
|
||||
const firstAsset = await client.readAsset(projectId, packedFirst.binding.assetSha256);
|
||||
const firstPixels = await decode(firstAsset.data);
|
||||
|
||||
let injected = "";
|
||||
try {
|
||||
await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 5, sourcePath: "textures/packed.png", baseAssetSha256: packedFirst.binding.assetSha256, basePixels: packedFirstPixels, offset: 4, dirty: [0, 255, 0, 255], faultAt: "after-asset-write" });
|
||||
}
|
||||
catch (error) { injected = error instanceof Error ? error.message : String(error); }
|
||||
const afterFailure = await client.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: packedId, tile: 1001 });
|
||||
const packedSecondPixels = packedFirstPixels.slice();
|
||||
packedSecondPixels.set([0, 255, 0, 255], 4);
|
||||
const packedSecond = await commit({ client, textureAssetId: packedId, kind: "PACKED", tile: 1001, revision: 5, sourcePath: "textures/packed.png", baseAssetSha256: packedFirst.binding.assetSha256, basePixels: packedFirstPixels, offset: 4, dirty: [0, 255, 0, 255] });
|
||||
|
||||
const udimBase = await client.putAsset(projectId, await encode(opaqueBlack), "image/png", "textures/paint.1002.png");
|
||||
const udimId = "image:M9Paint:tile:1002";
|
||||
const udim = await commit({ client, textureAssetId: udimId, kind: "UDIM", tile: 1002, revision: 5, sourcePath: "textures/paint.1002.png", baseAssetSha256: udimBase.sha256, basePixels: opaqueBlack, offset: 12, dirty: [0, 0, 255, 255] });
|
||||
client.terminate();
|
||||
|
||||
const restarted = new StorageClient();
|
||||
const reopenedPacked = await restarted.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: packedId, tile: 1001 });
|
||||
const reopenedUdim = await restarted.readTexturePaintTileBinding({ schemaVersion: 1, projectId, textureAssetId: udimId, tile: 1002 });
|
||||
const reopenedAsset = await restarted.readAsset(projectId, reopenedPacked.binding!.assetSha256);
|
||||
const reopenedPixels = await decode(reopenedAsset.data);
|
||||
restarted.terminate();
|
||||
return {
|
||||
packedBase: packedBase.sha256,
|
||||
packedFirst: packedFirst.binding,
|
||||
firstPixels: Array.from(firstPixels),
|
||||
injected,
|
||||
afterFailure: afterFailure.binding,
|
||||
packedSecond: packedSecond.binding,
|
||||
udim: udim.binding,
|
||||
reopenedPacked: reopenedPacked.binding,
|
||||
reopenedUdim: reopenedUdim.binding,
|
||||
reopenedPixels: Array.from(reopenedPixels),
|
||||
expectedPackedPixels: Array.from(packedSecondPixels),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.packedFirst).toMatchObject({ kind: "PACKED", tile: 1001, generation: 1, pixelSha256: expect.stringMatching(/^[a-f0-9]{64}$/) });
|
||||
expect(result.packedFirst.assetSha256).not.toBe(result.packedBase);
|
||||
expect(result.firstPixels.slice(0, 4)).toEqual([255, 0, 0, 255]);
|
||||
expect(result.injected).toContain("STORAGE_TRANSACTION");
|
||||
expect(result.afterFailure).toEqual(result.packedFirst);
|
||||
expect(result.packedSecond).toMatchObject({ kind: "PACKED", tile: 1001, generation: 2 });
|
||||
expect(result.packedSecond.assetSha256).not.toBe(result.packedFirst.assetSha256);
|
||||
expect(result.udim).toMatchObject({ kind: "UDIM", tile: 1002, generation: 1 });
|
||||
expect(result.reopenedPacked).toEqual(result.packedSecond);
|
||||
expect(result.reopenedUdim).toEqual(result.udim);
|
||||
expect(result.reopenedPixels).toEqual(result.expectedPackedPixels);
|
||||
});
|
||||
47
web/tests/e2e/ui-context.spec.ts
Normal file
47
web/tests/e2e/ui-context.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M7-13 keeps menu, modal Escape and focus return consistent", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
|
||||
const searchTrigger = page.getByRole("button", { name: "操作搜索" });
|
||||
const fileMenu = page.getByRole("button", { name: "文件", exact: true });
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(fileMenu).toBeFocused();
|
||||
await searchTrigger.focus();
|
||||
await page.keyboard.press("F3");
|
||||
const dialog = page.getByRole("dialog", { name: "Operator Search" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "搜索操作" })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(page.getByRole("textbox", { name: "搜索操作" })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(searchTrigger).toBeFocused();
|
||||
|
||||
await fileMenu.click();
|
||||
const menu = page.getByRole("menu", { name: "文件" });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "打开" })).toBeFocused();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.getByRole("menuitem", { name: "保存" })).toBeFocused();
|
||||
await page.keyboard.press("End");
|
||||
await expect(page.getByRole("menuitem", { name: "关闭" })).toBeFocused();
|
||||
await page.keyboard.press("Home");
|
||||
await expect(page.getByRole("menuitem", { name: "打开" })).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(fileMenu).toBeFocused();
|
||||
|
||||
await fileMenu.click();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(fileMenu).toBeFocused();
|
||||
await fileMenu.click();
|
||||
await page.getByRole("button", { name: "操作搜索" }).click();
|
||||
await expect(page.getByRole("menu", { name: "文件" })).toHaveCount(0);
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(searchTrigger).toBeFocused();
|
||||
});
|
||||
61
web/tests/e2e/viewport-consistency.spec.ts
Normal file
61
web/tests/e2e/viewport-consistency.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
for (const offscreen of [false, true]) {
|
||||
test(`M7-15 ${offscreen ? "Offscreen" : "main-thread"} viewport uses the shared camera and selection contract`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto(offscreen ? "/?offscreen=1" : "/");
|
||||
await page.setInputFiles("[data-testid=blend-file-input]", basicBlend);
|
||||
await expect(page.getByText("BasicCube", { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
const canvas = page.locator("canvas.viewport-canvas");
|
||||
await expect(canvas).toHaveAttribute("data-renderer-backend", offscreen ? "offscreen-worker" : "webgl-pbr");
|
||||
if (offscreen) await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
|
||||
|
||||
const initial = await canvas.evaluate((element) => ({
|
||||
position: element.getAttribute("data-camera-position"),
|
||||
target: element.getAttribute("data-camera-target"),
|
||||
yaw: element.getAttribute("data-camera-yaw"),
|
||||
pitch: element.getAttribute("data-camera-pitch"),
|
||||
distance: element.getAttribute("data-camera-distance"),
|
||||
}));
|
||||
expect(initial.position).toBe("4.219781,-4.219781,3.658811");
|
||||
expect(initial.target).toBe("0,0,0");
|
||||
expect(initial.yaw).toBe("-0.785398");
|
||||
expect(initial.pitch).toBe("0.550000");
|
||||
expect(initial.distance).toBe("7.000000");
|
||||
|
||||
await canvas.evaluate((element, useOffscreen) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const x = bounds.left + bounds.width / 2;
|
||||
const y = bounds.top + bounds.height / 2;
|
||||
element.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: x, clientY: y }));
|
||||
if (useOffscreen) {
|
||||
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: x, clientY: y, pointerId: 11, buttons: 1 }));
|
||||
element.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: x, clientY: y, pointerId: 11, buttons: 0 }));
|
||||
}
|
||||
}, offscreen);
|
||||
await expect(page.locator(".blender-app")).toHaveAttribute("data-selected-object-ids", /.+/);
|
||||
|
||||
await canvas.evaluate((element) => element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 120 })));
|
||||
await expect.poll(async () => await canvas.getAttribute("data-camera-distance"), { timeout: 5_000 }).not.toBe(initial.distance);
|
||||
const zoomed = await canvas.getAttribute("data-camera-distance");
|
||||
expect(Number(zoomed)).toBeCloseTo(7 * Math.exp(0.12), 4);
|
||||
});
|
||||
}
|
||||
|
||||
test("M7-15 main and Offscreen camera state stays identical for the same orbit input", async ({ browser }) => {
|
||||
const states: Array<Record<string, string | null>> = [];
|
||||
for (const offscreen of [false, true]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
await page.goto(offscreen ? "/?offscreen=1" : "/");
|
||||
const canvas = page.locator("canvas.viewport-canvas");
|
||||
if (offscreen) await expect.poll(async () => Number(await canvas.getAttribute("data-renderer-pixels") ?? "0"), { timeout: 20_000 }).toBeGreaterThan(0);
|
||||
await canvas.evaluate((element) => element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -80 })));
|
||||
await expect.poll(async () => await canvas.getAttribute("data-camera-distance"), { timeout: 5_000 }).not.toBe("7.000000");
|
||||
states.push(await canvas.evaluate((element) => ({ position: element.getAttribute("data-camera-position"), target: element.getAttribute("data-camera-target"), yaw: element.getAttribute("data-camera-yaw"), pitch: element.getAttribute("data-camera-pitch"), distance: element.getAttribute("data-camera-distance") })));
|
||||
await page.close();
|
||||
}
|
||||
expect(states[0]).toEqual(states[1]);
|
||||
});
|
||||
41
web/tests/e2e/worker-crash-recovery.spec.ts
Normal file
41
web/tests/e2e/worker-crash-recovery.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
for (const source of ["engine", "storage"] as const) {
|
||||
test("M7-08 " + source + " Worker crash keeps the project visible and restores the operation log", async ({ page }) => {
|
||||
await page.goto("/?worker-fault=" + source);
|
||||
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
|
||||
const app = page.locator(".blender-app");
|
||||
await page.getByTestId("blend-file-input").setInputFiles(basicBlend);
|
||||
await expect(page.getByText("Cube", { exact: true })).toBeVisible();
|
||||
|
||||
const initialDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "保存项目" }).click();
|
||||
await initialDownload;
|
||||
await expect(app).toHaveAttribute("data-dirty", "false");
|
||||
const committedRevision = await app.getAttribute("data-committed-main-revision");
|
||||
|
||||
await page.getByRole("button", { name: "添加立方体" }).click();
|
||||
await expect(app).toHaveAttribute("data-dirty", "true");
|
||||
const editedRevision = await app.getAttribute("data-current-main-revision");
|
||||
const editedStats = await page.getByTestId("scene-stats").textContent();
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
await page.getByTestId("inject-worker-crash").click();
|
||||
await expect(app).toHaveAttribute("data-worker-fault-source", source);
|
||||
await expect(app).toHaveAttribute("data-worker-fault-code", "WORKER_TERMINATED");
|
||||
await expect(page.getByTestId("worker-fault-banner")).toContainText("current project list and scene are retained");
|
||||
await expect(app).toHaveAttribute("data-project-snapshot-revision", editedRevision ?? "");
|
||||
await expect(page.getByTestId("scene-stats")).toHaveText(editedStats ?? "");
|
||||
|
||||
await page.getByTestId("restart-and-recover").click();
|
||||
await expect(app).toHaveAttribute("data-worker-recovery-status", "SUCCEEDED", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("worker-fault-banner")).toHaveCount(0);
|
||||
await expect(app).toHaveAttribute("data-committed-main-revision", committedRevision ?? "");
|
||||
await expect(app).toHaveAttribute("data-dirty", "true");
|
||||
await expect(page.getByTestId("scene-stats")).toHaveText(editedStats ?? "");
|
||||
await expect(page.getByTestId("engine-status")).toContainText("project restored");
|
||||
});
|
||||
}
|
||||
76
web/tests/unit/compositor-unsupported-gate.test.mjs
Normal file
76
web/tests/unit/compositor-unsupported-gate.test.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "compositor-unsupported-unit-"));
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/compositor.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "compositor.mjs"), transpiled.outputText.replaceAll('from "./capability-gates"', 'from "./capability-gates.mjs"'));
|
||||
const gates = ts.transpileModule(fs.readFileSync(path.join(repoRoot, "web/protocol/capability-gates.ts"), "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: path.join(repoRoot, "web/protocol/capability-gates.ts"),
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(gates.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "capability-gates.mjs"), gates.outputText);
|
||||
const compositor = await import(pathToFileURL(path.join(temporary, "compositor.mjs")));
|
||||
|
||||
const node = (id, type, properties = {}, blenderType) => ({ id, type, name: id, properties, ...(blenderType ? { blenderType } : {}) });
|
||||
const graph = () => ({
|
||||
schemaVersion: 1,
|
||||
id: "compositor:unsupported",
|
||||
name: "Unsupported",
|
||||
outputNodeId: "out",
|
||||
resources: [],
|
||||
nodes: [
|
||||
node("color", "CONSTANT_COLOR", { color: [0.125, 0.25, 0.5, 0.75] }),
|
||||
node("out", "COMPOSITE"),
|
||||
node("glare", "UNSUPPORTED", {}, "CompositorNodeGlare"),
|
||||
],
|
||||
links: [{ fromNodeId: "color", fromSocket: "Image", toNodeId: "out", toSocket: "Image" }],
|
||||
});
|
||||
|
||||
test("M11-08 preserves the unsupported graph and blocks CPU execution before evaluation", async () => {
|
||||
const value = graph();
|
||||
const before = structuredClone(value);
|
||||
const gate = compositor.gateCompositorGraph(value, new Set());
|
||||
assert.equal(gate.status, "BLOCKED");
|
||||
assert.deepEqual(gate.issues.map((issue) => issue.code), ["COMPOSITOR_NODE_UNSUPPORTED"]);
|
||||
let cancellationChecks = 0;
|
||||
assert.throws(
|
||||
() => compositor.executeCompositorGraph(value, new Map(), { width: 2, height: 2, cancelled: () => { cancellationChecks++; return false; } }),
|
||||
{ code: "COMPOSITOR_NODE_UNSUPPORTED" },
|
||||
);
|
||||
assert.equal(cancellationChecks, 0);
|
||||
assert.deepEqual(value, before);
|
||||
});
|
||||
|
||||
test("M11-08 blocks a cached unsupported graph before a cache hit", async () => {
|
||||
const value = graph();
|
||||
const before = structuredClone(value);
|
||||
const cache = new compositor.CompositorFrameCache(512);
|
||||
const key = await compositor.compositorFrameCacheKey(value, new Map(), 1, 2, 2);
|
||||
cache.set(key, {
|
||||
composite: { width: 2, height: 2, data: new Float32Array(16), colorSpace: "LINEAR_SRGB" },
|
||||
viewers: new Map(),
|
||||
evaluatedNodeIds: ["cached"],
|
||||
});
|
||||
await assert.rejects(
|
||||
() => compositor.executeCompositorGraphCached(value, new Map(), cache, { frame: 1, width: 2, height: 2 }),
|
||||
{ code: "COMPOSITOR_NODE_UNSUPPORTED" },
|
||||
);
|
||||
assert.equal(cache.size, 1);
|
||||
assert.deepEqual(value, before);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
89
web/tests/unit/compositor-webgpu.test.mjs
Normal file
89
web/tests/unit/compositor-webgpu.test.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "compositor-webgpu-unit-"));
|
||||
|
||||
function transpile(sourceName, outputName, replacements = []) {
|
||||
const sourcePath = path.join(root, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const output = replacements.reduce((value, [from, to]) => value.replaceAll(from, to), transpiled.outputText);
|
||||
fs.writeFileSync(path.join(temporary, outputName), output);
|
||||
}
|
||||
|
||||
transpile("capability-gates.ts", "capability-gates.mjs");
|
||||
transpile("compositor.ts", "compositor.mjs", [
|
||||
['from "./capability-gates"', 'from "./capability-gates.mjs"'],
|
||||
]);
|
||||
const compositor = await import(pathToFileURL(path.join(temporary, "compositor.mjs")));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-07/compositor-node-golden.json"), "utf8"));
|
||||
const sourceColor = [0.125, 0.25, 0.5, 0.75];
|
||||
|
||||
function graph(nodeTypes, extraNodes = [], resources = []) {
|
||||
const nodes = nodeTypes.map((type, index) => ({
|
||||
id: `node:${index}`,
|
||||
type,
|
||||
name: `${type}:${index}`,
|
||||
properties: type === "CONSTANT_COLOR" ? { color: sourceColor } : type === "EXPOSURE" ? { exposure: 1 } : {},
|
||||
}));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: `graph:${nodeTypes.join("-")}`,
|
||||
name: "M11-07 unit",
|
||||
outputNodeId: nodes.at(-1).id,
|
||||
nodes: [...nodes, ...extraNodes],
|
||||
links: nodes.slice(1).map((node, index) => ({ fromNodeId: nodes[index].id, fromSocket: "Image", toNodeId: node.id, toSocket: "Image" })),
|
||||
resources,
|
||||
};
|
||||
}
|
||||
|
||||
function repeatedPixel(pixel, count) {
|
||||
const result = new Float32Array(count * 4);
|
||||
for (let index = 0; index < count; index++) result.set(pixel, index * 4);
|
||||
return result;
|
||||
}
|
||||
|
||||
test("M11-07 freezes only nodes with an independent CPU/WebGPU golden", () => {
|
||||
assert.deepEqual(compositor.COMPOSITOR_WEBGPU_NODE_ALLOWLIST, golden.allowlist);
|
||||
for (const candidate of golden.cases) {
|
||||
const value = graph(candidate.nodeTypes);
|
||||
const plan = compositor.compileCompositorWebGPUPlan(value);
|
||||
assert.deepEqual(plan.instructions.map((instruction) => instruction.type), candidate.nodeTypes);
|
||||
const cpu = compositor.executeCompositorGraph(value, new Map(), { width: golden.width, height: golden.height });
|
||||
const expected = repeatedPixel(candidate.pixel, golden.width * golden.height);
|
||||
assert.deepEqual(cpu.composite.data, expected, candidate.scene);
|
||||
assert.equal(crypto.createHash("sha256").update(new Uint8Array(cpu.composite.data.buffer)).digest("hex"), candidate.float32Sha256);
|
||||
}
|
||||
});
|
||||
|
||||
test("M11-07 rejects every CPU-only or undeclared node before WebGPU compilation", () => {
|
||||
const properties = { ALPHA_OVER: {}, BLUR: { radius: 1 }, IMAGE: { resourceId: "resource:test" }, MIX: { factor: 0.5 }, RENDER_LAYER: { resourceId: "resource:test" }, TRANSFORM: {}, UNSUPPORTED: {}, VIEWER: {} };
|
||||
for (const type of golden.blockedNodeTypes) {
|
||||
const node = { id: `blocked:${type}`, type, name: type, properties: properties[type], ...(type === "UNSUPPORTED" ? { blenderType: "CompositorNodeGlare" } : {}) };
|
||||
const resources = type === "IMAGE" || type === "RENDER_LAYER" ? [{ id: "resource:test", kind: type, sourceId: "image:test" }] : [];
|
||||
assert.throws(() => compositor.compileCompositorWebGPUPlan(graph(["CONSTANT_COLOR", "COMPOSITE"], [node], resources)), { code: "COMPOSITOR_NODE_UNSUPPORTED" });
|
||||
}
|
||||
});
|
||||
|
||||
test("M11-07 rejects disconnected allowlisted nodes and non-Image links", () => {
|
||||
assert.throws(
|
||||
() => compositor.compileCompositorWebGPUPlan(graph(["CONSTANT_COLOR", "COMPOSITE"], [{ id: "extra", type: "INVERT", name: "extra", properties: {} }])),
|
||||
{ code: "COMPOSITOR_GRAPH_INVALID" },
|
||||
);
|
||||
const malformed = graph(["CONSTANT_COLOR", "EXPOSURE", "COMPOSITE"]);
|
||||
malformed.links[0].toSocket = "Value";
|
||||
assert.throws(() => compositor.compileCompositorWebGPUPlan(malformed), { code: "COMPOSITOR_GRAPH_INVALID" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
142
web/tests/unit/curve-topology-editor.test.mjs
Normal file
142
web/tests/unit/curve-topology-editor.test.mjs
Normal file
@@ -0,0 +1,142 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/curve-topology-editor.ts");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "curve-topology-editor-unit-"));
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "curve-topology-editor.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const curve = await import(pathToFileURL(modulePath));
|
||||
|
||||
function claim(operator, selection) {
|
||||
const base = {
|
||||
schemaVersion: 1,
|
||||
operator,
|
||||
dataId: "curve:Curve",
|
||||
baseRevision: 17,
|
||||
inputSplineCount: 4,
|
||||
inputPointCount: 12,
|
||||
selectedSplineIndices: selection === "SPLINES" || selection === "POINTS_OR_SPLINES" ? [1] : [],
|
||||
selectedPointIndices: selection === "POINTS" ? [2] : [],
|
||||
addedSplineCount: 0,
|
||||
addedPointCount: 0,
|
||||
outputSplineCount: 4,
|
||||
outputPointCount: 12,
|
||||
payloadBytes: 256,
|
||||
};
|
||||
if (operator === "ADD_SPLINE") return { ...base, addedSplineCount: 1, addedPointCount: 2, outputSplineCount: 5, outputPointCount: 14 };
|
||||
if (operator === "DUPLICATE") return { ...base, addedSplineCount: 1, addedPointCount: 2, outputSplineCount: 5, outputPointCount: 14 };
|
||||
if (operator === "EXTRUDE") return { ...base, addedPointCount: 1, outputPointCount: 13 };
|
||||
if (operator === "SPLIT") return { ...base, addedSplineCount: 1, outputSplineCount: 5 };
|
||||
if (operator === "SUBDIVIDE") return { ...base, addedPointCount: 2, outputPointCount: 14, subdivideCuts: 2 };
|
||||
return base;
|
||||
}
|
||||
|
||||
test("M9-04 freezes the Blender-sourced Curve topology allowlist and budgets", () => {
|
||||
const manifest = curve.createCurveTopologyEditorManifest();
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.equal(manifest.sourceAuthority, "blender-5.2.0/source/blender/editors/curve/curve_ops.cc");
|
||||
assert.equal(manifest.atomicMainTransaction, true);
|
||||
assert.deepEqual(manifest.budget, {
|
||||
maxSplines: 65_536,
|
||||
maxPoints: 1_000_000,
|
||||
maxSelectedElements: 100_000,
|
||||
maxAddedSplinesPerOperation: 4_096,
|
||||
maxAddedPointsPerOperation: 100_000,
|
||||
maxPayloadBytes: 67_108_864,
|
||||
maxSubdivideCuts: 64,
|
||||
maxDataIdBytes: 256,
|
||||
maxOperationsPerMainTransaction: 1,
|
||||
});
|
||||
assert.deepEqual(manifest.operators.map((operator) => operator.id), [
|
||||
"ADD_SPLINE", "DECIMATE", "DELETE", "DISSOLVE_VERTICES", "DUPLICATE", "EXTRUDE",
|
||||
"MAKE_SEGMENT", "SEPARATE", "SET_HANDLE_TYPE", "SET_SPLINE_TYPE", "SPLIT", "SUBDIVIDE",
|
||||
"SWITCH_DIRECTION", "TOGGLE_CYCLIC",
|
||||
]);
|
||||
assert.deepEqual(manifest.operators.filter((operator) => operator.gate.status === "READY").map((operator) => operator.id), ["TOGGLE_CYCLIC"]);
|
||||
assert.ok(manifest.operators.filter((operator) => operator.id !== "TOGGLE_CYCLIC").every((operator) => operator.gate.status === "BLOCKED" && operator.gate.reasonCode === "CURVE_TOPOLOGY_OPERATOR_NOT_VERIFIED"));
|
||||
});
|
||||
|
||||
test("M9-04 accepts one revision-bound budget claim for every allowlisted operator", () => {
|
||||
for (const descriptor of curve.CURVE_TOPOLOGY_EDITOR_OPERATORS) {
|
||||
const parsed = curve.parseCurveTopologyOperationClaim(claim(descriptor.id, descriptor.selection), 17);
|
||||
assert.equal(parsed.operator, descriptor.id);
|
||||
assert.equal(parsed.baseRevision, 17);
|
||||
}
|
||||
});
|
||||
|
||||
test("M9-04 rejects unknown, stale, ambiguous and over-budget Curve topology claims", () => {
|
||||
const valid = claim("SUBDIVIDE", "POINTS_OR_SPLINES");
|
||||
const cases = [
|
||||
[{ ...valid, operator: "SPIN" }, "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED"],
|
||||
[valid, "REVISION_CONFLICT", 18],
|
||||
[{ ...valid, selectedSplineIndices: [1, 1] }, "NON_MESH_PROPERTY_INVALID"],
|
||||
[{ ...valid, selectedPointIndices: [2] }, "NON_MESH_PROPERTY_INVALID"],
|
||||
[{ ...valid, dataId: "data:Curve" }, "NON_MESH_PROPERTY_INVALID"],
|
||||
[{ ...claim("DELETE", "POINTS_OR_SPLINES"), inputSplineCount: 0, selectedSplineIndices: [0] }, "NON_MESH_PROPERTY_INVALID"],
|
||||
[{ ...valid, outputPointCount: 15 }, "NON_MESH_PROPERTY_INVALID"],
|
||||
[{ ...valid, payloadBytes: curve.CURVE_TOPOLOGY_EDITOR_BUDGET.maxPayloadBytes + 1 }, "NON_MESH_DATA_BUDGET_EXCEEDED"],
|
||||
[{ ...valid, subdivideCuts: 65 }, "NON_MESH_DATA_BUDGET_EXCEEDED"],
|
||||
[{ ...claim("DELETE", "POINTS_OR_SPLINES"), subdivideCuts: 1 }, "NON_MESH_PROPERTY_INVALID"],
|
||||
[{ ...valid, futureField: true }, "NON_MESH_PROPERTY_INVALID"],
|
||||
];
|
||||
for (const [value, code, revision = 17] of cases) {
|
||||
assert.throws(() => curve.parseCurveTopologyOperationClaim(value, revision), (error) => error.code === code);
|
||||
}
|
||||
});
|
||||
|
||||
test("M9-05 builds one revision-bound TOGGLE_CYCLIC Main command", () => {
|
||||
const operation = curve.buildCurveToggleCyclicOperation({
|
||||
schemaVersion: 1,
|
||||
dataId: "curve:WebCurveData",
|
||||
baseRevision: 23,
|
||||
splineIndex: 1,
|
||||
splineCount: 2,
|
||||
pointCount: 7,
|
||||
cyclicU: [false, true],
|
||||
}, 23);
|
||||
assert.deepEqual(operation.command, {
|
||||
type: "setCurveTopology",
|
||||
dataId: "curve:WebCurveData",
|
||||
baseRevision: 23,
|
||||
cyclicU: [false, false],
|
||||
});
|
||||
assert.equal(operation.claim.operator, "TOGGLE_CYCLIC");
|
||||
assert.deepEqual(operation.claim.selectedSplineIndices, [1]);
|
||||
assert.equal(operation.previousCyclic, true);
|
||||
assert.equal(operation.nextCyclic, false);
|
||||
});
|
||||
|
||||
test("M9-05 keeps stale and malformed TOGGLE_CYCLIC requests out of Main", () => {
|
||||
const valid = {
|
||||
schemaVersion: 1,
|
||||
dataId: "curve:WebCurveData",
|
||||
baseRevision: 23,
|
||||
splineIndex: 0,
|
||||
splineCount: 2,
|
||||
pointCount: 7,
|
||||
cyclicU: [false, false],
|
||||
};
|
||||
assert.throws(() => curve.buildCurveToggleCyclicOperation(valid, 24), (error) => error.code === "REVISION_CONFLICT");
|
||||
for (const value of [
|
||||
{ ...valid, splineIndex: 2 },
|
||||
{ ...valid, splineCount: 0 },
|
||||
{ ...valid, cyclicU: [false] },
|
||||
{ ...valid, dataId: "surface:WebSurfaceData" },
|
||||
]) {
|
||||
assert.throws(() => curve.buildCurveToggleCyclicOperation(value, 23), (error) => error.code === "NON_MESH_PROPERTY_INVALID");
|
||||
}
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
66
web/tests/unit/diagnostic-report.test.mjs
Normal file
66
web/tests/unit/diagnostic-report.test.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/diagnostic-report.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
|
||||
const { APP_DIAGNOSTIC_MESSAGES, appendAppDiagnostic, createAppDiagnosticEntry, createAppDiagnosticReport } = await import(moduleUrl);
|
||||
|
||||
test("M7-17 keeps user summaries stable while retaining detailed causes", () => {
|
||||
const cause = new Error("native allocator detail");
|
||||
const error = new Error("WORKER_CRASH_INJECTED: engine.worker.ts:91", { cause });
|
||||
error.code = "WORKER_TERMINATED";
|
||||
const entry = createAppDiagnosticEntry({
|
||||
sequence: 1,
|
||||
occurredAt: "2026-08-15T20:00:00.000Z",
|
||||
area: "ENGINE",
|
||||
code: "ENGINE_WORKER_TERMINATED",
|
||||
error,
|
||||
context: { projectId: "basic_scene", revision: 9 },
|
||||
});
|
||||
assert.equal(entry.summary, APP_DIAGNOSTIC_MESSAGES.ENGINE_WORKER_TERMINATED);
|
||||
assert.equal(entry.summary.includes("WORKER_CRASH_INJECTED"), false);
|
||||
assert.match(entry.detail, /WORKER_CRASH_INJECTED/);
|
||||
assert.equal(entry.sourceCode, "WORKER_TERMINATED");
|
||||
assert.equal(entry.cause, "native allocator detail");
|
||||
});
|
||||
|
||||
test("M7-17 bounds the ledger and exports a sequence-sorted schema v1 report", () => {
|
||||
const entries = [3, 1, 2].map((sequence) => createAppDiagnosticEntry({
|
||||
sequence,
|
||||
occurredAt: `2026-08-15T20:00:0${sequence}.000Z`,
|
||||
area: "STORAGE",
|
||||
code: "STORAGE_BUDGET_FAILED",
|
||||
error: { code: "STORAGE_TRANSACTION", message: `detail-${sequence}` },
|
||||
}));
|
||||
assert.deepEqual(appendAppDiagnostic(entries.slice(0, 2), entries[2], 2).map((entry) => entry.sequence), [1, 2]);
|
||||
const report = createAppDiagnosticReport({
|
||||
generatedAt: "2026-08-15T20:01:00.000Z",
|
||||
runtime: { url: "http://127.0.0.1:5173/", userAgent: "test", language: "zh-CN", crossOriginIsolated: true },
|
||||
project: { projectId: "basic_scene", revision: 9 },
|
||||
entries,
|
||||
});
|
||||
assert.equal(report.schemaVersion, 1);
|
||||
assert.equal(report.product, "Web Blender Modeler V1");
|
||||
assert.deepEqual(report.entries.map((entry) => entry.sequence), [1, 2, 3]);
|
||||
assert.deepEqual(report.entries.map((entry) => entry.detail), ["detail-1", "detail-2", "detail-3"]);
|
||||
});
|
||||
|
||||
test("M7-17 user-visible status setters never interpolate raw exception detail", () => {
|
||||
const appSource = fs.readFileSync(path.join(repoRoot, "web/app/src/app/App.tsx"), "utf8");
|
||||
const statusLines = appSource.split("\n").filter((line) => /set(?:Engine|Storage|Wasm|Manifest)Status\(|setViewportError\(/.test(line));
|
||||
assert.ok(statusLines.length > 20, "expected to audit all user-visible status boundaries");
|
||||
for (const line of statusLines) {
|
||||
assert.doesNotMatch(line, /error\.message|errorMessage\(|fault\.error\.message/, line.trim());
|
||||
}
|
||||
assert.doesNotMatch(appSource, /\{fault\.error\.message\}/);
|
||||
});
|
||||
62
web/tests/unit/editing-domain-recovery.test.mjs
Normal file
62
web/tests/unit/editing-domain-recovery.test.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/editing-domain-recovery.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
|
||||
const recovery = await import(moduleUrl);
|
||||
|
||||
const hash = "a".repeat(64);
|
||||
const base = (domain, dataId) => ({
|
||||
schemaVersion: 1,
|
||||
domain,
|
||||
baseline: { objectIds: [`object:${domain}`], dataIds: [dataId], objectCount: 1, revision: 4, identityHash: hash },
|
||||
workerRestart: { status: "RECOVERED", workerGeneration: 2, revisionBefore: 4, revisionAfter: 4, hashBefore: hash, hashAfter: hash, liveHandles: 1, temporaryResourcesAfter: 0 },
|
||||
oom: { status: "RECOVERED", faultPoint: "GPU_GEOMETRY_UPLOAD", code: "GPU_GEOMETRY_BUDGET_EXCEEDED", revisionBefore: 4, revisionAfter: 4, hashBefore: hash, hashAfter: hash, releasedBytes: 4096, temporaryResourcesAfter: 0 },
|
||||
gpuRelease: { status: "RECOVERED", backend: "WEBGL2", releaseCount: 1, reinitCount: 1, disposedResources: 6, visiblePixels: 12, pixelHashBefore: hash, pixelHashAfter: hash },
|
||||
smallScene: { status: "RECOVERED", revision: 4, identityHash: hash, objectCount: 1, dataIds: [dataId], visiblePixels: 12 },
|
||||
});
|
||||
|
||||
test("M9-14 parses all three editing domains and preserves recovery invariants", () => {
|
||||
const reports = recovery.parseEditingDomainRecoverySuite([
|
||||
base("CURVE", "curve:Recovery"),
|
||||
base("GREASE_PENCIL", "grease-pencil:Recovery"),
|
||||
base("PAINT", "mesh:Recovery"),
|
||||
]);
|
||||
assert.deepEqual(reports.map((report) => report.domain), ["CURVE", "GREASE_PENCIL", "PAINT"]);
|
||||
assert.equal(reports.every((report) => report.workerRestart.hashAfter === report.baseline.identityHash), true);
|
||||
assert.equal(reports.every((report) => report.oom.releasedBytes > 0 && report.gpuRelease.visiblePixels > 0), true);
|
||||
});
|
||||
|
||||
test("M9-14 rejects stale identity, duplicate domains and GPU release drift", () => {
|
||||
const curve = base("CURVE", "curve:Recovery");
|
||||
const stale = structuredClone(curve);
|
||||
stale.smallScene.identityHash = "b".repeat(64);
|
||||
assert.throws(() => recovery.parseEditingDomainRecoveryEvidence(stale), /smallScene identity/);
|
||||
assert.throws(() => recovery.parseEditingDomainRecoverySuite([curve, curve, base("PAINT", "mesh:Recovery")]), /duplicate editing domain/);
|
||||
const releaseDrift = structuredClone(curve);
|
||||
releaseDrift.gpuRelease.releaseCount = 2;
|
||||
assert.throws(() => recovery.parseEditingDomainRecoveryEvidence(releaseDrift), /release and reinitialize exactly once/);
|
||||
});
|
||||
|
||||
test("M9-14 summarizes only visible objects in the requested editing domain", () => {
|
||||
const snapshot = {
|
||||
nodes: [
|
||||
{ id: "object:curve", type: "CURVE", visible: true, dataId: "curve:one" },
|
||||
{ id: "object:hidden", type: "CURVE", visible: false, dataId: "curve:two" },
|
||||
{ id: "object:mesh", type: "MESH", visible: true, dataId: "mesh:one" },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(recovery.summarizeEditingDomain(snapshot, "CURVE"), { objectIds: ["object:curve"], dataIds: ["curve:one"], objectCount: 1 });
|
||||
assert.deepEqual(recovery.summarizeEditingDomain(snapshot, "PAINT"), { objectIds: ["object:mesh"], dataIds: ["mesh:one"], objectCount: 1 });
|
||||
assert.throws(() => recovery.summarizeEditingDomain({ nodes: [] }, "GREASE_PENCIL"), /DOMAIN_MISSING/);
|
||||
});
|
||||
165
web/tests/unit/external-vfont.test.mjs
Normal file
165
web/tests/unit/external-vfont.test.mjs
Normal file
@@ -0,0 +1,165 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "external-vfont-unit-"));
|
||||
for (const name of ["asset-path", "external-vfont"]) {
|
||||
const sourcePath = path.join(repoRoot, `web/protocol/${name}.ts`);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, `${name}.mjs`), transpiled.outputText.replace('"./asset-path"', '"./asset-path.mjs"'));
|
||||
}
|
||||
const font = await import(pathToFileURL(path.join(temporary, "external-vfont.mjs")));
|
||||
const importSourcePath = path.join(repoRoot, "web/app/src/fonts/external-vfont-import.ts");
|
||||
const importTranspiled = ts.transpileModule(fs.readFileSync(importSourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: importSourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(importTranspiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "external-vfont-import.mjs"), importTranspiled.outputText.replace('"../../../protocol/external-vfont"', '"./external-vfont.mjs"'));
|
||||
const importer = await import(pathToFileURL(path.join(temporary, "external-vfont-import.mjs")));
|
||||
const pfb = fs.readFileSync(path.join(repoRoot, "blender-5.2.0/release/datafiles/bfont.pfb"));
|
||||
const data = pfb.buffer.slice(pfb.byteOffset, pfb.byteOffset + pfb.byteLength);
|
||||
const digest = crypto.createHash("sha256").update(pfb).digest("hex");
|
||||
const request = { sourcePath: "//fonts/bfont.pfb", mimeType: "application/x-font-type1", byteLength: pfb.byteLength, sha256: digest, data };
|
||||
|
||||
test("M9-01 validates a real project-relative PFB before storage or Main mutation", async () => {
|
||||
const protocolSource = fs.readFileSync(path.join(repoRoot, "web/protocol/external-vfont.ts"), "utf8");
|
||||
assert.doesNotMatch(protocolSource, /StorageClient|WebEngineClient|storage\.worker|web-engine\.worker/);
|
||||
const validated = await font.validateExternalVFontImport(request);
|
||||
assert.deepEqual({ ...validated, data: undefined }, {
|
||||
schemaVersion: 1,
|
||||
sourcePath: "//fonts/bfont.pfb",
|
||||
fileName: "bfont.pfb",
|
||||
format: "PFB",
|
||||
mimeType: "application/x-font-type1",
|
||||
byteLength: 25181,
|
||||
sha256: "a33954fdab9fb09b9d308cb7f970518293128922ffc523c0a22b3b314a9a56c6",
|
||||
data: undefined,
|
||||
});
|
||||
assert.notEqual(validated.data, request.data);
|
||||
assert.deepEqual(new Uint8Array(validated.data), new Uint8Array(request.data));
|
||||
});
|
||||
|
||||
test("M9-01 blocks path escape, type spoofing, size overflow and hash drift", async () => {
|
||||
const cases = [
|
||||
[{ ...request, sourcePath: "../../outside.pfb" }, "NON_MESH_RESOURCE_OUTSIDE_PROJECT"],
|
||||
[{ ...request, sourcePath: "//assets/bfont.pfb" }, "NON_MESH_RESOURCE_OUTSIDE_PROJECT"],
|
||||
[{ ...request, sourcePath: "//fonts/bfont.ttf", mimeType: "font/ttf" }, "NON_MESH_BINARY_INVALID"],
|
||||
[{ ...request, mimeType: "font/otf" }, "NON_MESH_BINARY_INVALID"],
|
||||
[{ ...request, byteLength: font.EXTERNAL_VFONT_MAX_BYTES + 1 }, "NON_MESH_DATA_BUDGET_EXCEEDED"],
|
||||
[{ ...request, byteLength: request.byteLength - 1 }, "NON_MESH_BINARY_INVALID"],
|
||||
[{ ...request, sha256: "0".repeat(64) }, "ASSET_SOURCE_HASH_MISMATCH"],
|
||||
];
|
||||
for (const [value, code] of cases) await assert.rejects(font.validateExternalVFontImport(value), (error) => error.code === code);
|
||||
});
|
||||
|
||||
test("M9-02 accepts only a matching OPFS content-addressed receipt before Main import", async () => {
|
||||
const validated = await font.validateExternalVFontImport(request);
|
||||
const stored = {
|
||||
assetId: `sha256:${digest}`,
|
||||
projectId: "m9-vfont-unit",
|
||||
sha256: digest,
|
||||
bytes: pfb.byteLength,
|
||||
mimeType: request.mimeType,
|
||||
sourcePath: "fonts/bfont.pfb",
|
||||
path: `projects/m9-vfont-unit/assets/sha256/${digest.slice(0, 2)}/${digest}`,
|
||||
createdAt: "2026-08-16T00:00:00.000Z",
|
||||
lastAccessAt: "2026-08-16T00:00:00.000Z",
|
||||
persisted: true,
|
||||
deduplicated: false,
|
||||
};
|
||||
const mainImport = font.createExternalVFontMainImport(validated, stored);
|
||||
assert.deepEqual({ ...mainImport, data: undefined }, {
|
||||
schemaVersion: 1,
|
||||
projectId: stored.projectId,
|
||||
assetId: stored.assetId,
|
||||
assetPath: stored.path,
|
||||
sourcePath: "//fonts/bfont.pfb",
|
||||
name: "bfont",
|
||||
format: "PFB",
|
||||
mimeType: request.mimeType,
|
||||
byteLength: pfb.byteLength,
|
||||
sha256: digest,
|
||||
data: undefined,
|
||||
});
|
||||
assert.notEqual(mainImport.data, validated.data);
|
||||
for (const receipt of [
|
||||
{ ...stored, persisted: false },
|
||||
{ ...stored, sha256: "0".repeat(64) },
|
||||
{ ...stored, bytes: stored.bytes - 1 },
|
||||
{ ...stored, path: `indexeddb:${stored.projectId}:${digest}` },
|
||||
]) {
|
||||
assert.throws(() => font.createExternalVFontMainImport(validated, receipt), (error) =>
|
||||
error.code === "ASSET_SOURCE_HASH_MISMATCH" || error.code === "NON_MESH_RESOURCE_MISSING");
|
||||
}
|
||||
});
|
||||
|
||||
test("M9-03 verifies the project asset before one Main font-style replacement", async () => {
|
||||
const stored = {
|
||||
asset: {
|
||||
assetId: `sha256:${digest}`,
|
||||
projectId: "m9-vfont-unit",
|
||||
sha256: digest,
|
||||
bytes: pfb.byteLength,
|
||||
mimeType: request.mimeType,
|
||||
sourcePath: "fonts/bfont.pfb",
|
||||
path: `projects/m9-vfont-unit/assets/sha256/${digest.slice(0, 2)}/${digest}`,
|
||||
createdAt: "2026-08-16T00:00:00.000Z",
|
||||
lastAccessAt: "2026-08-16T00:00:00.000Z",
|
||||
},
|
||||
data: data.slice(0),
|
||||
};
|
||||
const vfont = { id: "vfont:bfont", name: "bfont", sourcePath: "//fonts/bfont.pfb", builtin: false, packed: true, packedByteLength: pfb.byteLength, sha256: digest };
|
||||
const original = { regular: "vfont:Bfont", bold: "vfont:Bfont", italic: "vfont:Bfont", boldItalic: "vfont:Bfont" };
|
||||
const snapshot = { nonMeshData: [{ id: "data:font", type: "FONT", fontLinks: original }], vfonts: [vfont] };
|
||||
const events = [];
|
||||
const result = await importer.replaceExternalVFontStyleInMain({
|
||||
projectId: stored.asset.projectId,
|
||||
sha256: digest,
|
||||
dataId: "data:font",
|
||||
vfontId: vfont.id,
|
||||
style: "regular",
|
||||
snapshot,
|
||||
storage: { readAsset: async () => { events.push("storage:verified"); return stored; } },
|
||||
engine: { applyCommand: async (command) => {
|
||||
events.push("main:committed");
|
||||
return { snapshot: { ...snapshot, nonMeshData: [{ ...snapshot.nonMeshData[0], fontLinks: command.links }] } };
|
||||
} },
|
||||
});
|
||||
assert.deepEqual(events, ["storage:verified", "main:committed"]);
|
||||
assert.deepEqual(result.previousLinks, original);
|
||||
assert.equal(result.links.regular, vfont.id);
|
||||
});
|
||||
|
||||
test("M9-03 blocks a missing project asset before Main replacement", async () => {
|
||||
let mainCalls = 0;
|
||||
const snapshot = {
|
||||
nonMeshData: [{ id: "data:font", type: "FONT", fontLinks: { regular: "vfont:Bfont", bold: "vfont:Bfont", italic: "vfont:Bfont", boldItalic: "vfont:Bfont" } }],
|
||||
vfonts: [{ id: "vfont:bfont", name: "bfont", sourcePath: "//fonts/bfont.pfb", builtin: false, packed: true, packedByteLength: pfb.byteLength, sha256: digest }],
|
||||
};
|
||||
await assert.rejects(importer.replaceExternalVFontStyleInMain({
|
||||
projectId: "m9-vfont-unit",
|
||||
sha256: digest,
|
||||
dataId: "data:font",
|
||||
vfontId: "vfont:bfont",
|
||||
style: "regular",
|
||||
snapshot,
|
||||
storage: { readAsset: async () => { const error = new Error("missing"); error.code = "NON_MESH_RESOURCE_MISSING"; throw error; } },
|
||||
engine: { applyCommand: async () => { mainCalls += 1; throw new Error("unexpected Main call"); } },
|
||||
}), (error) => error.code === "NON_MESH_RESOURCE_MISSING");
|
||||
assert.equal(mainCalls, 0);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
258
web/tests/unit/geometry-nodes.test.mjs
Normal file
258
web/tests/unit/geometry-nodes.test.mjs
Normal file
@@ -0,0 +1,258 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "geometry-nodes-unit-"));
|
||||
|
||||
function transpile(sourceName, outputName, replacements = []) {
|
||||
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const output = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText);
|
||||
fs.writeFileSync(path.join(temporary, outputName), output);
|
||||
}
|
||||
|
||||
transpile("capability-gates.ts", "capability-gates.mjs");
|
||||
transpile("geometry-nodes.ts", "geometry-nodes.mjs", [
|
||||
['from "./capability-gates"', 'from "./capability-gates.mjs"'],
|
||||
]);
|
||||
const geometryNodes = await import(pathToFileURL(path.join(temporary, "geometry-nodes.mjs")));
|
||||
|
||||
function graph() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "node-group:Unit",
|
||||
name: "Unit",
|
||||
interfaceInputs: [{ id: "input:geometry", name: "Geometry", direction: "INPUT", dataType: "GEOMETRY" }],
|
||||
interfaceOutputs: [{ id: "output:geometry", name: "Geometry", direction: "OUTPUT", dataType: "GEOMETRY" }],
|
||||
nodes: [{
|
||||
id: "geometry-node:7",
|
||||
type: "GeometryNodeTransform",
|
||||
name: "Transform Geometry",
|
||||
sockets: [
|
||||
{ id: "input:mode", name: "Mode", direction: "INPUT", dataType: "MENU", defaultValue: 0 },
|
||||
{ id: "input:rotation", name: "Rotation", direction: "INPUT", dataType: "ROTATION", defaultValue: [0, 0, 0] },
|
||||
{ id: "input:matrix", name: "Transform", direction: "INPUT", dataType: "MATRIX" },
|
||||
{ id: "output:geometry", name: "Geometry", direction: "OUTPUT", dataType: "GEOMETRY" },
|
||||
],
|
||||
}],
|
||||
links: [],
|
||||
groupReferences: [],
|
||||
graphHash: "a".repeat(64),
|
||||
};
|
||||
}
|
||||
|
||||
test("M10-01 parses Blender Main socket types and stable graph identities", () => {
|
||||
const parsed = geometryNodes.parseGeometryNodeGraph(graph());
|
||||
assert.equal(parsed.id, "node-group:Unit");
|
||||
assert.deepEqual(parsed.nodes[0].sockets.map((socket) => socket.dataType), ["MENU", "ROTATION", "MATRIX", "GEOMETRY"]);
|
||||
assert.equal(geometryNodes.validateGeometryNodeGraph(parsed).status, "SUPPORTED");
|
||||
assert.deepEqual(geometryNodes.GEOMETRY_NODE_GRAPH_BUDGET, {
|
||||
maxGraphs: 4_096,
|
||||
maxNodesPerGraph: 4_096,
|
||||
maxLinksPerGraph: 16_384,
|
||||
maxSocketsPerGraph: 65_536,
|
||||
maxInterfaceSocketsPerGraph: 4_096,
|
||||
maxIdentifierBytes: 256,
|
||||
maxNameBytes: 1_024,
|
||||
});
|
||||
});
|
||||
|
||||
test("M10-01 rejects duplicate stable IDs and malformed graph hashes", () => {
|
||||
const duplicateNode = graph();
|
||||
duplicateNode.nodes.push(structuredClone(duplicateNode.nodes[0]));
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeGraph(duplicateNode), { code: "GN_INVALID_GRAPH" });
|
||||
|
||||
const duplicateSocket = graph();
|
||||
duplicateSocket.nodes[0].sockets.push(structuredClone(duplicateSocket.nodes[0].sockets[0]));
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeGraph(duplicateSocket), { code: "GN_INVALID_GRAPH" });
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeGraph({ ...graph(), graphHash: "A".repeat(64) }), { code: "GN_INVALID_GRAPH" });
|
||||
});
|
||||
|
||||
test("M10-01 fails closed before oversized graph topology enters SceneIR", () => {
|
||||
const oversized = graph();
|
||||
oversized.nodes = Array.from({ length: geometryNodes.GEOMETRY_NODE_GRAPH_BUDGET.maxNodesPerGraph + 1 },
|
||||
(_value, index) => ({ id: `node:${index}`, type: "NodeGroupInput", name: "Input", sockets: [] }));
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeGraph(oversized), { code: "GN_GRAPH_BUDGET_EXCEEDED" });
|
||||
const graphSet = Array.from({ length: geometryNodes.GEOMETRY_NODE_GRAPH_BUDGET.maxGraphs + 1 }, graph);
|
||||
assert.deepEqual(geometryNodes.validateGeometryNodeGraphSet(graphSet).issues.map((issue) => issue.code), ["GN_GRAPH_BUDGET_EXCEEDED"]);
|
||||
});
|
||||
|
||||
test("M10-02 freezes the allowlist and blocks unsupported nodes without rewriting the graph", () => {
|
||||
const allowed = graph();
|
||||
const before = structuredClone(allowed);
|
||||
assert.equal(geometryNodes.GEOMETRY_NODE_ALLOWLIST_SCHEMA, 1);
|
||||
assert.deepEqual(geometryNodes.GEOMETRY_NODE_ALLOWLIST, [
|
||||
"NodeGroupInput",
|
||||
"NodeGroupOutput",
|
||||
"GeometryNodeTransform",
|
||||
"GeometryNodeSetPosition",
|
||||
"GeometryNodeJoinGeometry",
|
||||
"GeometryNodeSeparateGeometry",
|
||||
"GeometryNodeRealizeInstances",
|
||||
"GeometryNodeStoreNamedAttribute",
|
||||
"FunctionNodeInputInt",
|
||||
"FunctionNodeInputVector",
|
||||
"FunctionNodeCompare",
|
||||
"ShaderNodeValue",
|
||||
"ShaderNodeMath",
|
||||
"GeometryNodeObjectInfo",
|
||||
"GeometryNodeCollectionInfo",
|
||||
"GeometryNodeImageInfo",
|
||||
]);
|
||||
assert.equal(geometryNodes.gateGeometryNodeGraph(allowed).status, "READY");
|
||||
|
||||
allowed.nodes.push({
|
||||
id: "geometry-node:8",
|
||||
type: "GeometryNodeSimulationOutput",
|
||||
name: "Simulation Output",
|
||||
sockets: [],
|
||||
});
|
||||
const blocked = geometryNodes.gateGeometryNodeGraph(allowed);
|
||||
assert.equal(blocked.status, "BLOCKED");
|
||||
assert.deepEqual(blocked.issues.map((issue) => issue.code), ["GN_NODE_UNSUPPORTED"]);
|
||||
assert.deepEqual(before, graph());
|
||||
assert.equal(allowed.nodes.at(-1).type, "GeometryNodeSimulationOutput");
|
||||
});
|
||||
|
||||
function domainCardinality(overrides = {}) {
|
||||
return {
|
||||
POINT: 8,
|
||||
EDGE: 12,
|
||||
FACE: 6,
|
||||
CORNER: 24,
|
||||
CURVE: 0,
|
||||
INSTANCE: 0,
|
||||
LAYER: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function field(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
graphId: "node-group:Unit",
|
||||
graphHash: "a".repeat(64),
|
||||
fieldId: "field:position",
|
||||
revision: 7,
|
||||
sourceDomain: "POINT",
|
||||
targetDomain: "CORNER",
|
||||
dataType: "FLOAT",
|
||||
transport: "JSON",
|
||||
domainCardinality: domainCardinality(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("M10-04 binds field materialization to exact domain cardinality and byte budgets", () => {
|
||||
assert.equal(geometryNodes.GEOMETRY_NODE_FIELD_SCHEMA, 1);
|
||||
assert.deepEqual(geometryNodes.GEOMETRY_NODE_FIELD_DOMAIN_BUDGET, {
|
||||
POINT: 1_000_000,
|
||||
EDGE: 2_000_000,
|
||||
FACE: 2_000_000,
|
||||
CORNER: 4_000_000,
|
||||
CURVE: 100_000,
|
||||
INSTANCE: 100_000,
|
||||
LAYER: 4_096,
|
||||
});
|
||||
assert.deepEqual(geometryNodes.GEOMETRY_NODE_FIELD_BUDGET, {
|
||||
maxFieldsPerBatch: 64,
|
||||
maxDomainConversionsPerBatch: 32,
|
||||
maxMaterializedElementsPerBatch: 4_000_000,
|
||||
maxMaterializedBytesPerBatch: 64 * 1024 * 1024,
|
||||
maxJsonScalarValuesPerField: 65_536,
|
||||
maxIdentifierBytes: 256,
|
||||
});
|
||||
|
||||
const batch = geometryNodes.parseGeometryNodeFieldMaterializationBatch([
|
||||
field(),
|
||||
field({
|
||||
fieldId: "field:offset",
|
||||
sourceDomain: "CONSTANT",
|
||||
targetDomain: "POINT",
|
||||
dataType: "VECTOR",
|
||||
}),
|
||||
]);
|
||||
assert.equal(batch.fieldCount, 2);
|
||||
assert.equal(batch.domainConversionCount, 1);
|
||||
assert.equal(batch.materializedElementCount, 32);
|
||||
assert.equal(batch.materializedByteLength, 192);
|
||||
assert.deepEqual(batch.fields.map((entry) => ({
|
||||
source: entry.sourceElementCount,
|
||||
target: entry.targetElementCount,
|
||||
scalars: entry.scalarValueCount,
|
||||
bytes: entry.materializedByteLength,
|
||||
conversion: entry.domainConversion,
|
||||
})), [
|
||||
{ source: 8, target: 24, scalars: 24, bytes: 96, conversion: true },
|
||||
{ source: 1, target: 8, scalars: 24, bytes: 96, conversion: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test("M10-04 blocks unbounded JSON fields, cardinality drift and aggregate overflow", () => {
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterializationBatch({}), { code: "GN_INVALID_GRAPH" });
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization({
|
||||
...field(),
|
||||
values: Array(24).fill(0),
|
||||
}), { code: "GN_FIELD_JSON_BUDGET_EXCEEDED" });
|
||||
|
||||
const largeCardinality = domainCardinality({ POINT: 100_000, CORNER: 300_000 });
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization(field({
|
||||
targetDomain: "POINT",
|
||||
dataType: "VECTOR",
|
||||
domainCardinality: largeCardinality,
|
||||
})), { code: "GN_FIELD_JSON_BUDGET_EXCEEDED" });
|
||||
assert.equal(geometryNodes.parseGeometryNodeFieldMaterialization(field({
|
||||
targetDomain: "POINT",
|
||||
dataType: "VECTOR",
|
||||
transport: "BINARY",
|
||||
domainCardinality: largeCardinality,
|
||||
})).materializedByteLength, 1_200_000);
|
||||
|
||||
const missingDomain = domainCardinality();
|
||||
delete missingDomain.LAYER;
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization(field({
|
||||
domainCardinality: missingDomain,
|
||||
})), { code: "GN_DOMAIN_CARDINALITY_MISMATCH" });
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization(field({
|
||||
domainCardinality: { ...domainCardinality(), VOXEL: 1 },
|
||||
})), { code: "GN_DOMAIN_CARDINALITY_MISMATCH" });
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization(field({
|
||||
dataType: "toString",
|
||||
})), { code: "GN_INVALID_GRAPH" });
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterialization(field({
|
||||
domainCardinality: domainCardinality({ EDGE: 2_000_001 }),
|
||||
})), { code: "GN_FIELD_BUDGET_EXCEEDED" });
|
||||
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterializationBatch([
|
||||
field({
|
||||
fieldId: "field:large-a",
|
||||
targetDomain: "CORNER",
|
||||
dataType: "COLOR",
|
||||
transport: "BINARY",
|
||||
domainCardinality: domainCardinality({ CORNER: 2_500_000 }),
|
||||
}),
|
||||
field({
|
||||
fieldId: "field:large-b",
|
||||
targetDomain: "CORNER",
|
||||
dataType: "COLOR",
|
||||
transport: "BINARY",
|
||||
domainCardinality: domainCardinality({ CORNER: 2_500_000 }),
|
||||
}),
|
||||
]), { code: "GN_FIELD_BUDGET_EXCEEDED" });
|
||||
|
||||
assert.throws(() => geometryNodes.parseGeometryNodeFieldMaterializationBatch(
|
||||
Array.from({ length: 33 }, (_value, index) => field({ fieldId: `field:conversion-${index}` })),
|
||||
), { code: "GN_FIELD_BUDGET_EXCEEDED" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
81
web/tests/unit/grease-pencil-marquee.test.mjs
Normal file
81
web/tests/unit/grease-pencil-marquee.test.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/grease-pencil-marquee.ts");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "grease-pencil-marquee-unit-"));
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "grease-pencil-marquee.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const marquee = await import(pathToFileURL(modulePath));
|
||||
|
||||
const drawing = {
|
||||
dataId: "grease-pencil:Data",
|
||||
layerId: "grease-pencil-layer:Data:Lines",
|
||||
frame: 12,
|
||||
drawingId: "grease-pencil-drawing:Data:4",
|
||||
};
|
||||
|
||||
function point(strokeIndex, pointIndex, viewportPosition) {
|
||||
return {
|
||||
...drawing,
|
||||
strokeId: `grease-pencil-stroke:Data:4:${strokeIndex}`,
|
||||
pointId: `grease-pencil-point:Data:4:${strokeIndex}:${pointIndex}`,
|
||||
strokeIndex,
|
||||
pointIndex,
|
||||
viewportPosition,
|
||||
};
|
||||
}
|
||||
|
||||
function request(candidates) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
baseRevision: 27,
|
||||
baseSelectionRevision: 4,
|
||||
drawing,
|
||||
box: { left: 0.2, top: 0.2, right: 0.8, bottom: 0.8 },
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
test("M9-06 selects stable point and stroke IDs only inside the current drawing marquee", () => {
|
||||
const result = marquee.selectGreasePencilMarquee(request([
|
||||
point(1, 1, [0.5, 0.5]),
|
||||
point(0, 2, [0.8, 0.2]),
|
||||
point(0, 0, [0.1, 0.5]),
|
||||
]), 27);
|
||||
assert.deepEqual(result.drawing, drawing);
|
||||
assert.equal(result.baseSelectionRevision, 4);
|
||||
assert.deepEqual(result.selectedStrokeIds, [
|
||||
"grease-pencil-stroke:Data:4:0",
|
||||
"grease-pencil-stroke:Data:4:1",
|
||||
]);
|
||||
assert.deepEqual(result.selectedPoints.map(({ pointId, strokeId, strokeIndex, pointIndex }) => ({ pointId, strokeId, strokeIndex, pointIndex })), [
|
||||
{ pointId: "grease-pencil-point:Data:4:0:2", strokeId: "grease-pencil-stroke:Data:4:0", strokeIndex: 0, pointIndex: 2 },
|
||||
{ pointId: "grease-pencil-point:Data:4:1:1", strokeId: "grease-pencil-stroke:Data:4:1", strokeIndex: 1, pointIndex: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("M9-06 rejects stale, foreign-drawing, duplicate and forged marquee candidates", () => {
|
||||
assert.throws(() => marquee.selectGreasePencilMarquee(request([point(0, 0, [0.5, 0.5])]), 28), (error) => error.code === "REVISION_CONFLICT");
|
||||
assert.throws(() => marquee.selectGreasePencilMarquee(request([{ ...point(0, 0, [0.5, 0.5]), drawingId: "grease-pencil-drawing:Data:5" }]), 27), (error) => error.code === "GREASE_PENCIL_SELECTION_SCOPE_INVALID");
|
||||
const duplicate = point(0, 0, [0.5, 0.5]);
|
||||
assert.throws(() => marquee.selectGreasePencilMarquee(request([duplicate, { ...duplicate }]), 27), (error) => error.code === "GREASE_PENCIL_SELECTION_INVALID");
|
||||
assert.throws(() => marquee.selectGreasePencilMarquee(request([{ ...point(0, 0, [0.5, 0.5]), pointId: "point:0" }]), 27), (error) => error.code === "GREASE_PENCIL_SELECTION_INVALID");
|
||||
assert.throws(() => marquee.selectGreasePencilMarquee({ ...request([]), box: { left: 0.8, top: 0.2, right: 0.2, bottom: 0.8 } }, 27), (error) => error.code === "GREASE_PENCIL_SELECTION_INVALID");
|
||||
});
|
||||
|
||||
test("M9-06 rejects a marquee candidate list over the bounded drawing point budget", () => {
|
||||
const candidates = new Array(marquee.GREASE_PENCIL_MARQUEE_BUDGET.maxCandidates + 1).fill(null);
|
||||
assert.throws(() => marquee.selectGreasePencilMarquee(request(candidates), 27), (error) => error.code === "GREASE_PENCIL_BUDGET_EXCEEDED");
|
||||
});
|
||||
83
web/tests/unit/grease-pencil-reorder.test.mjs
Normal file
83
web/tests/unit/grease-pencil-reorder.test.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/grease-pencil-reorder.ts");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "grease-pencil-reorder-unit-"));
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "grease-pencil-reorder.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const reorder = await import(pathToFileURL(modulePath));
|
||||
|
||||
const dataId = "grease-pencil:Data";
|
||||
const linesId = "grease-pencil-layer:Data:Lines";
|
||||
const draftsId = "grease-pencil-layer:Data:Web Drafts";
|
||||
const drawingId = "grease-pencil-drawing:Data:4";
|
||||
const drawing = { id: drawingId, strokeCount: 0, pointCount: 0, strokes: [] };
|
||||
const data = [{
|
||||
id: dataId,
|
||||
name: "Data",
|
||||
geometryStatus: "available",
|
||||
layerCount: 2,
|
||||
frameCount: 2,
|
||||
strokeCount: 0,
|
||||
pointCount: 0,
|
||||
layers: [
|
||||
{ id: linesId, name: "Lines", visible: true, locked: false, opacity: 1, frames: [{ frame: 1, drawing: { ...drawing, id: "grease-pencil-drawing:Data:0" } }] },
|
||||
{ id: draftsId, name: "Web Drafts", visible: true, locked: false, opacity: 1, frames: [{ frame: 1, drawing }] },
|
||||
],
|
||||
}];
|
||||
|
||||
test("M9-08 binds layer and frame reorder commands to stable IDs and Main revision", () => {
|
||||
const layer = reorder.validateGreasePencilReorderCommand({
|
||||
type: "moveGreasePencilLayer",
|
||||
schemaVersion: 1,
|
||||
dataId,
|
||||
layerId: draftsId,
|
||||
direction: "DOWN",
|
||||
baseRevision: 27,
|
||||
}, 27, data);
|
||||
assert.deepEqual(layer, {
|
||||
type: "moveGreasePencilLayer",
|
||||
schemaVersion: 1,
|
||||
dataId,
|
||||
layerId: draftsId,
|
||||
direction: "DOWN",
|
||||
baseRevision: 27,
|
||||
});
|
||||
|
||||
const frame = reorder.validateGreasePencilReorderCommand({
|
||||
type: "moveGreasePencilFrame",
|
||||
schemaVersion: 1,
|
||||
dataId,
|
||||
layerId: draftsId,
|
||||
frame: 1,
|
||||
targetFrame: 12,
|
||||
drawingId,
|
||||
baseRevision: 27,
|
||||
}, 27, data);
|
||||
assert.equal(frame.drawingId, drawingId);
|
||||
assert.equal(frame.targetFrame, 12);
|
||||
});
|
||||
|
||||
test("M9-08 rejects stale, no-op, forged drawing and occupied-target reorders", () => {
|
||||
const layerCommand = { type: "moveGreasePencilLayer", schemaVersion: 1, dataId, layerId: draftsId, direction: "DOWN", baseRevision: 27 };
|
||||
assert.throws(() => reorder.validateGreasePencilReorderCommand(layerCommand, 28, data), (error) => error.code === "REVISION_CONFLICT");
|
||||
assert.throws(() => reorder.validateGreasePencilReorderCommand({ ...layerCommand, layerId: linesId }, 27, data), (error) => error.code === "GREASE_PENCIL_SCHEMA_INVALID");
|
||||
|
||||
const frameCommand = { type: "moveGreasePencilFrame", schemaVersion: 1, dataId, layerId: draftsId, frame: 1, targetFrame: 12, drawingId, baseRevision: 27 };
|
||||
assert.throws(() => reorder.validateGreasePencilReorderCommand({ ...frameCommand, drawingId: "grease-pencil-drawing:Data:5" }, 27, data), (error) => error.code === "GREASE_PENCIL_SCHEMA_INVALID");
|
||||
assert.throws(() => reorder.validateGreasePencilReorderCommand({ ...frameCommand, targetFrame: 1 }, 27, data), (error) => error.code === "GREASE_PENCIL_SCHEMA_INVALID");
|
||||
const occupied = [{ ...data[0], layers: data[0].layers.map((layer) => layer.id === draftsId ? { ...layer, frames: [...layer.frames, { frame: 12, drawing: { ...drawing, id: "grease-pencil-drawing:Data:5" } }] } : layer) }];
|
||||
assert.throws(() => reorder.validateGreasePencilReorderCommand(frameCommand, 27, occupied), (error) => error.code === "GREASE_PENCIL_SCHEMA_INVALID");
|
||||
});
|
||||
78
web/tests/unit/grease-pencil-selection.test.mjs
Normal file
78
web/tests/unit/grease-pencil-selection.test.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/grease-pencil-selection.ts");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "grease-pencil-selection-unit-"));
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const modulePath = path.join(temporary, "grease-pencil-selection.mjs");
|
||||
fs.writeFileSync(modulePath, transpiled.outputText);
|
||||
const selection = await import(pathToFileURL(modulePath));
|
||||
|
||||
const drawing = {
|
||||
dataId: "grease-pencil:Data",
|
||||
layerId: "grease-pencil-layer:Data:Lines",
|
||||
frame: 12,
|
||||
drawingId: "grease-pencil-drawing:Data:4",
|
||||
};
|
||||
|
||||
function point(strokeIndex, pointIndex) {
|
||||
return {
|
||||
...drawing,
|
||||
strokeId: `grease-pencil-stroke:Data:4:${strokeIndex}`,
|
||||
pointId: `grease-pencil-point:Data:4:${strokeIndex}:${pointIndex}`,
|
||||
strokeIndex,
|
||||
pointIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function edit(baseSelectionRevision, source, operation, points) {
|
||||
return { schemaVersion: 1, baseSelectionRevision, source, operation, points };
|
||||
}
|
||||
|
||||
test("M9-07 serializes 2D canvas and 3D viewport edits through one selection revision", () => {
|
||||
const initial = selection.createGreasePencilSelectionState(drawing);
|
||||
const canvas = selection.applyGreasePencilSelectionEdit(initial, edit(0, "CANVAS_2D", "REPLACE", [point(0, 1)]), drawing);
|
||||
assert.equal(canvas.revision, 1);
|
||||
assert.equal(canvas.lastSource, "CANVAS_2D");
|
||||
assert.deepEqual(canvas.selectedPoints.map((item) => item.pointId), [point(0, 1).pointId]);
|
||||
|
||||
const viewport = selection.applyGreasePencilSelectionEdit(canvas, edit(1, "VIEWPORT_3D", "ADD", [point(0, 0), point(1, 0)]), drawing);
|
||||
assert.equal(viewport.revision, 2);
|
||||
assert.equal(viewport.lastSource, "VIEWPORT_3D");
|
||||
assert.deepEqual(viewport.selectedPoints.map((item) => item.pointId), [point(0, 0).pointId, point(0, 1).pointId, point(1, 0).pointId]);
|
||||
|
||||
const toggled = selection.applyGreasePencilSelectionEdit(viewport, edit(2, "CANVAS_2D", "TOGGLE", [point(0, 1)]), drawing);
|
||||
assert.equal(toggled.revision, 3);
|
||||
assert.deepEqual(toggled.selectedPoints.map((item) => item.pointId), [point(0, 0).pointId, point(1, 0).pointId]);
|
||||
});
|
||||
|
||||
test("M9-07 rejects stale, foreign, duplicate and index-aliased selection edits", () => {
|
||||
const initial = selection.createGreasePencilSelectionState(drawing);
|
||||
assert.throws(
|
||||
() => selection.applyGreasePencilSelectionEdit(initial, edit(1, "VIEWPORT_3D", "REPLACE", [point(0, 0)]), drawing),
|
||||
(error) => error.code === "REVISION_CONFLICT",
|
||||
);
|
||||
assert.throws(
|
||||
() => selection.applyGreasePencilSelectionEdit(initial, edit(0, "CANVAS_2D", "REPLACE", [{ ...point(0, 0), drawingId: "grease-pencil-drawing:Data:5" }]), drawing),
|
||||
(error) => error.code === "GREASE_PENCIL_SELECTION_SCOPE_INVALID",
|
||||
);
|
||||
assert.throws(
|
||||
() => selection.applyGreasePencilSelectionEdit(initial, edit(0, "CANVAS_2D", "REPLACE", [point(0, 0), point(0, 0)]), drawing),
|
||||
(error) => error.code === "GREASE_PENCIL_SELECTION_INVALID",
|
||||
);
|
||||
assert.throws(
|
||||
() => selection.applyGreasePencilSelectionEdit(initial, edit(0, "CANVAS_2D", "REPLACE", [point(0, 0), { ...point(0, 1), pointIndex: 0 }]), drawing),
|
||||
(error) => error.code === "GREASE_PENCIL_SELECTION_INVALID",
|
||||
);
|
||||
});
|
||||
41
web/tests/unit/nanovdb-device-recovery.test.mjs
Normal file
41
web/tests/unit/nanovdb-device-recovery.test.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/nanovdb-device-recovery.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
|
||||
const recovery = await import(moduleUrl);
|
||||
|
||||
test("M8-15 deterministically bounds visible-page replay by resident capacity", () => {
|
||||
assert.deepEqual(recovery.planNanoVDBDeviceLossReplay([3, 1, 3, 2], 4, 2), {
|
||||
schemaVersion: 1,
|
||||
visiblePageIds: [1, 2, 3],
|
||||
replayedPageIds: [1, 2],
|
||||
skippedPageIds: [3],
|
||||
pageCount: 4,
|
||||
residentPageCapacity: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("M8-15 rejects invalid replay bounds and visible page IDs", () => {
|
||||
for (const args of [
|
||||
[[0], 0, 1],
|
||||
[[0], 8193, 1],
|
||||
[[0], 2, 0],
|
||||
[[0], 2, 3],
|
||||
[[-1], 2, 1],
|
||||
[[2], 2, 1],
|
||||
[[0.5], 2, 1],
|
||||
]) {
|
||||
assert.throws(() => recovery.planNanoVDBDeviceLossReplay(...args), /NANOVDB_INVALID_ARGUMENT/);
|
||||
}
|
||||
});
|
||||
182
web/tests/unit/nanovdb-page-feedback.test.mjs
Normal file
182
web/tests/unit/nanovdb-page-feedback.test.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/nanovdb-page-feedback.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
|
||||
const feedback = await import(moduleUrl);
|
||||
|
||||
test("M8-01 freezes the bounded GPU feedback word layout", () => {
|
||||
const buffer = feedback.createNanoVDBPageFeedbackBuffer();
|
||||
const words = new Uint32Array(buffer);
|
||||
assert.equal(buffer.byteLength, (feedback.NANOVDB_PAGE_FEEDBACK_HEADER_WORDS + feedback.NANOVDB_PAGE_FEEDBACK_DEFAULT_CAPACITY) * 4);
|
||||
assert.deepEqual([...words.slice(0, 4)], [1, 1024, 0, 0]);
|
||||
assert.ok([...words.slice(4)].every((word) => word === 0xffffffff));
|
||||
assert.deepEqual(feedback.NANOVDB_PAGE_FEEDBACK_WORD, { schemaVersion: 0, capacity: 1, count: 2, overflow: 3, pageIds: 4 });
|
||||
assert.match(feedback.NANOVDB_PAGE_FEEDBACK_WGSL, /count: atomic<u32>/);
|
||||
assert.match(feedback.NANOVDB_PAGE_FEEDBACK_WGSL, /overflow: atomic<u32>/);
|
||||
assert.match(feedback.NANOVDB_PAGE_FEEDBACK_WGSL, /page_ids: array<atomic<u32>>/);
|
||||
});
|
||||
|
||||
test("M8-02 records unique page IDs with a physical-capacity bound", () => {
|
||||
assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /arrayLength\(&nanovdb_page_feedback\.page_ids\)/);
|
||||
assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /min\(nanovdb_page_feedback\.capacity, physical_capacity\)/);
|
||||
assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /slot < capacity/);
|
||||
assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /atomicCompareExchangeWeak/);
|
||||
assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /claim\.old_value == page_id/);
|
||||
assert.match(feedback.NANOVDB_PAGE_FEEDBACK_RECORD_WGSL, /atomicStore\(&nanovdb_page_feedback\.overflow, 1u\)/);
|
||||
});
|
||||
|
||||
test("M8-03 sorts, deduplicates and binds CPU feedback to a render revision", () => {
|
||||
const buffer = feedback.createNanoVDBPageFeedbackBuffer(4);
|
||||
const words = new Uint32Array(buffer);
|
||||
words[feedback.NANOVDB_PAGE_FEEDBACK_WORD.count] = 4;
|
||||
words.set([7, 2, 7, 5], feedback.NANOVDB_PAGE_FEEDBACK_WORD.pageIds);
|
||||
assert.deepEqual(feedback.parseNanoVDBPageFeedbackBatch(buffer, 8, 42), {
|
||||
schemaVersion: 1,
|
||||
renderRevision: 42,
|
||||
attemptedCount: 4,
|
||||
gpuStoredCount: 4,
|
||||
uniqueCount: 3,
|
||||
pageIds: [2, 5, 7],
|
||||
status: "READY",
|
||||
errorCode: null,
|
||||
});
|
||||
assert.throws(
|
||||
() => feedback.parseNanoVDBPageFeedbackBatch(buffer, 8, -1),
|
||||
(error) => error.code === "INVALID_ARGUMENT",
|
||||
);
|
||||
assert.throws(
|
||||
() => feedback.parseNanoVDBPageFeedbackBatch(buffer, 8, 1.5),
|
||||
(error) => error.code === "INVALID_ARGUMENT",
|
||||
);
|
||||
});
|
||||
|
||||
test("M8-04 rejects stale frame feedback before page I/O", async () => {
|
||||
const buffer = feedback.createNanoVDBPageFeedbackBuffer(4);
|
||||
const words = new Uint32Array(buffer);
|
||||
words[feedback.NANOVDB_PAGE_FEEDBACK_WORD.count] = 3;
|
||||
words.set([7, 2, 5], feedback.NANOVDB_PAGE_FEEDBACK_WORD.pageIds);
|
||||
const batch = feedback.parseNanoVDBPageFeedbackBatch(buffer, 8, 42);
|
||||
const requests = [];
|
||||
const requestPage = async (pageId, renderRevision) => {
|
||||
requests.push({ pageId, renderRevision });
|
||||
};
|
||||
|
||||
assert.deepEqual(await feedback.dispatchNanoVDBPageFeedbackBatch(batch, 43, requestPage), {
|
||||
schemaVersion: 1,
|
||||
renderRevision: 42,
|
||||
currentRenderRevision: 43,
|
||||
status: "STALE",
|
||||
requestedPageIds: [],
|
||||
requestedCount: 0,
|
||||
errorCode: "REVISION_CONFLICT",
|
||||
});
|
||||
assert.deepEqual(await feedback.dispatchNanoVDBPageFeedbackBatch(batch, 41, requestPage), {
|
||||
schemaVersion: 1,
|
||||
renderRevision: 42,
|
||||
currentRenderRevision: 41,
|
||||
status: "STALE",
|
||||
requestedPageIds: [],
|
||||
requestedCount: 0,
|
||||
errorCode: "REVISION_CONFLICT",
|
||||
});
|
||||
assert.deepEqual(requests, []);
|
||||
|
||||
assert.deepEqual(await feedback.dispatchNanoVDBPageFeedbackBatch(batch, 42, requestPage), {
|
||||
schemaVersion: 1,
|
||||
renderRevision: 42,
|
||||
currentRenderRevision: 42,
|
||||
status: "ACCEPTED",
|
||||
requestedPageIds: [2, 5, 7],
|
||||
requestedCount: 3,
|
||||
errorCode: null,
|
||||
});
|
||||
assert.deepEqual(requests, [
|
||||
{ pageId: 2, renderRevision: 42 },
|
||||
{ pageId: 5, renderRevision: 42 },
|
||||
{ pageId: 7, renderRevision: 42 },
|
||||
]);
|
||||
await assert.rejects(
|
||||
feedback.dispatchNanoVDBPageFeedbackBatch(batch, -1, requestPage),
|
||||
(error) => error.code === "INVALID_ARGUMENT",
|
||||
);
|
||||
});
|
||||
|
||||
test("M8-01 parses ready and overflow feedback with a stable overflow code", () => {
|
||||
const readyBuffer = feedback.createNanoVDBPageFeedbackBuffer(4);
|
||||
const readyWords = new Uint32Array(readyBuffer);
|
||||
readyWords[feedback.NANOVDB_PAGE_FEEDBACK_WORD.count] = 3;
|
||||
readyWords.set([7, 2, 5], feedback.NANOVDB_PAGE_FEEDBACK_WORD.pageIds);
|
||||
assert.deepEqual(feedback.parseNanoVDBPageFeedbackBuffer(readyBuffer, 8), {
|
||||
schemaVersion: 1,
|
||||
capacity: 4,
|
||||
attemptedCount: 3,
|
||||
storedCount: 3,
|
||||
pageIds: [7, 2, 5],
|
||||
status: "READY",
|
||||
errorCode: null,
|
||||
});
|
||||
|
||||
const overflowBuffer = feedback.createNanoVDBPageFeedbackBuffer(2);
|
||||
const overflowWords = new Uint32Array(overflowBuffer);
|
||||
overflowWords[feedback.NANOVDB_PAGE_FEEDBACK_WORD.count] = 4;
|
||||
overflowWords[feedback.NANOVDB_PAGE_FEEDBACK_WORD.overflow] = 1;
|
||||
overflowWords.set([3, 4], feedback.NANOVDB_PAGE_FEEDBACK_WORD.pageIds);
|
||||
assert.deepEqual(feedback.parseNanoVDBPageFeedbackBuffer(overflowBuffer, 8), {
|
||||
schemaVersion: 1,
|
||||
capacity: 2,
|
||||
attemptedCount: 4,
|
||||
storedCount: 2,
|
||||
pageIds: [3, 4],
|
||||
status: "OVERFLOW",
|
||||
errorCode: "NANOVDB_PAGE_FEEDBACK_OVERFLOW",
|
||||
});
|
||||
});
|
||||
|
||||
test("M8-01 rejects layout drift, invalid pages and inconsistent overflow", () => {
|
||||
assert.throws(() => feedback.createNanoVDBPageFeedbackBuffer(0), (error) => error.code === "INVALID_ARGUMENT");
|
||||
assert.throws(() => feedback.createNanoVDBPageFeedbackBuffer(8193), (error) => error.code === "INVALID_ARGUMENT");
|
||||
|
||||
const wrongSchema = feedback.createNanoVDBPageFeedbackBuffer(2);
|
||||
new Uint32Array(wrongSchema)[0] = 2;
|
||||
assert.throws(() => feedback.parseNanoVDBPageFeedbackBuffer(wrongSchema, 8), (error) => error.code === "PROTOCOL_MISMATCH");
|
||||
|
||||
const wrongCapacity = feedback.createNanoVDBPageFeedbackBuffer(2);
|
||||
new Uint32Array(wrongCapacity)[1] = 8193;
|
||||
assert.throws(() => feedback.parseNanoVDBPageFeedbackBuffer(wrongCapacity, 8), (error) => error.code === "PROTOCOL_MISMATCH");
|
||||
|
||||
const wrongSize = feedback.createNanoVDBPageFeedbackBuffer(2).slice(0, 20);
|
||||
assert.throws(() => feedback.parseNanoVDBPageFeedbackBuffer(wrongSize, 8), (error) => error.code === "PROTOCOL_MISMATCH");
|
||||
|
||||
const inconsistent = feedback.createNanoVDBPageFeedbackBuffer(2);
|
||||
const inconsistentWords = new Uint32Array(inconsistent);
|
||||
inconsistentWords[2] = 3;
|
||||
inconsistentWords.set([1, 2], 4);
|
||||
assert.throws(() => feedback.parseNanoVDBPageFeedbackBuffer(inconsistent, 8), (error) => error.code === "PROTOCOL_MISMATCH");
|
||||
|
||||
const invalidPage = feedback.createNanoVDBPageFeedbackBuffer(2);
|
||||
const invalidPageWords = new Uint32Array(invalidPage);
|
||||
invalidPageWords[2] = 1;
|
||||
invalidPageWords[4] = 8;
|
||||
assert.throws(() => feedback.parseNanoVDBPageFeedbackBuffer(invalidPage, 8), (error) => error.code === "PROTOCOL_MISMATCH");
|
||||
});
|
||||
|
||||
test("M8-01 reset clears atomic words and stale page IDs", () => {
|
||||
const buffer = feedback.createNanoVDBPageFeedbackBuffer(3);
|
||||
const words = new Uint32Array(buffer);
|
||||
words[2] = 5;
|
||||
words[3] = 1;
|
||||
words.set([1, 2, 3], 4);
|
||||
feedback.resetNanoVDBPageFeedbackBuffer(buffer);
|
||||
assert.deepEqual([...words], [1, 3, 0, 0, 0xffffffff, 0xffffffff, 0xffffffff]);
|
||||
});
|
||||
47
web/tests/unit/nanovdb-progressive-redraw.test.mjs
Normal file
47
web/tests/unit/nanovdb-progressive-redraw.test.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/nanovdb-progressive-redraw.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
|
||||
const redraw = await import(moduleUrl);
|
||||
|
||||
test("M8-10 validates a bounded progressive redraw budget", () => {
|
||||
assert.equal(redraw.NANOVDB_PROGRESSIVE_REDRAW_MAX_FRAMES, 32);
|
||||
assert.equal(redraw.validateNanoVDBProgressiveRedrawLimit(1), 1);
|
||||
assert.equal(redraw.validateNanoVDBProgressiveRedrawLimit(1024), 1024);
|
||||
for (const value of [0, 1.5, 1025, Number.POSITIVE_INFINITY]) {
|
||||
assert.throws(() => redraw.validateNanoVDBProgressiveRedrawLimit(value), /NANOVDB_INVALID_ARGUMENT/);
|
||||
}
|
||||
});
|
||||
|
||||
test("M8-10 caps repeated successful uploads with a stable error code", () => {
|
||||
assert.deepEqual(redraw.consumeNanoVDBProgressiveRedrawBudget(0, 2), {
|
||||
allowed: true,
|
||||
redrawCount: 1,
|
||||
capped: false,
|
||||
errorCode: null,
|
||||
});
|
||||
assert.deepEqual(redraw.consumeNanoVDBProgressiveRedrawBudget(1, 2), {
|
||||
allowed: true,
|
||||
redrawCount: 2,
|
||||
capped: true,
|
||||
errorCode: "NANOVDB_PROGRESSIVE_REDRAW_LIMIT",
|
||||
});
|
||||
assert.deepEqual(redraw.consumeNanoVDBProgressiveRedrawBudget(2, 2), {
|
||||
allowed: false,
|
||||
redrawCount: 2,
|
||||
capped: true,
|
||||
errorCode: "NANOVDB_PROGRESSIVE_REDRAW_LIMIT",
|
||||
});
|
||||
assert.throws(() => redraw.consumeNanoVDBProgressiveRedrawBudget(-1, 2), /NANOVDB_INVALID_ARGUMENT/);
|
||||
});
|
||||
52
web/tests/unit/nanovdb-render-golden.test.mjs
Normal file
52
web/tests/unit/nanovdb-render-golden.test.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/nanovdb-render-golden.ts");
|
||||
const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', "");
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const golden = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"));
|
||||
const thresholds = { maxChannelError: 2, meanAbsoluteError: 0.5, rmsError: 1, alphaCoverageDeltaRatio: 0.25 };
|
||||
|
||||
test("M8-19 accepts a bounded RGBA8 desktop/WebGPU difference", () => {
|
||||
const reference = Uint8Array.from([0, 10, 20, 0, 100, 110, 120, 255]);
|
||||
const actual = Uint8Array.from([0, 11, 18, 0, 101, 110, 120, 255]);
|
||||
assert.deepEqual(golden.compareNanoVDBRenderGolden(reference, actual, thresholds), {
|
||||
schemaVersion: 1,
|
||||
status: "READY",
|
||||
pixelCount: 2,
|
||||
comparedChannels: 8,
|
||||
maxChannelError: 2,
|
||||
meanAbsoluteError: 0.5,
|
||||
rmsError: Math.sqrt(6 / 8),
|
||||
referenceAlphaPixels: 1,
|
||||
actualAlphaPixels: 1,
|
||||
alphaCoverageDeltaRatio: 0,
|
||||
thresholds,
|
||||
errorCode: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("M8-19 blocks channel and alpha coverage drift with a stable code", () => {
|
||||
const reference = Uint8Array.from([0, 0, 0, 0, 10, 10, 10, 255]);
|
||||
const actual = Uint8Array.from([9, 0, 0, 255, 10, 10, 10, 255]);
|
||||
const result = golden.compareNanoVDBRenderGolden(reference, actual, thresholds);
|
||||
assert.equal(result.status, "BLOCKED");
|
||||
assert.equal(result.errorCode, "NANOVDB_GOLDEN_MISMATCH");
|
||||
assert.equal(result.maxChannelError, 255);
|
||||
assert.equal(result.alphaCoverageDeltaRatio, 0.5);
|
||||
for (const args of [
|
||||
[new Uint8Array(), new Uint8Array(), thresholds],
|
||||
[new Uint8Array(4), new Uint8Array(8), thresholds],
|
||||
[new Uint8Array(3), new Uint8Array(3), thresholds],
|
||||
[new Uint8Array(4), new Uint8Array(4), { ...thresholds, rmsError: -1 }],
|
||||
]) assert.throws(() => golden.compareNanoVDBRenderGolden(...args), /NANOVDB_INVALID_ARGUMENT/);
|
||||
});
|
||||
91
web/tests/unit/nla.test.mjs
Normal file
91
web/tests/unit/nla.test.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "nla-unit-"));
|
||||
|
||||
function transpile(sourceName, outputName, replacements = []) {
|
||||
const sourcePath = path.join(root, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const output = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText);
|
||||
fs.writeFileSync(path.join(temporary, outputName), output);
|
||||
}
|
||||
|
||||
transpile("capability-gates.ts", "capability-gates.mjs");
|
||||
transpile("nla.ts", "nla.mjs", [['from "./capability-gates"', 'from "./capability-gates.mjs"']]);
|
||||
const nla = await import(pathToFileURL(path.join(temporary, "nla.mjs")));
|
||||
|
||||
const actionId = "action:Move:object:Cube";
|
||||
const context = {
|
||||
actionIds: new Set([actionId]),
|
||||
actionChannelPaths: new Map([[actionId, new Set(["location[0]"])]]),
|
||||
ownerId: "object:Cube",
|
||||
};
|
||||
|
||||
function tracks() {
|
||||
return [{
|
||||
schemaVersion: 1,
|
||||
id: "track:Move",
|
||||
ownerId: "object:Cube",
|
||||
name: "Move",
|
||||
muted: false,
|
||||
solo: false,
|
||||
selected: true,
|
||||
strips: [
|
||||
{ id: "strip:A", actionId, frameStart: 20, frameEnd: 40, actionFrameStart: 1, actionFrameEnd: 11, scale: 2, repeat: 1, blendIn: 0, blendOut: 0, influence: 1, blendMode: "REPLACE", extrapolation: "NOTHING", muted: false, selected: true, stripType: "CLIP" },
|
||||
{ id: "strip:B", actionId, frameStart: 45, frameEnd: 65, actionFrameStart: 1, actionFrameEnd: 11, scale: 1, repeat: 2, blendIn: 0, blendOut: 0, influence: 1, blendMode: "REPLACE", extrapolation: "NOTHING", muted: false, selected: true, stripType: "CLIP" },
|
||||
],
|
||||
}];
|
||||
}
|
||||
|
||||
test("M10-12 moves one NLA strip without mutating the source stack", () => {
|
||||
const source = tracks();
|
||||
const before = structuredClone(source);
|
||||
const moved = nla.moveNlaStrip(source, {
|
||||
type: "moveNLAStrip",
|
||||
objectId: "object:Cube",
|
||||
trackId: "track:Move",
|
||||
stripId: "strip:A",
|
||||
frameStart: 5,
|
||||
baseRevision: 7,
|
||||
}, context);
|
||||
assert.deepEqual(source, before);
|
||||
assert.deepEqual(moved[0].strips.map((strip) => [strip.id, strip.frameStart, strip.frameEnd]), [
|
||||
["strip:A", 5, 25],
|
||||
["strip:B", 45, 65],
|
||||
]);
|
||||
});
|
||||
|
||||
test("M10-12 rejects overlap, unknown identities and invalid operator budgets", () => {
|
||||
const command = { type: "moveNLAStrip", objectId: "object:Cube", trackId: "track:Move", stripId: "strip:A", frameStart: 30, baseRevision: 7 };
|
||||
assert.throws(() => nla.moveNlaStrip(tracks(), command, context), { code: "NLA_INVALID_STACK" });
|
||||
assert.throws(() => nla.moveNlaStrip(tracks(), { ...command, trackId: "track:missing" }, context), { code: "NLA_INVALID_STACK" });
|
||||
assert.throws(() => nla.moveNlaStrip(tracks(), { ...command, frameStart: 1_000_001 }, context), { code: "NLA_INVALID_STACK" });
|
||||
});
|
||||
|
||||
test("M10-15 rejects NLA topology that exceeds the browser memory budget", () => {
|
||||
const oversized = new Array(nla.NLA_STACK_BUDGET.maxTracks + 1).fill(tracks()[0]);
|
||||
assert.throws(() => nla.parseNlaTracks(oversized), { code: "NLA_BUDGET_EXCEEDED" });
|
||||
const tooManyStrips = [{
|
||||
...tracks()[0],
|
||||
strips: new Array(nla.NLA_STACK_BUDGET.maxStripsPerTrack + 1).fill(tracks()[0].strips[0]),
|
||||
}];
|
||||
assert.throws(() => nla.parseNlaTracks(tooManyStrips), { code: "NLA_BUDGET_EXCEEDED" });
|
||||
});
|
||||
|
||||
test("M10-15 rejects undeclared NLA input and recovers on the next valid stack", () => {
|
||||
const malicious = tracks();
|
||||
malicious[0].strips[0].proxySuccess = true;
|
||||
assert.throws(() => nla.parseNlaTracks(malicious), { code: "NLA_INVALID_STACK" });
|
||||
assert.equal(nla.gateNlaTracks(tracks(), context).status, "READY");
|
||||
});
|
||||
79
web/tests/unit/paint-depth-visibility.test.mjs
Normal file
79
web/tests/unit/paint-depth-visibility.test.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "paint-depth-visibility-unit-"));
|
||||
|
||||
function transpile(sourceName, outputName, transform = (source) => source) {
|
||||
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, outputName), transform(transpiled.outputText));
|
||||
}
|
||||
|
||||
transpile("paint.ts", "paint.mjs");
|
||||
transpile("paint-depth-visibility.ts", "paint-depth-visibility.mjs", (source) => source.replace('from "./paint"', 'from "./paint.mjs"'));
|
||||
const visibility = await import(pathToFileURL(path.join(temporary, "paint-depth-visibility.mjs")));
|
||||
|
||||
const request = {
|
||||
schemaVersion: 1,
|
||||
objectId: "object:Paint",
|
||||
meshId: "mesh:Paint",
|
||||
revision: 7,
|
||||
vertexIndices: [6, 2, 4],
|
||||
};
|
||||
|
||||
test("M9-09 binds GPU depth visibility to stable vertex identities and Main revision", () => {
|
||||
const parsed = visibility.validatePaintDepthVisibilityRequest(request, 7);
|
||||
assert.deepEqual(parsed.vertexIndices, [2, 4, 6]);
|
||||
const result = visibility.validatePaintDepthVisibilityResult({
|
||||
...parsed,
|
||||
backend: "MAIN_THREAD_WEBGL2",
|
||||
source: "GPU_RGBA_DEPTH_READBACK",
|
||||
width: 64,
|
||||
height: 32,
|
||||
depthReadbackBytes: 64 * 32 * 4,
|
||||
occluderPixelCount: 512,
|
||||
visibleVertexIndices: [2, 6],
|
||||
}, parsed);
|
||||
assert.deepEqual(result.visibleVertexIndices, [2, 6]);
|
||||
assert.equal(result.source, "GPU_RGBA_DEPTH_READBACK");
|
||||
});
|
||||
|
||||
test("M9-09 fails closed on stale, forged, duplicate and over-budget depth samples", () => {
|
||||
assert.throws(() => visibility.validatePaintDepthVisibilityRequest(request, 8), (error) => error.code === "REVISION_CONFLICT");
|
||||
assert.throws(() => visibility.validatePaintDepthVisibilityRequest({ ...request, objectId: "mesh:Paint" }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID");
|
||||
assert.throws(() => visibility.validatePaintDepthVisibilityRequest({ ...request, vertexIndices: [2, 2] }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID");
|
||||
assert.throws(() => visibility.validatePaintDepthVisibilityRequest({ ...request, undeclared: true }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID");
|
||||
assert.throws(
|
||||
() => visibility.validatePaintDepthVisibilityRequest({ ...request, vertexIndices: Array.from({ length: 100_001 }, (_, index) => index) }, 7),
|
||||
(error) => error.code === "PAINT_BUDGET_EXCEEDED",
|
||||
);
|
||||
});
|
||||
|
||||
test("M9-09 rejects result drift and accepts a verified empty visible set", () => {
|
||||
const parsed = visibility.validatePaintDepthVisibilityRequest(request, 7);
|
||||
const base = {
|
||||
...parsed,
|
||||
backend: "OFFSCREEN_WEBGL2",
|
||||
source: "GPU_RGBA_DEPTH_READBACK",
|
||||
width: 32,
|
||||
height: 32,
|
||||
depthReadbackBytes: 4096,
|
||||
occluderPixelCount: 128,
|
||||
visibleVertexIndices: [],
|
||||
};
|
||||
assert.deepEqual(visibility.validatePaintDepthVisibilityResult(base, parsed).visibleVertexIndices, []);
|
||||
assert.throws(() => visibility.validatePaintDepthVisibilityResult({ ...base, visibleVertexIndices: [99] }, parsed), (error) => error.code === "PAINT_SCHEMA_INVALID");
|
||||
assert.throws(() => visibility.validatePaintDepthVisibilityResult({ ...base, depthReadbackBytes: 4095 }, parsed), (error) => error.code === "PAINT_BUDGET_EXCEEDED");
|
||||
assert.throws(() => visibility.validatePaintDepthVisibilityResult({ ...base, source: "CPU_RAYCAST" }, parsed), (error) => error.code === "PAINT_SCHEMA_INVALID");
|
||||
});
|
||||
92
web/tests/unit/paint-pbvh-capability.test.mjs
Normal file
92
web/tests/unit/paint-pbvh-capability.test.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "paint-pbvh-capability-unit-"));
|
||||
|
||||
function transpile(sourceName, outputName, replacements = []) {
|
||||
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const output = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText);
|
||||
fs.writeFileSync(path.join(temporary, outputName), output);
|
||||
}
|
||||
|
||||
transpile("capability-gates.ts", "capability-gates.mjs");
|
||||
transpile("paint-pbvh-capability.ts", "paint-pbvh-capability.mjs", [
|
||||
['from "./capability-gates"', 'from "./capability-gates.mjs"'],
|
||||
]);
|
||||
const pbvh = await import(pathToFileURL(path.join(temporary, "paint-pbvh-capability.mjs")));
|
||||
|
||||
const base = {
|
||||
schemaVersion: 1,
|
||||
operation: "PBVH_BRUSH",
|
||||
domain: "VERTEX_COLOR",
|
||||
brush: "DRAW",
|
||||
objectId: "object:Paint",
|
||||
meshId: "mesh:Paint",
|
||||
baseRevision: 7,
|
||||
};
|
||||
|
||||
test("M9-13 freezes the active Blender 5.2 PBVH-related brush inventory", () => {
|
||||
const inventory = pbvh.paintPBVHBrushInventory();
|
||||
const counts = Object.fromEntries(["SCULPT", "VERTEX_COLOR", "WEIGHT", "TEXTURE"].map((domain) => [
|
||||
domain,
|
||||
inventory.filter((entry) => entry.domain === domain).length,
|
||||
]));
|
||||
assert.deepEqual(counts, { SCULPT: 32, VERTEX_COLOR: 4, WEIGHT: 4, TEXTURE: 6 });
|
||||
assert.equal(new Set(inventory.map((entry) => `${entry.domain}:${entry.brush}`)).size, 46);
|
||||
assert.ok(inventory.every((entry) => entry.source.endsWith("DNA_brush_enums.h")));
|
||||
});
|
||||
|
||||
test("M9-13 blocks every PBVH brush when the WASM entrypoint is absent", () => {
|
||||
for (const entry of pbvh.paintPBVHBrushInventory()) {
|
||||
const gate = pbvh.gatePaintPBVHCapability({ ...base, domain: entry.domain, brush: entry.brush }, {
|
||||
nativeEntrypointPresent: false,
|
||||
sessionContextReady: false,
|
||||
verifiedBrushes: new Set(),
|
||||
currentRevision: 7,
|
||||
currentObjectId: base.objectId,
|
||||
currentMeshId: base.meshId,
|
||||
});
|
||||
assert.equal(gate.taskId, "N-017");
|
||||
assert.equal(gate.status, "BLOCKED");
|
||||
assert.deepEqual(gate.issues.map((issue) => issue.code), ["PAINT_PBVH_UNAVAILABLE"]);
|
||||
assert.equal(gate.issues[0].recoverable, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("M9-13 remains fail-closed after symbol discovery until context and a brush golden exist", () => {
|
||||
const common = { nativeEntrypointPresent: true, verifiedBrushes: new Set(), currentRevision: 7 };
|
||||
assert.equal(pbvh.gatePaintPBVHCapability(base, { ...common, sessionContextReady: false }).issues[0].code, "PAINT_PBVH_CONTEXT_UNAVAILABLE");
|
||||
assert.equal(pbvh.gatePaintPBVHCapability(base, { ...common, sessionContextReady: true }).issues[0].code, "PAINT_PBVH_BRUSH_UNVERIFIED");
|
||||
assert.equal(pbvh.gatePaintPBVHCapability(base, {
|
||||
...common,
|
||||
sessionContextReady: true,
|
||||
verifiedBrushes: new Set(["VERTEX_COLOR:DRAW"]),
|
||||
}).status, "READY");
|
||||
});
|
||||
|
||||
test("M9-13 validates identity and revision before reporting runtime availability", () => {
|
||||
const context = {
|
||||
nativeEntrypointPresent: false,
|
||||
sessionContextReady: false,
|
||||
verifiedBrushes: new Set(),
|
||||
currentRevision: 7,
|
||||
currentObjectId: base.objectId,
|
||||
currentMeshId: base.meshId,
|
||||
};
|
||||
assert.equal(pbvh.gatePaintPBVHCapability({ ...base, baseRevision: 6 }, context).issues[0].code, "REVISION_CONFLICT");
|
||||
assert.equal(pbvh.gatePaintPBVHCapability({ ...base, objectId: "object:Other" }, context).issues[0].code, "PAINT_SCHEMA_INVALID");
|
||||
assert.throws(() => pbvh.parsePaintPBVHCapabilityRequest({ ...base, brush: "FAKE" }), { code: "PAINT_PBVH_BRUSH_UNVERIFIED" });
|
||||
assert.throws(() => pbvh.parsePaintPBVHCapabilityRequest({ ...base, proxySuccess: true }), { code: "PAINT_SCHEMA_INVALID" });
|
||||
});
|
||||
94
web/tests/unit/paint-stroke-session.test.mjs
Normal file
94
web/tests/unit/paint-stroke-session.test.mjs
Normal file
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "paint-stroke-session-unit-"));
|
||||
|
||||
function transpile(sourceName, outputName, replacements = []) {
|
||||
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const output = replacements.reduce((source, [from, to]) => source.replace(from, to), transpiled.outputText);
|
||||
fs.writeFileSync(path.join(temporary, outputName), output);
|
||||
}
|
||||
|
||||
transpile("paint.ts", "paint.mjs");
|
||||
transpile("paint-stroke-session.ts", "paint-stroke-session.mjs", [['from "./paint"', 'from "./paint.mjs"']]);
|
||||
const sessions = await import(pathToFileURL(path.join(temporary, "paint-stroke-session.mjs")));
|
||||
|
||||
const begin = {
|
||||
schemaVersion: 1,
|
||||
pointerSessionId: "paint-pointer:unit-1",
|
||||
baseRevision: 7,
|
||||
target: { mode: "VERTEX_COLOR", meshId: "mesh:Paint", attributeName: "StrokeColor", domain: "POINT" },
|
||||
};
|
||||
|
||||
test("M9-10 merges ordered pointer chunks into one deterministic Main command", () => {
|
||||
const store = new sessions.PaintStrokeSessionStore();
|
||||
assert.equal(store.begin(begin, 7).state, "OPEN");
|
||||
store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 0, indices: [3, 1], values: [1, 0, 0, 1, 0, 1, 0, 1] }, 7);
|
||||
const buffered = store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 1, indices: [1, 2], values: [0, 0, 1, 1, 1, 1, 0, 1] }, 7);
|
||||
assert.deepEqual({ chunks: buffered.chunkCount, received: buffered.receivedEntryCount, unique: buffered.uniqueEntryCount, bytes: buffered.bufferedBytes }, { chunks: 2, received: 4, unique: 3, bytes: 80 });
|
||||
const committed = store.commit({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, expectedChunkCount: 2 }, 7);
|
||||
assert.deepEqual(committed.command, {
|
||||
type: "setVertexColors",
|
||||
meshId: "mesh:Paint",
|
||||
attributeName: "StrokeColor",
|
||||
domain: "POINT",
|
||||
indices: [1, 2, 3],
|
||||
colors: [0, 0, 1, 1, 1, 1, 0, 1, 1, 0, 0, 1],
|
||||
});
|
||||
assert.equal(committed.receipt.state, "READY");
|
||||
assert.equal(store.activeCount, 0);
|
||||
});
|
||||
|
||||
test("M9-10 cancels without a Main command and rejects stale or discontinuous chunks", () => {
|
||||
const store = new sessions.PaintStrokeSessionStore();
|
||||
store.begin(begin, 7);
|
||||
assert.throws(() => store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 1, indices: [0], values: [1, 1, 1, 1] }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID");
|
||||
assert.throws(() => store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 0, indices: [0], values: [1, 1, 1, 1] }, 8), (error) => error.code === "REVISION_CONFLICT");
|
||||
const cancelled = store.cancel({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7 });
|
||||
assert.equal(cancelled.state, "CANCELLED");
|
||||
assert.equal(store.activeCount, 0);
|
||||
});
|
||||
|
||||
test("M9-10 merges weight chunks with their normalize policy into one Main command", () => {
|
||||
const store = new sessions.PaintStrokeSessionStore();
|
||||
const weightBegin = {
|
||||
schemaVersion: 1,
|
||||
pointerSessionId: "paint-pointer:weight-unit",
|
||||
baseRevision: 9,
|
||||
target: { mode: "WEIGHT", objectId: "object:Paint", vertexGroup: "StrokeWeight", normalize: true, mirror: false },
|
||||
};
|
||||
store.begin(weightBegin, 9);
|
||||
store.append({ schemaVersion: 1, pointerSessionId: weightBegin.pointerSessionId, baseRevision: 9, chunkIndex: 0, indices: [4, 2], values: [0.25, 0.75] }, 9);
|
||||
store.append({ schemaVersion: 1, pointerSessionId: weightBegin.pointerSessionId, baseRevision: 9, chunkIndex: 1, indices: [4], values: [0.5] }, 9);
|
||||
const committed = store.commit({ schemaVersion: 1, pointerSessionId: weightBegin.pointerSessionId, baseRevision: 9, expectedChunkCount: 2 }, 9);
|
||||
assert.deepEqual(committed.command, {
|
||||
type: "setVertexWeights",
|
||||
objectId: "object:Paint",
|
||||
vertexGroup: "StrokeWeight",
|
||||
indices: [2, 4],
|
||||
values: [0.75, 0.5],
|
||||
normalize: true,
|
||||
mirror: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("M9-10 bounds chunks, values and final chunk counts before Main", () => {
|
||||
const store = new sessions.PaintStrokeSessionStore();
|
||||
store.begin(begin, 7);
|
||||
assert.throws(() => store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 0, indices: [0], values: [2, 0, 0, 1] }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID");
|
||||
store.append({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, chunkIndex: 0, indices: [0], values: [1, 0, 0, 1] }, 7);
|
||||
assert.throws(() => store.commit({ schemaVersion: 1, pointerSessionId: begin.pointerSessionId, baseRevision: 7, expectedChunkCount: 2 }, 7), (error) => error.code === "PAINT_SCHEMA_INVALID");
|
||||
assert.equal(store.activeCount, 0);
|
||||
});
|
||||
121
web/tests/unit/physics-cache-family.test.mjs
Normal file
121
web/tests/unit/physics-cache-family.test.mjs
Normal file
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "physics-cache-family-unit-"));
|
||||
|
||||
function transpile(sourceName, outputName, replacements = []) {
|
||||
const sourcePath = path.join(root, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const output = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText);
|
||||
fs.writeFileSync(path.join(temporary, outputName), output);
|
||||
}
|
||||
|
||||
transpile("capability-gates.ts", "capability-gates.mjs");
|
||||
transpile("physics-simulation.ts", "physics-simulation.mjs", [
|
||||
['from "./capability-gates"', 'from "./capability-gates.mjs"'],
|
||||
]);
|
||||
const physics = await import(pathToFileURL(path.join(temporary, "physics-simulation.mjs")));
|
||||
|
||||
const digest = async (value) => Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", value)),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
|
||||
async function cachedSystem(family, index = 0, cachePatch = {}) {
|
||||
const source = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, index]).buffer;
|
||||
const payload = Uint8Array.from([index + 1, 2, index + 3, 4]).buffer;
|
||||
const first = payload.slice(0, 2);
|
||||
const second = payload.slice(2, 4);
|
||||
const settingsHash = await digest(Uint8Array.from([index + 11]).buffer);
|
||||
const cache = {
|
||||
schemaVersion: 1,
|
||||
cacheKey: `physics-${family.toLowerCase()}-1-2`,
|
||||
family,
|
||||
source: index % 2 === 0 ? "BLENDER_DESKTOP_BAKE" : "BLENDER_SERVER_BAKE",
|
||||
blenderVersion: "5.2.0",
|
||||
sourceBlendSha256: await digest(source),
|
||||
settingsHash,
|
||||
inputHash: await digest(Uint8Array.from([index + 21]).buffer),
|
||||
cacheSha256: await digest(payload),
|
||||
frameStart: 1,
|
||||
frameEnd: 2,
|
||||
byteLength: payload.byteLength,
|
||||
frames: [
|
||||
{ frame: 1, byteOffset: 0, byteLength: 2, sha256: await digest(first) },
|
||||
{ frame: 2, byteOffset: 2, byteLength: 2, sha256: await digest(second) },
|
||||
],
|
||||
status: "COMPLETE",
|
||||
...cachePatch,
|
||||
};
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
systems: [{
|
||||
id: `physics:${family.toLowerCase()}`,
|
||||
family,
|
||||
ownerObjectId: `object:${family}`,
|
||||
settingsHash,
|
||||
settings: { enabled: true },
|
||||
dependencyIds: [],
|
||||
cache,
|
||||
}],
|
||||
};
|
||||
return { manifest, source, payload };
|
||||
}
|
||||
|
||||
test("M10-14 verifies source, payload and frame hashes for every Physics family", async () => {
|
||||
for (const [index, family] of physics.PHYSICS_FAMILIES.entries()) {
|
||||
const value = await cachedSystem(family, index);
|
||||
const parsed = physics.parsePhysicsSimulationManifest(value.manifest);
|
||||
const cache = await physics.verifyPhysicsCachePayload(parsed.systems[0], value.source, value.payload);
|
||||
assert.equal(cache.family, family);
|
||||
assert.equal(cache.source, index % 2 === 0 ? "BLENDER_DESKTOP_BAKE" : "BLENDER_SERVER_BAKE");
|
||||
assert.equal(cache.frames.length, 2);
|
||||
assert.deepEqual(physics.selectPhysicsCacheFrame(parsed.systems[0], 2), { cacheKey: cache.cacheKey, frame: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
test("M10-14 rejects source and cache byte drift before playback", async () => {
|
||||
const value = await cachedSystem("CLOTH");
|
||||
const parsed = physics.parsePhysicsSimulationManifest(value.manifest);
|
||||
await assert.rejects(
|
||||
physics.verifyPhysicsCachePayload(parsed.systems[0], Uint8Array.from([1]).buffer, value.payload),
|
||||
{ code: "PHYSICS_CACHE_SOURCE_MISMATCH" },
|
||||
);
|
||||
await assert.rejects(
|
||||
physics.verifyPhysicsCachePayload(parsed.systems[0], value.source, Uint8Array.from([9, 9, 9, 9]).buffer),
|
||||
{ code: "PHYSICS_CACHE_HASH_MISMATCH" },
|
||||
);
|
||||
});
|
||||
|
||||
test("M10-14 rejects family, version, range, byte budget and undeclared cache fields", async () => {
|
||||
const value = await cachedSystem("FLUID");
|
||||
const cache = value.manifest.systems[0].cache;
|
||||
const invalid = [
|
||||
[{ ...cache, schemaVersion: 2 }, "PROTOCOL_MISMATCH"],
|
||||
[{ ...cache, blenderVersion: "5.3.0" }, "PROTOCOL_MISMATCH"],
|
||||
[{ ...cache, family: "CLOTH" }, "PHYSICS_MANIFEST_INVALID"],
|
||||
[{ ...cache, byteLength: physics.PHYSICS_SIMULATION_BUDGET.maxCacheBytes + 1 }, "PHYSICS_BUDGET_EXCEEDED"],
|
||||
[{ ...cache, frames: [{ ...cache.frames[0], byteOffset: 1 }, cache.frames[1]] }, "PHYSICS_CACHE_FRAME_MISMATCH"],
|
||||
[{ ...cache, frames: [cache.frames[0]] }, "PHYSICS_CACHE_FRAME_MISMATCH"],
|
||||
[{ ...cache, proxySuccess: true }, "PHYSICS_MANIFEST_INVALID"],
|
||||
];
|
||||
for (const [candidate, code] of invalid) {
|
||||
assert.throws(() => physics.parsePhysicsSimulationManifest({
|
||||
...value.manifest,
|
||||
systems: [{ ...value.manifest.systems[0], cache: candidate }],
|
||||
}), { code });
|
||||
}
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
98
web/tests/unit/physics-solver-probe.test.mjs
Normal file
98
web/tests/unit/physics-solver-probe.test.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "physics-solver-probe-unit-"));
|
||||
|
||||
function transpile(sourceName, outputName, replacements = []) {
|
||||
const sourcePath = path.join(root, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const output = replacements.reduce((source, [from, to]) => source.replaceAll(from, to), transpiled.outputText);
|
||||
fs.writeFileSync(path.join(temporary, outputName), output);
|
||||
}
|
||||
|
||||
transpile("capability-gates.ts", "capability-gates.mjs");
|
||||
transpile("physics-simulation.ts", "physics-simulation.mjs", [
|
||||
['from "./capability-gates"', 'from "./capability-gates.mjs"'],
|
||||
]);
|
||||
const physics = await import(pathToFileURL(path.join(temporary, "physics-simulation.mjs")));
|
||||
|
||||
const mib = 1024 * 1024;
|
||||
|
||||
test("M10-13 defaults every Physics family to desktop/server bake", async () => {
|
||||
const capabilities = await physics.probePhysicsSolverCapabilities(undefined, {
|
||||
threadMode: "PTHREAD",
|
||||
memoryLimitBytes: 2_048 * mib,
|
||||
});
|
||||
assert.deepEqual(capabilities.map((entry) => entry.family), physics.PHYSICS_FAMILIES);
|
||||
assert.ok(capabilities.every((entry) => entry.localSolver === "BLOCKED"));
|
||||
assert.ok(capabilities.every((entry) => entry.solverProbe === "EXPORT_UNAVAILABLE"));
|
||||
assert.ok(capabilities.every((entry) => entry.unsupportedRoute === "DESKTOP_SERVER_BAKE"));
|
||||
});
|
||||
|
||||
test("M10-13 probes export, initialization, threads and memory per family", async () => {
|
||||
const initialization = {
|
||||
RIGID_BODY: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib },
|
||||
SOFT_BODY: { initialized: false, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib },
|
||||
CLOTH: { initialized: true, requiredThreadMode: "PTHREAD", requiredMemoryBytes: 128 * mib },
|
||||
FLUID: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 512 * mib },
|
||||
DYNAMIC_PAINT: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: Number.NaN },
|
||||
};
|
||||
const initialized = [];
|
||||
const runtime = {
|
||||
hasFamilyExport: (family) => family !== "HAIR",
|
||||
initializeFamily: async (family) => {
|
||||
initialized.push(family);
|
||||
if (family === "PARTICLE") throw new Error("init failed");
|
||||
return initialization[family];
|
||||
},
|
||||
};
|
||||
const capabilities = await physics.probePhysicsSolverCapabilities(runtime, {
|
||||
threadMode: "SINGLE",
|
||||
memoryLimitBytes: 256 * mib,
|
||||
});
|
||||
assert.deepEqual(Object.fromEntries(capabilities.map((entry) => [entry.family, entry.solverProbe])), {
|
||||
RIGID_BODY: "READY",
|
||||
SOFT_BODY: "INITIALIZATION_FAILED",
|
||||
CLOTH: "THREADS_UNAVAILABLE",
|
||||
FLUID: "MEMORY_UNAVAILABLE",
|
||||
DYNAMIC_PAINT: "INVALID_RESULT",
|
||||
PARTICLE: "INITIALIZATION_FAILED",
|
||||
HAIR: "EXPORT_UNAVAILABLE",
|
||||
});
|
||||
assert.deepEqual(initialized, ["RIGID_BODY", "SOFT_BODY", "CLOTH", "FLUID", "DYNAMIC_PAINT", "PARTICLE"]);
|
||||
assert.deepEqual(physics.selectPhysicsExecutionRoute("RIGID_BODY", capabilities), {
|
||||
family: "RIGID_BODY", mode: "LOCAL_SOLVER", probe: "READY",
|
||||
});
|
||||
assert.deepEqual(physics.selectPhysicsExecutionRoute("CLOTH", capabilities), {
|
||||
family: "CLOTH", mode: "DESKTOP_SERVER_BAKE", probe: "THREADS_UNAVAILABLE",
|
||||
});
|
||||
assert.equal(physics.gatePhysicsExecution("RIGID_BODY", "LOCAL_SOLVER", capabilities).status, "READY");
|
||||
const blocked = physics.gatePhysicsExecution("CLOTH", "LOCAL_SOLVER", capabilities);
|
||||
assert.equal(blocked.status, "BLOCKED");
|
||||
assert.equal(blocked.issues[0].code, "PHYSICS_SOLVER_UNAVAILABLE");
|
||||
assert.match(blocked.issues[0].message, /desktop\/server bake/);
|
||||
});
|
||||
|
||||
test("M10-13 fails closed on an invalid environment or forged probe result", async () => {
|
||||
await assert.rejects(
|
||||
physics.probePhysicsSolverCapabilities(undefined, { threadMode: "SINGLE", memoryLimitBytes: 0 }),
|
||||
{ code: "PHYSICS_MANIFEST_INVALID" },
|
||||
);
|
||||
const capabilities = await physics.probePhysicsSolverCapabilities({
|
||||
hasFamilyExport: () => true,
|
||||
initializeFamily: () => ({ initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: -1 }),
|
||||
}, { threadMode: "PTHREAD", memoryLimitBytes: 2_048 * mib });
|
||||
assert.ok(capabilities.every((entry) => entry.solverProbe === "INVALID_RESULT"));
|
||||
assert.ok(capabilities.every((entry) => entry.localSolver === "BLOCKED"));
|
||||
});
|
||||
80
web/tests/unit/recent-projects.test.mjs
Normal file
80
web/tests/unit/recent-projects.test.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/recent-projects.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
|
||||
const {
|
||||
normalizeRecentProjects,
|
||||
parseRecentProjectIndex,
|
||||
removeRecentProject,
|
||||
upsertRecentProject,
|
||||
classifyRecentProjectIdentity,
|
||||
} = await import(moduleUrl);
|
||||
|
||||
function project(projectId, overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
projectId,
|
||||
displayName: `${projectId}.blend`,
|
||||
revision: 7,
|
||||
bytes: 1024,
|
||||
sha256: (projectId.charCodeAt(0) % 16).toString(16).repeat(64),
|
||||
updatedAt: "2026-08-15T12:00:00.000Z",
|
||||
lastOpenedAt: "2026-08-15T12:00:00.000Z",
|
||||
backend: "opfs",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("M7-09 recent projects sort and deduplicate deterministically", () => {
|
||||
const older = project("alpha", { revision: 2, updatedAt: "2026-08-15T08:00:00-04:00", lastOpenedAt: "2026-08-15T08:00:00-04:00" });
|
||||
const newer = project("alpha", { revision: 3, updatedAt: "2026-08-15T12:01:00.000Z", lastOpenedAt: "2026-08-15T12:01:00.000Z" });
|
||||
const second = project("beta", { lastOpenedAt: "2026-08-15T11:59:00.000Z" });
|
||||
const forward = normalizeRecentProjects([older, second, newer]);
|
||||
const reverse = normalizeRecentProjects([newer, second, older]);
|
||||
|
||||
assert.deepEqual(forward, reverse);
|
||||
assert.deepEqual(forward.index.projects.map(({ projectId, revision }) => [projectId, revision]), [["alpha", 3], ["beta", 7]]);
|
||||
assert.equal(forward.index.projects[0].updatedAt, "2026-08-15T12:01:00.000Z");
|
||||
});
|
||||
|
||||
test("M7-09 malformed records are quarantined without hiding valid projects", () => {
|
||||
const parsed = parseRecentProjectIndex({
|
||||
schemaVersion: 1,
|
||||
projects: [project("valid"), { ...project("bad"), sha256: "not-a-digest" }, { ...project("blank"), displayName: " " }],
|
||||
});
|
||||
assert.equal(parsed.quarantined, 2);
|
||||
assert.deepEqual(parsed.index.projects.map((item) => item.projectId), ["valid"]);
|
||||
assert.deepEqual(parseRecentProjectIndex({ schemaVersion: 99, projects: [] }), {
|
||||
index: { schemaVersion: 1, projects: [] },
|
||||
quarantined: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("M7-09 upsert and removal keep a bounded stable index", () => {
|
||||
let index = { schemaVersion: 1, projects: [] };
|
||||
index = upsertRecentProject(index, project("alpha"));
|
||||
index = upsertRecentProject(index, project("alpha", { revision: 9, lastOpenedAt: "2026-08-15T12:02:00Z" }));
|
||||
index = upsertRecentProject(index, project("beta"));
|
||||
assert.deepEqual(index.projects.map(({ projectId, revision }) => [projectId, revision]), [["alpha", 9], ["beta", 7]]);
|
||||
assert.deepEqual(removeRecentProject(index, "alpha").projects.map((item) => item.projectId), ["beta"]);
|
||||
assert.equal(normalizeRecentProjects(index.projects, 0).index.projects.length, 0);
|
||||
});
|
||||
|
||||
test("M7-10 recent project integrity classifies missing and mismatched content", () => {
|
||||
const expected = { revision: 7, bytes: 1024, sha256: "a".repeat(64) };
|
||||
assert.equal(classifyRecentProjectIdentity(expected, undefined), "MISSING");
|
||||
assert.equal(classifyRecentProjectIdentity(expected, { ...expected, sha256: "b".repeat(64) }), "HASH_MISMATCH");
|
||||
assert.equal(classifyRecentProjectIdentity(expected, { ...expected, revision: 8 }), "METADATA_MISMATCH");
|
||||
assert.equal(classifyRecentProjectIdentity(expected, expected), undefined);
|
||||
});
|
||||
77
web/tests/unit/render-budget.test.mjs
Normal file
77
web/tests/unit/render-budget.test.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(root, "web/protocol/render-budget.ts");
|
||||
const source = fs.readFileSync(sourcePath, "utf8")
|
||||
.replace('import type { ErrorCode } from "./error";\n', "")
|
||||
.replace('import { MAX_GPU_TEXTURE_ASSETS, MAX_GPU_TEXTURE_BYTES, MAX_GPU_TEXTURE_DIMENSION, type GPUTextureAsset } from "./render-assets";\n', "const MAX_GPU_TEXTURE_ASSETS = 256; const MAX_GPU_TEXTURE_BYTES = 64 * 1024 * 1024; const MAX_GPU_TEXTURE_DIMENSION = 16_384;\n")
|
||||
.replace('import type { SceneSnapshotIR } from "./scene-ir";\n', "");
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const budget = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-03/render-resource-budget.json"), "utf8"));
|
||||
|
||||
function snapshot(lightCount) {
|
||||
const lights = Array.from({ length: lightCount }, (_, index) => ({
|
||||
id: `light:${index}`, lightType: 2, castsShadow: true,
|
||||
}));
|
||||
const nodes = lights.map((light, index) => ({
|
||||
id: `object:light:${index}`, type: "LIGHT", visible: true, dataId: light.id,
|
||||
}));
|
||||
return { lights, nodes };
|
||||
}
|
||||
|
||||
test("M11-03 freezes explicit Three WebGL2 and WebGPU product budgets", () => {
|
||||
for (const [backend, expected] of [["THREE_WEBGL2", golden.webgl2], ["THREE_WEBGPU", golden.webgpu]]) {
|
||||
const actual = budget.resolvePBRRenderBudget(backend);
|
||||
for (const [field, value] of Object.entries(expected)) assert.equal(actual[field], value, `${backend}.${field}`);
|
||||
}
|
||||
const clamped = budget.resolvePBRRenderBudget("THREE_WEBGPU", {
|
||||
maxLights: 32, maxShadowMaps: 4, maxShadowMapDimension: 1024, maxTextureDimension2D: 8192,
|
||||
});
|
||||
assert.deepEqual([clamped.maxLights, clamped.maxShadowMaps, clamped.shadowMapDimension, clamped.maxTextureDimension], [32, 4, 1024, 8192]);
|
||||
assert.throws(() => budget.resolvePBRRenderBudget("THREE_WEBGPU", { maxLights: 0 }), /GPU_TEXTURE_BUDGET_EXCEEDED/);
|
||||
});
|
||||
|
||||
test("M11-03 deterministically bounds lights and shadow maps", () => {
|
||||
const report = budget.planPBRLightingBudget(snapshot(golden.overflow.requestedLights));
|
||||
assert.equal(report.status, "BLOCKED");
|
||||
assert.equal(report.requestedLights, golden.overflow.requestedLights);
|
||||
assert.equal(report.renderedLightNodeIds.length, golden.overflow.renderedLights);
|
||||
assert.equal(report.droppedLightNodeIds.length, golden.overflow.droppedLights);
|
||||
assert.equal(report.requestedShadowMaps, golden.overflow.requestedShadowMaps);
|
||||
assert.equal(report.shadowLightNodeIds.length, golden.overflow.renderedShadowMaps);
|
||||
assert.equal(report.shadowBlockedLightNodeIds.length, golden.overflow.blockedShadowMaps);
|
||||
assert.deepEqual(report.issues.map((issue) => issue.code), golden.overflow.codes);
|
||||
assert.equal(report.renderedLightNodeIds[0], "object:light:0");
|
||||
assert.equal(report.renderedLightNodeIds.at(-1), "object:light:13");
|
||||
});
|
||||
|
||||
test("M11-03 rejects aggregate texture allocation before decode", () => {
|
||||
const small = { assetId: "asset:small", imageId: "image:small", usage: "BASE_COLOR", width: 4, height: 4, byteLength: 16 };
|
||||
assert.deepEqual(budget.planPBRTextureBudget([small]), {
|
||||
schemaVersion: 1,
|
||||
backend: "THREE_WEBGL2",
|
||||
status: "READY",
|
||||
budget: budget.resolvePBRRenderBudget("THREE_WEBGL2"),
|
||||
requestedAssets: 1,
|
||||
payloadBytes: 16,
|
||||
decodedGPUBytes: 64,
|
||||
maxRequestedDimension: 4,
|
||||
issues: [],
|
||||
});
|
||||
const tooMany = Array.from({ length: 257 }, (_, index) => ({ ...small, assetId: `asset:${index}`, imageId: `image:${index}` }));
|
||||
const report = budget.planPBRTextureBudget(tooMany);
|
||||
assert.equal(report.status, "BLOCKED");
|
||||
assert.equal(report.requestedAssets, 257);
|
||||
assert.equal(report.issues[0].code, "GPU_TEXTURE_BUDGET_EXCEEDED");
|
||||
assert.equal(budget.planPBRTextureBudget([{ ...small, width: 16_384, height: 16_384 }]).status, "BLOCKED");
|
||||
});
|
||||
65
web/tests/unit/render-image-comparison.test.mjs
Normal file
65
web/tests/unit/render-image-comparison.test.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(root, "web/protocol/render-image-comparison.ts");
|
||||
const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', "");
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const comparison = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"));
|
||||
|
||||
const thresholds = {
|
||||
maxMeanAbsoluteError: 8,
|
||||
maxRootMeanSquaredError: 16,
|
||||
maxP95ChannelError: 16,
|
||||
maxBadPixelRatio: 0.1,
|
||||
badPixelChannelError: 32,
|
||||
foregroundDeltaFromReferenceBackground: 32,
|
||||
minForegroundIntersectionOverUnion: 0.8,
|
||||
maxAlphaCoverageDeltaRatio: 0,
|
||||
};
|
||||
|
||||
function frame(width = 4, height = 4) {
|
||||
const pixels = new Uint8Array(width * height * 4);
|
||||
for (let index = 0; index < pixels.length; index += 4) pixels.set([64, 64, 64, 255], index);
|
||||
for (const pixel of [5, 6, 9, 10]) pixels.set([0, 0, 0, 255], pixel * 4);
|
||||
return pixels;
|
||||
}
|
||||
|
||||
test("M11-04 reports every explainable metric for identical SRGB8 frames", () => {
|
||||
const reference = frame();
|
||||
const report = comparison.compareRenderImages(reference, reference.slice(), 4, 4, thresholds);
|
||||
assert.equal(report.status, "READY");
|
||||
assert.deepEqual(
|
||||
[report.meanAbsoluteError, report.rootMeanSquaredError, report.p95ChannelError, report.badPixelRatio],
|
||||
[0, 0, 0, 0],
|
||||
);
|
||||
assert.equal(report.foregroundIntersectionOverUnion, 1);
|
||||
assert.equal(report.checks.length, 6);
|
||||
assert.equal(report.errorCode, null);
|
||||
});
|
||||
|
||||
test("M11-04 rejects a non-empty but compositionally wrong frame", () => {
|
||||
const reference = frame();
|
||||
const wrong = frame();
|
||||
for (let index = 0; index < wrong.length; index += 4) wrong.set([64, 64, 64, 255], index);
|
||||
wrong.set([255, 0, 0, 255], 0);
|
||||
const report = comparison.compareRenderImages(reference, wrong, 4, 4, thresholds);
|
||||
assert.equal(report.status, "BLOCKED");
|
||||
assert.equal(report.errorCode, "RENDER_REFERENCE_MISMATCH");
|
||||
assert.ok(report.foregroundIntersectionOverUnion < thresholds.minForegroundIntersectionOverUnion);
|
||||
assert.ok(report.checks.some((item) => !item.passed));
|
||||
});
|
||||
|
||||
test("M11-04 rejects invalid dimensions, byte lengths and thresholds", () => {
|
||||
assert.throws(() => comparison.compareRenderImages(frame(), frame(), 0, 4, thresholds), /INVALID_ARGUMENT/);
|
||||
assert.throws(() => comparison.compareRenderImages(frame(), frame().subarray(1), 4, 4, thresholds), /INVALID_ARGUMENT/);
|
||||
assert.throws(() => comparison.compareRenderImages(frame(), frame(), 4, 4, { ...thresholds, maxBadPixelRatio: 2 }), /INVALID_ARGUMENT/);
|
||||
});
|
||||
53
web/tests/unit/render-routing.test.mjs
Normal file
53
web/tests/unit/render-routing.test.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(root, "web/protocol/render-routing.ts");
|
||||
const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', "");
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const routing = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-05/render-routing.json"), "utf8"));
|
||||
|
||||
const request = (overrides = {}) => ({ schemaVersion: 1, renderEngine: "BLENDER_EEVEE", backend: "WEBGL2", complexity: "BOUNDED", ...overrides });
|
||||
|
||||
test("M11-05 keeps bounded Eevee on the declared local backend", () => {
|
||||
const result = routing.routeRenderExecution(request());
|
||||
assert.deepEqual(result, {
|
||||
schemaVersion: 1, target: "WEB_LOCAL_BOUNDED", status: "READY", capability: "WEB_REALTIME_BOUNDED", reason: "BOUNDED_EEVEE", issues: [],
|
||||
});
|
||||
assert.deepEqual({ target: result.target, status: result.status, capability: result.capability, reason: result.reason }, golden.boundedEevee);
|
||||
assert.equal(routing.routeRenderExecution(request({ backend: "WEBGPU" }), { webgpuAvailable: true, webgpuRendererBundled: true }).status, "READY");
|
||||
});
|
||||
|
||||
test("M11-05 routes Cycles, complex Eevee and hardware to SERVER_JOB without a local success", () => {
|
||||
for (const [candidate, expected] of [
|
||||
[request({ renderEngine: "BLENDER_CYCLES", backend: "CYCLES" }), golden.cycles],
|
||||
[request({ complexity: "COMPLEX", backend: "EEVEE_COMPLEX" }), golden.complexEevee],
|
||||
[request({ hardwareBackend: "OPTIX" }), golden.hardware],
|
||||
]) {
|
||||
const result = routing.routeRenderExecution(candidate);
|
||||
assert.equal(result.target, expected.target);
|
||||
assert.equal(result.status, expected.withoutEndpoint.status);
|
||||
assert.equal(result.reason, expected.withoutEndpoint.reason);
|
||||
assert.equal(result.issues[0].code, expected.withoutEndpoint.code);
|
||||
}
|
||||
const available = routing.routeRenderExecution(request({ renderEngine: "BLENDER_CYCLES", backend: "CYCLES" }), { serverRenderAvailable: true });
|
||||
assert.deepEqual([available.target, available.status, available.capability], [golden.cycles.target, golden.cycles.withEndpoint.status, golden.cycles.withEndpoint.capability]);
|
||||
});
|
||||
|
||||
test("M11-05 blocks unavailable WebGPU and unknown engines and rejects malformed routes", () => {
|
||||
const webgpu = routing.routeRenderExecution(request({ backend: "WEBGPU" }));
|
||||
assert.deepEqual([webgpu.target, webgpu.status, webgpu.issues[0].code], [golden.webgpu.target, golden.webgpu.status, golden.webgpu.code]);
|
||||
const unknown = routing.routeRenderExecution(request({ renderEngine: "UNKNOWN_ENGINE" }));
|
||||
assert.deepEqual([unknown.target, unknown.status, unknown.capability, unknown.issues[0].code], [golden.unknownEngine.target, golden.unknownEngine.status, golden.unknownEngine.capability, golden.unknownEngine.code]);
|
||||
assert.throws(() => routing.routeRenderExecution(request({ backend: "INVALID" })), /INVALID_ARGUMENT/);
|
||||
assert.throws(() => routing.routeRenderExecution(request({ renderEngine: "invalid engine" })), /INVALID_ARGUMENT/);
|
||||
});
|
||||
164
web/tests/unit/sequencer-audio-recovery.test.mjs
Normal file
164
web/tests/unit/sequencer-audio-recovery.test.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "sequencer-audio-recovery-unit-"));
|
||||
const protocolPath = path.join(root, "web/protocol/sequencer-audio-session.ts");
|
||||
const runtimePath = path.join(root, "web/app/src/sequencer/SequencerAudioSession.ts");
|
||||
|
||||
function transpile(source, fileName) {
|
||||
const result = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
return result.outputText;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(temporary, "sequencer-audio-session.mjs"),
|
||||
transpile(fs.readFileSync(protocolPath, "utf8").replace('import type { ErrorCode } from "./error";\n', ""), protocolPath),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(temporary, "SequencerAudioSession.mjs"),
|
||||
transpile(
|
||||
fs.readFileSync(runtimePath, "utf8").replace(
|
||||
'from "../../../protocol/sequencer-audio-session";',
|
||||
'from "./sequencer-audio-session.mjs";',
|
||||
),
|
||||
runtimePath,
|
||||
),
|
||||
);
|
||||
|
||||
const protocol = await import(pathToFileURL(path.join(temporary, "sequencer-audio-session.mjs")));
|
||||
const runtime = await import(pathToFileURL(path.join(temporary, "SequencerAudioSession.mjs")));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-13/sequencer-audio-recovery.json"), "utf8"));
|
||||
|
||||
class FakeAudioParam {
|
||||
value = 1;
|
||||
events = [];
|
||||
cancelScheduledValues(time) { this.events.push(["cancel", time]); }
|
||||
setValueAtTime(value, time) { this.value = value; this.events.push(["set", value, time]); }
|
||||
}
|
||||
|
||||
class FakeGainNode {
|
||||
gain = new FakeAudioParam();
|
||||
connected = false;
|
||||
disconnected = false;
|
||||
connect() { this.connected = true; }
|
||||
disconnect() { this.disconnected = true; }
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
static instances = [];
|
||||
state = "suspended";
|
||||
currentTime = 1;
|
||||
destination = {};
|
||||
output = new FakeGainNode();
|
||||
closeCount = 0;
|
||||
constructor() { FakeAudioContext.instances.push(this); }
|
||||
createGain() { return this.output; }
|
||||
async resume() { this.state = "running"; }
|
||||
async suspend() { this.state = "suspended"; }
|
||||
async close() { this.closeCount += 1; this.state = "closed"; }
|
||||
}
|
||||
|
||||
const summary = (report) => [
|
||||
report.contextState,
|
||||
report.outputState,
|
||||
report.muted,
|
||||
report.outputGain,
|
||||
report.issueCode,
|
||||
];
|
||||
|
||||
test("M11-13 restores output after real session suspend and mute transitions", async () => {
|
||||
FakeAudioContext.instances.length = 0;
|
||||
const session = new runtime.SequencerAudioSession({
|
||||
scope: { AudioContext: FakeAudioContext },
|
||||
outputGain: golden.outputGain,
|
||||
});
|
||||
const reports = [];
|
||||
reports.push(await session.initialize());
|
||||
reports.push(session.setMuted(true));
|
||||
reports.push(await session.resume());
|
||||
reports.push(session.setMuted(false));
|
||||
reports.push(await session.suspend());
|
||||
reports.push(await session.resume());
|
||||
reports.push(await session.close());
|
||||
|
||||
assert.deepEqual(reports.map(summary), golden.lifecycle);
|
||||
assert.deepEqual(reports.map((report) => report.revision), [1, 2, 3, 4, 5, 6, 7]);
|
||||
const context = FakeAudioContext.instances[0];
|
||||
assert.equal(context.output.gain.value, 0);
|
||||
assert.equal(context.output.connected, true);
|
||||
assert.equal(context.output.disconnected, true);
|
||||
assert.equal(context.closeCount, 1);
|
||||
assert.ok(context.output.gain.events.some((event) => event[0] === "set" && event[1] === 0));
|
||||
assert.ok(context.output.gain.events.some((event) => event[0] === "set" && event[1] === golden.outputGain));
|
||||
});
|
||||
|
||||
test("M11-13 blocks a missing audio device and can retry after the runtime becomes available", async () => {
|
||||
FakeAudioContext.instances.length = 0;
|
||||
const scope = {};
|
||||
const session = new runtime.SequencerAudioSession({ scope, outputGain: golden.outputGain });
|
||||
const missing = await session.initialize();
|
||||
assert.deepEqual(
|
||||
[missing.contextState, missing.outputState, missing.issueCode],
|
||||
golden.missingDevice,
|
||||
);
|
||||
assert.deepEqual(session.snapshot(), missing);
|
||||
|
||||
scope.AudioContext = FakeAudioContext;
|
||||
const recovered = await session.recoverDevice();
|
||||
assert.deepEqual(summary(recovered), golden.lifecycle[0]);
|
||||
const resumed = await session.resume();
|
||||
assert.deepEqual(summary(resumed), ["RUNNING", "ENABLED", false, golden.outputGain, null]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("M11-13 keeps failed resume and malformed reports silent and structured", async () => {
|
||||
class ResumeFailureContext extends FakeAudioContext {
|
||||
async resume() { throw new Error("injected resume failure"); }
|
||||
}
|
||||
const failed = new runtime.SequencerAudioSession({
|
||||
contextFactory: () => new ResumeFailureContext(),
|
||||
outputGain: golden.outputGain,
|
||||
});
|
||||
await failed.initialize();
|
||||
const resumeFailure = await failed.resume();
|
||||
assert.deepEqual(
|
||||
[resumeFailure.contextState, resumeFailure.outputState, resumeFailure.issueCode],
|
||||
golden.resumeFailure,
|
||||
);
|
||||
await failed.close();
|
||||
|
||||
const unavailable = new runtime.SequencerAudioSession({
|
||||
contextFactory: () => { throw new Error("no output device"); },
|
||||
});
|
||||
assert.deepEqual(
|
||||
[
|
||||
(await unavailable.initialize()).contextState,
|
||||
unavailable.snapshot().outputState,
|
||||
unavailable.snapshot().issueCode,
|
||||
],
|
||||
golden.missingDevice,
|
||||
);
|
||||
assert.throws(() => new runtime.SequencerAudioSession({ outputGain: 0 }), {
|
||||
code: "SEQUENCER_AUDIO_CONTEXT_INVALID",
|
||||
});
|
||||
assert.throws(() => protocol.parseSequencerAudioSessionReport({
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
contextState: "RUNNING",
|
||||
outputState: "ENABLED",
|
||||
muted: true,
|
||||
outputGain: 1,
|
||||
issueCode: null,
|
||||
}), { code: "SEQUENCER_AUDIO_CONTEXT_INVALID" });
|
||||
});
|
||||
68
web/tests/unit/sequencer-codec-probe.test.mjs
Normal file
68
web/tests/unit/sequencer-codec-probe.test.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "sequencer-codec-probe-unit-"));
|
||||
|
||||
function transpile(sourceName, outputName, replacements = []) {
|
||||
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, outputName), replacements.reduce((value, [from, to]) => value.replaceAll(from, to), result.outputText));
|
||||
}
|
||||
|
||||
transpile("capability-gates.ts", "capability-gates.mjs");
|
||||
transpile("asset-path.ts", "asset-path.mjs");
|
||||
transpile("sequencer.ts", "sequencer.mjs", [
|
||||
['from "./capability-gates"', 'from "./capability-gates.mjs"'],
|
||||
['from "./asset-path"', 'from "./asset-path.mjs"'],
|
||||
]);
|
||||
const sequencer = await import(pathToFileURL(path.join(temporary, "sequencer.mjs")));
|
||||
|
||||
const requests = [
|
||||
{ schemaVersion: 1, stripType: "IMAGE", mimeType: "image/png", byteLength: 261, sourceSha256: "a".repeat(64) },
|
||||
{ schemaVersion: 1, stripType: "SOUND", mimeType: "audio/wav", byteLength: 16_044, sourceSha256: "b".repeat(64) },
|
||||
{ schemaVersion: 1, stripType: "MOVIE", mimeType: "video/mp4", byteLength: 1_484, sourceSha256: "c".repeat(64) },
|
||||
];
|
||||
const ready = [
|
||||
{ backend: "IMAGE_BITMAP", decoded: { width: 8, height: 8 } },
|
||||
{ backend: "WEB_AUDIO", decoded: { sampleRate: 8_000, channels: 1, durationFrames: 8_000 } },
|
||||
{ backend: "HTML_MEDIA", decoded: { width: 16, height: 16, durationMicros: 1_000_000 } },
|
||||
];
|
||||
|
||||
test("M11-09 gates IMAGE, SOUND and MOVIE only with identity-bound ready probe receipts", () => {
|
||||
for (const [index, request] of requests.entries()) {
|
||||
assert.deepEqual(sequencer.parseSequencerCodecProbeRequest(request), request);
|
||||
const result = { ...request, status: "READY", backend: ready[index].backend, reason: null, decoded: ready[index].decoded };
|
||||
assert.equal(sequencer.gateSequencerCodec(request, result).status, "READY");
|
||||
}
|
||||
});
|
||||
|
||||
test("M11-09 rejects extension fields, family mismatch, forged backend and source drift", () => {
|
||||
assert.throws(() => sequencer.parseSequencerCodecProbeRequest({ ...requests[0], sourcePath: "image.mp4" }), { code: "SEQUENCER_SCHEMA_INVALID" });
|
||||
assert.throws(() => sequencer.parseSequencerCodecProbeRequest({ ...requests[0], mimeType: "video/mp4" }), { code: "SEQUENCER_SCHEMA_INVALID" });
|
||||
const forged = { ...requests[0], status: "READY", backend: "HTML_MEDIA", reason: null, decoded: { width: 8, height: 8 } };
|
||||
assert.deepEqual(sequencer.gateSequencerCodec(requests[0], forged).issues.map((issue) => issue.code), ["SEQUENCER_CODEC_UNSUPPORTED"]);
|
||||
const drift = { ...requests[0], sourceSha256: "d".repeat(64), status: "READY", backend: "IMAGE_BITMAP", reason: null, decoded: { width: 8, height: 8 } };
|
||||
assert.deepEqual(sequencer.gateSequencerCodec(requests[0], drift).issues.map((issue) => issue.code), ["SEQUENCER_CODEC_UNSUPPORTED"]);
|
||||
});
|
||||
|
||||
test("M11-09 keeps runtime unavailability and decode failure blocked", () => {
|
||||
for (const [index, request] of requests.entries()) {
|
||||
const result = { ...request, status: "BLOCKED", backend: ready[index].backend, reason: "DECODE_FAILED", decoded: null };
|
||||
const gate = sequencer.gateSequencerCodec(request, result);
|
||||
assert.equal(gate.status, "BLOCKED");
|
||||
assert.deepEqual(gate.issues.map((issue) => issue.code), ["SEQUENCER_CODEC_UNSUPPORTED"]);
|
||||
}
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
132
web/tests/unit/sequencer-final-export.test.mjs
Normal file
132
web/tests/unit/sequencer-final-export.test.mjs
Normal file
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(root, "web/protocol/sequencer-export.ts");
|
||||
const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', "");
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const finalExport = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-12/sequencer-final-export.json"), "utf8"));
|
||||
|
||||
const request = (overrides = {}) => ({
|
||||
schemaVersion: 1,
|
||||
timelineId: "sequencer:scene:SequencerScene",
|
||||
timelineRevision: 1,
|
||||
sourceBlendSha256: golden.sourceBlendSha256,
|
||||
frameStart: 1,
|
||||
frameEnd: 250,
|
||||
fpsNumerator: 24000,
|
||||
fpsDenominator: 1001,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
container: "MPEG4",
|
||||
videoCodec: "H264",
|
||||
audioCodec: "AAC",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("M11-12 requires the declared server export capability", async () => {
|
||||
const blocked = await finalExport.routeSequencerFinalExport(request(), {
|
||||
serverExportAvailable: false,
|
||||
browserVideoEncoderAvailable: false,
|
||||
});
|
||||
assert.deepEqual(blocked, golden.withoutServer);
|
||||
|
||||
const routed = await finalExport.routeSequencerFinalExport(request(), {
|
||||
serverExportAvailable: true,
|
||||
browserVideoEncoderAvailable: false,
|
||||
});
|
||||
assert.deepEqual(routed, golden.withServer);
|
||||
});
|
||||
|
||||
test("M11-12 never treats browser VideoEncoder detection as local final-export support", async () => {
|
||||
for (const serverExportAvailable of [false, true]) {
|
||||
const withoutEncoder = await finalExport.routeSequencerFinalExport(request(), {
|
||||
serverExportAvailable,
|
||||
browserVideoEncoderAvailable: false,
|
||||
});
|
||||
const withEncoder = await finalExport.routeSequencerFinalExport(request(), {
|
||||
serverExportAvailable,
|
||||
browserVideoEncoderAvailable: true,
|
||||
});
|
||||
assert.equal(withoutEncoder.route, "SERVER_EXPORT");
|
||||
assert.equal(withEncoder.route, "SERVER_EXPORT");
|
||||
assert.equal(withoutEncoder.localEncoding, "BLOCKED");
|
||||
assert.equal(withEncoder.localEncoding, "BLOCKED");
|
||||
assert.equal(withEncoder.browserVideoEncoderDetected, true);
|
||||
assert.deepEqual(
|
||||
{ ...withEncoder, browserVideoEncoderDetected: false },
|
||||
withoutEncoder,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("M11-12 hashes settings and source revision into deterministic request identities", async () => {
|
||||
const baseline = await finalExport.routeSequencerFinalExport(request(), {
|
||||
serverExportAvailable: true,
|
||||
browserVideoEncoderAvailable: false,
|
||||
});
|
||||
assert.equal(baseline.settingsSha256, golden.settingsSha256);
|
||||
assert.equal(baseline.requestSha256, golden.requestSha256);
|
||||
assert.deepEqual(
|
||||
await finalExport.routeSequencerFinalExport({ ...request(), container: "MPEG4" }, {
|
||||
serverExportAvailable: true,
|
||||
browserVideoEncoderAvailable: false,
|
||||
}),
|
||||
baseline,
|
||||
);
|
||||
|
||||
const revisionDrift = await finalExport.routeSequencerFinalExport(request({ timelineRevision: 2 }), {
|
||||
serverExportAvailable: true,
|
||||
browserVideoEncoderAvailable: false,
|
||||
});
|
||||
assert.equal(revisionDrift.settingsSha256, baseline.settingsSha256);
|
||||
assert.notEqual(revisionDrift.requestSha256, baseline.requestSha256);
|
||||
|
||||
const settingsDrift = await finalExport.routeSequencerFinalExport(request({ width: 1280 }), {
|
||||
serverExportAvailable: true,
|
||||
browserVideoEncoderAvailable: false,
|
||||
});
|
||||
assert.notEqual(settingsDrift.settingsSha256, baseline.settingsSha256);
|
||||
assert.notEqual(settingsDrift.requestSha256, baseline.requestSha256);
|
||||
});
|
||||
|
||||
test("M11-12 rejects undeclared, malformed, and over-budget export requests", async () => {
|
||||
await assert.rejects(
|
||||
finalExport.routeSequencerFinalExport({ ...request(), localEncoding: "READY" }, {
|
||||
serverExportAvailable: true,
|
||||
browserVideoEncoderAvailable: true,
|
||||
}),
|
||||
{ code: "SEQUENCER_EXPORT_REQUEST_INVALID" },
|
||||
);
|
||||
await assert.rejects(
|
||||
finalExport.routeSequencerFinalExport(request({ container: "WEBM", videoCodec: "H264" }), {
|
||||
serverExportAvailable: true,
|
||||
browserVideoEncoderAvailable: true,
|
||||
}),
|
||||
{ code: "SEQUENCER_EXPORT_REQUEST_INVALID" },
|
||||
);
|
||||
await assert.rejects(
|
||||
finalExport.routeSequencerFinalExport(request({ frameStart: -1_000_000, frameEnd: 1_000_000 }), {
|
||||
serverExportAvailable: true,
|
||||
browserVideoEncoderAvailable: true,
|
||||
}),
|
||||
{ code: "SEQUENCER_EXPORT_REQUEST_INVALID" },
|
||||
);
|
||||
await assert.rejects(
|
||||
finalExport.routeSequencerFinalExport(request(), {
|
||||
serverExportAvailable: true,
|
||||
browserVideoEncoderAvailable: true,
|
||||
codecName: "h264",
|
||||
}),
|
||||
{ code: "SEQUENCER_EXPORT_REQUEST_INVALID" },
|
||||
);
|
||||
});
|
||||
120
web/tests/unit/sequencer-media-cache.test.mjs
Normal file
120
web/tests/unit/sequencer-media-cache.test.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "sequencer-media-cache-unit-"));
|
||||
|
||||
function transpile(sourceName, outputName, replacements = []) {
|
||||
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, outputName), replacements.reduce((value, [from, to]) => value.replaceAll(from, to), result.outputText));
|
||||
}
|
||||
|
||||
transpile("capability-gates.ts", "capability-gates.mjs");
|
||||
transpile("asset-path.ts", "asset-path.mjs");
|
||||
transpile("sequencer.ts", "sequencer.mjs", [
|
||||
['from "./capability-gates"', 'from "./capability-gates.mjs"'],
|
||||
['from "./asset-path"', 'from "./asset-path.mjs"'],
|
||||
]);
|
||||
transpile("sequencer-media-cache.ts", "sequencer-media-cache.mjs", [
|
||||
['from "./sequencer"', 'from "./sequencer.mjs"'],
|
||||
]);
|
||||
const mediaCache = await import(pathToFileURL(path.join(temporary, "sequencer-media-cache.mjs")));
|
||||
|
||||
const source = {
|
||||
schemaVersion: 1,
|
||||
stripType: "MOVIE",
|
||||
mimeType: "video/mp4",
|
||||
byteLength: 1_484,
|
||||
sourceSha256: "a".repeat(64),
|
||||
};
|
||||
const capability = {
|
||||
...source,
|
||||
status: "READY",
|
||||
backend: "HTML_MEDIA",
|
||||
reason: null,
|
||||
decoded: { width: 16, height: 16, durationMicros: 1_000_000 },
|
||||
};
|
||||
const profile = {
|
||||
kind: "MOVIE_RGBA8_FRAME",
|
||||
width: 2,
|
||||
height: 2,
|
||||
colorSpace: "SRGB8",
|
||||
alphaMode: "STRAIGHT",
|
||||
};
|
||||
const payload = new Uint8Array([
|
||||
1, 2, 3, 255, 4, 5, 6, 255,
|
||||
7, 8, 9, 255, 10, 11, 12, 255,
|
||||
]).buffer;
|
||||
|
||||
test("M11-10 binds proxy identity to source, decode receipt, profile and source frame", async () => {
|
||||
const manifest = await mediaCache.createSequencerMediaCacheManifest(source, capability, profile, 12, payload);
|
||||
assert.equal(manifest.payloadByteLength, 16);
|
||||
assert.match(manifest.identitySha256, /^[a-f0-9]{64}$/);
|
||||
assert.match(manifest.payloadSha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(mediaCache.sequencerMediaCacheKey(manifest), `sequencer-media-cache:v1:${manifest.identitySha256}`);
|
||||
assert.deepEqual(await mediaCache.verifySequencerMediaCacheEntry(manifest, payload, source, capability), manifest);
|
||||
|
||||
const reordered = { ...capability, decoded: { durationMicros: 1_000_000, height: 16, width: 16 } };
|
||||
assert.equal(
|
||||
await mediaCache.computeSequencerMediaCacheIdentity(source, capability, profile, 12),
|
||||
await mediaCache.computeSequencerMediaCacheIdentity(source, reordered, profile, 12),
|
||||
);
|
||||
assert.notEqual(
|
||||
await mediaCache.computeSequencerMediaCacheIdentity(source, capability, profile, 12),
|
||||
await mediaCache.computeSequencerMediaCacheIdentity(source, capability, profile, 13),
|
||||
);
|
||||
});
|
||||
|
||||
test("M11-10 rejects source, decode capability, identity and payload drift independently", async () => {
|
||||
const manifest = await mediaCache.createSequencerMediaCacheManifest(source, capability, profile, 0, payload);
|
||||
const changedSource = { ...source, sourceSha256: "b".repeat(64) };
|
||||
const changedSourceCapability = { ...capability, sourceSha256: changedSource.sourceSha256 };
|
||||
await assert.rejects(
|
||||
mediaCache.verifySequencerMediaCacheEntry(manifest, payload, changedSource, changedSourceCapability),
|
||||
{ code: "SEQUENCER_CACHE_SOURCE_MISMATCH" },
|
||||
);
|
||||
const changedCapability = { ...capability, decoded: { ...capability.decoded, durationMicros: 999_999 } };
|
||||
await assert.rejects(
|
||||
mediaCache.verifySequencerMediaCacheEntry(manifest, payload, source, changedCapability),
|
||||
{ code: "SEQUENCER_CACHE_CAPABILITY_MISMATCH" },
|
||||
);
|
||||
await assert.rejects(
|
||||
mediaCache.verifySequencerMediaCacheEntry({ ...manifest, identitySha256: "c".repeat(64) }, payload, source, capability),
|
||||
{ code: "SEQUENCER_CACHE_IDENTITY_MISMATCH" },
|
||||
);
|
||||
const corrupted = payload.slice(0);
|
||||
new Uint8Array(corrupted)[0] ^= 0xff;
|
||||
await assert.rejects(
|
||||
mediaCache.verifySequencerMediaCacheEntry(manifest, corrupted, source, capability),
|
||||
{ code: "SEQUENCER_CACHE_HASH_MISMATCH" },
|
||||
);
|
||||
});
|
||||
|
||||
test("M11-10 keeps blocked receipts, extension fields and oversized profiles out of cache", async () => {
|
||||
await assert.rejects(
|
||||
mediaCache.createSequencerMediaCacheManifest(source, { ...capability, status: "BLOCKED", backend: null, reason: "RUNTIME_UNAVAILABLE", decoded: null }, profile, 0, payload),
|
||||
{ code: "SEQUENCER_CODEC_UNSUPPORTED" },
|
||||
);
|
||||
assert.throws(() => mediaCache.parseSequencerMediaProxyProfile({ ...profile, codec: "h264" }), { code: "SEQUENCER_SCHEMA_INVALID" });
|
||||
assert.throws(
|
||||
() => mediaCache.parseSequencerMediaProxyProfile({ ...profile, width: 4096, height: 4097 }),
|
||||
{ code: "SEQUENCER_BUDGET_EXCEEDED" },
|
||||
);
|
||||
await assert.rejects(
|
||||
mediaCache.createSequencerMediaCacheManifest(source, capability, { ...profile, width: 17 }, 0, new ArrayBuffer(17 * 2 * 4)),
|
||||
{ code: "SEQUENCER_SCHEMA_INVALID" },
|
||||
);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
87
web/tests/unit/sequencer-media-revision.test.mjs
Normal file
87
web/tests/unit/sequencer-media-revision.test.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "sequencer-media-revision-unit-"));
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/sequencer-media-revision.ts");
|
||||
const result = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
fs.writeFileSync(path.join(temporary, "sequencer-media-revision.mjs"), result.outputText);
|
||||
const revision = await import(pathToFileURL(path.join(temporary, "sequencer-media-revision.mjs")));
|
||||
|
||||
const request = (operation, requestRevision, frame = 24) => ({
|
||||
schemaVersion: 1,
|
||||
requestId: `media:${requestRevision}`,
|
||||
timelineId: "sequencer:main",
|
||||
timelineRevision: 7,
|
||||
requestRevision,
|
||||
operation,
|
||||
frame,
|
||||
});
|
||||
const completed = (value) => ({
|
||||
...value,
|
||||
status: "COMPLETED",
|
||||
sourceFrame: value.frame,
|
||||
payloadSha256: "a".repeat(64),
|
||||
});
|
||||
const state = (latestRequestRevision, timelineRevision = 7) => ({
|
||||
schemaVersion: 1,
|
||||
timelineId: "sequencer:main",
|
||||
timelineRevision,
|
||||
latestRequestRevision,
|
||||
});
|
||||
|
||||
test("M11-11 publishes matching latest SEEK, SCRUB and DECODE results", () => {
|
||||
for (const [index, operation] of ["SEEK", "SCRUB", "DECODE"].entries()) {
|
||||
const active = request(operation, index + 1);
|
||||
assert.deepEqual(revision.gateSequencerMediaRevision(active, state(index + 1), completed(active)), {
|
||||
status: "PUBLISH",
|
||||
code: null,
|
||||
operation,
|
||||
requestRevision: index + 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("M11-11 marks superseded and timeline-replaced results stale", () => {
|
||||
const oldSeek = request("SEEK", 3);
|
||||
assert.deepEqual(revision.gateSequencerMediaRevision(oldSeek, state(4), completed(oldSeek)), {
|
||||
status: "STALE", code: "REVISION_CONFLICT", operation: "SEEK", requestRevision: 3,
|
||||
});
|
||||
const oldDecode = request("DECODE", 5);
|
||||
assert.deepEqual(revision.gateSequencerMediaRevision(oldDecode, state(6, 8), completed(oldDecode)), {
|
||||
status: "STALE", code: "REVISION_CONFLICT", operation: "DECODE", requestRevision: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("M11-11 rejects result identity forgery without publishing it", () => {
|
||||
const active = request("SCRUB", 9, 48);
|
||||
for (const forged of [
|
||||
{ ...completed(active), requestId: "media:forged" },
|
||||
{ ...completed(active), timelineRevision: 8 },
|
||||
{ ...completed(active), requestRevision: 10 },
|
||||
{ ...completed(active), operation: "DECODE" },
|
||||
{ ...completed(active), frame: 49 },
|
||||
]) {
|
||||
assert.equal(revision.gateSequencerMediaRevision(active, state(9), forged).status, "STALE");
|
||||
}
|
||||
});
|
||||
|
||||
test("M11-11 rejects undeclared fields, invalid operations and fractional revisions", () => {
|
||||
const active = request("SEEK", 1);
|
||||
assert.throws(() => revision.parseSequencerMediaRevisionRequest({ ...active, signal: "late" }), { code: "SEQUENCER_SCHEMA_INVALID" });
|
||||
assert.throws(() => revision.parseSequencerMediaRevisionRequest({ ...active, operation: "PLAY" }), { code: "SEQUENCER_SCHEMA_INVALID" });
|
||||
assert.throws(() => revision.parseSequencerMediaRevisionState({ ...state(1), timelineRevision: 7.5 }), { code: "SEQUENCER_SCHEMA_INVALID" });
|
||||
assert.throws(() => revision.parseSequencerMediaRevisionResult({ ...completed(active), payloadSha256: "bad" }), { code: "SEQUENCER_SCHEMA_INVALID" });
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
66
web/tests/unit/server-render-job.test.mjs
Normal file
66
web/tests/unit/server-render-job.test.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(root, "web/protocol/server-render-job.ts");
|
||||
const source = fs.readFileSync(sourcePath, "utf8").replace('import type { ErrorCode } from "./error";\n', "");
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const jobs = await import("data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"));
|
||||
|
||||
const sourceBytes = new TextEncoder().encode("server-render-source-v1").buffer;
|
||||
const build = { version: "5.2.0", buildSha256: "a".repeat(64) };
|
||||
const settings = {
|
||||
renderEngine: "BLENDER_CYCLES",
|
||||
frameStart: 1,
|
||||
frameEnd: 1,
|
||||
resolutionX: 32,
|
||||
resolutionY: 32,
|
||||
resolutionPercentage: 100,
|
||||
samples: 4,
|
||||
outputMime: "image/png",
|
||||
transparent: false,
|
||||
};
|
||||
|
||||
test("M11-06 binds source bytes, Blender build and canonical render settings", async () => {
|
||||
const request = await jobs.createServerRenderJobRequest(sourceBytes, build, settings, { jobId: "render:test", sourceRevision: 7 });
|
||||
assert.equal(request.sourceBlendByteLength, sourceBytes.byteLength);
|
||||
assert.match(request.sourceBlendSha256, /^[a-f0-9]{64}$/);
|
||||
assert.match(request.settingsSha256, /^[a-f0-9]{64}$/);
|
||||
assert.match(request.requestSha256, /^[a-f0-9]{64}$/);
|
||||
assert.deepEqual(await jobs.verifyServerRenderJobRequest(request, sourceBytes), request);
|
||||
const reordered = await jobs.createServerRenderJobRequest(sourceBytes, build, { transparent: settings.transparent, ...settings }, { jobId: "render:test", sourceRevision: 7 });
|
||||
assert.equal(reordered.settingsSha256, request.settingsSha256);
|
||||
assert.equal(await jobs.createServerRenderJobKey(request), await jobs.createServerRenderJobKey(reordered));
|
||||
});
|
||||
|
||||
test("M11-06 verifies output hash and rejects every provenance drift", async () => {
|
||||
const request = await jobs.createServerRenderJobRequest(sourceBytes, build, settings, { jobId: "render:verify", sourceRevision: 2 });
|
||||
const output = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01]).buffer;
|
||||
const result = await jobs.createServerRenderJobResult(request, output);
|
||||
assert.equal(result.status, "SUCCEEDED");
|
||||
assert.deepEqual(await jobs.verifyServerRenderJobResult(result, request, output), result);
|
||||
await assert.rejects(jobs.verifyServerRenderJobRequest(request, Uint8Array.from([1, 2, 3]).buffer), { code: "SERVER_RENDER_SOURCE_HASH_MISMATCH" });
|
||||
await assert.rejects(jobs.verifyServerRenderJobResult(result, request, Uint8Array.from([9, 9]).buffer), { code: "SERVER_RENDER_OUTPUT_HASH_MISMATCH" });
|
||||
await assert.rejects(jobs.verifyServerRenderJobResult({ ...result, sourceBlendSha256: "b".repeat(64) }, request, output), { code: "SERVER_RENDER_BINDING_MISMATCH" });
|
||||
await assert.rejects(jobs.verifyServerRenderJobRequest({ ...request, blenderBuild: { ...build, buildSha256: "b".repeat(64) } }), { code: "SERVER_RENDER_REQUEST_HASH_MISMATCH" });
|
||||
await assert.rejects(jobs.createServerRenderJobRequest(sourceBytes, { ...build, version: "5.3.0" }, settings), { code: "SERVER_RENDER_BUILD_INVALID" });
|
||||
await assert.rejects(jobs.createServerRenderJobRequest(sourceBytes, build, { ...settings, samples: 0 }), { code: "SERVER_RENDER_SETTINGS_INVALID" });
|
||||
});
|
||||
|
||||
test("M11-06 rejects malformed result metadata and settings budget abuse", async () => {
|
||||
await assert.rejects(jobs.createServerRenderJobRequest(sourceBytes, build, { ...settings, payload: "x".repeat(300_000) }), { code: "SERVER_RENDER_SETTINGS_INVALID" });
|
||||
const request = await jobs.createServerRenderJobRequest(sourceBytes, build, settings);
|
||||
const result = await jobs.createServerRenderJobResult(request, Uint8Array.from([1]).buffer);
|
||||
await assert.rejects(jobs.verifyServerRenderJobResult({ ...result, outputByteLength: result.outputByteLength + 1 }, request), { code: "SERVER_RENDER_RESULT_HASH_MISMATCH" });
|
||||
await assert.rejects(jobs.verifyServerRenderJobResult({ ...result, outputMime: "image/openexr" }, request), { code: "SERVER_RENDER_OUTPUT_INVALID" });
|
||||
await assert.rejects(jobs.verifyServerRenderJobResult({ ...result, errorCode: "SERVER_RENDER_FAILED" }, request), { code: "SERVER_RENDER_OUTPUT_INVALID" });
|
||||
await assert.rejects(jobs.verifyServerRenderJobRequest({ ...request, unexpected: true }), { code: "SERVER_RENDER_REQUEST_INVALID" });
|
||||
});
|
||||
179
web/tests/unit/shader-compiler.test.mjs
Normal file
179
web/tests/unit/shader-compiler.test.mjs
Normal file
@@ -0,0 +1,179 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "shader-compiler-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/shader-compiler.ts");
|
||||
const outputPath = path.join(temporary, "shader-compiler.mjs");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(outputPath, transpiled.outputText);
|
||||
const compiler = await import(pathToFileURL(outputPath));
|
||||
|
||||
function material(overrides = {}) {
|
||||
return {
|
||||
id: "material:ShaderUnit",
|
||||
name: "ShaderUnit",
|
||||
baseColor: [0.2, 0.3, 0.4, 1],
|
||||
roughness: 0.5,
|
||||
metallic: 0.1,
|
||||
emissionColor: [0, 0, 0, 1],
|
||||
alpha: 1,
|
||||
ior: 1.45,
|
||||
shaderGraphHash: "a".repeat(64),
|
||||
nodes: [
|
||||
{ id: "rgb", type: "RGB", name: "RGB", defaultValue: [0.1, 0.2, 0.3, 1] },
|
||||
{ id: "value-a", type: "VALUE", name: "A", defaultValue: [0.2] },
|
||||
{ id: "value-b", type: "VALUE", name: "B", defaultValue: [0.22] },
|
||||
{ id: "math", type: "MATH", name: "Add", properties: { operation: "ADD" } },
|
||||
{ id: "image", type: "IMAGE_TEXTURE", name: "Image", imageId: "image:Normal" },
|
||||
{ id: "normal", type: "NORMAL_MAP", name: "Normal" },
|
||||
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
|
||||
{ id: "output", type: "OUTPUT", name: "Output" },
|
||||
],
|
||||
links: [
|
||||
{ fromNodeId: "rgb", fromSocket: "Color", toNodeId: "principled", toSocket: "Base Color" },
|
||||
{ fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
|
||||
{ fromNodeId: "value-b", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
|
||||
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
|
||||
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" },
|
||||
{ fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("M10-07 compiles the declared Principled/Image/Normal/Math closure", () => {
|
||||
const report = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]) });
|
||||
assert.equal(report.status, "COMPILED");
|
||||
assert.equal(report.taskId, "M10-07");
|
||||
assert.equal(report.backend, "WEBGL2_THREE_PHYSICAL");
|
||||
assert.equal(report.material.baseColor[2], 0.3);
|
||||
assert.ok(Math.abs(report.material.roughness - 0.42) < 1e-8);
|
||||
assert.deepEqual(report.textureBindings, [{ imageId: "image:Normal", usage: "NORMAL" }]);
|
||||
assert.match(report.graphHash, /^[0-9a-f]{64}$/);
|
||||
assert.ok(report.instructions.some((instruction) => instruction.operation === "ADD"));
|
||||
assert.ok(report.nodeOrder.indexOf("value-a") < report.nodeOrder.indexOf("math"));
|
||||
assert.ok(report.nodeOrder.indexOf("math") < report.nodeOrder.indexOf("principled"));
|
||||
assert.ok(report.nodeOrder.indexOf("principled") < report.nodeOrder.indexOf("output"));
|
||||
});
|
||||
|
||||
test("M10-07 blocks unknown nodes and preserves the graph metadata", () => {
|
||||
const source = material();
|
||||
source.nodes.push({ id: "mix", type: "UNSUPPORTED", name: "ShaderNodeMix" });
|
||||
const report = compiler.compileMaterialGraph(source);
|
||||
assert.equal(report.status, "BLOCKED");
|
||||
assert.equal(report.issues[0].code, "SHADER_NODE_UNSUPPORTED");
|
||||
assert.equal(source.nodes.at(-1).type, "UNSUPPORTED");
|
||||
});
|
||||
|
||||
test("M10-07 rejects cycles, duplicate links, and missing image resources", () => {
|
||||
const cyclic = material({
|
||||
links: [
|
||||
{ fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
|
||||
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
|
||||
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
|
||||
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
|
||||
{ fromNodeId: "image", fromSocket: "Color", toNodeId: "normal", toSocket: "Color" },
|
||||
{ fromNodeId: "normal", fromSocket: "Normal", toNodeId: "principled", toSocket: "Normal" },
|
||||
],
|
||||
});
|
||||
const cyclicReport = compiler.compileMaterialGraph(cyclic);
|
||||
assert.equal(cyclicReport.status, "BLOCKED");
|
||||
assert.ok(cyclicReport.issues.some((issue) => issue.code === "SHADER_GRAPH_CYCLE"));
|
||||
const missingReport = compiler.compileMaterialGraph(material(), { imageIds: new Set() });
|
||||
assert.equal(missingReport.status, "BLOCKED");
|
||||
assert.ok(missingReport.issues.some((issue) => issue.code === "SHADER_EXTERNAL_RESOURCE_MISSING"));
|
||||
});
|
||||
|
||||
test("M10-07 keeps the fallback graph fingerprint deterministic", () => {
|
||||
const source = material();
|
||||
delete source.shaderGraphHash;
|
||||
const first = compiler.compileMaterialGraph(source);
|
||||
const second = compiler.compileMaterialGraph(source);
|
||||
assert.equal(first.graphHash, second.graphHash);
|
||||
assert.match(first.graphHash, /^[0-9a-f]{64}$/);
|
||||
const canonical = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
materialId: source.id,
|
||||
nodes: source.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
name: node.name,
|
||||
imageId: node.imageId ?? null,
|
||||
defaultValue: node.defaultValue ?? null,
|
||||
properties: node.properties ?? null,
|
||||
})),
|
||||
links: source.links.map((link) => ({
|
||||
fromNodeId: link.fromNodeId,
|
||||
fromSocket: link.fromSocket,
|
||||
toNodeId: link.toNodeId,
|
||||
toSocket: link.toSocket,
|
||||
})),
|
||||
});
|
||||
assert.equal(first.graphHash, createHash("sha256").update(canonical).digest("hex"));
|
||||
});
|
||||
|
||||
test("M10-07 fails closed before oversized topology or forged graph hashes are compiled", () => {
|
||||
const oversized = material({
|
||||
nodes: Array.from({ length: compiler.SHADER_COMPILE_BUDGET.maxNodes + 1 }, (_value, index) => ({
|
||||
id: `value:${index}`,
|
||||
type: "VALUE",
|
||||
name: `Value ${index}`,
|
||||
defaultValue: [0],
|
||||
})),
|
||||
links: [],
|
||||
});
|
||||
const oversizedReport = compiler.compileMaterialGraph(oversized);
|
||||
assert.equal(oversizedReport.status, "BLOCKED");
|
||||
assert.equal(oversizedReport.issues[0].code, "SHADER_NODE_UNSUPPORTED");
|
||||
const forged = compiler.compileMaterialGraph(material({ shaderGraphHash: "A".repeat(64) }));
|
||||
assert.equal(forged.status, "BLOCKED");
|
||||
assert.equal(forged.issues[0].code, "SHADER_INVALID_GRAPH");
|
||||
});
|
||||
|
||||
test("M10-08 binds graph, texture identity, color space and backend into compileKey", () => {
|
||||
const textureIdentities = new Map([["image:Normal", {
|
||||
assetId: "asset:normal-v1",
|
||||
sha256: "b".repeat(64),
|
||||
colorSpace: "NON_COLOR",
|
||||
}]]);
|
||||
const first = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]), textureIdentities });
|
||||
const second = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]), textureIdentities: new Map([["image:Normal", { ...textureIdentities.get("image:Normal"), sha256: "c".repeat(64) }]]) });
|
||||
const linear = compiler.compileMaterialGraph(material(), { imageIds: new Set(["image:Normal"]), textureIdentities: new Map([["image:Normal", { ...textureIdentities.get("image:Normal"), colorSpace: "LINEAR" }]]) });
|
||||
assert.equal(first.status, "COMPILED");
|
||||
assert.match(first.compileKey, /^[0-9a-f]{64}$/);
|
||||
assert.notEqual(first.compileKey, second.compileKey);
|
||||
assert.notEqual(first.compileKey, linear.compileKey);
|
||||
assert.notEqual(
|
||||
first.compileKey,
|
||||
compiler.createShaderCompileKey({
|
||||
graphHash: first.graphHash,
|
||||
rendererBackend: "WEBGPU",
|
||||
textures: [{ imageId: "image:Normal", usage: "NORMAL", assetId: "asset:normal-v1", sha256: "b".repeat(64), colorSpace: "NON_COLOR" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("M10-08 rejects an unknown renderer backend and malformed texture identity", () => {
|
||||
const backend = compiler.compileMaterialGraph(material(), { rendererBackend: "WEBGPU" });
|
||||
assert.equal(backend.status, "BLOCKED");
|
||||
assert.equal(backend.issues[0].code, "CAPABILITY_MISSING");
|
||||
const malformed = compiler.compileMaterialGraph(material(), {
|
||||
imageIds: new Set(["image:Normal"]),
|
||||
textureIdentities: new Map([["image:Normal", { sha256: "not-a-digest" }]]),
|
||||
});
|
||||
assert.equal(malformed.status, "BLOCKED");
|
||||
assert.equal(malformed.issues.at(-1).code, "SHADER_INVALID_GRAPH");
|
||||
});
|
||||
123
web/tests/unit/simulation-cache.test.mjs
Normal file
123
web/tests/unit/simulation-cache.test.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "simulation-cache-unit-"));
|
||||
const sourcePath = path.join(root, "web/protocol/simulation-cache.ts");
|
||||
const outputPath = path.join(temporary, "simulation-cache.mjs");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(outputPath, transpiled.outputText);
|
||||
const simulation = await import(pathToFileURL(outputPath));
|
||||
|
||||
const digest = async (value) => Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", value)),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
|
||||
async function manifest(overrides = {}) {
|
||||
const payload = Uint8Array.from([1, 2, 3, 4]).buffer;
|
||||
const binding = {
|
||||
graphId: "node-group:SimulationUnit",
|
||||
graphHash: await digest(Uint8Array.from([1]).buffer),
|
||||
sourceBlendSha256: await digest(Uint8Array.from([2]).buffer),
|
||||
sourceRevision: 7,
|
||||
inputHash: await digest(Uint8Array.from([3]).buffer),
|
||||
blenderVersion: "5.2.0",
|
||||
frameStart: 1,
|
||||
frameEnd: 1,
|
||||
};
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
...binding,
|
||||
revisionHash: await simulation.computeSimulationCacheRevisionHash(binding),
|
||||
cacheSha256: await digest(payload),
|
||||
byteLength: payload.byteLength,
|
||||
frames: [{ frame: 1, byteOffset: 0, byteLength: payload.byteLength, sha256: await digest(payload) }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("M10-05 binds a cache key to the full graph, source, revision and input identity", async () => {
|
||||
const value = await manifest();
|
||||
const verified = await simulation.verifySimulationCacheRevisionBinding(value);
|
||||
assert.equal(verified.sourceRevision, 7);
|
||||
assert.equal(simulation.simulationCacheKey(verified), `sim2-${value.revisionHash}`);
|
||||
assert.equal(simulation.simulationCacheKey(verified).length, 69);
|
||||
});
|
||||
|
||||
test("M10-05 rejects graph, source, revision, input and range drift", async () => {
|
||||
const value = await manifest();
|
||||
for (const patch of [
|
||||
{ graphHash: "0".repeat(64) },
|
||||
{ sourceBlendSha256: "1".repeat(64) },
|
||||
{ sourceRevision: 8 },
|
||||
{ inputHash: "2".repeat(64) },
|
||||
{ frameStart: 2, frameEnd: 2, frames: [{ ...value.frames[0], frame: 2 }] },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
simulation.verifySimulationCacheRevisionBinding({ ...value, ...patch }),
|
||||
{ code: "SIMULATION_CACHE_REVISION_MISMATCH" },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("M10-05 rejects legacy schemas, undeclared fields and invalid revisions", async () => {
|
||||
const value = await manifest();
|
||||
assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, schemaVersion: 1 }), { code: "PROTOCOL_MISMATCH" });
|
||||
assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, sourceRevision: -1 }), { code: "SIMULATION_CACHE_INVALID" });
|
||||
assert.throws(() => simulation.parseSimulationCacheManifest({ ...value, staleKey: "accepted" }), { code: "SIMULATION_CACHE_INVALID" });
|
||||
assert.throws(() => simulation.parseSimulationCacheManifest({
|
||||
...value,
|
||||
frames: [{ ...value.frames[0], payload: [1, 2, 3, 4] }],
|
||||
}), { code: "SIMULATION_CACHE_INVALID" });
|
||||
});
|
||||
|
||||
test("M10-15 reports cache byte and frame budgets before allocating payload bytes", async () => {
|
||||
const value = await manifest();
|
||||
assert.throws(() => simulation.parseSimulationCacheManifest({
|
||||
...value,
|
||||
byteLength: simulation.SIMULATION_CACHE_BUDGET.maxCacheBytes + 1,
|
||||
}), { code: "SIMULATION_CACHE_BUDGET_EXCEEDED" });
|
||||
assert.throws(() => simulation.parseSimulationCacheManifest({
|
||||
...value,
|
||||
frameEnd: value.frameStart + simulation.SIMULATION_CACHE_BUDGET.maxFrames,
|
||||
}), { code: "SIMULATION_CACHE_BUDGET_EXCEEDED" });
|
||||
});
|
||||
|
||||
test("M10-06 plans deterministic LRU eviction while retaining protected playback caches", () => {
|
||||
const key = (digit) => `sim2-${digit.repeat(64)}`;
|
||||
const candidates = [
|
||||
{ cacheKey: key("a"), byteLength: 4, createdAt: "2026-08-16T12:00:00.000Z", lastAccessAt: "2026-08-16T12:00:00.000Z" },
|
||||
{ cacheKey: key("b"), byteLength: 4, createdAt: "2026-08-16T12:00:01.000Z", lastAccessAt: "2026-08-16T12:00:01.000Z" },
|
||||
{ cacheKey: key("c"), byteLength: 4, createdAt: "2026-08-16T12:00:02.000Z", lastAccessAt: "2026-08-16T12:00:02.000Z" },
|
||||
];
|
||||
const plan = simulation.planSimulationCacheLRU(candidates, 4, [key("a")]);
|
||||
assert.deepEqual(plan.cacheKeys, [key("b"), key("c")]);
|
||||
assert.deepEqual(plan.protectedCacheKeys, [key("a")]);
|
||||
assert.equal(plan.beforeBytes, 12);
|
||||
assert.equal(plan.remainingBytes, 4);
|
||||
assert.equal(plan.removedBytes, 8);
|
||||
assert.equal(plan.budgetSatisfied, true);
|
||||
});
|
||||
|
||||
test("M10-06 reports an unsatisfied LRU budget instead of evicting an active cache", () => {
|
||||
const cacheKey = `sim2-${"d".repeat(64)}`;
|
||||
const plan = simulation.planSimulationCacheLRU([
|
||||
{ cacheKey, byteLength: 8, createdAt: "2026-08-16T12:00:00.000Z", lastAccessAt: "2026-08-16T12:00:00.000Z" },
|
||||
], 0, [cacheKey]);
|
||||
assert.deepEqual(plan.cacheKeys, []);
|
||||
assert.equal(plan.remainingBytes, 8);
|
||||
assert.equal(plan.budgetSatisfied, false);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
29
web/tests/unit/storage-budget.test.mjs
Normal file
29
web/tests/unit/storage-budget.test.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/storage-budget.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
|
||||
const { createStorageBudget, formatStorageBytes } = await import(moduleUrl);
|
||||
|
||||
test("M7-11 storage budget reports stable category totals", () => {
|
||||
const budget = createStorageBudget("budget", { projectBytes: 10, snapshotBytes: 20, lodBytes: 30, mediaBytes: 40, vdbBytes: 50 });
|
||||
assert.deepEqual(budget, { schemaVersion: 1, projectId: "budget", projectBytes: 10, snapshotBytes: 20, lodBytes: 30, mediaBytes: 40, vdbBytes: 50, totalBytes: 150 });
|
||||
assert.equal(formatStorageBytes(1024), "1.0 KiB");
|
||||
assert.equal(formatStorageBytes(1024 * 1024), "1.0 MiB");
|
||||
});
|
||||
|
||||
test("M7-11 storage budget rejects invalid or overflowing categories", () => {
|
||||
assert.throws(() => createStorageBudget("budget", { mediaBytes: -1 }), /STORAGE_BUDGET_INVALID/);
|
||||
assert.throws(() => createStorageBudget("budget", { projectBytes: Number.MAX_SAFE_INTEGER, mediaBytes: Number.MAX_SAFE_INTEGER }), /STORAGE_BUDGET_INVALID/);
|
||||
assert.throws(() => formatStorageBytes(-1), /STORAGE_BUDGET_INVALID/);
|
||||
});
|
||||
71
web/tests/unit/texture-paint-asset.test.mjs
Normal file
71
web/tests/unit/texture-paint-asset.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "texture-paint-asset-unit-"));
|
||||
|
||||
function transpile(sourceName, outputName, replacements = []) {
|
||||
const sourcePath = path.join(repoRoot, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const output = replacements.reduce((source, [from, to]) => source.replace(from, to), transpiled.outputText);
|
||||
fs.writeFileSync(path.join(temporary, outputName), output);
|
||||
}
|
||||
|
||||
transpile("paint.ts", "paint.mjs");
|
||||
transpile("texture-paint-asset.ts", "texture-paint-asset.mjs", [['from "./paint"', 'from "./paint.mjs"']]);
|
||||
const protocol = await import(pathToFileURL(path.join(temporary, "texture-paint-asset.mjs")));
|
||||
const hash = "1".repeat(64);
|
||||
const target = {
|
||||
schemaVersion: 1,
|
||||
projectId: "texture-project",
|
||||
imageId: "image:Paint",
|
||||
textureAssetId: "image:Paint:tile:1001",
|
||||
kind: "PACKED",
|
||||
tile: 1001,
|
||||
revision: 4,
|
||||
width: 2,
|
||||
height: 2,
|
||||
mimeType: "image/png",
|
||||
colorSpace: "SRGB",
|
||||
sourcePath: "textures/paint.png",
|
||||
baseAssetSha256: hash,
|
||||
};
|
||||
const patch = {
|
||||
schemaVersion: 1,
|
||||
textureAssetId: target.textureAssetId,
|
||||
tile: 1001,
|
||||
revision: 4,
|
||||
width: 2,
|
||||
height: 2,
|
||||
format: "RGBA8",
|
||||
colorSpace: "SRGB",
|
||||
baseSha256: "2".repeat(64),
|
||||
resultSha256: "3".repeat(64),
|
||||
byteOffset: 0,
|
||||
bytes: new Uint8Array([1, 2, 3, 255]),
|
||||
};
|
||||
|
||||
test("M9-11 binds a dirty range to one packed or UDIM tile identity", () => {
|
||||
const parsed = protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target, patch });
|
||||
assert.equal(parsed.target.kind, "PACKED");
|
||||
assert.equal(parsed.patch.textureAssetId, target.textureAssetId);
|
||||
assert.equal(protocol.texturePaintTileBindingKey({ schemaVersion: 1, projectId: target.projectId, textureAssetId: target.textureAssetId, tile: 1001 }), "texture-paint:v1:texture-project:image%3APaint%3Atile%3A1001:1001");
|
||||
});
|
||||
|
||||
test("M9-11 rejects target, tile, revision and path drift before storage", () => {
|
||||
assert.throws(() => protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target, patch: { ...patch, tile: 1002 } }), /PAINT_SCHEMA_INVALID/);
|
||||
assert.throws(() => protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target, patch: { ...patch, revision: 5 } }), /PAINT_SCHEMA_INVALID/);
|
||||
assert.throws(() => protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target: { ...target, sourcePath: "../escape.png" }, patch }), /PAINT_SCHEMA_INVALID/);
|
||||
assert.throws(() => protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target: { ...target, baseAssetSha256: "bad" }, patch }), /PAINT_SCHEMA_INVALID/);
|
||||
assert.throws(() => protocol.validateTexturePaintTileCommit({ schemaVersion: 1, target, patch, extra: true }), /PAINT_SCHEMA_INVALID/);
|
||||
});
|
||||
35
web/tests/unit/ui-schema.test.mjs
Normal file
35
web/tests/unit/ui-schema.test.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/ui-schema.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
|
||||
const { createDefaultWebWorkspaceState, reduceUICommand } = await import(moduleUrl);
|
||||
|
||||
test("M7-13 menu and modal overlays are mutually exclusive and Escape-closeable", () => {
|
||||
let state = createDefaultWebWorkspaceState();
|
||||
state = reduceUICommand(state, { type: "toggleMenu", menu: "文件" });
|
||||
assert.equal(state.openMenu, "文件");
|
||||
assert.equal(state.operatorSearchOpen, false);
|
||||
state = reduceUICommand(state, { type: "toggleMenu", menu: "编辑" });
|
||||
assert.equal(state.openMenu, "编辑");
|
||||
assert.equal(state.operatorSearchOpen, false);
|
||||
state = reduceUICommand(state, { type: "toggleOperatorSearch", open: true });
|
||||
assert.equal(state.operatorSearchOpen, true);
|
||||
assert.equal(state.openMenu, null);
|
||||
state = reduceUICommand(state, { type: "toggleOperatorSearch", open: false });
|
||||
assert.equal(state.operatorSearchOpen, false);
|
||||
state = reduceUICommand(state, { type: "toggleMenu", menu: "窗口" });
|
||||
assert.equal(state.openMenu, "窗口");
|
||||
state = reduceUICommand(state, { type: "toggleMenu", menu: "窗口" });
|
||||
assert.equal(state.openMenu, null);
|
||||
});
|
||||
31
web/tests/unit/viewport-camera.test.mjs
Normal file
31
web/tests/unit/viewport-camera.test.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/viewport-camera.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
|
||||
const { VIEWPORT_DEFAULT_ORBIT, applyOrbitDelta, cameraState, orbitPosition, orbitStateFromPosition } = await import(moduleUrl);
|
||||
|
||||
test("M7-15 main and Offscreen orbit camera contract is deterministic", () => {
|
||||
const position = orbitPosition(VIEWPORT_DEFAULT_ORBIT);
|
||||
assert.deepEqual(position.map((value) => Number(value.toFixed(6))), [4.219781, -4.219781, 3.658811]);
|
||||
const restored = orbitStateFromPosition(position);
|
||||
assert.ok(Math.abs(restored.yaw - VIEWPORT_DEFAULT_ORBIT.yaw) < 1e-12);
|
||||
assert.ok(Math.abs(restored.pitch - VIEWPORT_DEFAULT_ORBIT.pitch) < 1e-12);
|
||||
assert.ok(Math.abs(restored.distance - VIEWPORT_DEFAULT_ORBIT.distance) < 1e-12);
|
||||
assert.deepEqual(restored.target, [0, 0, 0]);
|
||||
const next = applyOrbitDelta({ ...VIEWPORT_DEFAULT_ORBIT, target: [0, 0, 0] }, 24, -12, 120);
|
||||
assert.equal(next.yaw, VIEWPORT_DEFAULT_ORBIT.yaw - 24 * 0.008);
|
||||
assert.equal(next.pitch, VIEWPORT_DEFAULT_ORBIT.pitch - 12 * 0.008);
|
||||
assert.equal(next.distance, VIEWPORT_DEFAULT_ORBIT.distance * Math.exp(0.12));
|
||||
assert.deepEqual(cameraState(next).position, orbitPosition(next));
|
||||
});
|
||||
71
web/tests/unit/weight-paint.test.mjs
Normal file
71
web/tests/unit/weight-paint.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "weight-paint-unit-"));
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/weight-paint.ts");
|
||||
const outputPath = path.join(temporary, "weight-paint.mjs");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(outputPath, transpiled.outputText);
|
||||
const weightPaint = await import(pathToFileURL(outputPath));
|
||||
|
||||
const vertices = [
|
||||
{ index: 0, position: [-1, 0, 0], influences: [{ group: "Root", weight: 1 }, { group: "Tip", weight: 0.25 }] },
|
||||
{ index: 1, position: [1, 0, 0], influences: [{ group: "Root", weight: 1 }, { group: "Tip", weight: 0.25 }] },
|
||||
{ index: 2, position: [0, 1, 0], influences: [{ group: "Root", weight: 0.25 }, { group: "Tip", weight: 1 }] },
|
||||
];
|
||||
|
||||
test("M9-12 normalizes after limiting influences", () => {
|
||||
const result = weightPaint.applyWeightPaintPatch(vertices, {
|
||||
schemaVersion: 1,
|
||||
vertexGroup: "WebPaintGroup",
|
||||
indices: [2],
|
||||
values: [0.5],
|
||||
normalize: true,
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(result[2].influences, [
|
||||
{ group: "Tip", weight: 2 / 3 },
|
||||
{ group: "WebPaintGroup", weight: 1 / 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("M9-12 mirrors onto a reciprocal verified coordinate map", () => {
|
||||
const result = weightPaint.applyWeightPaintPatch(vertices, {
|
||||
schemaVersion: 1,
|
||||
vertexGroup: "WebPaintGroup",
|
||||
indices: [0],
|
||||
values: [0.8],
|
||||
mirror: true,
|
||||
mirrorAxis: 0,
|
||||
mirrorTolerance: 1e-4,
|
||||
});
|
||||
assert.equal(result[0].influences.at(-1).weight, 0.8);
|
||||
assert.equal(result[1].influences.at(-1).weight, 0.8);
|
||||
});
|
||||
|
||||
test("M9-12 rejects an unverified symmetry map and invalid limits", () => {
|
||||
assert.throws(() => weightPaint.parseWeightPaintOptions({ limit: 33 }), /PAINT_SCHEMA_INVALID/);
|
||||
assert.throws(() => weightPaint.applyWeightPaintPatch([
|
||||
...vertices,
|
||||
{ index: 3, position: [4, 0, 0], influences: [] },
|
||||
], {
|
||||
schemaVersion: 1,
|
||||
vertexGroup: "WebPaintGroup",
|
||||
indices: [0],
|
||||
values: [0.8],
|
||||
mirror: true,
|
||||
mirrorAxis: 0,
|
||||
mirrorTolerance: 1e-4,
|
||||
}), /WEIGHT_MIRROR_SYMMETRY_UNVERIFIED/);
|
||||
});
|
||||
39
web/tests/unit/worker-fault.test.mjs
Normal file
39
web/tests/unit/worker-fault.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const sourcePath = path.join(repoRoot, "web/protocol/worker-fault.ts");
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
|
||||
const { createWorkerFault } = await import(moduleUrl);
|
||||
|
||||
test("M7-08 maps engine and storage crashes to one recoverable fault contract", () => {
|
||||
assert.deepEqual(createWorkerFault("engine", "engine crashed"), {
|
||||
source: "engine",
|
||||
error: {
|
||||
code: "WORKER_TERMINATED",
|
||||
severity: "error",
|
||||
message: "engine crashed",
|
||||
recoverable: true,
|
||||
cause: "engine-worker-fault",
|
||||
},
|
||||
});
|
||||
assert.deepEqual(createWorkerFault("storage", "storage crashed"), {
|
||||
source: "storage",
|
||||
error: {
|
||||
code: "WORKER_TERMINATED",
|
||||
severity: "error",
|
||||
message: "storage crashed",
|
||||
recoverable: true,
|
||||
cause: "storage-worker-fault",
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user