Advance M8-M11 parity workflows
This commit is contained in:
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