feat: add render graph driven renderer architecture
Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
@@ -0,0 +1,88 @@
|
||||
/** Test-only headless authority assembled from the example's public node definitions. */
|
||||
import { compileFxNodeComposition, composeNode, composeSocket, setTheme } from "@lib/composition/index.js";
|
||||
import type { FxNodeCompositionData } from "@lib/composition/index.js";
|
||||
import { bindFxNodeHeadless } from "@lib/headless-runtime.js";
|
||||
import {
|
||||
APPLICATION_HEADER_STYLES,
|
||||
APPLICATION_ID,
|
||||
APPLICATION_RESOURCES,
|
||||
APPLICATION_VERSION,
|
||||
applicationCompatibility,
|
||||
} from "../examples/blender/nodes/application.js";
|
||||
import { exampleTheme } from "../examples/shared/theme.js";
|
||||
import {
|
||||
anySocket,
|
||||
floatSocket,
|
||||
vectorSocket,
|
||||
colorSocket,
|
||||
shaderSocket,
|
||||
geometrySocket,
|
||||
} from "../examples/blender/nodes/socket-types.js";
|
||||
import { frameNode } from "../examples/blender/nodes/common/frame.js";
|
||||
import { rerouteNode } from "../examples/blender/nodes/common/reroute.js";
|
||||
import { groupInputNode } from "../examples/blender/nodes/common/group-input.js";
|
||||
import { groupOutputNode } from "../examples/blender/nodes/common/group-output.js";
|
||||
import { valueNode } from "../examples/blender/nodes/shader/value.js";
|
||||
import { colorNode } from "../examples/blender/nodes/shader/color.js";
|
||||
import { mathNode } from "../examples/blender/nodes/shader/math.js";
|
||||
import { vectorMathNode } from "../examples/blender/nodes/shader/vector-math.js";
|
||||
import { mixNode } from "../examples/blender/nodes/shader/mix.js";
|
||||
import { colorRampNode } from "../examples/blender/nodes/shader/color-ramp.js";
|
||||
import { textureCoordinateNode } from "../examples/blender/nodes/shader/texture-coordinate.js";
|
||||
import { noiseTextureNode } from "../examples/blender/nodes/shader/noise-texture.js";
|
||||
import { imageTextureNode } from "../examples/blender/nodes/shader/image-texture.js";
|
||||
import { principledBsdfNode } from "../examples/blender/nodes/shader/principled-bsdf.js";
|
||||
import { materialOutputNode } from "../examples/blender/nodes/shader/material-output.js";
|
||||
import { positionNode } from "../examples/blender/nodes/geometry/position.js";
|
||||
import { meshCubeNode } from "../examples/blender/nodes/geometry/mesh-cube.js";
|
||||
import { setPositionNode } from "../examples/blender/nodes/geometry/set-position.js";
|
||||
import { transformGeometryNode } from "../examples/blender/nodes/geometry/transform-geometry.js";
|
||||
import { joinGeometryNode } from "../examples/blender/nodes/geometry/join-geometry.js";
|
||||
import { imageNode } from "../examples/blender/nodes/compositor/image.js";
|
||||
import { colorBalanceNode } from "../examples/shared/nodes/color-balance.js";
|
||||
|
||||
const seed = {
|
||||
schemaVersion: 2,
|
||||
id: APPLICATION_ID,
|
||||
version: APPLICATION_VERSION,
|
||||
nodeStyles: APPLICATION_HEADER_STYLES,
|
||||
resources: APPLICATION_RESOURCES,
|
||||
compatibility: applicationCompatibility,
|
||||
socketTypes: {},
|
||||
nodes: {},
|
||||
} as const;
|
||||
const themed = setTheme(seed, exampleTheme);
|
||||
const socket1 = composeSocket(themed, ...anySocket);
|
||||
const socket2 = composeSocket(socket1, ...floatSocket);
|
||||
const socket3 = composeSocket(socket2, ...vectorSocket);
|
||||
const socket4 = composeSocket(socket3, ...colorSocket);
|
||||
const socket5 = composeSocket(socket4, ...shaderSocket);
|
||||
const socket6 = composeSocket(socket5, ...geometrySocket);
|
||||
const node1 = composeNode(socket6, ...frameNode);
|
||||
const node2 = composeNode(node1, ...rerouteNode);
|
||||
const node3 = composeNode(node2, ...groupInputNode);
|
||||
const node4 = composeNode(node3, ...groupOutputNode);
|
||||
const node5 = composeNode(node4, ...valueNode);
|
||||
const node6 = composeNode(node5, ...colorNode);
|
||||
const node7 = composeNode(node6, ...mathNode);
|
||||
const node8 = composeNode(node7, ...vectorMathNode);
|
||||
const node9 = composeNode(node8, ...mixNode);
|
||||
const firstBranch = composeNode(node9, ...colorRampNode);
|
||||
const node11 = composeNode(socket6, ...textureCoordinateNode);
|
||||
const node12 = composeNode(node11, ...noiseTextureNode);
|
||||
const node13 = composeNode(node12, ...imageTextureNode);
|
||||
const node14 = composeNode(node13, ...principledBsdfNode);
|
||||
const node15 = composeNode(node14, ...materialOutputNode);
|
||||
const secondBranch = composeNode(node15, ...positionNode);
|
||||
const node17 = composeNode(socket6, ...meshCubeNode);
|
||||
const node18 = composeNode(node17, ...setPositionNode);
|
||||
const node19 = composeNode(node18, ...transformGeometryNode);
|
||||
const node20 = composeNode(node19, ...joinGeometryNode);
|
||||
const node21 = composeNode(node20, ...imageNode);
|
||||
const thirdBranch = composeNode(node21, ...colorBalanceNode);
|
||||
const source: FxNodeCompositionData = {
|
||||
...firstBranch,
|
||||
nodes: { ...firstBranch.nodes, ...secondBranch.nodes, ...thirdBranch.nodes },
|
||||
};
|
||||
export const APPLICATION_COMPILED = compileFxNodeComposition(source);
|
||||
export const APPLICATION_HEADLESS = bindFxNodeHeadless(APPLICATION_COMPILED);
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
planAtlasCompaction,
|
||||
planAtlasUpsert,
|
||||
removeAtlasItem,
|
||||
type AtlasLayout,
|
||||
} from "@lib/worker/atlas-allocator.js";
|
||||
|
||||
const add = (layout: AtlasLayout | undefined, id: string, width: number, height: number) => {
|
||||
const plan = planAtlasUpsert(layout, { id, width, height });
|
||||
assert.equal(plan.ok, true);
|
||||
return plan.ok ? plan.layout : undefined!;
|
||||
};
|
||||
const overlaps = (a: { x: number; y: number; width: number; height: number }, b: typeof a) =>
|
||||
a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
||||
function valid(layout: AtlasLayout): void {
|
||||
const regions = [...layout.regions.values()],
|
||||
free = [...layout.free];
|
||||
regions.forEach((region, index) => {
|
||||
assert(region.x >= 0 && region.y >= 0);
|
||||
assert(region.x + region.width <= layout.width);
|
||||
assert(region.y + region.height <= layout.height);
|
||||
regions.slice(index + 1).forEach((other) => assert.equal(overlaps(region, other), false));
|
||||
free.forEach((other) => assert.equal(overlaps(region, other), false));
|
||||
});
|
||||
free.forEach((region, index) => {
|
||||
assert(region.x >= 0 && region.y >= 0);
|
||||
assert(region.x + region.width <= layout.width);
|
||||
assert(region.y + region.height <= layout.height);
|
||||
free.slice(index + 1).forEach((other) => assert.equal(overlaps(region, other), false));
|
||||
});
|
||||
const covered = [...regions, ...free].reduce((sum, region) => sum + region.width * region.height, 0);
|
||||
assert.equal(covered, layout.width * layout.height);
|
||||
}
|
||||
|
||||
test("atlas allocation is deterministic, bounded, and preserves shrink capacity", () => {
|
||||
const first = add(undefined, "a", 100, 80);
|
||||
assert.deepEqual(
|
||||
{ width: first.width, height: first.height, region: first.regions.get("a") },
|
||||
{
|
||||
width: 256,
|
||||
height: 256,
|
||||
region: { x: 0, y: 0, width: 100, height: 80 },
|
||||
},
|
||||
);
|
||||
const second = add(first, "b", 90, 70),
|
||||
replay = add(add(undefined, "a", 100, 80), "b", 90, 70);
|
||||
assert.deepEqual([...second.regions], [...replay.regions]);
|
||||
const shrunk = add(second, "a", 40, 30);
|
||||
assert.deepEqual(shrunk.regions.get("a"), second.regions.get("a"));
|
||||
assert.deepEqual(shrunk.items.get("a"), { width: 40, height: 30 });
|
||||
valid(shrunk);
|
||||
});
|
||||
|
||||
test("atlas relocation, removal, growth, and capacity failure remain atomic", () => {
|
||||
let layout = add(undefined, "a", 200, 200);
|
||||
layout = add(layout, "b", 200, 200);
|
||||
const before = [...layout.regions];
|
||||
const oversized = planAtlasUpsert(layout, { id: "bad", width: 8193, height: 1 });
|
||||
assert.deepEqual(oversized, { ok: false, code: "atlas.dimension" });
|
||||
assert.deepEqual([...layout.regions], before);
|
||||
const grown = add(layout, "a", 500, 300);
|
||||
assert(grown.width * grown.height >= layout.width * layout.height);
|
||||
valid(grown);
|
||||
const removed = removeAtlasItem(grown, "b")!;
|
||||
assert.equal(removed.items.has("b"), false);
|
||||
valid(removed);
|
||||
assert.equal(removeAtlasItem(removed, "a"), undefined);
|
||||
});
|
||||
|
||||
test("atlas compaction is deferred unless occupancy and savings justify it", () => {
|
||||
let layout = add(undefined, "large", 1000, 1000);
|
||||
layout = add(layout, "small", 100, 100);
|
||||
layout = removeAtlasItem(layout, "large")!;
|
||||
const compact = planAtlasCompaction(layout);
|
||||
assert(compact?.ok);
|
||||
if (compact?.ok) {
|
||||
assert(compact.layout.width * compact.layout.height <= (layout.width * layout.height) / 2);
|
||||
valid(compact.layout);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
test("browser client has no DOM lifecycle ownership", () => {
|
||||
const path = new URL("../src/browser/client.ts", import.meta.url),
|
||||
text = readFileSync(path, "utf8"),
|
||||
source = ts.createSourceFile(path.pathname, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
const forbidden = new Set([
|
||||
"document",
|
||||
"window",
|
||||
"ResizeObserver",
|
||||
"MutationObserver",
|
||||
"isConnected",
|
||||
"addEventListener",
|
||||
"removeEventListener",
|
||||
"createElement",
|
||||
"getBoundingClientRect",
|
||||
"focus",
|
||||
"setPointerCapture",
|
||||
"releasePointerCapture",
|
||||
"hasPointerCapture",
|
||||
"tabIndex",
|
||||
"touchAction",
|
||||
"clientWidth",
|
||||
"clientHeight",
|
||||
]),
|
||||
found = new Set<string>();
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isIdentifier(node) && forbidden.has(node.text)) found.add(node.text);
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
assert.deepEqual([...found], []);
|
||||
assert(!text.includes('from "./add-node-menu.js"'));
|
||||
assert(!/\b(?:this\.)?canvas\.(?:width|height)\s*=/.test(text));
|
||||
assert(!existsSync(new URL("../src/browser/add-node-menu.ts", import.meta.url)));
|
||||
const publicIndex = readFileSync(new URL("../src/index.ts", import.meta.url), "utf8");
|
||||
assert(publicIndex.includes("FxNodeResourceOpenRequest"));
|
||||
assert(!publicIndex.includes("FxNodeResourceOpenTarget"));
|
||||
assert(!publicIndex.includes("FxNodeResourceHitRegion"));
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("right-click DOM menu searches and adds through one worker gesture", async ({ page }) => {
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
await page.evaluate(() => {
|
||||
const events: {
|
||||
mutations: Array<{ version: number; cause: string; mutations: readonly unknown[] }>;
|
||||
snapshots: number[];
|
||||
requests: Array<{ frozen: boolean; revision: number; position: { x: number; y: number } }>;
|
||||
} = { mutations: [], snapshots: [], requests: [] };
|
||||
(window as typeof window & { menuEvents: typeof events }).menuEvents = events;
|
||||
window.root.onMutations((event) => events.mutations.push(event));
|
||||
window.root.onSnapshots((event) => events.snapshots.push(event.version));
|
||||
window.view.onHostRequests((request) => {
|
||||
if (request.kind === "add-node-menu")
|
||||
events.requests.push({
|
||||
frozen: Object.isFrozen(request) && Object.isFrozen(request.viewPosition),
|
||||
revision: request.compositionRevision,
|
||||
position: { ...request.viewPosition },
|
||||
});
|
||||
});
|
||||
});
|
||||
const canvas = page.locator("#primary");
|
||||
await canvas.click({ button: "right", position: { x: 40, y: 50 } });
|
||||
const dialog = page.getByRole("dialog", { name: "Add node" });
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(
|
||||
await dialog.getByRole("group").evaluateAll((groups) =>
|
||||
groups.map((group) => ({
|
||||
name: group.getAttribute("aria-label"),
|
||||
options: [...group.querySelectorAll('[role="option"]')].map((item) => item.textContent?.trim()),
|
||||
})),
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "Common", options: ["Frame", "Reroute", "Group Input", "Group Output"] },
|
||||
{
|
||||
name: "Shader",
|
||||
options: [
|
||||
"Value",
|
||||
"Color",
|
||||
"Math",
|
||||
"Vector Math",
|
||||
"Mix",
|
||||
"Color Ramp",
|
||||
"Texture Coordinate",
|
||||
"Noise Texture",
|
||||
"Image Texture",
|
||||
"Principled BSDF",
|
||||
"Material Output",
|
||||
],
|
||||
},
|
||||
{ name: "Geometry", options: ["Position", "Mesh Cube", "Set Position", "Transform Geometry", "Join Geometry"] },
|
||||
{ name: "Compositor", options: ["Image", "Color Balance"] },
|
||||
]);
|
||||
const search = page.getByRole("combobox", { name: "Search nodes" });
|
||||
await search.fill(" ShAdEr ");
|
||||
await expect(dialog.getByRole("option")).toHaveText([
|
||||
"Value",
|
||||
"Color",
|
||||
"Math",
|
||||
"Vector Math",
|
||||
"Mix",
|
||||
"Color Ramp",
|
||||
"Texture Coordinate",
|
||||
"Noise Texture",
|
||||
"Image Texture",
|
||||
"Principled BSDF",
|
||||
"Material Output",
|
||||
]);
|
||||
await search.fill("noise-texture");
|
||||
await expect(dialog.getByRole("option")).toHaveText(["Noise Texture"]);
|
||||
await search.press("Enter");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.root.getState().then((snapshot) => snapshot.nodes.length)))
|
||||
.toBe(1);
|
||||
const result = await page.evaluate(async () => ({
|
||||
snapshot: await window.root.getState(),
|
||||
events: (
|
||||
window as typeof window & {
|
||||
menuEvents: {
|
||||
mutations: Array<{ version: number; cause: string; mutations: readonly unknown[] }>;
|
||||
snapshots: number[];
|
||||
requests: Array<{ frozen: boolean; revision: number; position: { x: number; y: number } }>;
|
||||
};
|
||||
}
|
||||
).menuEvents,
|
||||
}));
|
||||
expect(result.snapshot.nodes[0]?.typeId).toBe("fxnode.shader.noise-texture");
|
||||
expect(result.snapshot.nodes[0]?.position).toEqual({ x: -120, y: 40 });
|
||||
expect(result.events.mutations).toHaveLength(1);
|
||||
expect(result.events.mutations[0]).toMatchObject({ version: 2, cause: "api" });
|
||||
expect(result.events.mutations[0]?.mutations).toHaveLength(1);
|
||||
expect(result.events.snapshots).toEqual([2]);
|
||||
expect(result.events.requests).toEqual([{ frozen: true, revision: 31, position: { x: 40, y: 50 } }]);
|
||||
const rootAdded = await page.evaluate(async () => {
|
||||
const beforeSelection = window.view.getHostSnapshot().selection.nodeCount;
|
||||
await window.root.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: -120, y: 40 } });
|
||||
const snapshot = await window.root.getState();
|
||||
return {
|
||||
id: snapshot.nodes.find((node) => node.typeId === "fxnode.shader.value")!.id,
|
||||
beforeSelection,
|
||||
afterSelection: window.view.getHostSnapshot().selection.nodeCount,
|
||||
};
|
||||
});
|
||||
expect(rootAdded.afterSelection).toBe(rootAdded.beforeSelection);
|
||||
await canvas.press("Delete");
|
||||
expect((await page.evaluate(() => window.root.getState())).nodes.map((node) => node.id)).toEqual([rootAdded.id]);
|
||||
await windowUndo(page);
|
||||
await windowUndo(page);
|
||||
await windowUndo(page);
|
||||
expect((await page.evaluate(() => window.root.getState())).nodes).toHaveLength(0);
|
||||
await canvas.click({ button: "right", position: { x: 80, y: 80 } });
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
window.fxnodeHost.destroy();
|
||||
await window.view.detach();
|
||||
window.root.destroy();
|
||||
});
|
||||
await expect(dialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("right-click on a node and Ctrl-RMB never open the add menu", async ({ page }) => {
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
const canvas = page.locator("#graph"),
|
||||
dialog = page.getByRole("dialog", { name: "Add node" });
|
||||
await canvas.click({ button: "right", position: { x: 320, y: 160 } });
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await canvas.hover({ position: { x: 30, y: 30 } });
|
||||
await page.keyboard.down("Control");
|
||||
await page.mouse.down({ button: "right" });
|
||||
await page.mouse.up({ button: "right" });
|
||||
await page.keyboard.up("Control");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
async function windowUndo(page: import("@playwright/test").Page): Promise<void> {
|
||||
await page.evaluate(() => window.root.undo());
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
test("@visual all supported catalog", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await page.goto("/examples/blender/all-supported/");
|
||||
await page.waitForFunction(() => Boolean(window.fxnodeExample));
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
await page.locator("canvas").press("Home");
|
||||
await page.evaluate(() => window.fxnodeExample.view!.whenRendered());
|
||||
await expect(page.locator("canvas")).toHaveScreenshot("all-supported.png", { animations: "disabled" });
|
||||
});
|
||||
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,129 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const expectedNodeTypes = [
|
||||
"fxnode.common.frame",
|
||||
"fxnode.common.reroute",
|
||||
"fxnode.common.group-input",
|
||||
"fxnode.common.group-output",
|
||||
"fxnode.shader.value",
|
||||
"fxnode.shader.color",
|
||||
"fxnode.shader.math",
|
||||
"fxnode.shader.vector-math",
|
||||
"fxnode.shader.mix",
|
||||
"fxnode.shader.color-ramp",
|
||||
"fxnode.shader.texture-coordinate",
|
||||
"fxnode.shader.noise-texture",
|
||||
"fxnode.shader.image-texture",
|
||||
"fxnode.shader.principled-bsdf",
|
||||
"fxnode.shader.material-output",
|
||||
"fxnode.geometry.position",
|
||||
"fxnode.geometry.mesh-cube",
|
||||
"fxnode.geometry.set-position",
|
||||
"fxnode.geometry.transform-geometry",
|
||||
"fxnode.geometry.join-geometry",
|
||||
"fxnode.compositor.image",
|
||||
"fxnode.compositor.color-balance",
|
||||
] as const;
|
||||
|
||||
test("example installs every startup node through the live worker API", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const sent: unknown[] = [],
|
||||
received: unknown[] = [];
|
||||
(
|
||||
window as unknown as { applicationStartupMessages: { sent: unknown[]; received: unknown[] } }
|
||||
).applicationStartupMessages = { sent, received };
|
||||
const NativeWorker = Worker;
|
||||
class StartupWorker extends NativeWorker {
|
||||
constructor(url: string | URL, options?: WorkerOptions) {
|
||||
super(url, options);
|
||||
super.addEventListener("message", (event) => received.push(structuredClone(event.data)));
|
||||
}
|
||||
override postMessage(message: unknown, options?: StructuredSerializeOptions | Transferable[]) {
|
||||
sent.push(structuredClone(message));
|
||||
super.postMessage(message, options as StructuredSerializeOptions);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: StartupWorker });
|
||||
});
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
const messages = (window as unknown as { applicationStartupMessages: { sent: any[]; received: any[] } })
|
||||
.applicationStartupMessages,
|
||||
init = messages.sent.find((message) => message.type === "init"),
|
||||
updates = messages.sent.filter((message) => message.type === "composition.update"),
|
||||
stateSet = messages.sent.find((message) => message.type === "state.set"),
|
||||
api = window.fxnodeExample.root!;
|
||||
const updateIds = new Set(updates.map((message) => message.id)),
|
||||
compositionReceipts = messages.received
|
||||
.filter((message) => message.type === "response" && updateIds.has(message.id))
|
||||
.map((message) => message.value),
|
||||
stateSetIndex = messages.sent.indexOf(stateSet),
|
||||
lastUpdateIndex = Math.max(...updates.map((message) => messages.sent.indexOf(message))),
|
||||
snapshot = await api.getState(),
|
||||
undo = await api.undo({ expectedVersion: 1 }),
|
||||
empty = await api.getState(),
|
||||
redo = await api.redo({ expectedVersion: 2 }),
|
||||
restored = await api.getState();
|
||||
return {
|
||||
initKeys: Object.keys(init).sort(),
|
||||
updates: updates.map((message) => ({
|
||||
kind: message.update.kind,
|
||||
id: message.update.id,
|
||||
expected: message.expected,
|
||||
})),
|
||||
compositionReceipts,
|
||||
stateSet,
|
||||
stateSetIndex,
|
||||
lastUpdateIndex,
|
||||
snapshot,
|
||||
undo,
|
||||
empty,
|
||||
redo,
|
||||
restored,
|
||||
saved: await api.save(),
|
||||
initialLayout: await (await fetch("/examples/blender/initialLayout.json")).json(),
|
||||
};
|
||||
});
|
||||
expect(result.initKeys).toEqual([
|
||||
"applicationId",
|
||||
"applicationVersion",
|
||||
"historyLimit",
|
||||
"id",
|
||||
"protocol",
|
||||
"resources",
|
||||
"type",
|
||||
]);
|
||||
expect(result.updates).toHaveLength(31);
|
||||
expect(result.updates.slice(0, 9).map((update) => update.kind)).toEqual([
|
||||
"theme.set",
|
||||
"header-styles.set",
|
||||
...Array(6).fill("socket.compose"),
|
||||
"compatibility.set",
|
||||
]);
|
||||
expect(result.updates.slice(9).map((update) => update.kind)).toEqual(Array(22).fill("node.compose"));
|
||||
expect(result.updates.slice(9).map((update) => update.id)).toEqual(expectedNodeTypes);
|
||||
expect(result.updates.map((update) => update.expected)).toEqual(Array(31).fill({ kind: "current" }));
|
||||
expect(result.compositionReceipts).toHaveLength(31);
|
||||
expect(
|
||||
result.compositionReceipts.every((receipt) => receipt.graphVersion === 0 && receipt.graphChanged === false),
|
||||
).toBe(true);
|
||||
expect(result.stateSetIndex).toBeGreaterThan(result.lastUpdateIndex);
|
||||
expect(result.stateSet.expected).toEqual({ kind: "current" });
|
||||
expect(result.stateSet.state.schemaVersion).toBeUndefined();
|
||||
expect(result.stateSet.state.version).toBeUndefined();
|
||||
expect(result.stateSet.state.nodes.every((node: any) => node.known === true)).toBe(true);
|
||||
expect(result.snapshot.version).toBe(1);
|
||||
expect(result.snapshot.nodes.length).toBe(result.initialLayout.nodes.length);
|
||||
expect(result.snapshot.nodes.every((node: any) => node.known)).toBe(true);
|
||||
expect(result.undo).toEqual({ status: "committed", version: 2 });
|
||||
expect(result.empty).toMatchObject({ version: 2, nodes: [], links: [] });
|
||||
expect(result.redo).toEqual({ status: "committed", version: 3 });
|
||||
expect(result.restored.nodes).toEqual(result.snapshot.nodes);
|
||||
expect(result.restored.links).toEqual(result.snapshot.links);
|
||||
expect(result.saved).toEqual({
|
||||
schemaVersion: 2,
|
||||
...result.initialLayout,
|
||||
nodes: result.initialLayout.nodes.map(({ known: _known, ...node }: any) => node),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("canvas-free authority owns independent attachable views", async ({ page }) => {
|
||||
await page.goto("/test/browser/client-runtime.html");
|
||||
const result = await page.evaluate(async () => {
|
||||
type Wire = Record<string, unknown> & { type: string; id?: string; viewId?: string };
|
||||
class FakeWorker {
|
||||
static readonly instances: FakeWorker[] = [];
|
||||
readonly posted: Wire[] = [];
|
||||
onmessage: ((event: MessageEvent<unknown>) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
onmessageerror: ((event: MessageEvent<unknown>) => void) | null = null;
|
||||
terminated = false;
|
||||
holdViewports = false;
|
||||
readonly heldViewportIds: string[] = [];
|
||||
|
||||
constructor() {
|
||||
FakeWorker.instances.push(this);
|
||||
}
|
||||
|
||||
postMessage(message: Wire): void {
|
||||
this.posted.push(message);
|
||||
if (!message.id) return;
|
||||
if (message.type === "init") this.respond(message.id);
|
||||
else if (message.type === "view.attach") {
|
||||
// Exercise the valid event-before-acknowledgement ordering.
|
||||
this.emit({
|
||||
protocol: 3,
|
||||
type: "view.selection.host",
|
||||
viewId: message.viewId,
|
||||
projection: { nodeCount: 0, linkCount: 0, canRemove: false, mute: { enabled: false } },
|
||||
});
|
||||
this.respond(message.id);
|
||||
} else if (message.type === "view.detach") this.respond(message.id);
|
||||
else if (message.type === "command") this.respond(message.id, { status: "committed", version: 1 });
|
||||
else if (message.type === "view.viewport") {
|
||||
if (this.holdViewports) this.heldViewportIds.push(message.id);
|
||||
else this.respond(message.id);
|
||||
} else if (message.type.startsWith("view.")) this.respond(message.id, { status: "noop", version: 0 });
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
|
||||
emit(data: unknown): void {
|
||||
queueMicrotask(() => this.onmessage?.(new MessageEvent("message", { data })));
|
||||
}
|
||||
|
||||
settleViewport(ok: boolean): void {
|
||||
const id = this.heldViewportIds.shift()!;
|
||||
this.emit({
|
||||
protocol: 3,
|
||||
type: "response",
|
||||
id,
|
||||
ok,
|
||||
...(ok ? {} : { error: { code: "viewport.test", message: "Test rejection" } }),
|
||||
});
|
||||
}
|
||||
|
||||
private respond(id: string, value?: unknown): void {
|
||||
this.emit({
|
||||
protocol: 3,
|
||||
type: "response",
|
||||
id,
|
||||
ok: true,
|
||||
...(value === undefined ? {} : { value }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const NativeWorker = window.Worker;
|
||||
Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: FakeWorker });
|
||||
try {
|
||||
const { createFxNode, FxNodeViewDetachedError } = (await import(
|
||||
"/src/index.ts" as string
|
||||
)) as typeof import("@lib/index.js");
|
||||
const api = await createFxNode({ applicationId: "client-test", applicationVersion: 1, resources: {} });
|
||||
const worker = FakeWorker.instances[0]!;
|
||||
const rootMessages = worker.posted.map((message) => message.type);
|
||||
const firstCanvas = document.createElement("canvas"),
|
||||
secondCanvas = document.createElement("canvas"),
|
||||
viewport = { width: 320, height: 180, dpr: 1 };
|
||||
const first = await api.attachView({ canvas: firstCanvas, viewport });
|
||||
const second = await api.attachView({
|
||||
canvas: secondCanvas,
|
||||
viewport,
|
||||
initialCamera: { center: { x: 2_000, y: 0 }, zoom: 0.75 },
|
||||
});
|
||||
const pointerLaneCount = worker.posted.filter(
|
||||
(message) =>
|
||||
message.type === "view.attach" &&
|
||||
typeof SharedArrayBuffer === "function" &&
|
||||
message.pointerLane instanceof SharedArrayBuffer,
|
||||
).length;
|
||||
let duplicateCode = "";
|
||||
try {
|
||||
await api.attachView({ canvas: firstCanvas, viewport });
|
||||
} catch (error) {
|
||||
duplicateCode = (error as { code?: string }).code ?? "";
|
||||
}
|
||||
worker.emit({
|
||||
protocol: 3,
|
||||
type: "view.selection.host",
|
||||
viewId: first.id,
|
||||
projection: {
|
||||
nodeCount: 1,
|
||||
linkCount: 0,
|
||||
canRemove: true,
|
||||
mute: { enabled: true, state: "all-unmuted" },
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
const selections = [first.getHostSnapshot().selection.nodeCount, second.getHostSnapshot().selection.nodeCount];
|
||||
|
||||
const modifiers = { alt: false, control: false, meta: false, shift: false };
|
||||
const viewportCount = () => worker.posted.filter((message) => message.type === "view.viewport").length;
|
||||
const beforeNoop = viewportCount();
|
||||
await first.setViewport(viewport);
|
||||
const noopDidNotPost = viewportCount() === beforeNoop;
|
||||
worker.holdViewports = true;
|
||||
const resizeA = first.setViewport({ width: 321, height: 180, dpr: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
const resizeB = first.setViewport({ width: 322, height: 180, dpr: 1 });
|
||||
const resizeC = first.setViewport({ width: 323, height: 180, dpr: 1 });
|
||||
const resizeMessagesBeforeA = viewportCount() - beforeNoop;
|
||||
const resourceRequests = [0, 0];
|
||||
first.onHostRequests((request) => {
|
||||
if (request.kind === "resource-open") resourceRequests[0] = resourceRequests[0]! + 1;
|
||||
});
|
||||
second.onHostRequests((request) => {
|
||||
if (request.kind === "resource-open") resourceRequests[1] = resourceRequests[1]! + 1;
|
||||
});
|
||||
first.feedInput({
|
||||
kind: "pointer",
|
||||
phase: "down",
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
position: { x: 12, y: 14 },
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
modifiers,
|
||||
});
|
||||
const inputDuringResize = [...worker.posted]
|
||||
.reverse()
|
||||
.find((message) => message.type === "view.input" && message.viewId === first.id)!;
|
||||
const resizeAWire = worker.posted.find(
|
||||
(message) => message.type === "view.viewport" && message.viewId === first.id,
|
||||
)!;
|
||||
worker.settleViewport(true);
|
||||
await resizeA;
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
const coalescedWire = [...worker.posted]
|
||||
.reverse()
|
||||
.find((message) => message.type === "view.viewport" && message.viewId === first.id)!;
|
||||
worker.settleViewport(false);
|
||||
const coalescedSettlements = await Promise.allSettled([resizeB, resizeC]);
|
||||
worker.holdViewports = false;
|
||||
const resourceOpenRequestId = [...worker.posted]
|
||||
.reverse()
|
||||
.find((message) => message.type === "view.input" && message.viewId === first.id)
|
||||
?.resourceOpenRequestId as string;
|
||||
first.feedInput({
|
||||
kind: "pointer",
|
||||
phase: "up",
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
position: { x: 12, y: 14 },
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
modifiers,
|
||||
});
|
||||
worker.emit({
|
||||
protocol: 3,
|
||||
type: "view.resource.open",
|
||||
viewId: first.id,
|
||||
requestId: resourceOpenRequestId,
|
||||
authorization: { viewId: first.id, token: "node:parameter:image", graphVersion: 0, compositionRevision: 0 },
|
||||
resource: {
|
||||
id: "image",
|
||||
kind: "image",
|
||||
title: "Image",
|
||||
openTitle: "Open image",
|
||||
accept: ["image/png"],
|
||||
maxBytes: 1024,
|
||||
maxWidth: 64,
|
||||
maxHeight: 64,
|
||||
maxPixels: 4096,
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
await first.setViewport({ width: 324, height: 180, dpr: 1 });
|
||||
for (const view of [first, second])
|
||||
view.feedInput({
|
||||
kind: "pointer",
|
||||
phase: "move",
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
position: { x: 10, y: 10 },
|
||||
button: -1,
|
||||
buttons: 0,
|
||||
modifiers,
|
||||
});
|
||||
const beforeDispatch = worker.posted.length;
|
||||
await api.dispatch({ type: "undo" });
|
||||
const dispatchTrace = worker.posted.slice(beforeDispatch).map((message) => message.type);
|
||||
|
||||
const detachA = first.detach(),
|
||||
detachB = first.detach(),
|
||||
stableDetachPromise = detachA === detachB;
|
||||
let detachedImmediately = false;
|
||||
try {
|
||||
first.getHostSnapshot();
|
||||
} catch (error) {
|
||||
detachedImmediately = error instanceof FxNodeViewDetachedError;
|
||||
}
|
||||
await detachA;
|
||||
const replacement = await api.attachView({ canvas: firstCanvas, viewport });
|
||||
await replacement.detach();
|
||||
await second.detach();
|
||||
api.destroy();
|
||||
return {
|
||||
rootMessages,
|
||||
idsDiffer: first.id !== second.id,
|
||||
duplicateCode,
|
||||
selections,
|
||||
resourceRequests,
|
||||
dispatchTrace,
|
||||
pointerLaneCount,
|
||||
noopDidNotPost,
|
||||
resizeMessagesBeforeA,
|
||||
coalescedWidth: (coalescedWire.viewport as { width: number }).width,
|
||||
coalescedSettlements: coalescedSettlements.map((item) => item.status),
|
||||
resizeHostGeneration: resizeAWire.hostGeneration,
|
||||
inputHostGeneration: inputDuringResize.hostGeneration,
|
||||
stableDetachPromise,
|
||||
detachedImmediately,
|
||||
terminated: worker.terminated,
|
||||
};
|
||||
} finally {
|
||||
Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker });
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.rootMessages).toEqual(["init"]);
|
||||
expect(result.idsDiffer).toBe(true);
|
||||
expect(result.duplicateCode).toBe("canvas.in-use");
|
||||
expect(result.selections).toEqual([1, 0]);
|
||||
// The coalesced resize is posted after the pointer request and intentionally clears that pre-existing request.
|
||||
expect(result.resourceRequests).toEqual([0, 0]);
|
||||
expect(result.dispatchTrace.at(-1)).toBe("command");
|
||||
expect(result.dispatchTrace.filter((type) => type === "view.pointer.flush")).toHaveLength(result.pointerLaneCount);
|
||||
expect(result.noopDidNotPost).toBe(true);
|
||||
expect(result.resizeMessagesBeforeA).toBe(1);
|
||||
expect(result.coalescedWidth).toBe(323);
|
||||
expect(result.coalescedSettlements).toEqual(["rejected", "rejected"]);
|
||||
expect(result.inputHostGeneration).toBeGreaterThan(result.resizeHostGeneration as number);
|
||||
expect(result.stableDetachPromise).toBe(true);
|
||||
expect(result.detachedImmediately).toBe(true);
|
||||
expect(result.terminated).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>FxNode client runtime test</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,277 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
type Point = Readonly<{ x: number; y: number }>;
|
||||
type ControlPage = Page & { readonly __controlPageBrand?: never };
|
||||
|
||||
// Canvas is 1200x640 and the zoom-1 origin is its center (600,320).
|
||||
// Layout rows are 24px high below a 24px header. Scalar numeric fields span
|
||||
// nearly the full row; compound and non-numeric controls retain split layout.
|
||||
const CONTROL_COORDS = Object.freeze({
|
||||
value: { x: 196, y: 147 },
|
||||
mathOperation: { x: 473, y: 147 },
|
||||
mathClamp: { x: 473, y: 171 },
|
||||
mathA: { x: 473, y: 195 },
|
||||
mathB: { x: 473, y: 219 },
|
||||
vectorOperation: { x: 853, y: 147 },
|
||||
vectorAX: { x: 792, y: 171 },
|
||||
vectorAY: { x: 853, y: 171 },
|
||||
colorSwatch: { x: 1003, y: 147 },
|
||||
groupString: { x: 623, y: 456 },
|
||||
} satisfies Record<string, Point>);
|
||||
|
||||
const open = async (page: Page): Promise<ControlPage> => {
|
||||
await page.goto("/examples/blender/control-test/");
|
||||
await page.evaluate(() => window.controlTest.ready);
|
||||
expect(await page.locator('input[type="file"]').count()).toBe(0);
|
||||
await page.evaluate(() => {
|
||||
window.controlEvents = { mutations: [], snapshots: [] };
|
||||
window.controlTest.root!.onMutations((event) => window.controlEvents.mutations.push(event.version));
|
||||
window.controlTest.root!.onSnapshots((event) => window.controlEvents.snapshots.push(event.version));
|
||||
});
|
||||
return page;
|
||||
};
|
||||
const state = (page: ControlPage) =>
|
||||
page.evaluate(async () => ({
|
||||
snapshot: await window.controlTest.root!.getState(),
|
||||
layout: await window.controlTest.root!.save(),
|
||||
events: window.controlEvents,
|
||||
}));
|
||||
const value = (layout: Awaited<ReturnType<typeof state>>["layout"], id: string, parameter: string) =>
|
||||
layout.nodes.find((node) => node.id === id)!.parameters[parameter]!;
|
||||
const socketValue = (layout: Awaited<ReturnType<typeof state>>["layout"], id: string, socket: string) =>
|
||||
layout.nodes.find((node) => node.id === id)!.sockets.find((item) => item.id === socket)!.defaultValue!;
|
||||
const invariant = (
|
||||
before: Awaited<ReturnType<typeof state>>,
|
||||
after: Awaited<ReturnType<typeof state>>,
|
||||
commits: number,
|
||||
) => {
|
||||
expect(after.snapshot.version - before.snapshot.version).toBe(commits);
|
||||
expect(after.events.mutations.slice(before.events.mutations.length)).toEqual(
|
||||
after.events.snapshots.slice(before.events.snapshots.length),
|
||||
);
|
||||
expect(after.events.mutations.length - before.events.mutations.length).toBe(commits);
|
||||
};
|
||||
async function scrub(
|
||||
page: ControlPage,
|
||||
point: Point,
|
||||
dx: number,
|
||||
modifiers: { shift?: boolean; control?: boolean } = {},
|
||||
) {
|
||||
const canvas = page.locator("#controls"),
|
||||
box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("Canvas bounds missing");
|
||||
if (modifiers.shift) await page.keyboard.down("Shift");
|
||||
if (modifiers.control) await page.keyboard.down("Control");
|
||||
await page.mouse.move(box.x + point.x, box.y + point.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + point.x + dx / 2, box.y + point.y, { steps: 3 });
|
||||
await page.mouse.move(box.x + point.x + dx, box.y + point.y, { steps: 3 });
|
||||
return async () => {
|
||||
await page.mouse.up();
|
||||
if (modifiers.control) await page.keyboard.up("Control");
|
||||
if (modifiers.shift) await page.keyboard.up("Shift");
|
||||
};
|
||||
}
|
||||
|
||||
test("number scrub is transient, atomic, cancellable, precise and snapping", async ({ page }) => {
|
||||
const p = await open(page),
|
||||
initial = await state(p),
|
||||
release = await scrub(p, CONTROL_COORDS.value, 30);
|
||||
const preview = await state(p);
|
||||
expect(preview.snapshot.version).toBe(initial.snapshot.version);
|
||||
expect(preview.events).toEqual(initial.events);
|
||||
await release();
|
||||
const normal = await state(p);
|
||||
invariant(initial, normal, 1);
|
||||
expect(value(normal.layout, "value", "value")).toEqual({ kind: "number", value: 3 });
|
||||
const cancelStart = await state(p),
|
||||
cancel = await scrub(p, CONTROL_COORDS.value, 30);
|
||||
await p.locator("#controls").press("Escape");
|
||||
await cancel();
|
||||
invariant(cancelStart, await state(p), 0);
|
||||
const shiftStart = await state(p),
|
||||
shiftedRelease = await scrub(p, CONTROL_COORDS.value, 30, { shift: true });
|
||||
await shiftedRelease();
|
||||
const shifted = await state(p);
|
||||
invariant(shiftStart, shifted, 1);
|
||||
expect((value(shifted.layout, "value", "value") as { value: number }).value - 3).toBeLessThan(3);
|
||||
const ctrlStart = await state(p),
|
||||
ctrlRelease = await scrub(p, CONTROL_COORDS.value, 37, { control: true });
|
||||
await ctrlRelease();
|
||||
const snapped = await state(p);
|
||||
invariant(ctrlStart, snapped, 1);
|
||||
expect(Number.isInteger((value(snapped.layout, "value", "value") as { value: number }).value)).toBe(true);
|
||||
});
|
||||
|
||||
test("numeric fields support typed assignment, cancellation, clamping and step arrows", async ({ page }) => {
|
||||
const p = await open(page),
|
||||
canvas = p.locator("#controls"),
|
||||
a = await state(p);
|
||||
await canvas.click({ position: CONTROL_COORDS.value });
|
||||
await p.keyboard.type("0.375");
|
||||
await p.keyboard.press("Enter");
|
||||
const b = await state(p);
|
||||
invariant(a, b, 1);
|
||||
expect(value(b.layout, "value", "value")).toEqual({ kind: "number", value: 0.375 });
|
||||
await canvas.click({ position: CONTROL_COORDS.value });
|
||||
await p.keyboard.type("999");
|
||||
await p.keyboard.press("Enter");
|
||||
const clamped = await state(p);
|
||||
invariant(b, clamped, 1);
|
||||
expect(value(clamped.layout, "value", "value")).toEqual({ kind: "number", value: 999 });
|
||||
await canvas.click({ position: CONTROL_COORDS.value });
|
||||
await p.keyboard.type("bad");
|
||||
await p.keyboard.press("Enter");
|
||||
invariant(clamped, await state(p), 0);
|
||||
await p.keyboard.press("Escape");
|
||||
await canvas.click({ position: { x: 235, y: 147 } });
|
||||
const stepped = await state(p);
|
||||
invariant(clamped, stepped, 1);
|
||||
expect(value(stepped.layout, "value", "value")).toEqual({ kind: "number", value: 1000 });
|
||||
await canvas.click({ position: { x: 115, y: 147 } });
|
||||
const decremented = await state(p);
|
||||
invariant(stepped, decremented, 1);
|
||||
expect(value(decremented.layout, "value", "value")).toEqual({ kind: "number", value: 999 });
|
||||
});
|
||||
|
||||
test("vector component scrubs commit only the selected component", async ({ page }) => {
|
||||
const p = await open(page),
|
||||
a = await state(p),
|
||||
r1 = await scrub(p, CONTROL_COORDS.vectorAY, 20);
|
||||
await r1();
|
||||
const b = await state(p);
|
||||
invariant(a, b, 1);
|
||||
expect(socketValue(b.layout, "vector", "vector:a")).toEqual({ kind: "vector", value: [0, 2, 0] });
|
||||
});
|
||||
|
||||
test("color picker keeps RGBA, HSV and hex previews transient until confirmation", async ({ page }) => {
|
||||
const p = await open(page),
|
||||
canvas = p.locator("#controls"),
|
||||
before = await state(p);
|
||||
await canvas.click({ position: CONTROL_COORDS.colorSwatch });
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("Canvas bounds missing");
|
||||
await page.mouse.move(box.x + 805, box.y + 245);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 850, box.y + 245, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
invariant(before, await state(p), 0);
|
||||
for (const [field, text] of [
|
||||
[{ x: 742, y: 378 }, "0.25"],
|
||||
[{ x: 752, y: 408 }, "120"],
|
||||
[{ x: 830, y: 439 }, "#336699CC"],
|
||||
] as const) {
|
||||
await canvas.click({ position: field });
|
||||
await page.keyboard.type(text);
|
||||
await page.keyboard.press("Enter");
|
||||
invariant(before, await state(p), 0);
|
||||
}
|
||||
await canvas.click({ position: { x: 723, y: 163 } });
|
||||
const committed = await state(p);
|
||||
invariant(before, committed, 1);
|
||||
expect(value(committed.layout, "color", "color")).toEqual({ kind: "color", value: [0.2, 0.4, 0.6, 0.8] });
|
||||
await canvas.click({ position: CONTROL_COORDS.colorSwatch });
|
||||
await canvas.click({ position: { x: 830, y: 439 } });
|
||||
await page.keyboard.type("#FF0000");
|
||||
await canvas.click({ position: { x: 20, y: 20 } });
|
||||
const outside = await state(p);
|
||||
invariant(committed, outside, 1);
|
||||
expect(value(outside.layout, "color", "color")).toEqual({ kind: "color", value: [1, 0, 0, 1] });
|
||||
await canvas.click({ position: CONTROL_COORDS.colorSwatch });
|
||||
await page.mouse.move(box.x + 805, box.y + 245);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 825, box.y + 220);
|
||||
await page.mouse.up();
|
||||
await canvas.press("Escape");
|
||||
invariant(outside, await state(p), 0);
|
||||
});
|
||||
|
||||
test("enum and boolean controls emit one mutation/snapshot pair", async ({ page }) => {
|
||||
const p = await open(page),
|
||||
canvas = p.locator("#controls"),
|
||||
a = await state(p);
|
||||
await canvas.click({ position: CONTROL_COORDS.mathOperation });
|
||||
const b = await state(p);
|
||||
invariant(a, b, 1);
|
||||
expect(value(b.layout, "math", "operation")).toEqual({ kind: "string", value: "subtract" });
|
||||
await canvas.press("ArrowDown");
|
||||
const c = await state(p);
|
||||
invariant(b, c, 1);
|
||||
expect(value(c.layout, "math", "operation")).toEqual({ kind: "string", value: "multiply" });
|
||||
await canvas.click({ position: CONTROL_COORDS.mathClamp });
|
||||
const d = await state(p);
|
||||
invariant(c, d, 1);
|
||||
expect(value(d.layout, "math", "clamp")).toEqual({ kind: "boolean", value: true });
|
||||
});
|
||||
|
||||
test("string editing commits on Enter and cancels on Escape or blur", async ({ page }) => {
|
||||
const p = await open(page),
|
||||
canvas = p.locator("#controls"),
|
||||
a = await state(p);
|
||||
await canvas.click({ position: CONTROL_COORDS.groupString });
|
||||
await p.keyboard.type(" Name");
|
||||
await p.keyboard.press("Enter");
|
||||
const b = await state(p);
|
||||
invariant(a, b, 1);
|
||||
expect(value(b.layout, "group", "interfaceName")).toEqual({ kind: "string", value: "Socket Name" });
|
||||
await canvas.click({ position: CONTROL_COORDS.groupString });
|
||||
await p.keyboard.type(" bad");
|
||||
await p.keyboard.press("Escape");
|
||||
invariant(b, await state(p), 0);
|
||||
await canvas.click({ position: CONTROL_COORDS.groupString });
|
||||
await p.keyboard.type(" worse");
|
||||
await p.locator("body").evaluate((body) => (body as HTMLElement).focus());
|
||||
await canvas.evaluate((element) => (element as HTMLCanvasElement).blur());
|
||||
invariant(b, await state(p), 0);
|
||||
});
|
||||
|
||||
test("Backspace resets defaults, undo restores, and linked socket is inert", async ({ page }) => {
|
||||
const p = await open(page),
|
||||
canvas = p.locator("#controls"),
|
||||
a = await state(p),
|
||||
r = await scrub(p, CONTROL_COORDS.value, 30);
|
||||
await r();
|
||||
const changed = await state(p);
|
||||
invariant(a, changed, 1);
|
||||
await canvas.hover({ position: CONTROL_COORDS.value });
|
||||
await canvas.press("Backspace");
|
||||
const reset = await state(p);
|
||||
invariant(changed, reset, 1);
|
||||
expect(value(reset.layout, "value", "value")).toEqual({ kind: "number", value: 0 });
|
||||
await p.evaluate(() => window.controlTest.root!.undo());
|
||||
const undone = await state(p);
|
||||
invariant(reset, undone, 1);
|
||||
expect(value(undone.layout, "value", "value")).toEqual({ kind: "number", value: 3 });
|
||||
await canvas.hover({ position: CONTROL_COORDS.mathA });
|
||||
await canvas.press("Backspace");
|
||||
invariant(undone, await state(p), 0);
|
||||
});
|
||||
|
||||
test("muting a link reveals its default control and unmuting hides it", async ({ page }) => {
|
||||
const p = await open(page),
|
||||
canvas = p.locator("#controls"),
|
||||
a = await state(p);
|
||||
await p.evaluate(() => {
|
||||
const id = window.controlTest.root!.save().then((layout) => layout.links[0]!.id);
|
||||
return id.then((link) => window.controlTest.root!.dispatch({ type: "link.mute", id: link, value: true }));
|
||||
});
|
||||
await p.evaluate(() => window.controlTest.view!.whenRendered());
|
||||
const muted = await state(p);
|
||||
invariant(a, muted, 1);
|
||||
expect(muted.layout.links[0]!.muted).toBe(true);
|
||||
const r = await scrub(p, CONTROL_COORDS.mathA, 20);
|
||||
await r();
|
||||
const edited = await state(p);
|
||||
invariant(muted, edited, 1);
|
||||
expect(socketValue(edited.layout, "math", "math:a")).toEqual({ kind: "number", value: 2 });
|
||||
await p.evaluate(() => {
|
||||
const id = window.controlTest.root!.save().then((layout) => layout.links[0]!.id);
|
||||
return id.then((link) => window.controlTest.root!.dispatch({ type: "link.mute", id: link, value: false }));
|
||||
});
|
||||
await p.evaluate(() => window.controlTest.view!.whenRendered());
|
||||
const live = await state(p);
|
||||
invariant(edited, live, 1);
|
||||
await canvas.click({ position: CONTROL_COORDS.mathA });
|
||||
invariant(live, await state(p), 0);
|
||||
expect((await state(p)).layout.links[0]!.muted).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
for (const example of ["minimal", "color-balance", "live-composition", "multi-view"])
|
||||
test(`${example} documentation image`, async ({ page }) => {
|
||||
await page.goto(`/examples/${example}/`);
|
||||
await page.evaluate(
|
||||
(name) => (name === "multi-view" ? window.fxnodeMultiView.ready : window.fxnodeStandalone.ready),
|
||||
example,
|
||||
);
|
||||
await expect(page).toHaveScreenshot(`${example}.png`, { animations: "disabled", fullPage: true });
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const examples = [
|
||||
{ path: "minimal", nodeId: "value", typeId: "example.minimal.value" },
|
||||
{ path: "color-balance", nodeId: "color-balance", typeId: "fxnode.compositor.color-balance" },
|
||||
{ path: "live-composition", nodeId: "live-node", typeId: "example.live.parameter" },
|
||||
] as const;
|
||||
|
||||
function capturePageErrors(page: Page): Error[] {
|
||||
const errors: Error[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error));
|
||||
return errors;
|
||||
}
|
||||
|
||||
test("gallery links every standalone application and loads its images", async ({ page }) => {
|
||||
await page.goto("/examples/");
|
||||
await expect(page.locator(".gallery a")).toHaveCount(5);
|
||||
expect(
|
||||
await page.locator(".gallery a").evaluateAll((links) => links.map((link) => link.getAttribute("href"))),
|
||||
).toEqual(["./minimal/", "./color-balance/", "./live-composition/", "./multi-view/", "./blender/"]);
|
||||
await expect(page.locator(".gallery img")).toHaveCount(4);
|
||||
expect(
|
||||
await page
|
||||
.locator(".gallery img")
|
||||
.evaluateAll((images) =>
|
||||
images.every((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("multi-view keeps view input and selection local while graph changes fan out", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const original = EventTarget.prototype.addEventListener;
|
||||
(window as unknown as { canvasListeners: Record<string, string[]> }).canvasListeners = {};
|
||||
EventTarget.prototype.addEventListener = function (type, listener, options) {
|
||||
if (this instanceof HTMLCanvasElement) {
|
||||
const events = (window as unknown as { canvasListeners: Record<string, string[]> }).canvasListeners;
|
||||
(events[this.id] ??= []).push(type);
|
||||
}
|
||||
return original.call(this, type, listener, options);
|
||||
};
|
||||
});
|
||||
await page.goto("/examples/multi-view/");
|
||||
await page.evaluate(() => window.fxnodeMultiView.ready);
|
||||
expect(
|
||||
await page.evaluate(() => (window as unknown as { canvasListeners: Record<string, string[]> }).canvasListeners),
|
||||
).toEqual({
|
||||
"view-a": [
|
||||
"pointerdown",
|
||||
"pointerdown",
|
||||
"pointermove",
|
||||
"pointerup",
|
||||
"pointercancel",
|
||||
"mousedown",
|
||||
"wheel",
|
||||
"keydown",
|
||||
"keyup",
|
||||
"focus",
|
||||
"blur",
|
||||
"contextmenu",
|
||||
"lostpointercapture",
|
||||
],
|
||||
"view-b": [
|
||||
"pointerdown",
|
||||
"pointerdown",
|
||||
"pointermove",
|
||||
"pointerup",
|
||||
"pointercancel",
|
||||
"mousedown",
|
||||
"wheel",
|
||||
"keydown",
|
||||
"keyup",
|
||||
"focus",
|
||||
"blur",
|
||||
"contextmenu",
|
||||
"lostpointercapture",
|
||||
],
|
||||
});
|
||||
|
||||
const baseline = await page.evaluate(async () => ({
|
||||
renders: [...window.fxnodeMultiView.renderCounts],
|
||||
ids: (await window.fxnodeMultiView.root!.getState()).nodes.map((node) => node.id),
|
||||
}));
|
||||
await page.getByRole("button", { name: "Add" }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.fxnodeMultiView.root!.getState().then((state) => state.nodes.length)))
|
||||
.toBe(baseline.ids.length + 1);
|
||||
await page.locator("#view-b").dispatchEvent("pointerdown", {
|
||||
pointerId: 2,
|
||||
pointerType: "mouse",
|
||||
clientX: 900,
|
||||
clientY: 300,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
});
|
||||
await expect(page.locator("article").nth(1)).toHaveClass(/active/);
|
||||
await page.getByRole("button", { name: "Add" }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.fxnodeMultiView.root!.getState().then((state) => state.nodes.length)))
|
||||
.toBe(baseline.ids.length + 2);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(counts) => window.fxnodeMultiView.renderCounts.every((value, i) => value > counts[i]!),
|
||||
baseline.renders,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
let result = await page.evaluate(async (baselineIds) => {
|
||||
const state = await window.fxnodeMultiView.root!.getState();
|
||||
const added = state.nodes.filter((node) => !baselineIds.includes(node.id));
|
||||
return {
|
||||
positions: added.map((node) => node.position),
|
||||
selections: window.fxnodeMultiView.views.map((view) => view.getHostSnapshot().selection.nodeCount),
|
||||
};
|
||||
}, baseline.ids);
|
||||
expect(result.positions.sort((a, b) => a.x - b.x)).toEqual([
|
||||
{ x: 480, y: -550 },
|
||||
{ x: 2080, y: -400 },
|
||||
]);
|
||||
expect(result.selections).toEqual([1, 1]);
|
||||
|
||||
await page.getByRole("button", { name: "Mute" }).click();
|
||||
await expect(page.getByRole("button", { name: "Unmute" })).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.fxnodeMultiView.root!.getState().then((state) => state.nodes.length)))
|
||||
.toBe(baseline.ids.length + 1);
|
||||
result = await page.evaluate(
|
||||
async (baselineIds) => ({
|
||||
positions: (await window.fxnodeMultiView.root!.getState()).nodes
|
||||
.filter((node) => !baselineIds.includes(node.id))
|
||||
.map((node) => node.position),
|
||||
selections: window.fxnodeMultiView.views.map((view) => view.getHostSnapshot().selection.nodeCount),
|
||||
}),
|
||||
baseline.ids,
|
||||
);
|
||||
expect(result.positions).toEqual([{ x: 480, y: -550 }]);
|
||||
expect(result.selections).toEqual([1, 0]);
|
||||
await page.evaluate(() => Promise.all([window.fxnodeMultiView.cleanup(), window.fxnodeMultiView.cleanup()]));
|
||||
expect(
|
||||
await page.evaluate(() => ({ root: window.fxnodeMultiView.root, views: window.fxnodeMultiView.views.length })),
|
||||
).toEqual({ root: null, views: 0 });
|
||||
});
|
||||
|
||||
for (const example of examples) {
|
||||
test(`${example.path} renders its known node and cleans up on pagehide`, async ({ page }) => {
|
||||
const errors = capturePageErrors(page);
|
||||
await page.goto(`/examples/${example.path}/`);
|
||||
await page.evaluate(() => window.fxnodeStandalone.ready);
|
||||
const result = await page.evaluate(async ({ nodeId }) => {
|
||||
const api = window.fxnodeStandalone.root;
|
||||
if (!api) return null;
|
||||
const node = (await api.getState()).nodes.find((candidate) => candidate.id === nodeId);
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("canvas")!;
|
||||
const pixels = canvas.getContext("2d")!.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
return {
|
||||
node: node && { id: node.id, typeId: node.typeId, known: node.known },
|
||||
nonEmpty: pixels.some((channel) => channel !== 0),
|
||||
};
|
||||
}, example);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.node).toEqual({ id: example.nodeId, typeId: example.typeId, known: true });
|
||||
expect(result?.nonEmpty).toBe(true);
|
||||
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent("pagehide")));
|
||||
expect(await page.evaluate(() => window.fxnodeStandalone.root)).toBeNull();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("live composition migrates the real node and records the graph/history transition", async ({ page }) => {
|
||||
const errors = capturePageErrors(page);
|
||||
await page.goto("/examples/live-composition/");
|
||||
await page.evaluate(() => window.fxnodeStandalone.ready);
|
||||
const beforeVersion = await page.evaluate(() => window.fxnodeStandalone.graphVersion);
|
||||
expect(beforeVersion).toBeDefined();
|
||||
await page.getByRole("button", { name: "Compose version 2" }).click();
|
||||
await expect(page.locator("#status")).toContainText("Version 2 committed");
|
||||
const result = await page.evaluate(async () => ({
|
||||
receipt: window.fxnodeStandalone.lastCompositionReceipt,
|
||||
state: await window.fxnodeStandalone.root!.getState(),
|
||||
}));
|
||||
expect(result.receipt?.status).toBe("committed");
|
||||
expect(result.receipt?.graphChanged).toBe(true);
|
||||
expect(result.receipt?.historyReset).toBe(true);
|
||||
expect(result.receipt?.graphVersion).toBe(beforeVersion! + 1);
|
||||
const node = result.state.nodes.find((candidate) => candidate.id === "live-node");
|
||||
expect(node?.typeId).toBe("example.live.parameter");
|
||||
expect(node?.typeVersion).toBe(2);
|
||||
expect(node?.parameters["detail"]).toEqual({ kind: "number", value: 0.5 });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createApplicationFxNode } from "../../examples/blender/application-browser.js";
|
||||
import { prepareFxNodeBrowserHost } from "../../examples/shared/browser-host.js";
|
||||
const initialLayout = { graphId: "browser", catalogVersion: 4, nodes: [], links: [], metadata: {} };
|
||||
window.ready = (async () => {
|
||||
const primary = document.querySelector<HTMLCanvasElement>("#primary");
|
||||
const addNodeMenuTemplate = document.querySelector<HTMLTemplateElement>("#add-node-menu-template");
|
||||
if (!primary || !addNodeMenuTemplate) throw new Error("Primary canvas or add-node menu template missing");
|
||||
const host = prepareFxNodeBrowserHost({ canvas: primary, addNodeMenuTemplate });
|
||||
let root: Awaited<ReturnType<typeof createApplicationFxNode>> | undefined,
|
||||
view: Awaited<ReturnType<NonNullable<typeof root>["attachView"]>> | undefined;
|
||||
try {
|
||||
root = await createApplicationFxNode();
|
||||
await root.setState(initialLayout);
|
||||
view = await root.attachView({ canvas: primary, viewport: host.initialViewport });
|
||||
host.attach(root, view);
|
||||
window.root = root;
|
||||
window.view = view;
|
||||
window.fxnodeHost = host;
|
||||
await view.whenRendered();
|
||||
return true;
|
||||
} catch (error) {
|
||||
host.destroy();
|
||||
await view?.detach();
|
||||
root?.destroy();
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,450 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
test("host snapshots are stable immutable projections with isolated subscriptions", async ({ page }) => {
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
const api = window.root,
|
||||
view = window.view,
|
||||
first = view.getHostSnapshot(),
|
||||
same = first === view.getHostSnapshot(),
|
||||
frozen = Object.isFrozen(first) && Object.isFrozen(first.selection) && Object.isFrozen(first.selection.mute);
|
||||
let good = 0,
|
||||
bad = 0;
|
||||
const offBad = view.subscribeHost(() => {
|
||||
bad++;
|
||||
throw new Error("subscriber");
|
||||
}),
|
||||
offGood = view.subscribeHost(() => good++);
|
||||
await api.composeNode("phase-one", {
|
||||
version: 1,
|
||||
title: "Phase One",
|
||||
behavior: "standard",
|
||||
style: "shader",
|
||||
parameters: {},
|
||||
sockets: {},
|
||||
ui: [],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
});
|
||||
const projected = view.getHostSnapshot();
|
||||
offBad();
|
||||
offBad();
|
||||
offGood();
|
||||
await api.removeNode("phase-one");
|
||||
const detached = structuredClone(projected);
|
||||
api.destroy();
|
||||
let readable = true,
|
||||
terminal = "";
|
||||
try {
|
||||
view.getHostSnapshot();
|
||||
} catch (e) {
|
||||
readable = false;
|
||||
terminal = (e as Error).name;
|
||||
}
|
||||
return {
|
||||
same,
|
||||
frozen,
|
||||
good,
|
||||
bad,
|
||||
firstRevision: first.compositionRevision,
|
||||
revision: projected.compositionRevision,
|
||||
detachedKeys: Object.keys(detached).sort(),
|
||||
readable,
|
||||
terminal,
|
||||
};
|
||||
});
|
||||
expect(result).toEqual({
|
||||
same: true,
|
||||
frozen: true,
|
||||
good: 1,
|
||||
bad: 1,
|
||||
firstRevision: 31,
|
||||
revision: 32,
|
||||
detachedKeys: ["colorPickerOpen", "compositionRevision", "selection"],
|
||||
readable: false,
|
||||
terminal: "FxNodeDestroyedError",
|
||||
});
|
||||
});
|
||||
test("getState is detached and setState is atomic, ordered, and undoable", async ({ page }) => {
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
const api = window.fxnodeExample.root!,
|
||||
view = window.fxnodeExample.view!,
|
||||
original = await api.getState(),
|
||||
detached = structuredClone(original);
|
||||
(detached.nodes as unknown[]).push({});
|
||||
const events: string[] = [];
|
||||
api.onMutations((event) => events.push(`mutation:${event.version}`));
|
||||
api.onSnapshots((event) => events.push(`snapshot:${event.version}`));
|
||||
const noop = await api.setState(original),
|
||||
target = { ...original, graphId: "replacement" as typeof original.graphId },
|
||||
changed = await api.setState(target, { expectedVersion: original.version }),
|
||||
after = await api.getState(),
|
||||
undo = await api.undo(),
|
||||
undone = await api.getState();
|
||||
api.destroy();
|
||||
return {
|
||||
originalVersion: original.version,
|
||||
originalNodes: original.nodes.length,
|
||||
detachedNodes: detached.nodes.length,
|
||||
noop,
|
||||
changed,
|
||||
afterGraphId: after.graphId,
|
||||
undo,
|
||||
undoneGraphId: undone.graphId,
|
||||
events,
|
||||
};
|
||||
});
|
||||
const v = result.originalVersion;
|
||||
expect(result.detachedNodes).toBe(result.originalNodes + 1);
|
||||
expect(result.noop).toEqual({ status: "noop", version: v });
|
||||
expect(result.changed).toEqual({ status: "committed", version: v + 1 });
|
||||
expect(result.afterGraphId).toBe("replacement");
|
||||
expect(result.undo).toEqual({ status: "committed", version: v + 2 });
|
||||
expect(result.undoneGraphId).not.toBe("replacement");
|
||||
expect(result.events).toEqual([`mutation:${v + 1}`, `snapshot:${v + 1}`, `mutation:${v + 2}`, `snapshot:${v + 2}`]);
|
||||
});
|
||||
test("explicit node and selection actions preserve worker authority and structured failures", async ({ page }) => {
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
const api = window.root,
|
||||
view = window.view,
|
||||
initial = await api.getState();
|
||||
let notifications = 0,
|
||||
selection = JSON.stringify(view.getHostSnapshot().selection);
|
||||
const off = view.subscribeHost(() => {
|
||||
const next = JSON.stringify(view.getHostSnapshot().selection);
|
||||
if (next !== selection) {
|
||||
selection = next;
|
||||
notifications++;
|
||||
}
|
||||
});
|
||||
let stale = "",
|
||||
unknown = "",
|
||||
duplicate = "";
|
||||
try {
|
||||
await view.removeSelected({ expectedVersion: initial.version + 1 });
|
||||
} catch (error) {
|
||||
stale = (error as Error & { code?: string }).code ?? "";
|
||||
}
|
||||
try {
|
||||
await view.addNode({ typeId: "missing", viewPosition: { x: 40, y: 40 }, nodeId: "unknown" });
|
||||
} catch (error) {
|
||||
unknown = (error as Error & { code?: string }).code ?? "";
|
||||
}
|
||||
const added = await view.addNode({
|
||||
typeId: "fxnode.shader.value",
|
||||
viewPosition: { x: 40, y: 40 },
|
||||
nodeId: "supplied-id",
|
||||
}),
|
||||
selected = structuredClone(view.getHostSnapshot().selection);
|
||||
try {
|
||||
await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 80, y: 80 }, nodeId: "supplied-id" });
|
||||
} catch (error) {
|
||||
duplicate = (error as Error & { code?: string }).code ?? "";
|
||||
}
|
||||
const muted = await view.setSelectedMuted(true),
|
||||
mutedSelection = structuredClone(view.getHostSnapshot().selection),
|
||||
afterMute = await api.getState();
|
||||
const removed = await view.removeSelected(),
|
||||
afterRemove = await api.getState(),
|
||||
empty = structuredClone(view.getHostSnapshot().selection);
|
||||
off();
|
||||
return {
|
||||
stale,
|
||||
unknown,
|
||||
duplicate,
|
||||
added,
|
||||
selected,
|
||||
muted,
|
||||
mutedSelection,
|
||||
node: afterMute.nodes.find((node) => node.id === "supplied-id"),
|
||||
removed,
|
||||
remaining: afterRemove.nodes.length,
|
||||
empty,
|
||||
notifications,
|
||||
};
|
||||
});
|
||||
expect(result.stale).toBe("version.stale");
|
||||
expect(result.unknown).toBe("node.type-unknown");
|
||||
expect(result.duplicate).toBe("node.duplicate");
|
||||
expect(result.added).toEqual({ status: "committed", version: 2 });
|
||||
expect(result.selected).toEqual({
|
||||
nodeCount: 1,
|
||||
linkCount: 0,
|
||||
canRemove: true,
|
||||
mute: { enabled: true, state: "all-unmuted" },
|
||||
});
|
||||
expect(result.muted).toEqual({ status: "committed", version: 3 });
|
||||
expect(result.node?.muted).toBe(true);
|
||||
expect(result.mutedSelection.mute).toEqual({ enabled: true, state: "all-muted" });
|
||||
expect(result.removed).toEqual({ status: "committed", version: 4 });
|
||||
expect(result.remaining).toBe(0);
|
||||
expect(result.empty).toEqual({ nodeCount: 0, linkCount: 0, canRemove: false, mute: { enabled: false } });
|
||||
expect(result.notifications).toBe(3);
|
||||
});
|
||||
test("dedicated worker renders, commits FIFO, fans out and destroys", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen", {
|
||||
value: () => {
|
||||
throw new Error("sabotaged");
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, "OffscreenCanvas", { value: undefined });
|
||||
});
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
const api = window.root;
|
||||
const trace: Array<
|
||||
{ kind: "mutation"; baseVersion: number; version: number; cause: string } | { kind: "snapshot"; version: number }
|
||||
> = [];
|
||||
api.onMutations((event) =>
|
||||
trace.push({ kind: "mutation", baseVersion: event.baseVersion, version: event.version, cause: event.cause }),
|
||||
);
|
||||
api.onSnapshots((event) => trace.push({ kind: "snapshot", version: event.version }));
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
api.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } }),
|
||||
api.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: 1, y: 1 } }),
|
||||
]);
|
||||
const beforeStale = { trace: structuredClone(trace), snapshot: await api.getState() };
|
||||
let staleError: { name: string; message: string; code?: string } | undefined;
|
||||
try {
|
||||
await api.dispatch({ type: "undo" }, { expectedVersion: 0 });
|
||||
} catch (error) {
|
||||
const value = error as Error & { code?: string };
|
||||
staleError = { name: value.name, message: value.message, ...(value.code ? { code: value.code } : {}) };
|
||||
}
|
||||
const afterStale = { trace: structuredClone(trace), snapshot: await api.getState() };
|
||||
const undo = await api.undo();
|
||||
const redo = await api.redo();
|
||||
|
||||
const beforeQueries = trace.length;
|
||||
const snap = await api.getState();
|
||||
const saved = await api.save();
|
||||
const queriesSilent = trace.length === beforeQueries;
|
||||
const mirror = document.querySelector<HTMLCanvasElement>("#mirror");
|
||||
const copy = document.querySelector<HTMLCanvasElement>("#copy");
|
||||
const primary = document.querySelector<HTMLCanvasElement>("#primary");
|
||||
if (!mirror || !copy || !primary) throw new Error("Canvas missing");
|
||||
mirror.getContext("2d")!.drawImage(primary, 0, 0);
|
||||
copy.getContext("2d")!.drawImage(primary, 0, 0);
|
||||
const pixel = (canvas: HTMLCanvasElement) => Array.from(canvas.getContext("2d")!.getImageData(2, 2, 1, 1).data);
|
||||
const pixels = [pixel(primary), pixel(mirror), pixel(copy)];
|
||||
api.destroy();
|
||||
let destroyed = false;
|
||||
try {
|
||||
await api.getState();
|
||||
} catch {
|
||||
destroyed = true;
|
||||
}
|
||||
return {
|
||||
a,
|
||||
b,
|
||||
undo,
|
||||
redo,
|
||||
trace,
|
||||
beforeStale,
|
||||
afterStale,
|
||||
staleError,
|
||||
queriesSilent,
|
||||
pixels,
|
||||
catalog: saved.catalogVersion,
|
||||
nodes: snap.nodes.length,
|
||||
destroyed,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.a).toEqual({ status: "committed", version: 2 });
|
||||
expect(result.b).toEqual({ status: "committed", version: 3 });
|
||||
expect(result.beforeStale.trace).toEqual([
|
||||
{ kind: "mutation", baseVersion: 1, version: 2, cause: "api" },
|
||||
{ kind: "snapshot", version: 2 },
|
||||
{ kind: "mutation", baseVersion: 2, version: 3, cause: "api" },
|
||||
{ kind: "snapshot", version: 3 },
|
||||
]);
|
||||
expect(result.beforeStale.snapshot.version).toBe(3);
|
||||
expect(result.staleError).toEqual({
|
||||
name: "FxNodeWorkerError",
|
||||
message: "Expected version does not match",
|
||||
code: "version.stale",
|
||||
});
|
||||
expect(result.afterStale).toEqual(result.beforeStale);
|
||||
expect(result.undo).toEqual({ status: "committed", version: 4 });
|
||||
expect(result.redo).toEqual({ status: "committed", version: 5 });
|
||||
expect(result.trace).toEqual([
|
||||
...result.beforeStale.trace,
|
||||
{ kind: "mutation", baseVersion: 3, version: 4, cause: "undo" },
|
||||
{ kind: "snapshot", version: 4 },
|
||||
{ kind: "mutation", baseVersion: 4, version: 5, cause: "redo" },
|
||||
{ kind: "snapshot", version: 5 },
|
||||
]);
|
||||
expect(result.queriesSilent).toBe(true);
|
||||
expect(result.catalog).toBe(4);
|
||||
expect(result.nodes).toBe(2);
|
||||
expect(result.destroyed).toBe(true);
|
||||
expect(result.pixels[0]).toEqual(result.pixels[1]);
|
||||
expect(result.pixels[0]).toEqual(result.pixels[2]);
|
||||
expect(result.pixels[0]?.[3]).toBe(255);
|
||||
});
|
||||
|
||||
test("steady-size frame presentation does not reallocate canvas backing stores", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const counts = { width: 0, height: 0 };
|
||||
(window as unknown as { canvasAssignments: typeof counts }).canvasAssignments = counts;
|
||||
for (const key of ["width", "height"] as const) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, key)!;
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, key, {
|
||||
...descriptor,
|
||||
set(value: number) {
|
||||
counts[key]++;
|
||||
descriptor.set!.call(this, value);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
await page.evaluate(async () => {
|
||||
const counts = (window as unknown as { canvasAssignments: { width: number; height: number } }).canvasAssignments;
|
||||
counts.width = counts.height = 0;
|
||||
for (let index = 0; index < 8; index++)
|
||||
await window.root.dispatch({
|
||||
type: "node.add",
|
||||
nodeType: "fxnode.shader.value",
|
||||
position: { x: index * 10, y: index * 10 },
|
||||
});
|
||||
await window.view.whenRendered();
|
||||
});
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => (window as unknown as { canvasAssignments: { width: number; height: number } }).canvasAssignments,
|
||||
),
|
||||
).toEqual({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
test("structured command errors with paths reject only their RPC", async ({ page }) => {
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
let error: { name?: string; code?: string; path?: string } = {};
|
||||
try {
|
||||
await window.root.dispatch({
|
||||
type: "link.add",
|
||||
link: {
|
||||
id: "stale",
|
||||
fromNodeId: "missing-a",
|
||||
fromSocketId: "missing-a:out",
|
||||
toNodeId: "missing-b",
|
||||
toSocketId: "missing-b:in",
|
||||
muted: false,
|
||||
extensions: {},
|
||||
},
|
||||
} as any);
|
||||
} catch (value) {
|
||||
const e = value as typeof error;
|
||||
error = {
|
||||
...(e.name === undefined ? {} : { name: e.name }),
|
||||
...(e.code === undefined ? {} : { code: e.code }),
|
||||
...(e.path === undefined ? {} : { path: e.path }),
|
||||
};
|
||||
}
|
||||
const snapshot = await window.root.getState();
|
||||
window.root.destroy();
|
||||
return { error, version: snapshot.version };
|
||||
});
|
||||
expect(result).toEqual({
|
||||
error: { name: "FxNodeWorkerError", code: "link.endpoint", path: "/links/stale" },
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("malformed and incoherent state receipts terminate the client", async ({ browser }) => {
|
||||
const installCorruptor = () => {
|
||||
const NativeWorker = Worker;
|
||||
let corruptType = "";
|
||||
class CorruptingWorker extends NativeWorker {
|
||||
private requestId = "";
|
||||
constructor(url: string | URL, options?: WorkerOptions) {
|
||||
super(url, options);
|
||||
super.addEventListener("message", (event) => {
|
||||
const message = event.data;
|
||||
if (!this.requestId || message?.type !== "response" || message.id !== this.requestId) return;
|
||||
event.stopImmediatePropagation();
|
||||
const type = corruptType,
|
||||
id = this.requestId;
|
||||
this.requestId = "";
|
||||
queueMicrotask(() =>
|
||||
this.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
protocol: 2,
|
||||
type: "response",
|
||||
id,
|
||||
ok: true,
|
||||
value: type === "state.set" ? null : { status: "noop", version: 2 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
override postMessage(message: unknown, options?: StructuredSerializeOptions | Transferable[]) {
|
||||
if ((message as { type?: string }).type === corruptType) this.requestId = (message as { id: string }).id;
|
||||
super.postMessage(message, options as StructuredSerializeOptions);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, "corruptReceiptType", { set: (value: string) => (corruptType = value) });
|
||||
Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: CorruptingWorker });
|
||||
};
|
||||
const statePage = await browser.newPage();
|
||||
await statePage.addInitScript(installCorruptor);
|
||||
await statePage.goto("/test/browser/index.html");
|
||||
await statePage.evaluate(() => window.ready);
|
||||
const malformed = await statePage.evaluate(async () => {
|
||||
const state = await window.root.getState();
|
||||
(window as unknown as { corruptReceiptType: string }).corruptReceiptType = "state.set";
|
||||
let first = "",
|
||||
terminal = "";
|
||||
try {
|
||||
await window.root.setState(state, { expectedVersion: state.version });
|
||||
} catch (error) {
|
||||
first = (error as Error).name;
|
||||
}
|
||||
try {
|
||||
await window.root.getState();
|
||||
} catch (error) {
|
||||
terminal = (error as Error).name;
|
||||
}
|
||||
return { first, terminal };
|
||||
});
|
||||
await statePage.close();
|
||||
const loadPage = await browser.newPage();
|
||||
await loadPage.addInitScript(installCorruptor);
|
||||
await loadPage.goto("/test/browser/index.html");
|
||||
await loadPage.evaluate(() => window.ready);
|
||||
const incoherent = await loadPage.evaluate(async () => {
|
||||
const state = await window.root.getState(),
|
||||
data = await window.root.getSaveData();
|
||||
(window as unknown as { corruptReceiptType: string }).corruptReceiptType = "load";
|
||||
let first = "",
|
||||
terminal = "";
|
||||
try {
|
||||
await window.root.load(data, { expectedVersion: state.version });
|
||||
} catch (error) {
|
||||
first = (error as Error).name;
|
||||
}
|
||||
try {
|
||||
await window.root.getState();
|
||||
} catch (error) {
|
||||
terminal = (error as Error).name;
|
||||
}
|
||||
return { first, terminal };
|
||||
});
|
||||
await loadPage.close();
|
||||
expect(malformed).toEqual({ first: "FxNodeProtocolError", terminal: "FxNodeProtocolError" });
|
||||
expect(incoherent).toEqual({ first: "FxNodeProtocolError", terminal: "FxNodeProtocolError" });
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FxNode, FxNodeView } from "@lib/index.js";
|
||||
import type { PreparedFxNodeBrowserHost } from "../../examples/shared/browser-host.js";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
root: FxNode;
|
||||
view: FxNodeView;
|
||||
fxnodeHost: PreparedFxNodeBrowserHost;
|
||||
ready: Promise<boolean>;
|
||||
fxnodeExample: FxNodeExampleHandle;
|
||||
parityExample: { root: FxNode; view: FxNodeView };
|
||||
controlTest: { root: FxNode | null; view: FxNodeView | null; ready: Promise<void> };
|
||||
linkToolsTest: { root: FxNode | null; view: FxNodeView | null; ready: Promise<void> };
|
||||
controlEvents: { mutations: number[]; snapshots: number[] };
|
||||
}
|
||||
interface FxNodeExampleHandle {
|
||||
root: FxNode | null;
|
||||
view: FxNodeView | null;
|
||||
ready: Promise<void>;
|
||||
readonly rendered: Promise<void>;
|
||||
}
|
||||
interface FxNodeEvidenceCounters {
|
||||
mutations: number;
|
||||
snapshots: number;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,315 @@
|
||||
<!doctype html>
|
||||
<script type="importmap">
|
||||
{ "imports": { "@lib/": "/src/" } }
|
||||
</script>
|
||||
<canvas id="primary" tabindex="0" style="width: 320px; height: 180px"></canvas><canvas id="mirror"></canvas
|
||||
><canvas id="copy"></canvas>
|
||||
<template id="add-node-menu-template">
|
||||
<div data-fxnode-add-menu style="position: fixed; z-index: 2147483647; visibility: hidden">
|
||||
<style>
|
||||
[data-fxnode-add-menu] * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
[data-fxnode-add-menu] [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.fxnode-add-dialog {
|
||||
width: 286px;
|
||||
max-height: min(520px, calc(100vh - 16px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
background: #25282d;
|
||||
color: #e5e5e5;
|
||||
border: 1px solid #111216;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 12px 32px #000a;
|
||||
font:
|
||||
12px/1.3 system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
.fxnode-add-title {
|
||||
font-weight: 650;
|
||||
margin: 1px 2px 7px;
|
||||
}
|
||||
.fxnode-add-search {
|
||||
width: 100%;
|
||||
border: 1px solid #111216;
|
||||
border-radius: 4px;
|
||||
background: #181a1e;
|
||||
color: #fff;
|
||||
padding: 7px 8px;
|
||||
outline: none;
|
||||
}
|
||||
.fxnode-add-search:focus {
|
||||
border-color: #ed5700;
|
||||
}
|
||||
.fxnode-add-results {
|
||||
overflow: auto;
|
||||
margin-top: 7px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.fxnode-add-group {
|
||||
margin: 4px 0 7px;
|
||||
}
|
||||
.fxnode-add-heading {
|
||||
padding: 3px 7px;
|
||||
color: #a5a8ad;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
}
|
||||
.fxnode-add-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #e5e5e5;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: default;
|
||||
}
|
||||
.fxnode-add-option:hover,
|
||||
.fxnode-add-option.active {
|
||||
background: #ed5700;
|
||||
color: #fff;
|
||||
}
|
||||
.fxnode-add-empty {
|
||||
padding: 14px 8px;
|
||||
color: #a5a8ad;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<section class="fxnode-add-dialog" role="dialog" aria-label="Add node">
|
||||
<div class="fxnode-add-title">Add Node</div>
|
||||
<input
|
||||
data-fxnode-menu-search
|
||||
class="fxnode-add-search"
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-label="Search nodes"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="fxnode-node-results"
|
||||
autocomplete="off"
|
||||
placeholder="Search…"
|
||||
/>
|
||||
<div data-fxnode-menu-results class="fxnode-add-results" id="fxnode-node-results" role="listbox">
|
||||
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Common">
|
||||
<div data-fxnode-menu-heading class="fxnode-add-heading">Common</div>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.common.frame"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Frame
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.common.reroute"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Reroute
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.common.group-input"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Group Input
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.common.group-output"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Group Output
|
||||
</button>
|
||||
</section>
|
||||
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Shader">
|
||||
<div data-fxnode-menu-heading class="fxnode-add-heading">Shader</div>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.shader.value"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Value
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.shader.color"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Color
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.shader.math"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Math
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.shader.vector-math"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Vector Math
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.shader.mix"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Mix
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.shader.color-ramp"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Color Ramp
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.shader.texture-coordinate"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Texture Coordinate
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.shader.noise-texture"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Noise Texture
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.shader.image-texture"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Image Texture
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.shader.principled-bsdf"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Principled BSDF
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.shader.material-output"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Material Output
|
||||
</button>
|
||||
</section>
|
||||
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Geometry">
|
||||
<div data-fxnode-menu-heading class="fxnode-add-heading">Geometry</div>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.geometry.position"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Position
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.geometry.mesh-cube"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Mesh Cube
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.geometry.set-position"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Set Position
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.geometry.transform-geometry"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Transform Geometry
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.geometry.join-geometry"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Join Geometry
|
||||
</button>
|
||||
</section>
|
||||
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Compositor">
|
||||
<div data-fxnode-menu-heading class="fxnode-add-heading">Compositor</div>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.compositor.image"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Image
|
||||
</button>
|
||||
<button
|
||||
data-fxnode-menu-option
|
||||
data-type-id="fxnode.compositor.color-balance"
|
||||
class="fxnode-add-option"
|
||||
type="button"
|
||||
role="option"
|
||||
>
|
||||
Color Balance
|
||||
</button>
|
||||
</section>
|
||||
<div data-fxnode-menu-empty class="fxnode-add-empty" role="status" hidden>No nodes found</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script type="module" src="./fixture.ts"></script>
|
||||
@@ -0,0 +1,507 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("direct input preserves modifiers, SAB ordering, and right-button menu rules", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const Native = Worker;
|
||||
const messages: unknown[] = [];
|
||||
let lane: SharedArrayBuffer | undefined;
|
||||
class Spy extends Native {
|
||||
override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) {
|
||||
const value = message as { type?: string; pointerLane?: SharedArrayBuffer };
|
||||
if (value.type === "view.attach") lane = value.pointerLane;
|
||||
if (["view.input", "view.pointer.flush", "view.viewport", "view.render"].includes(value.type ?? ""))
|
||||
messages.push(message);
|
||||
super.postMessage(message, options as StructuredSerializeOptions);
|
||||
}
|
||||
}
|
||||
Object.defineProperties(window, { phase1Messages: { value: messages }, phase1Lane: { get: () => lane } });
|
||||
Object.defineProperty(window, "Worker", { value: Spy });
|
||||
});
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
const api = window.fxnodeExample.root!,
|
||||
view = window.fxnodeExample.view!,
|
||||
w = window as unknown as { phase1Messages: any[]; phase1Lane?: SharedArrayBuffer };
|
||||
w.phase1Messages.length = 0;
|
||||
const mods0 = { alt: false, control: false, meta: false, shift: false },
|
||||
mods15 = { alt: true, control: true, meta: true, shift: true };
|
||||
const before = api.getState();
|
||||
view.feedInput({
|
||||
kind: "pointer",
|
||||
phase: "down",
|
||||
pointerId: 9,
|
||||
pointerType: "mouse",
|
||||
position: { x: 380, y: 160 },
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
modifiers: mods0,
|
||||
});
|
||||
view.feedInput({
|
||||
kind: "pointer",
|
||||
phase: "move",
|
||||
pointerId: 9,
|
||||
pointerType: "mouse",
|
||||
position: { x: 410, y: 180 },
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
modifiers: mods15,
|
||||
});
|
||||
view.feedInput({
|
||||
kind: "pointer",
|
||||
phase: "up",
|
||||
pointerId: 9,
|
||||
pointerType: "mouse",
|
||||
position: { x: 410, y: 180 },
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
modifiers: mods0,
|
||||
});
|
||||
await view.whenRendered();
|
||||
const after = await api.getState();
|
||||
const invalidBefore = w.phase1Messages.length,
|
||||
laneBefore = w.phase1Lane ? Array.from(new Int32Array(w.phase1Lane)) : [];
|
||||
let invalid = "";
|
||||
try {
|
||||
view.feedInput({ kind: "wheel", position: { x: 0, y: 0 }, delta: { x: NaN, y: 0 }, modifiers: mods0 });
|
||||
} catch (e) {
|
||||
invalid = (e as Error).name;
|
||||
}
|
||||
const invalidAfter = w.phase1Messages.length,
|
||||
laneAfter = w.phase1Lane ? Array.from(new Int32Array(w.phase1Lane)) : [];
|
||||
w.phase1Messages.length = 0;
|
||||
view.feedInput({
|
||||
kind: "pointer",
|
||||
phase: "move",
|
||||
pointerId: 10,
|
||||
pointerType: "mouse",
|
||||
position: { x: -3, y: -4 },
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
modifiers: mods15,
|
||||
});
|
||||
await view.setViewport({ width: 301, height: 179, dpr: 1 });
|
||||
const ordered = structuredClone(w.phase1Messages);
|
||||
w.phase1Messages.length = 0;
|
||||
const rendered = view.whenRendered();
|
||||
await rendered;
|
||||
const renderMessage = w.phase1Messages.find((x) => x.type === "view.render");
|
||||
w.phase1Messages.length = 0;
|
||||
view.feedInput({
|
||||
kind: "pointer",
|
||||
phase: "down",
|
||||
pointerId: 11,
|
||||
pointerType: "mouse",
|
||||
position: { x: 1, y: 1 },
|
||||
button: 2,
|
||||
buttons: 3,
|
||||
modifiers: mods0,
|
||||
});
|
||||
const rmb = structuredClone(w.phase1Messages.at(-1));
|
||||
return {
|
||||
before: await before,
|
||||
after,
|
||||
invalid,
|
||||
invalidBefore,
|
||||
invalidAfter,
|
||||
laneBefore,
|
||||
laneAfter,
|
||||
ordered,
|
||||
renderedWithDedicatedMessage: !!renderMessage,
|
||||
rmb,
|
||||
};
|
||||
});
|
||||
expect(result.after.nodes.find((n) => n.id === "math")?.position).toEqual({ x: -270, y: 150 });
|
||||
const inputs = result.ordered.length;
|
||||
expect(result.invalid).toBe("TypeError");
|
||||
expect(result.invalidAfter).toBe(result.invalidBefore);
|
||||
expect(result.laneAfter).toEqual(result.laneBefore);
|
||||
expect(result.ordered.map((x: any) => x.type)).toEqual(["view.viewport"]);
|
||||
expect(result.ordered[0].pointerFence.before.event.modifiers).toBe(15);
|
||||
expect(result.ordered[0].viewport).toEqual({ width: 301, height: 179, dpr: 1 });
|
||||
expect(result.renderedWithDedicatedMessage).toBe(true);
|
||||
const sentMasks = (result as any).rmb.event.modifiers;
|
||||
expect(sentMasks).toBe(0);
|
||||
expect((result as any).rmb.nodeMenuRequestId).toBeUndefined();
|
||||
expect(inputs).toBe(1);
|
||||
});
|
||||
|
||||
test("direct input invalidates a pending add-menu request", async ({ page }) => {
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
await page.evaluate(() => {
|
||||
const api = window.fxnodeExample.root!,
|
||||
view = window.fxnodeExample.view!,
|
||||
modifiers = { alt: false, control: false, meta: false, shift: false };
|
||||
view.feedInput({
|
||||
kind: "pointer",
|
||||
phase: "down",
|
||||
pointerId: 31,
|
||||
pointerType: "mouse",
|
||||
position: { x: 20, y: 20 },
|
||||
button: 2,
|
||||
buttons: 2,
|
||||
modifiers,
|
||||
});
|
||||
view.feedInput({ kind: "wheel", position: { x: 20, y: 20 }, delta: { x: 0, y: 1 }, modifiers });
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
expect(await page.locator("[data-fxnode-add-menu]").count()).toBe(0);
|
||||
});
|
||||
|
||||
test("synchronous input and viewport postMessage failures throw", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const Native = Worker;
|
||||
let fail = "";
|
||||
class Spy extends Native {
|
||||
override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) {
|
||||
if ((message as any)?.type === fail) throw new DOMException("blocked", "DataCloneError");
|
||||
super.postMessage(message, options as StructuredSerializeOptions);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, "failWorkerPost", { set: (v: string) => (fail = v) });
|
||||
Object.defineProperty(window, "Worker", { value: Spy });
|
||||
});
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
(window as any).failWorkerPost = "view.input";
|
||||
try {
|
||||
window.view.feedInput({ kind: "focus", phase: "focus" });
|
||||
return "none";
|
||||
} catch (e) {
|
||||
return (e as Error).name;
|
||||
}
|
||||
}),
|
||||
).toBe("FxNodeProtocolError");
|
||||
await page.reload();
|
||||
await page.evaluate(() => window.ready);
|
||||
expect(
|
||||
await page.evaluate(async () => {
|
||||
(window as any).failWorkerPost = "view.viewport";
|
||||
try {
|
||||
await window.view.setViewport({ width: 10, height: 10, dpr: 1 });
|
||||
return "none";
|
||||
} catch (e) {
|
||||
return (e as Error).name;
|
||||
}
|
||||
}),
|
||||
).toBe("FxNodeProtocolError");
|
||||
});
|
||||
|
||||
test("dedicated actions and resource transfers embed transactional SAB fences", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const Native = Worker,
|
||||
messages: unknown[] = [],
|
||||
resourceTransfers: boolean[] = [];
|
||||
let lane: SharedArrayBuffer | undefined,
|
||||
fail = "";
|
||||
class Spy extends Native {
|
||||
override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) {
|
||||
const value = message as { type?: string; pointerLane?: SharedArrayBuffer; resource?: { bytes?: ArrayBuffer } };
|
||||
if (value.type === "view.attach") lane = value.pointerLane;
|
||||
if (
|
||||
["view.node.add", "view.selection.mute", "view.selection.remove", "view.resource.set"].includes(
|
||||
value.type ?? "",
|
||||
)
|
||||
)
|
||||
messages.push(structuredClone(message));
|
||||
if (value.type === "view.resource.set")
|
||||
resourceTransfers.push(Array.isArray(options) && options[0] === value.resource?.bytes);
|
||||
if (value.type === fail) throw new DOMException("blocked", "DataCloneError");
|
||||
super.postMessage(message, options as StructuredSerializeOptions);
|
||||
}
|
||||
}
|
||||
Object.defineProperties(window, {
|
||||
actionMessages: { value: messages },
|
||||
actionLane: { get: () => lane },
|
||||
resourceTransfers: { value: resourceTransfers },
|
||||
failActionPost: { set: (value: string) => (fail = value) },
|
||||
});
|
||||
Object.defineProperty(window, "Worker", { value: Spy });
|
||||
});
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
const api = window.root,
|
||||
view = window.view,
|
||||
w = window as any,
|
||||
modifiers = { alt: false, control: false, meta: false, shift: false },
|
||||
move = (x: number) =>
|
||||
view.feedInput({
|
||||
kind: "pointer",
|
||||
phase: "move",
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
position: { x, y: x + 1 },
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
modifiers,
|
||||
});
|
||||
move(1);
|
||||
await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 40, y: 40 }, nodeId: "fenced" });
|
||||
move(2);
|
||||
await view.setSelectedMuted(true);
|
||||
move(3);
|
||||
await view.removeSelected();
|
||||
move(4);
|
||||
const transferred = new ArrayBuffer(1);
|
||||
let stale = "";
|
||||
try {
|
||||
await view.provideResource(
|
||||
{ token: "missing", viewId: view.id, graphVersion: 4, compositionRevision: 30 },
|
||||
{ name: "x.png", mime: "image/png", bytes: transferred },
|
||||
);
|
||||
} catch (error) {
|
||||
stale = (error as any).code;
|
||||
}
|
||||
const laneBefore = Array.from(new Int32Array(w.actionLane));
|
||||
move(5);
|
||||
const beforeFailure = Array.from(new Int32Array(w.actionLane)),
|
||||
failedBuffer = new ArrayBuffer(1);
|
||||
w.failActionPost = "view.resource.set";
|
||||
let failure = "";
|
||||
try {
|
||||
await view.provideResource(
|
||||
{ token: "missing", viewId: view.id, graphVersion: 4, compositionRevision: 30 },
|
||||
{ name: "x.png", mime: "image/png", bytes: failedBuffer },
|
||||
);
|
||||
} catch (error) {
|
||||
failure = (error as Error).name;
|
||||
}
|
||||
const sent = structuredClone(w.actionMessages),
|
||||
afterFailure = Array.from(new Int32Array(w.actionLane));
|
||||
return {
|
||||
sent,
|
||||
laneBefore,
|
||||
beforeFailure,
|
||||
afterFailure,
|
||||
failure,
|
||||
stale,
|
||||
detached: transferred.byteLength,
|
||||
failedBytes: failedBuffer.byteLength,
|
||||
transfers: w.resourceTransfers,
|
||||
};
|
||||
});
|
||||
expect(result.sent.map((message: any) => message.type)).toEqual([
|
||||
"view.node.add",
|
||||
"view.selection.mute",
|
||||
"view.selection.remove",
|
||||
"view.resource.set",
|
||||
"view.resource.set",
|
||||
]);
|
||||
const generations = result.sent.map((message: any) => message.pointerFence.generation),
|
||||
base = generations[0];
|
||||
expect(generations).toEqual([base, base + 1, base + 2, base + 3, base + 4]);
|
||||
expect(result.sent.map((message: any) => message.pointerFence.before.event.position.x)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(result.laneBefore[1]).toBe(base + 3);
|
||||
expect(result.beforeFailure[1]).toBe(base + 3);
|
||||
expect(result.afterFailure[1]).toBe(base + 3);
|
||||
expect(result.failure).toBe("FxNodeProtocolError");
|
||||
expect(result.stale).toBe("resource.stale");
|
||||
expect(result.detached).toBe(0);
|
||||
expect(result.failedBytes).toBe(1);
|
||||
expect(result.transfers).toEqual([true, true]);
|
||||
});
|
||||
|
||||
test("selection publication does not wait for frame consumption", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const Native = Worker;
|
||||
let block = false;
|
||||
class Spy extends Native {
|
||||
override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) {
|
||||
if ((message as any)?.type === "view.frame.consumed" && block) return;
|
||||
super.postMessage(message, options as StructuredSerializeOptions);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, "blockFrameConsumption", { set: (value: boolean) => (block = value) });
|
||||
Object.defineProperty(window, "Worker", { value: Spy });
|
||||
});
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
(window as any).blockFrameConsumption = true;
|
||||
const api = window.root,
|
||||
view = window.view,
|
||||
before = view.getHostSnapshot();
|
||||
const receipt = await view.addNode({
|
||||
typeId: "fxnode.shader.value",
|
||||
viewPosition: { x: 40, y: 40 },
|
||||
nodeId: "blocked-frame",
|
||||
}),
|
||||
after = view.getHostSnapshot();
|
||||
return { receipt, changed: before !== after, selection: structuredClone(after.selection) };
|
||||
});
|
||||
expect(result).toEqual({
|
||||
receipt: { status: "committed", version: 2 },
|
||||
changed: true,
|
||||
selection: { nodeCount: 1, linkCount: 0, canRemove: true, mute: { enabled: true, state: "all-unmuted" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("worker gestures stay transient and commit exactly one paired event", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const NativeWorker = window.Worker;
|
||||
let moves = 0;
|
||||
class TrackedWorker extends NativeWorker {
|
||||
override postMessage(message: unknown, transfer: Transferable[]): void;
|
||||
override postMessage(message: unknown, options?: StructuredSerializeOptions): void;
|
||||
override postMessage(message: unknown, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void {
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
(message as { type?: unknown }).type === "view.input" &&
|
||||
(message as { event?: { kind?: unknown; phase?: unknown } }).event?.kind === "pointer" &&
|
||||
(message as { event?: { phase?: unknown } }).event?.phase === "move"
|
||||
)
|
||||
moves++;
|
||||
if (Array.isArray(transferOrOptions)) super.postMessage(message, transferOrOptions);
|
||||
else super.postMessage(message, transferOrOptions);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, "Worker", { value: TrackedWorker });
|
||||
Object.defineProperty(window, "pointerMoveMessages", { get: () => moves });
|
||||
});
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
expect(await page.evaluate(() => crossOriginIsolated)).toBe(true);
|
||||
await page.evaluate(() => {
|
||||
const h = window.fxnodeExample;
|
||||
const w = window as typeof window & { gestureEvents: { m: number; s: number } };
|
||||
w.gestureEvents = { m: 0, s: 0 };
|
||||
h.root!.onMutations(() => w.gestureEvents.m++);
|
||||
h.root!.onSnapshots(() => w.gestureEvents.s++);
|
||||
});
|
||||
const canvas = page.locator("#graph"),
|
||||
snapshot = () => page.evaluate(() => window.fxnodeExample.root!.getState());
|
||||
const original = await snapshot();
|
||||
// Math header is deterministic: world (-300,170), view origin (600,320).
|
||||
await canvas.click({ position: { x: 380, y: 160 } });
|
||||
expect((await snapshot()).version).toBe(original.version);
|
||||
expect(
|
||||
await page.evaluate(() => (window as typeof window & { gestureEvents: { m: number; s: number } }).gestureEvents),
|
||||
).toEqual({ m: 0, s: 0 });
|
||||
const bounds = await canvas.boundingBox();
|
||||
if (!bounds) throw new Error("canvas bounds missing");
|
||||
await canvas.hover({ position: { x: 380, y: 160 } });
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + 410, bounds.y + 180, { steps: 4 });
|
||||
expect((await snapshot()).nodes.find((n) => n.id === "math")?.position).toEqual({ x: -300, y: 170 });
|
||||
await page.mouse.up();
|
||||
expect((await snapshot()).nodes.find((n) => n.id === "math")?.position).toEqual({ x: -270, y: 150 });
|
||||
expect(
|
||||
await page.evaluate(() => (window as typeof window & { gestureEvents: { m: number; s: number } }).gestureEvents),
|
||||
).toEqual({ m: 1, s: 1 });
|
||||
expect(
|
||||
await page.evaluate(() => (window as typeof window & { pointerMoveMessages: number }).pointerMoveMessages),
|
||||
).toBe(0);
|
||||
// G and ordinary drag cancel without mutation; RMB is suppressed by the canvas.
|
||||
await canvas.press("g");
|
||||
await page.mouse.move(bounds.x + 390, bounds.y + 210);
|
||||
await canvas.press("Escape");
|
||||
expect((await snapshot()).version).toBe(original.version + 1);
|
||||
await canvas.hover({ position: { x: 390, y: 180 } });
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + 430, bounds.y + 220);
|
||||
await canvas.press("Escape");
|
||||
await page.mouse.up();
|
||||
await canvas.click({ position: { x: 30, y: 30 }, button: "right" });
|
||||
expect((await snapshot()).version).toBe(original.version + 1);
|
||||
// Box, MMB, wheel and Home are view/selection-only.
|
||||
await canvas.hover({ position: { x: 20, y: 20 } });
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + 250, bounds.y + 300);
|
||||
await page.mouse.up();
|
||||
await canvas.hover({ position: { x: 30, y: 30 } });
|
||||
await page.mouse.down({ button: "middle" });
|
||||
await page.mouse.move(bounds.x + 40, bounds.y + 40);
|
||||
await page.mouse.up({ button: "middle" });
|
||||
await page.mouse.wheel(0, 30);
|
||||
await canvas.press("Home");
|
||||
expect((await snapshot()).version).toBe(original.version + 1);
|
||||
});
|
||||
|
||||
test("non-isolated hosts retain the ordered pointer message fallback", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window, "crossOriginIsolated", { value: false });
|
||||
const NativeWorker = window.Worker;
|
||||
let moves = 0;
|
||||
class TrackedWorker extends NativeWorker {
|
||||
override postMessage(message: unknown, transfer: Transferable[]): void;
|
||||
override postMessage(message: unknown, options?: StructuredSerializeOptions): void;
|
||||
override postMessage(message: unknown, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void {
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
(message as { type?: unknown }).type === "view.input" &&
|
||||
(message as { event?: { kind?: unknown; phase?: unknown } }).event?.kind === "pointer" &&
|
||||
(message as { event?: { phase?: unknown } }).event?.phase === "move"
|
||||
)
|
||||
moves++;
|
||||
if (Array.isArray(transferOrOptions)) super.postMessage(message, transferOrOptions);
|
||||
else super.postMessage(message, transferOrOptions);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, "Worker", { value: TrackedWorker });
|
||||
Object.defineProperty(window, "pointerMoveMessages", { get: () => moves });
|
||||
});
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
expect(await page.evaluate(() => crossOriginIsolated)).toBe(false);
|
||||
const canvas = page.locator("#graph"),
|
||||
bounds = await canvas.boundingBox();
|
||||
if (!bounds) throw new Error("canvas bounds missing");
|
||||
await canvas.hover({ position: { x: 380, y: 160 } });
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + 410, bounds.y + 180, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
expect(
|
||||
(await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math")
|
||||
?.position,
|
||||
).toEqual({ x: -270, y: 150 });
|
||||
expect(
|
||||
await page.evaluate(() => (window as typeof window & { pointerMoveMessages: number }).pointerMoveMessages),
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("wheel input visibly zooms around the pointer without changing graph state", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const NativeWorker = window.Worker;
|
||||
let wheels = 0;
|
||||
class TrackedWorker extends NativeWorker {
|
||||
override postMessage(message: unknown, transfer: Transferable[]): void;
|
||||
override postMessage(message: unknown, options?: StructuredSerializeOptions): void;
|
||||
override postMessage(message: unknown, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void {
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
(message as { type?: unknown }).type === "view.input" &&
|
||||
(message as { event?: { kind?: unknown } }).event?.kind === "wheel"
|
||||
)
|
||||
wheels++;
|
||||
if (Array.isArray(transferOrOptions)) super.postMessage(message, transferOrOptions);
|
||||
else super.postMessage(message, transferOrOptions);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, "Worker", { value: TrackedWorker });
|
||||
Object.defineProperty(window, "wheelMessages", { get: () => wheels });
|
||||
});
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
const canvas = page.locator("#graph"),
|
||||
before = await canvas.screenshot(),
|
||||
version = (await page.evaluate(() => window.fxnodeExample.root!.getState())).version;
|
||||
await canvas.hover({ position: { x: 400, y: 240 } });
|
||||
await page.mouse.wheel(0, -240);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as typeof window & { wheelMessages: number }).wheelMessages))
|
||||
.toBe(1);
|
||||
await page.evaluate(() => window.fxnodeExample.view!.whenRendered());
|
||||
const zoomed = await canvas.screenshot();
|
||||
expect(zoomed.equals(before)).toBe(false);
|
||||
expect((await page.evaluate(() => window.fxnodeExample.root!.getState())).version).toBe(version);
|
||||
await page.mouse.wheel(0, 240);
|
||||
await page.evaluate(() => window.fxnodeExample.view!.whenRendered());
|
||||
expect((await canvas.screenshot()).equals(zoomed)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const MAP = {
|
||||
knifeX: 600,
|
||||
parallelTop: 90,
|
||||
parallelBottom: 420,
|
||||
chainKnifeX: 430,
|
||||
chainTop: 390,
|
||||
chainBottom: 485,
|
||||
mathAHeader: { x: 850, y: 90 },
|
||||
transformHeader: { x: 440, y: 560 },
|
||||
noiseHeader: { x: 850, y: 560 },
|
||||
} as const;
|
||||
|
||||
async function open(page: Page) {
|
||||
await page.goto("/examples/blender/link-tools-test/");
|
||||
await page.evaluate(() => window.linkToolsTest.ready);
|
||||
await page.evaluate(() => {
|
||||
const events = { mutations: [] as number[], snapshots: [] as number[] };
|
||||
Object.assign(window, { linkToolEvents: events });
|
||||
window.linkToolsTest.root!.onMutations((e) => events.mutations.push(e.version));
|
||||
window.linkToolsTest.root!.onSnapshots((e) => events.snapshots.push(e.version));
|
||||
});
|
||||
return page.locator("#link-tools");
|
||||
}
|
||||
|
||||
async function publicState(page: Page) {
|
||||
return page.evaluate(async () => ({
|
||||
save: await window.linkToolsTest.root!.save(),
|
||||
snapshot: await window.linkToolsTest.root!.getState(),
|
||||
events: structuredClone(
|
||||
(window as typeof window & { linkToolEvents: { mutations: number[]; snapshots: number[] } }).linkToolEvents,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
async function knife(
|
||||
page: Page,
|
||||
canvas: ReturnType<Page["locator"]>,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
alt = false,
|
||||
release = true,
|
||||
) {
|
||||
await canvas.hover({ position: from });
|
||||
await page.keyboard.down("Control");
|
||||
if (alt) await page.keyboard.down("Alt");
|
||||
await page.mouse.down({ button: "right" });
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("canvas bounds missing");
|
||||
await page.mouse.move(box.x + to.x, box.y + to.y, { steps: 12 });
|
||||
if (release) await page.mouse.up({ button: "right" });
|
||||
if (alt) await page.keyboard.up("Alt");
|
||||
await page.keyboard.up("Control");
|
||||
}
|
||||
|
||||
test("Ctrl-RMB knife is transient then removes every crossed link atomically", async ({ page }) => {
|
||||
const canvas = await open(page),
|
||||
before = await publicState(page);
|
||||
await knife(
|
||||
page,
|
||||
canvas,
|
||||
{ x: MAP.knifeX, y: MAP.parallelTop },
|
||||
{ x: MAP.knifeX, y: MAP.parallelBottom },
|
||||
false,
|
||||
false,
|
||||
);
|
||||
const during = await publicState(page);
|
||||
expect(during).toEqual(before);
|
||||
// The actual canvas must contain the white knife/highlight, not merely worker state.
|
||||
expect(
|
||||
await page.evaluate(
|
||||
({ x, top, bottom }) => {
|
||||
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
|
||||
d = c.getContext("2d")!.getImageData(x - 3, top, 7, bottom - top).data;
|
||||
let n = 0;
|
||||
for (let i = 0; i < d.length; i += 4) if (d[i]! > 220 && d[i + 1]! > 220 && d[i + 2]! > 220) n++;
|
||||
return n;
|
||||
},
|
||||
{ x: MAP.knifeX, top: MAP.parallelTop, bottom: MAP.parallelBottom },
|
||||
),
|
||||
).toBeGreaterThan(150);
|
||||
await page.mouse.up({ button: "right" });
|
||||
await page.keyboard.up("Control");
|
||||
const removed = await publicState(page);
|
||||
expect(removed.save.links.map((l) => l.id).sort()).toEqual(["chain-in", "chain-out"]);
|
||||
expect(removed.snapshot.version).toBe(before.snapshot.version + 1);
|
||||
expect(removed.events).toEqual({
|
||||
mutations: [before.snapshot.version + 1],
|
||||
snapshots: [before.snapshot.version + 1],
|
||||
});
|
||||
await page.keyboard.press("Control+z");
|
||||
const undone = await publicState(page);
|
||||
expect(undone.save.links).toEqual(before.save.links);
|
||||
expect(undone.snapshot.version).toBe(before.snapshot.version + 2);
|
||||
});
|
||||
|
||||
test("Escape and pointer cancellation discard knife and context menu stays suppressed", async ({ page }) => {
|
||||
const canvas = await open(page);
|
||||
await page.evaluate(() => {
|
||||
(window as any).unblockedMenus = 0;
|
||||
document.addEventListener("contextmenu", (event) => {
|
||||
if (!event.defaultPrevented) (window as any).unblockedMenus++;
|
||||
});
|
||||
});
|
||||
await knife(page, canvas, { x: 600, y: 55 }, { x: 600, y: 390 }, false, false);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.mouse.up({ button: "right" });
|
||||
await page.keyboard.up("Control");
|
||||
expect((await publicState(page)).events).toEqual({ mutations: [], snapshots: [] });
|
||||
await knife(page, canvas, { x: 600, y: 55 }, { x: 600, y: 390 }, false, false);
|
||||
await canvas.dispatchEvent("pointercancel", {
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
button: 2,
|
||||
buttons: 0,
|
||||
clientX: 600,
|
||||
clientY: 390,
|
||||
});
|
||||
await page.mouse.up({ button: "right" });
|
||||
await page.keyboard.up("Control");
|
||||
expect((await publicState(page)).save.links).toHaveLength(5);
|
||||
expect(await page.evaluate(() => (window as any).unblockedMenus)).toBe(0);
|
||||
});
|
||||
|
||||
test("Ctrl-Alt-RMB mutes a link, restores its default editor, and undo is atomic", async ({ page }) => {
|
||||
const canvas = await open(page),
|
||||
before = await publicState(page);
|
||||
await knife(page, canvas, { x: 600, y: 100 }, { x: 600, y: 205 }, true);
|
||||
const muted = await publicState(page),
|
||||
link = muted.save.links.find((l) => l.id === "parallel-a");
|
||||
expect(link?.muted).toBe(true);
|
||||
expect(muted.events).toEqual({ mutations: [before.snapshot.version + 1], snapshots: [before.snapshot.version + 1] });
|
||||
// Red pixels on the formerly grey link and a clickable default-value row prove rendering/hit behavior.
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
|
||||
d = c.getContext("2d")!.getImageData(500, 50, 200, 130).data;
|
||||
let n = 0;
|
||||
for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++;
|
||||
return n;
|
||||
}),
|
||||
).toBeGreaterThan(5);
|
||||
await canvas.click({ position: { x: 850, y: 125 } });
|
||||
expect((await publicState(page)).snapshot.version).toBe(before.snapshot.version + 1);
|
||||
await knife(page, canvas, { x: 600, y: 100 }, { x: 600, y: 205 }, true);
|
||||
expect((await publicState(page)).save.links.find((l) => l.id === "parallel-a")?.muted).toBe(false);
|
||||
await page.keyboard.press("Control+z");
|
||||
expect((await publicState(page)).save.links.find((l) => l.id === "parallel-a")?.muted).toBe(true);
|
||||
});
|
||||
|
||||
test("reroute effective mute is red without changing downstream authored flags", async ({ page }) => {
|
||||
const canvas = await open(page);
|
||||
await knife(page, canvas, { x: MAP.chainKnifeX, y: MAP.chainTop }, { x: MAP.chainKnifeX, y: MAP.chainBottom }, true);
|
||||
const state = await publicState(page);
|
||||
expect(state.save.links.find((l) => l.id === "chain-in")?.muted).toBe(true);
|
||||
expect(state.save.links.find((l) => l.id === "chain-out")?.muted).toBe(false);
|
||||
await page.evaluate(() => window.linkToolsTest.view!.whenRendered());
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
|
||||
d = c.getContext("2d")!.getImageData(590, 420, 250, 100).data;
|
||||
let n = 0;
|
||||
for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++;
|
||||
return n;
|
||||
}),
|
||||
).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
test("M mutes operators with bypasses and visibly grays generators", async ({ page }) => {
|
||||
const canvas = await open(page);
|
||||
for (const [id, point] of [
|
||||
["math-a", MAP.mathAHeader],
|
||||
["transform", MAP.transformHeader],
|
||||
] as const) {
|
||||
await canvas.click({ position: point });
|
||||
const prior = await publicState(page),
|
||||
before = prior.snapshot.version;
|
||||
await canvas.press("m");
|
||||
const muted = await publicState(page);
|
||||
expect(muted.save.nodes.find((n) => n.id === id)?.muted).toBe(true);
|
||||
expect(muted.snapshot.version).toBe(before + 1);
|
||||
expect(muted.events.mutations).toHaveLength(prior.events.mutations.length + 1);
|
||||
expect(muted.events.snapshots).toHaveLength(prior.events.snapshots.length + 1);
|
||||
await page.evaluate(() => window.linkToolsTest.view!.whenRendered());
|
||||
expect(
|
||||
await page.evaluate(({ x, y }) => {
|
||||
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
|
||||
d = c.getContext("2d")!.getImageData(x - 100, y, 200, 100).data;
|
||||
let n = 0;
|
||||
for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++;
|
||||
return n;
|
||||
}, point),
|
||||
).toBeGreaterThan(5);
|
||||
await page.keyboard.press("Control+z");
|
||||
expect((await publicState(page)).save.nodes.find((n) => n.id === id)?.muted).toBe(false);
|
||||
}
|
||||
await canvas.click({ position: MAP.noiseHeader });
|
||||
const before = await publicState(page),
|
||||
beforeLight = await page.evaluate(() => {
|
||||
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
|
||||
d = c.getContext("2d")!.getImageData(780, 550, 165, 85).data;
|
||||
let n = 0;
|
||||
for (let i = 0; i < d.length; i += 4) n += d[i]! + d[i + 1]! + d[i + 2]!;
|
||||
return n;
|
||||
});
|
||||
await canvas.press("m");
|
||||
await page.evaluate(() => window.linkToolsTest.view!.whenRendered());
|
||||
const muted = await publicState(page),
|
||||
afterLight = await page.evaluate(() => {
|
||||
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
|
||||
d = c.getContext("2d")!.getImageData(780, 550, 165, 85).data;
|
||||
let n = 0;
|
||||
for (let i = 0; i < d.length; i += 4) n += d[i]! + d[i + 1]! + d[i + 2]!;
|
||||
return n;
|
||||
});
|
||||
expect(muted.save.nodes.find((n) => n.id === "noise")?.muted).toBe(true);
|
||||
expect(muted.snapshot.version).toBe(before.snapshot.version + 1);
|
||||
expect(afterLight).toBeLessThan(beforeLight * 0.8);
|
||||
await page.keyboard.press("Control+z");
|
||||
expect((await publicState(page)).save.nodes.find((n) => n.id === "noise")?.muted).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,490 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { GraphState } from "@lib/core/types.js";
|
||||
|
||||
test("browser host disconnect lifecycle is opt-in and preserves same-task reparenting", async ({ page }) => {
|
||||
await page.goto("/test/browser/index.html");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js");
|
||||
const makeApi = () => {
|
||||
let detaches = 0;
|
||||
const api = {
|
||||
feedInput() {},
|
||||
setViewport() {},
|
||||
detach() {
|
||||
detaches++;
|
||||
return Promise.resolve();
|
||||
},
|
||||
getHostSnapshot: () => ({ colorPickerOpen: false }),
|
||||
onHostRequests: () => () => {},
|
||||
onCompositionChanges: () => () => {},
|
||||
onMutations: () => () => {},
|
||||
} as any;
|
||||
return { api, count: () => detaches };
|
||||
};
|
||||
const explicitCanvas = document.createElement("canvas"),
|
||||
automaticCanvas = document.createElement("canvas"),
|
||||
replacementParent = document.createElement("div");
|
||||
document.body.append(explicitCanvas, automaticCanvas, replacementParent);
|
||||
const explicitApi = makeApi(),
|
||||
automaticApi = makeApi(),
|
||||
explicitHost = prepareFxNodeBrowserHost({ canvas: explicitCanvas }),
|
||||
automaticHost = prepareFxNodeBrowserHost({
|
||||
canvas: automaticCanvas,
|
||||
lifecycle: "detach-on-disconnect",
|
||||
});
|
||||
explicitHost.attach(explicitApi.api, explicitApi.api);
|
||||
automaticHost.attach(automaticApi.api, automaticApi.api);
|
||||
explicitCanvas.remove();
|
||||
replacementParent.append(automaticCanvas);
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
const afterReparent = automaticApi.count();
|
||||
automaticCanvas.remove();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
const resultValue = {
|
||||
explicit: explicitApi.count(),
|
||||
afterReparent,
|
||||
afterRemoval: automaticApi.count(),
|
||||
};
|
||||
explicitHost.destroy();
|
||||
automaticHost.destroy();
|
||||
replacementParent.remove();
|
||||
return resultValue;
|
||||
});
|
||||
expect(result).toEqual({ explicit: 0, afterReparent: 0, afterRemoval: 1 });
|
||||
});
|
||||
|
||||
test("core lifecycle owns no DOM state and the host adapter cleans up its policy", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
window.fxnodeHost.destroy();
|
||||
window.root.destroy();
|
||||
const { createFxNode } = await import("@lib/index.js");
|
||||
const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js");
|
||||
const { APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION, APPLICATION_HEADER_STYLES } = await import(
|
||||
"../../examples/blender/nodes/application.js"
|
||||
);
|
||||
const { exampleTheme } = await import("../../examples/shared/theme.js");
|
||||
const { anySocket, floatSocket } = await import("../../examples/blender/nodes/socket-types.js");
|
||||
const { valueNode } = await import("../../examples/blender/nodes/shader/value.js");
|
||||
const { applicationCompatibility } = await import("../../examples/blender/nodes/application.js");
|
||||
const layout = { schemaVersion: 1, graphId: "lifecycle", catalogVersion: 7, nodes: [], links: [], metadata: {} };
|
||||
const applicationOptions = {
|
||||
applicationId: APPLICATION_ID,
|
||||
applicationVersion: APPLICATION_VERSION,
|
||||
resources: APPLICATION_RESOURCES,
|
||||
};
|
||||
const state = {
|
||||
graphId: layout.graphId as GraphState["graphId"],
|
||||
catalogVersion: APPLICATION_VERSION,
|
||||
nodes: [],
|
||||
links: [],
|
||||
metadata: {},
|
||||
};
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("#primary")!;
|
||||
canvas.setAttribute("tabindex", "7");
|
||||
canvas.style.touchAction = "pan-x";
|
||||
const width = canvas.width,
|
||||
height = canvas.height;
|
||||
const installValue = async (api: Awaited<ReturnType<typeof createFxNode>>) => {
|
||||
await api.setTheme(exampleTheme);
|
||||
await api.setHeaderStyles(APPLICATION_HEADER_STYLES);
|
||||
await api.composeSocket(...anySocket);
|
||||
await api.composeSocket(...floatSocket);
|
||||
await api.setCompatibility(applicationCompatibility);
|
||||
await api.composeNode(...valueNode);
|
||||
};
|
||||
let later = 0;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
const api = await createFxNode(applicationOptions);
|
||||
if (index === 0) await installValue(api);
|
||||
await api.setState(state, { expectedVersion: 0 });
|
||||
if (index === 0) {
|
||||
api.onMutations(() => {
|
||||
throw new Error("intentional subscriber failure");
|
||||
});
|
||||
api.onMutations(() => later++);
|
||||
await api.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } });
|
||||
}
|
||||
const view = await api.attachView({ canvas, viewport: { width: 1200, height: 640, dpr: 1 } });
|
||||
const barrier = view.whenRendered();
|
||||
await view.detach();
|
||||
api.destroy();
|
||||
await barrier.catch((error) => error);
|
||||
}
|
||||
const host = prepareFxNodeBrowserHost({ canvas }),
|
||||
api = await createFxNode(applicationOptions),
|
||||
view = await api.attachView({ canvas, viewport: host.initialViewport });
|
||||
await api.setState(state, { expectedVersion: 0 });
|
||||
host.attach(api, view);
|
||||
host.destroy();
|
||||
api.destroy();
|
||||
return {
|
||||
later,
|
||||
tabIndex: canvas.getAttribute("tabindex"),
|
||||
touchAction: canvas.style.touchAction,
|
||||
widthUnchanged: canvas.width === width,
|
||||
heightUnchanged: canvas.height === height,
|
||||
};
|
||||
});
|
||||
expect(result).toEqual({
|
||||
later: 1,
|
||||
tabIndex: "7",
|
||||
touchAction: "pan-x",
|
||||
widthUnchanged: true,
|
||||
heightUnchanged: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("bad load retains structured issues and returned values are detached", async ({ page }) => {
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
let error: { code?: string; issues?: readonly unknown[] } = {};
|
||||
try {
|
||||
await window.root.load({ nope: true });
|
||||
} catch (value) {
|
||||
error = value as typeof error;
|
||||
}
|
||||
const saved = await window.root.save();
|
||||
(saved.nodes as unknown as unknown[]).push({});
|
||||
const snapshot = await window.root.getState();
|
||||
(snapshot.nodes as unknown as unknown[]).push({});
|
||||
const nextSaved = await window.root.save();
|
||||
const nextSnapshot = await window.root.getState();
|
||||
window.fxnodeHost.destroy();
|
||||
window.root.destroy();
|
||||
return {
|
||||
code: error.code,
|
||||
issues: error.issues?.length ?? 0,
|
||||
saved: nextSaved.nodes.length,
|
||||
snapshot: nextSnapshot.nodes.length,
|
||||
};
|
||||
});
|
||||
expect(result.code).toBeTruthy();
|
||||
expect(result.issues).toBeGreaterThan(0);
|
||||
expect(result.saved).toBe(0);
|
||||
expect(result.snapshot).toBe(0);
|
||||
});
|
||||
|
||||
test("direct client never touches DOM lifecycle APIs", async ({ page }) => {
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
window.fxnodeHost.destroy();
|
||||
window.root.destroy();
|
||||
const { createFxNode } = await import("@lib/index.js"),
|
||||
{ APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION } = await import(
|
||||
"../../examples/blender/nodes/application.js"
|
||||
),
|
||||
canvas = document.querySelector<HTMLCanvasElement>("#primary")!,
|
||||
NativeObserver = ResizeObserver;
|
||||
const before = {
|
||||
tabindex: canvas.getAttribute("tabindex"),
|
||||
touchAction: canvas.style.touchAction,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
children: document.body.childElementCount,
|
||||
};
|
||||
class ThrowingObserver {
|
||||
constructor() {
|
||||
throw new Error("ResizeObserver constructed");
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, "ResizeObserver", { configurable: true, writable: true, value: ThrowingObserver });
|
||||
const original = {
|
||||
add: canvas.addEventListener,
|
||||
rect: canvas.getBoundingClientRect,
|
||||
focus: canvas.focus,
|
||||
capture: canvas.setPointerCapture,
|
||||
};
|
||||
canvas.addEventListener = (() => {
|
||||
throw new Error("listener registered");
|
||||
}) as typeof canvas.addEventListener;
|
||||
canvas.getBoundingClientRect = (() => {
|
||||
throw new Error("layout read");
|
||||
}) as typeof canvas.getBoundingClientRect;
|
||||
canvas.focus = (() => {
|
||||
throw new Error("focused");
|
||||
}) as typeof canvas.focus;
|
||||
canvas.setPointerCapture = (() => {
|
||||
throw new Error("captured");
|
||||
}) as typeof canvas.setPointerCapture;
|
||||
try {
|
||||
const version = APPLICATION_VERSION,
|
||||
api = await createFxNode({
|
||||
applicationId: APPLICATION_ID,
|
||||
applicationVersion: APPLICATION_VERSION,
|
||||
resources: APPLICATION_RESOURCES,
|
||||
});
|
||||
await api.setState(
|
||||
{
|
||||
graphId: "direct-dom-boundary" as GraphState["graphId"],
|
||||
catalogVersion: version,
|
||||
nodes: [],
|
||||
links: [],
|
||||
metadata: {},
|
||||
},
|
||||
{ expectedVersion: 0 },
|
||||
);
|
||||
api.destroy();
|
||||
} finally {
|
||||
canvas.addEventListener = original.add;
|
||||
canvas.getBoundingClientRect = original.rect;
|
||||
canvas.focus = original.focus;
|
||||
canvas.setPointerCapture = original.capture;
|
||||
Object.defineProperty(window, "ResizeObserver", { configurable: true, writable: true, value: NativeObserver });
|
||||
}
|
||||
return {
|
||||
before,
|
||||
after: {
|
||||
tabindex: canvas.getAttribute("tabindex"),
|
||||
touchAction: canvas.style.touchAction,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
children: document.body.childElementCount,
|
||||
},
|
||||
};
|
||||
});
|
||||
expect(result.after).toEqual(result.before);
|
||||
});
|
||||
|
||||
test("browser host is exclusive and preserves application DOM changes on cleanup", async ({ page }) => {
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
window.fxnodeHost.destroy();
|
||||
window.root.destroy();
|
||||
const { createFxNode } = await import("@lib/index.js"),
|
||||
{ prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js"),
|
||||
{ APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION } = await import(
|
||||
"../../examples/blender/nodes/application.js"
|
||||
),
|
||||
canvas = document.querySelector<HTMLCanvasElement>("#primary")!;
|
||||
canvas.removeAttribute("tabindex");
|
||||
canvas.style.touchAction = "";
|
||||
const host = prepareFxNodeBrowserHost({ canvas });
|
||||
let duplicate = "";
|
||||
try {
|
||||
prepareFxNodeBrowserHost({ canvas });
|
||||
} catch (error) {
|
||||
duplicate = (error as Error).message;
|
||||
}
|
||||
const version = APPLICATION_VERSION,
|
||||
api = await createFxNode({
|
||||
applicationId: APPLICATION_ID,
|
||||
applicationVersion: APPLICATION_VERSION,
|
||||
resources: APPLICATION_RESOURCES,
|
||||
});
|
||||
await api.setState(
|
||||
{
|
||||
graphId: "host-lifecycle" as GraphState["graphId"],
|
||||
catalogVersion: version,
|
||||
nodes: [],
|
||||
links: [],
|
||||
metadata: {},
|
||||
},
|
||||
{ expectedVersion: 0 },
|
||||
);
|
||||
const view = await api.attachView({ canvas, viewport: host.initialViewport });
|
||||
host.attach(api, view);
|
||||
canvas.setAttribute("tabindex", "9");
|
||||
canvas.style.touchAction = "pan-y";
|
||||
host.destroy();
|
||||
api.destroy();
|
||||
const replacement = prepareFxNodeBrowserHost({ canvas });
|
||||
replacement.destroy();
|
||||
return { duplicate, tabindex: canvas.getAttribute("tabindex"), touchAction: canvas.style.touchAction };
|
||||
});
|
||||
expect(result).toEqual({
|
||||
duplicate: "Canvas already has an active FxNode browser host",
|
||||
tabindex: "9",
|
||||
touchAction: "pan-y",
|
||||
});
|
||||
});
|
||||
|
||||
test("worker resource requests close delayed add-menu UI without intercepting input", async ({ page }) => {
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js"),
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.style.cssText = "width:200px;height:120px";
|
||||
document.body.append(canvas);
|
||||
let request: ((value: any) => void) | undefined,
|
||||
pickers = 0,
|
||||
forwarded = 0;
|
||||
const target = Object.freeze({
|
||||
kind: "resource-open" as const,
|
||||
authorization: Object.freeze({ token: "resource", graphVersion: 0, compositionRevision: 0 }),
|
||||
resource: Object.freeze({
|
||||
id: "image",
|
||||
kind: "image" as const,
|
||||
title: "Image",
|
||||
openTitle: "Open",
|
||||
accept: Object.freeze(["image/png"]),
|
||||
maxBytes: 100,
|
||||
maxWidth: 10,
|
||||
maxHeight: 10,
|
||||
maxPixels: 100,
|
||||
}),
|
||||
}),
|
||||
snapshot = Object.freeze({
|
||||
compositionRevision: 0,
|
||||
colorPickerOpen: false,
|
||||
selection: Object.freeze({
|
||||
nodeCount: 0,
|
||||
linkCount: 0,
|
||||
canRemove: false,
|
||||
mute: Object.freeze({ enabled: false as const }),
|
||||
}),
|
||||
});
|
||||
const api = {
|
||||
feedInput(value: any) {
|
||||
if (value.kind === "pointer" && value.phase === "down" && value.button === 0) forwarded++;
|
||||
},
|
||||
setViewport() {},
|
||||
getHostSnapshot: () => snapshot,
|
||||
onHostRequests(callback: (value: any) => void) {
|
||||
request = callback;
|
||||
return () => {};
|
||||
},
|
||||
onCompositionChanges() {
|
||||
return () => {};
|
||||
},
|
||||
onMutations() {
|
||||
return () => {};
|
||||
},
|
||||
addNode() {
|
||||
return Promise.resolve({ status: "noop", version: 0 });
|
||||
},
|
||||
} as any,
|
||||
host = prepareFxNodeBrowserHost({
|
||||
canvas,
|
||||
activateResourcePicker: () => {
|
||||
pickers++;
|
||||
},
|
||||
});
|
||||
host.attach(api, api);
|
||||
const rect = canvas.getBoundingClientRect(),
|
||||
fire = (pointerId: number, button: number, x: number) => {
|
||||
canvas.dispatchEvent(
|
||||
new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
pointerId,
|
||||
pointerType: "mouse",
|
||||
button,
|
||||
buttons: button === 2 ? 2 : 1,
|
||||
clientX: rect.left + x,
|
||||
clientY: rect.top + 10,
|
||||
}),
|
||||
);
|
||||
};
|
||||
fire(81, 2, 80);
|
||||
fire(82, 0, 10);
|
||||
request?.(target);
|
||||
request?.({
|
||||
kind: "add-node-menu",
|
||||
viewPosition: { x: 80, y: 10 },
|
||||
compositionRevision: 0,
|
||||
});
|
||||
const menus = document.querySelectorAll("[data-fxnode-add-menu]").length;
|
||||
host.destroy();
|
||||
canvas.remove();
|
||||
return { pickers, menus, forwarded };
|
||||
});
|
||||
expect(result).toEqual({ pickers: 1, menus: 0, forwarded: 1 });
|
||||
});
|
||||
|
||||
test("default resource picker ignores obsolete asynchronous reads", async ({ page }) => {
|
||||
await page.goto("/test/browser/index.html");
|
||||
await page.evaluate(() => window.ready);
|
||||
const result = await page.evaluate(async () => {
|
||||
const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js"),
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.style.cssText = "width:100px;height:100px";
|
||||
document.body.append(canvas);
|
||||
const target = Object.freeze({
|
||||
kind: "resource-open" as const,
|
||||
authorization: Object.freeze({ token: "resource", graphVersion: 0, compositionRevision: 0 }),
|
||||
resource: Object.freeze({
|
||||
id: "image",
|
||||
kind: "image" as const,
|
||||
title: "Image",
|
||||
openTitle: "Open",
|
||||
accept: Object.freeze(["image/png"]),
|
||||
maxBytes: 100,
|
||||
maxWidth: 10,
|
||||
maxHeight: 10,
|
||||
maxPixels: 100,
|
||||
}),
|
||||
}),
|
||||
snapshot = Object.freeze({
|
||||
compositionRevision: 0,
|
||||
colorPickerOpen: false,
|
||||
selection: Object.freeze({
|
||||
nodeCount: 0,
|
||||
linkCount: 0,
|
||||
canRemove: false,
|
||||
mute: Object.freeze({ enabled: false as const }),
|
||||
}),
|
||||
});
|
||||
let resolveA!: (value: ArrayBuffer) => void, resolveB!: (value: ArrayBuffer) => void;
|
||||
const a = new File([new Uint8Array([1])], "a.png", { type: "image/png" }),
|
||||
b = new File([new Uint8Array([2])], "b.png", { type: "image/png" });
|
||||
Object.defineProperty(a, "arrayBuffer", {
|
||||
value: () => new Promise<ArrayBuffer>((resolve) => (resolveA = resolve)),
|
||||
});
|
||||
Object.defineProperty(b, "arrayBuffer", {
|
||||
value: () => new Promise<ArrayBuffer>((resolve) => (resolveB = resolve)),
|
||||
});
|
||||
let request: ((value: any) => void) | undefined;
|
||||
const provided: string[] = [],
|
||||
api = {
|
||||
feedInput() {},
|
||||
setViewport() {},
|
||||
getHostSnapshot: () => snapshot,
|
||||
onHostRequests(callback: (value: any) => void) {
|
||||
request = callback;
|
||||
return () => {};
|
||||
},
|
||||
onCompositionChanges() {
|
||||
return () => {};
|
||||
},
|
||||
onMutations() {
|
||||
return () => {};
|
||||
},
|
||||
addNode() {
|
||||
return Promise.resolve({ status: "noop", version: 0 });
|
||||
},
|
||||
provideResource(_authorization: unknown, data: { name: string }) {
|
||||
provided.push(data.name);
|
||||
return Promise.resolve({ status: "committed", version: provided.length });
|
||||
},
|
||||
} as any,
|
||||
host = prepareFxNodeBrowserHost({ canvas });
|
||||
host.attach(api, api);
|
||||
const choose = (file: File) => {
|
||||
request?.(target);
|
||||
const input = document.querySelector<HTMLInputElement>("[data-fxnode-resource-file]")!;
|
||||
Object.defineProperty(input, "files", {
|
||||
configurable: true,
|
||||
value: { 0: file, length: 1, item: (index: number) => (index === 0 ? file : null) },
|
||||
});
|
||||
input.dispatchEvent(new Event("change"));
|
||||
};
|
||||
choose(a);
|
||||
choose(b);
|
||||
resolveB(new Uint8Array([2]).buffer);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
resolveA(new Uint8Array([1]).buffer);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
host.destroy();
|
||||
canvas.remove();
|
||||
return provided;
|
||||
});
|
||||
expect(result).toEqual(["b.png"]);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("M muting is one paired gesture for bypass nodes and generators", async ({ page }) => {
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
const canvas = page.locator("#graph");
|
||||
await page.evaluate(() => {
|
||||
const api = window.fxnodeExample.root!,
|
||||
view = window.fxnodeExample.view!;
|
||||
const state = { mutations: [] as number[], snapshots: [] as number[] };
|
||||
(window as typeof window & { muteEvents: typeof state }).muteEvents = state;
|
||||
// Subscriber exceptions are deliberately isolated from subsequent listeners.
|
||||
api.onMutations(() => {
|
||||
throw new Error("intentional test subscriber");
|
||||
});
|
||||
api.onMutations((event) => state.mutations.push(event.version));
|
||||
api.onSnapshots((event) => state.snapshots.push(event.version));
|
||||
});
|
||||
const baseline = (await page.evaluate(() => window.fxnodeExample.root!.getState())).version;
|
||||
|
||||
// Fixture coordinates use the documented 1200x640 viewport and world origin
|
||||
// (600,320). Header centers are stable layout data, not worker introspection.
|
||||
await canvas.click({ position: { x: 380, y: 160 } }); // Math (-300,170), width 160
|
||||
await canvas.press("m");
|
||||
let snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState());
|
||||
expect(snapshot.nodes.find((node) => node.id === "math")?.muted).toBe(true);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => (window as typeof window & { muteEvents: { mutations: number[]; snapshots: number[] } }).muteEvents,
|
||||
),
|
||||
).toEqual({ mutations: [baseline + 1], snapshots: [baseline + 1] });
|
||||
|
||||
await canvas.press("m");
|
||||
snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState());
|
||||
expect(snapshot.nodes.find((node) => node.id === "math")?.muted).toBe(false);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => (window as typeof window & { muteEvents: { mutations: number[]; snapshots: number[] } }).muteEvents,
|
||||
),
|
||||
).toEqual({ mutations: [baseline + 1, baseline + 2], snapshots: [baseline + 1, baseline + 2] });
|
||||
|
||||
await canvas.click({ position: { x: 580, y: 140 } }); // Noise generator (-100,190)
|
||||
await canvas.press("m");
|
||||
snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState());
|
||||
expect(snapshot.nodes.find((node) => node.id === "noise")?.muted).toBe(true);
|
||||
expect(snapshot.version).toBe(baseline + 3);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => (window as typeof window & { muteEvents: { mutations: number[]; snapshots: number[] } }).muteEvents,
|
||||
),
|
||||
).toEqual({
|
||||
mutations: [baseline + 1, baseline + 2, baseline + 3],
|
||||
snapshots: [baseline + 1, baseline + 2, baseline + 3],
|
||||
});
|
||||
await canvas.press("Control+z");
|
||||
snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState());
|
||||
expect(snapshot.nodes.find((node) => node.id === "noise")?.muted).toBe(false);
|
||||
expect(snapshot.nodes.find((node) => node.id === "math")?.muted).toBe(false);
|
||||
});
|
||||
|
||||
test("equivalent selection summaries retain identity and API mute is one-step undoable", async ({ page }) => {
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
const canvas = page.locator("#graph");
|
||||
expect(
|
||||
await page.evaluate(async () => {
|
||||
const api = window.fxnodeExample.root!,
|
||||
view = window.fxnodeExample.view!;
|
||||
await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 900, y: 500 }, nodeId: "summary-a" });
|
||||
const first = view.getHostSnapshot().selection;
|
||||
await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 1100, y: 500 }, nodeId: "summary-b" });
|
||||
return view.getHostSnapshot().selection === first;
|
||||
}),
|
||||
).toBe(true);
|
||||
await page.evaluate(() =>
|
||||
window.fxnodeExample.root!.dispatch({ type: "node.mute", id: "summary-b" as never, value: true }),
|
||||
);
|
||||
expect((await page.evaluate(() => window.fxnodeExample.view!.getHostSnapshot())).selection.mute).toEqual({
|
||||
enabled: true,
|
||||
state: "all-muted",
|
||||
});
|
||||
const result = await page.evaluate(async () => {
|
||||
const api = window.fxnodeExample.root!,
|
||||
view = window.fxnodeExample.view!,
|
||||
order: string[] = [];
|
||||
api.onMutations((event) => order.push(`mutation:${event.version}`));
|
||||
api.onSnapshots((event) => order.push(`snapshot:${event.version}`));
|
||||
const receipt = await view.setSelectedMuted(false);
|
||||
order.push(`receipt:${receipt.version}`);
|
||||
const unmuted = structuredClone(view.getHostSnapshot().selection.mute),
|
||||
undo = await api.undo(),
|
||||
muted = structuredClone(view.getHostSnapshot().selection.mute),
|
||||
redo = await api.redo(),
|
||||
again = structuredClone(view.getHostSnapshot().selection.mute);
|
||||
return { receipt, unmuted, undo, muted, redo, again, order };
|
||||
});
|
||||
expect(result.unmuted).toEqual({ enabled: true, state: "all-unmuted" });
|
||||
expect(result.muted).toEqual({ enabled: true, state: "all-muted" });
|
||||
expect(result.again).toEqual({ enabled: true, state: "all-unmuted" });
|
||||
expect(result.receipt.status).toBe("committed");
|
||||
expect(result.undo.status).toBe("committed");
|
||||
expect(result.redo.status).toBe("committed");
|
||||
expect(result.order).toEqual([
|
||||
`mutation:${result.receipt.version}`,
|
||||
`snapshot:${result.receipt.version}`,
|
||||
`receipt:${result.receipt.version}`,
|
||||
`mutation:${result.undo.version}`,
|
||||
`snapshot:${result.undo.version}`,
|
||||
`mutation:${result.redo.version}`,
|
||||
`snapshot:${result.redo.version}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("collapse chevron animates for click, H, API, and interrupted reversal then idles", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
let frames = 0;
|
||||
const original = CanvasRenderingContext2D.prototype.drawImage;
|
||||
Object.defineProperty(CanvasRenderingContext2D.prototype, "drawImage", {
|
||||
value: function (this: CanvasRenderingContext2D, ...args: unknown[]) {
|
||||
frames++;
|
||||
return Reflect.apply(original, this, args);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, "frameDraws", { get: () => frames });
|
||||
});
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
const canvas = page.locator("#graph");
|
||||
await canvas.click({ position: { x: 380, y: 160 } });
|
||||
const chevron = () =>
|
||||
page.evaluate(() =>
|
||||
Array.from(
|
||||
document.querySelector<HTMLCanvasElement>("#graph")!.getContext("2d")!.getImageData(304, 154, 16, 16).data,
|
||||
),
|
||||
);
|
||||
const expanded = await chevron(),
|
||||
before = await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws);
|
||||
await canvas.click({ position: { x: 312, y: 162 } });
|
||||
expect(
|
||||
(await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math")
|
||||
?.collapsed,
|
||||
).toBe(true);
|
||||
await page.waitForTimeout(180);
|
||||
const collapsed = await chevron(),
|
||||
animated = await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws);
|
||||
expect(collapsed).not.toEqual(expanded);
|
||||
expect(animated - before).toBeGreaterThan(1);
|
||||
await canvas.press("h");
|
||||
await page.waitForTimeout(180);
|
||||
expect(
|
||||
(await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math")
|
||||
?.collapsed,
|
||||
).toBe(false);
|
||||
expect(await chevron()).toEqual(expanded);
|
||||
await page.evaluate(async () => {
|
||||
const api = window.fxnodeExample.root!,
|
||||
view = window.fxnodeExample.view!,
|
||||
id = (await api.getState()).nodes.find((node) => node.id === "math")!.id;
|
||||
return api.dispatch({ type: "node.collapse", id, value: true });
|
||||
});
|
||||
await page.waitForTimeout(30);
|
||||
await page.evaluate(async () => {
|
||||
const api = window.fxnodeExample.root!,
|
||||
view = window.fxnodeExample.view!,
|
||||
id = (await api.getState()).nodes.find((node) => node.id === "math")!.id;
|
||||
return api.dispatch({ type: "node.collapse", id, value: false });
|
||||
});
|
||||
await page.waitForTimeout(180);
|
||||
expect(
|
||||
(await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math")
|
||||
?.collapsed,
|
||||
).toBe(false);
|
||||
expect(await chevron()).toEqual(expanded);
|
||||
const idle = await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws);
|
||||
await page.waitForTimeout(180);
|
||||
expect(await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws)).toBe(idle);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
async function stable(canvas: Locator): Promise<void> {
|
||||
const first = await canvas.screenshot();
|
||||
await canvas.page().evaluate(() => window.parityExample.view.whenRendered());
|
||||
expect((await canvas.screenshot()).equals(first)).toBe(true);
|
||||
}
|
||||
|
||||
test("@visual structural parity baseline and focused controls", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto("/examples/blender/parity/");
|
||||
await page.waitForFunction(() => Boolean(window.parityExample));
|
||||
const canvas = page.locator("canvas");
|
||||
await canvas.press("Home");
|
||||
await page.evaluate(() => window.parityExample.view.whenRendered());
|
||||
await stable(canvas);
|
||||
const regions = await canvas.evaluate((element) => {
|
||||
const context = (element as HTMLCanvasElement).getContext("2d")!;
|
||||
const regions = [
|
||||
{ x: 40, y: 110, w: 260, h: 290 },
|
||||
{ x: 1025, y: 110, w: 370, h: 330 },
|
||||
{ x: 650, y: 615, w: 470, h: 240 },
|
||||
];
|
||||
return regions.map((region) => {
|
||||
const data = context.getImageData(region.x, region.y, region.w, region.h).data;
|
||||
let body = 0,
|
||||
controls = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i] === 53 && data[i + 1] === 56 && data[i + 2] === 62) body++;
|
||||
if (data[i] === 36 && data[i + 1] === 39 && data[i + 2] === 43) controls++;
|
||||
}
|
||||
return { body, controls };
|
||||
});
|
||||
});
|
||||
for (const region of regions) expect(region.body).toBeGreaterThan(1000);
|
||||
expect(regions.reduce((sum, region) => sum + region.controls, 0)).toBeGreaterThan(500);
|
||||
await expect(canvas).toHaveScreenshot("parity-structural.png", { animations: "disabled" });
|
||||
|
||||
// Crops deliberately cover the widget structures rather than Blender pixels:
|
||||
// image selectors/projection, grading mode rows, and ramp/noise conditional rows.
|
||||
const imageGrading = await page.screenshot({
|
||||
clip: { x: 90, y: 500, width: 1260, height: 420 },
|
||||
animations: "disabled",
|
||||
});
|
||||
expect(imageGrading).toMatchSnapshot("parity-image-grading.png");
|
||||
const rampNoise = await page.screenshot({
|
||||
clip: { x: 380, y: 55, width: 1060, height: 450 },
|
||||
animations: "disabled",
|
||||
});
|
||||
expect(rampNoise).toMatchSnapshot("parity-ramp-noise.png");
|
||||
});
|
||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<canvas id="presentation" width="500" height="300"></canvas>
|
||||
<script type="module" src="./presentation-render.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("renderer paints composition-resolved background, header, socket, and link colors", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/test/browser/presentation-render.html");
|
||||
await page.waitForTimeout(500);
|
||||
expect(errors).toEqual([]);
|
||||
const result = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
presentationResult: {
|
||||
background: number[];
|
||||
header: number[];
|
||||
socket: number[];
|
||||
linkContainsCustomColor: boolean;
|
||||
};
|
||||
}
|
||||
).presentationResult,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
background: [1, 2, 3, 255],
|
||||
header: [118, 84, 171, 255],
|
||||
socket: [161, 178, 195, 255],
|
||||
linkContainsCustomColor: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { compileFxNodeComposition } from "@lib/composition/index.js";
|
||||
import { bindFxNodeHeadless } from "@lib/headless-runtime.js";
|
||||
import { layoutGraph } from "@lib/layout/layout-graph.js";
|
||||
import { worldToView } from "@lib/layout/geometry.js";
|
||||
import { renderCanvas } from "@lib/render/canvas-renderer.js";
|
||||
import { linkId } from "@lib/core/types.js";
|
||||
|
||||
const theme = {
|
||||
background: "#010203",
|
||||
grid: "#010203",
|
||||
frame: "#070809",
|
||||
frameHeader: "#0a0b0c",
|
||||
body: "#0d0e0f",
|
||||
control: "#101112",
|
||||
controlFill: "#131415",
|
||||
controlEditing: "#161718",
|
||||
textSelection: "#191a1b",
|
||||
outline: "#1c1d1e",
|
||||
text: "#1f2021",
|
||||
muted: "#222324",
|
||||
shadow: "#00000000",
|
||||
nodeSelected: "#28292a",
|
||||
nodeActive: "#2b2c2d",
|
||||
unknownHeader: "#2e2f30",
|
||||
unknownSocket: "#313233",
|
||||
linkMuted: "#343536",
|
||||
knifeMuted: "#373839",
|
||||
emphasis: "#3a3b3c",
|
||||
focus: "#3d3e3f",
|
||||
editOutline: "#404142",
|
||||
resize: "#434445",
|
||||
muteOverlay: "#464748",
|
||||
boxSelectionFill: "#494a4b",
|
||||
checkerLight: "#4c4d4e",
|
||||
checkerDark: "#4f5051",
|
||||
widgetBorder: "#525354",
|
||||
rampBorder: "#555657",
|
||||
resourceBackground: "#58595a",
|
||||
} as const;
|
||||
const base = () =>
|
||||
({
|
||||
version: 1,
|
||||
behavior: "standard",
|
||||
style: "custom",
|
||||
parameters: {},
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
}) as const;
|
||||
const composition = {
|
||||
schemaVersion: 2,
|
||||
id: "browser-render-proof",
|
||||
version: 1,
|
||||
compatibility: { wildcardInputTypes: [] },
|
||||
theme,
|
||||
socketTypes: { signal: { title: "Signal", color: "#a1b2c3", acceptsFrom: ["signal"] } },
|
||||
nodeStyles: { custom: { header: "#7654ab" } },
|
||||
resources: {},
|
||||
nodes: {
|
||||
source: {
|
||||
...base(),
|
||||
title: "Source",
|
||||
sockets: {
|
||||
out: {
|
||||
title: "Out",
|
||||
direction: "output",
|
||||
type: "signal",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [{ kind: "socket", socket: "out" }],
|
||||
},
|
||||
target: {
|
||||
...base(),
|
||||
title: "Target",
|
||||
sockets: {
|
||||
in: {
|
||||
title: "In",
|
||||
direction: "input",
|
||||
type: "signal",
|
||||
maxIncomingLinks: 1,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [{ kind: "socket", socket: "in" }],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
const compiled = compileFxNodeComposition(composition),
|
||||
runtime = bindFxNodeHeadless(compiled);
|
||||
const source = runtime.materializeNode("source", "source", { x: -200, y: 80 }),
|
||||
target = runtime.materializeNode("target", "target", { x: 100, y: 80 });
|
||||
const link = {
|
||||
id: linkId("wire"),
|
||||
fromNodeId: source.id,
|
||||
fromSocketId: source.sockets[0]!.id,
|
||||
toNodeId: target.id,
|
||||
toSocketId: target.sockets[0]!.id,
|
||||
muted: false,
|
||||
extensions: {},
|
||||
};
|
||||
const graph = { ...runtime.emptyDocument("render"), nodes: { source, target }, links: { wire: link } };
|
||||
const transform = { center: { x: 0, y: 0 }, zoom: 1, viewport: { x: 500, y: 300 }, dpr: 1 },
|
||||
layout = layoutGraph(compiled, graph, transform);
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("#presentation")!,
|
||||
context = canvas.getContext("2d")!;
|
||||
renderCanvas(context as unknown as OffscreenCanvasRenderingContext2D, layout, compiled.theme);
|
||||
const pixel = (x: number, y: number) => Array.from(context.getImageData(Math.round(x), Math.round(y), 1, 1).data);
|
||||
const contains = (x: number, y: number, rgba: readonly number[]) => {
|
||||
const data = context.getImageData(Math.round(x) - 3, Math.round(y) - 3, 7, 7).data;
|
||||
for (let i = 0; i < data.length; i += 4) if (rgba.every((v, j) => data[i + j] === v)) return true;
|
||||
return false;
|
||||
};
|
||||
const node = layout.nodes.get(source.id)!,
|
||||
socket = layout.sockets.get(source.sockets[0]!.id)!,
|
||||
wire = layout.links.get("wire" as never)!,
|
||||
header = worldToView({ x: node.bounds.x + 60, y: node.bounds.y - 12 }, transform),
|
||||
socketPoint = worldToView(socket.anchor, transform),
|
||||
middle = worldToView(wire.points[Math.floor(wire.points.length / 2)]!, transform);
|
||||
const result = {
|
||||
background: pixel(3, 3),
|
||||
header: pixel(header.x, header.y),
|
||||
socket: pixel(socketPoint.x, socketPoint.y),
|
||||
linkContainsCustomColor: contains(middle.x, middle.y, [161, 178, 195, 255]),
|
||||
};
|
||||
(window as unknown as { presentationResult: typeof result }).presentationResult = result;
|
||||
@@ -0,0 +1,274 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import type { FxNode } from "@lib/index.js";
|
||||
import type { GraphLayoutV2, GraphSnapshot } from "@lib/core/types.js";
|
||||
import type { ColorRamp } from "@lib/widgets/color-ramp.js";
|
||||
|
||||
type Point = Readonly<{ x: number; y: number }>;
|
||||
type Events = { mutations: number[]; snapshots: number[] };
|
||||
type State = { snapshot: GraphSnapshot; layout: GraphLayoutV2; events: Events };
|
||||
type RampWindow = Window &
|
||||
typeof globalThis & {
|
||||
rampTest: { root: FxNode | null; view: import("@lib/index.js").FxNodeView | null; ready: Promise<void> };
|
||||
rampEvents: Events;
|
||||
};
|
||||
|
||||
// The fixture ramp's compound world origin is frame (-300,250) + child (40,-40),
|
||||
// hence (-260,210), width 320. At zoom 1 the canvas origin is (600,320).
|
||||
// Layout's documented ramp bounds start at node.x+10 and node.y-header-2 rows-3:
|
||||
// screen origin (350,185), width 300; rows are toolbar +0, menus +22,
|
||||
// gradient +46, handles +74, and details +104.
|
||||
const P = Object.freeze({
|
||||
add: { x: 368, y: 195 },
|
||||
remove: { x: 404, y: 195 },
|
||||
flip: { x: 479, y: 195 },
|
||||
distribute: { x: 593, y: 195 },
|
||||
mode: { x: 395, y: 217 },
|
||||
interpolation: { x: 501, y: 217 },
|
||||
hue: { x: 614, y: 217 },
|
||||
gradient: { x: 500, y: 242 },
|
||||
firstHandle: { x: 386, y: 271 },
|
||||
selector: { x: 387, y: 299 },
|
||||
position: { x: 483, y: 299 },
|
||||
swatch: { x: 380, y: 321 },
|
||||
r: { x: 440, y: 321 },
|
||||
g: { x: 500, y: 321 },
|
||||
b: { x: 560, y: 321 },
|
||||
a: { x: 620, y: 321 },
|
||||
} satisfies Record<string, Point>);
|
||||
|
||||
async function open(page: Page) {
|
||||
await page.goto("/examples/blender/ramp-test/");
|
||||
await page.evaluate(() => (window as unknown as RampWindow).rampTest.ready);
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as RampWindow;
|
||||
w.rampEvents = { mutations: [], snapshots: [] };
|
||||
w.rampTest.root!.onMutations((e) => w.rampEvents.mutations.push(e.version));
|
||||
w.rampTest.root!.onSnapshots((e) => w.rampEvents.snapshots.push(e.version));
|
||||
});
|
||||
return page.locator("#ramp");
|
||||
}
|
||||
const state = (page: Page): Promise<State> =>
|
||||
page.evaluate(async () => {
|
||||
const w = window as unknown as RampWindow;
|
||||
return {
|
||||
snapshot: await w.rampTest.root!.getState(),
|
||||
layout: await w.rampTest.root!.save(),
|
||||
events: structuredClone(w.rampEvents),
|
||||
};
|
||||
});
|
||||
const ramp = (s: State): ColorRamp =>
|
||||
(s.layout.nodes.find((n) => n.id === "ramp")!.parameters.ramp as { readonly kind: "json"; readonly value: unknown })
|
||||
.value as ColorRamp;
|
||||
function pairs(before: State, after: State, count: number) {
|
||||
const m = after.events.mutations.slice(before.events.mutations.length),
|
||||
s = after.events.snapshots.slice(before.events.snapshots.length);
|
||||
expect(m).toEqual(s);
|
||||
expect(m).toHaveLength(count);
|
||||
expect(after.snapshot.version - before.snapshot.version).toBe(count);
|
||||
}
|
||||
async function drag(page: Page, from: Point, dx: number, shift = false) {
|
||||
const box = await page.locator("#ramp").boundingBox();
|
||||
if (!box) throw new Error("Canvas missing");
|
||||
if (shift) await page.keyboard.down("Shift");
|
||||
await page.mouse.move(box.x + from.x, box.y + from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + from.x + dx, box.y + from.y, { steps: 5 });
|
||||
return async () => {
|
||||
await page.mouse.up();
|
||||
if (shift) await page.keyboard.up("Shift");
|
||||
};
|
||||
}
|
||||
const undo = (page: Page) => page.evaluate(() => (window as unknown as RampWindow).rampTest.root!.undo());
|
||||
|
||||
test("handle drag previews pixels, commits once, and Escape/RMB cancel", async ({ page }) => {
|
||||
const canvas = await open(page),
|
||||
a = await state(page),
|
||||
pixels = await canvas.screenshot(),
|
||||
release = await drag(page, P.firstHandle, 45);
|
||||
await expect.poll(async () => (await canvas.screenshot()).equals(pixels)).toBe(false);
|
||||
const preview = await state(page);
|
||||
expect(preview).toEqual(a);
|
||||
await release();
|
||||
const b = await state(page);
|
||||
pairs(a, b, 1);
|
||||
expect(ramp(b).stops.find((s) => s.id === "red")!.position).toBeGreaterThan(0.2);
|
||||
await page.evaluate(() => (window as unknown as RampWindow).rampTest.view!.whenRendered());
|
||||
const stopped = await canvas.screenshot();
|
||||
await page.mouse.move(700, 400);
|
||||
expect(await canvas.screenshot()).toEqual(stopped);
|
||||
const cancel = await drag(page, { ...P.firstHandle, x: 386 + 45 }, 30);
|
||||
await canvas.press("Escape");
|
||||
await cancel();
|
||||
pairs(b, await state(page), 0);
|
||||
const cancelRmb = await drag(page, { ...P.firstHandle, x: 386 + 45 }, -25);
|
||||
await page.mouse.down({ button: "right" });
|
||||
await page.mouse.up({ button: "right" });
|
||||
await cancelRmb();
|
||||
pairs(b, await state(page), 0);
|
||||
});
|
||||
|
||||
test("plain gradient click inserts one transiently-active stop and undo removes it", async ({ page }) => {
|
||||
const canvas = await open(page),
|
||||
a = await state(page);
|
||||
await canvas.click({ position: P.gradient });
|
||||
const b = await state(page);
|
||||
pairs(a, b, 1);
|
||||
expect(ramp(b).stops).toHaveLength(4);
|
||||
expect(ramp(b).stops.filter((s) => !ramp(a).stops.some((old) => old.id === s.id))).toHaveLength(1);
|
||||
expect(JSON.stringify(b.layout)).not.toContain("activeRamp");
|
||||
expect(JSON.stringify(b.snapshot)).not.toContain("activeRamp");
|
||||
await undo(page);
|
||||
const c = await state(page);
|
||||
pairs(b, c, 1);
|
||||
expect(ramp(c)).toEqual(ramp(a));
|
||||
});
|
||||
|
||||
test("transparent gradient checker is clipped to its exact bounds", async ({ page }) => {
|
||||
const canvas = await open(page);
|
||||
const pixels = await canvas.evaluate((element) => {
|
||||
const context = (element as HTMLCanvasElement).getContext("2d")!;
|
||||
return {
|
||||
x: Array.from(context.getImageData(643, 240, 1, 1).data),
|
||||
y: Array.from(context.getImageData(500, 260, 1, 1).data),
|
||||
};
|
||||
});
|
||||
for (const pixel of [pixels.x, pixels.y]) {
|
||||
expect(pixel[3]).toBe(255);
|
||||
expect(pixel.slice(0, 3)).not.toEqual([119, 119, 119]);
|
||||
expect(pixel.slice(0, 3)).not.toEqual([170, 170, 170]);
|
||||
}
|
||||
});
|
||||
|
||||
test("toolbar add/remove commits atomically and enforces two stops; shortcuts stay scoped", async ({ page }) => {
|
||||
const canvas = await open(page),
|
||||
a = await state(page);
|
||||
await canvas.click({ position: P.firstHandle });
|
||||
await canvas.click({ position: P.add });
|
||||
const b = await state(page);
|
||||
pairs(a, b, 1);
|
||||
expect(ramp(b).stops).toHaveLength(4);
|
||||
await canvas.click({ position: P.remove });
|
||||
const c = await state(page);
|
||||
pairs(b, c, 1);
|
||||
expect(ramp(c).stops).toHaveLength(3);
|
||||
await canvas.press("Delete");
|
||||
const d = await state(page);
|
||||
pairs(c, d, 1);
|
||||
expect(ramp(d).stops).toHaveLength(2);
|
||||
await canvas.press("Delete");
|
||||
pairs(d, await state(page), 0);
|
||||
await canvas.press("g");
|
||||
await canvas.press("m");
|
||||
expect((await state(page)).layout.nodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("mode, interpolation, and hue controls cycle legal values and survive save/load", async ({ page }) => {
|
||||
const canvas = await open(page);
|
||||
let a = await state(page);
|
||||
const controls: readonly [Point, "colorMode" | "interpolation" | "hueInterpolation", readonly string[]][] = [
|
||||
[P.mode, "colorMode", ["rgb", "hsv", "hsl"]],
|
||||
[P.interpolation, "interpolation", ["linear", "ease", "constant", "cardinal", "b-spline"]],
|
||||
[P.hue, "hueInterpolation", ["near", "far", "clockwise", "counter-clockwise"]],
|
||||
];
|
||||
for (const [point, key, legal] of controls) {
|
||||
await canvas.click({ position: point });
|
||||
const b = await state(page);
|
||||
pairs(a, b, 1);
|
||||
expect(legal).toContain(ramp(b)[key]);
|
||||
a = b;
|
||||
}
|
||||
const saved = a.layout,
|
||||
persisted = ramp(a);
|
||||
await canvas.click({ position: P.mode });
|
||||
const changed = await state(page);
|
||||
await page.evaluate(
|
||||
(layout: unknown) => (window as unknown as RampWindow).rampTest.root!.load(layout),
|
||||
saved as unknown,
|
||||
);
|
||||
const loaded = await state(page);
|
||||
pairs(changed, loaded, 1);
|
||||
expect(ramp(loaded)).toEqual(persisted);
|
||||
});
|
||||
|
||||
test("position scrub alters only the active stop position", async ({ page }) => {
|
||||
await open(page);
|
||||
let a = await state(page);
|
||||
const finishPos = await drag(page, P.position, 10, true);
|
||||
await finishPos();
|
||||
let b = await state(page);
|
||||
pairs(a, b, 1);
|
||||
expect(ramp(b).stops[0]!.position).toBeCloseTo(ramp(a).stops[0]!.position + 0.01);
|
||||
expect(ramp(b).stops[0]!.color).toEqual(ramp(a).stops[0]!.color);
|
||||
});
|
||||
|
||||
test("active stop swatch opens an Oklch picker with transient preview and atomic commit", async ({ page }) => {
|
||||
const canvas = await open(page),
|
||||
before = await state(page);
|
||||
await canvas.click({ position: P.swatch });
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("Canvas missing");
|
||||
await page.mouse.move(box.x + 756, box.y + 409);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 816, box.y + 409, { steps: 4 });
|
||||
pairs(before, await state(page), 0);
|
||||
await page.mouse.up();
|
||||
pairs(before, await state(page), 0);
|
||||
await canvas.click({ position: { x: 676, y: 322 } });
|
||||
const committed = await state(page);
|
||||
pairs(before, committed, 1);
|
||||
expect(ramp(committed).stops[0]!.color).not.toEqual(ramp(before).stops[0]!.color);
|
||||
await canvas.click({ position: P.swatch });
|
||||
await page.mouse.move(box.x + 756, box.y + 409);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 776, box.y + 372);
|
||||
await page.mouse.up();
|
||||
await canvas.press("Escape");
|
||||
pairs(committed, await state(page), 0);
|
||||
});
|
||||
|
||||
test("Backspace resets ramp or active color, undo restores, and Escape is inert", async ({ page }) => {
|
||||
const canvas = await open(page),
|
||||
a = await state(page);
|
||||
await canvas.click({ position: P.position });
|
||||
await canvas.press("Backspace");
|
||||
const reset = await state(page);
|
||||
pairs(a, reset, 1);
|
||||
expect(ramp(reset).stops.map((s) => s.position)).toEqual([0, 1]);
|
||||
await undo(page);
|
||||
const restored = await state(page);
|
||||
pairs(reset, restored, 1);
|
||||
expect(ramp(restored)).toEqual(ramp(a));
|
||||
await canvas.hover({ position: P.swatch });
|
||||
await canvas.press("Backspace");
|
||||
const black = await state(page);
|
||||
pairs(restored, black, 1);
|
||||
expect(ramp(black).stops[0]!.color).toEqual([0, 0, 0, 1]);
|
||||
await canvas.press("Escape");
|
||||
pairs(black, await state(page), 0);
|
||||
});
|
||||
|
||||
test("flip and distribute are one-commit, one-step-undo tools", async ({ page }) => {
|
||||
const canvas = await open(page);
|
||||
let a = await state(page);
|
||||
await canvas.click({ position: P.flip });
|
||||
let b = await state(page);
|
||||
pairs(a, b, 1);
|
||||
expect(ramp(b).stops.map((s) => s.position)).toEqual([
|
||||
expect.closeTo(0.1),
|
||||
expect.closeTo(0.65),
|
||||
expect.closeTo(0.9),
|
||||
]);
|
||||
await undo(page);
|
||||
let c = await state(page);
|
||||
pairs(b, c, 1);
|
||||
expect(ramp(c)).toEqual(ramp(a));
|
||||
a = c;
|
||||
await canvas.click({ position: P.distribute });
|
||||
b = await state(page);
|
||||
pairs(a, b, 1);
|
||||
expect(ramp(b).stops.map((s) => s.position)).toEqual([0.1, 0.5, 0.9]);
|
||||
await undo(page);
|
||||
c = await state(page);
|
||||
pairs(b, c, 1);
|
||||
expect(ramp(c)).toEqual(ramp(a));
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
type Point = Readonly<{ x: number; y: number }>;
|
||||
type Saved = Awaited<ReturnType<typeof saved>>;
|
||||
|
||||
// The parity page is deliberately used instead of a test-only worker hook. At
|
||||
// 1280x1200, zoom one maps world (0,0) to canvas (640,600). These are centers
|
||||
// of public canvas controls derived from the descriptor layout's 24px rows.
|
||||
const P = Object.freeze({
|
||||
image: {
|
||||
resource: { x: 260, y: 525 },
|
||||
interpolation: { x: 312, y: 552 },
|
||||
projection: { x: 312, y: 576 },
|
||||
flatExtension: { x: 312, y: 600 },
|
||||
boxBlend: { x: 312, y: 600 },
|
||||
boxExtension: { x: 312, y: 624 },
|
||||
},
|
||||
compositor: {
|
||||
resource: { x: 420, y: 965 },
|
||||
source: { x: 464, y: 992 },
|
||||
frames: { x: 464, y: 1016 },
|
||||
start: { x: 464, y: 1040 },
|
||||
offset: { x: 464, y: 1064 },
|
||||
cyclic: { x: 464, y: 1088 },
|
||||
refresh: { x: 464, y: 1112 },
|
||||
},
|
||||
noise: { dimensions: { x: 653, y: 456 }, type: { x: 653, y: 480 } },
|
||||
master: {
|
||||
mode: { x: 961, y: 896 },
|
||||
scalar: { x: 729, y: 1048 },
|
||||
liftWheel: { x: 729, y: 990 },
|
||||
whiteTemperature: { x: 961, y: 944 },
|
||||
eye: { x: 860, y: 1016 },
|
||||
},
|
||||
});
|
||||
|
||||
async function open(page: Page) {
|
||||
await page.setViewportSize({ width: 1280, height: 1200 });
|
||||
await page.goto("/examples/blender/parity/");
|
||||
await page.waitForFunction(() => !!window.parityExample);
|
||||
await page.evaluate(() => {
|
||||
const w = window as typeof window & { requestedEvents: { m: number[]; s: number[] } };
|
||||
w.requestedEvents = { m: [], s: [] };
|
||||
window.parityExample!.root.onMutations((e) => w.requestedEvents.m.push(e.version));
|
||||
window.parityExample!.root.onMutations(() => {
|
||||
throw new Error("intentional requested-node subscriber failure");
|
||||
});
|
||||
window.parityExample!.root.onSnapshots((e) => w.requestedEvents.s.push(e.version));
|
||||
});
|
||||
return page.locator("#graph");
|
||||
}
|
||||
async function saved(page: Page) {
|
||||
return page.evaluate(async () => ({
|
||||
layout: await window.parityExample!.root.save(),
|
||||
snapshot: await window.parityExample!.root.getState(),
|
||||
events: (window as typeof window & { requestedEvents: { m: number[]; s: number[] } }).requestedEvents,
|
||||
}));
|
||||
}
|
||||
const node = (state: Saved, id: string) => state.layout.nodes.find((n) => n.id === id)!;
|
||||
const parameter = (state: Saved, id: string, key: string) => node(state, id).parameters[key]!;
|
||||
function paired(a: Saved, b: Saved, count = 1) {
|
||||
expect(b.snapshot.version - a.snapshot.version).toBe(count);
|
||||
expect(b.events.m.slice(a.events.m.length)).toEqual(b.events.s.slice(a.events.s.length));
|
||||
expect(b.events.m.length - a.events.m.length).toBe(count);
|
||||
}
|
||||
async function pixels(page: Page, x: number, y: number, w: number, h: number) {
|
||||
return page.locator("#graph").evaluate(
|
||||
(c, r) => {
|
||||
const d = (c as HTMLCanvasElement).getContext("2d")!.getImageData(r.x, r.y, r.w, r.h).data;
|
||||
let hash = 2166136261;
|
||||
for (const v of d) hash = Math.imul(hash ^ v, 16777619);
|
||||
return hash >>> 0;
|
||||
},
|
||||
{ x, y, w, h },
|
||||
);
|
||||
}
|
||||
async function scrub(page: Page, p: Point, dx = 25) {
|
||||
const box = await page.locator("#graph").boundingBox();
|
||||
if (!box) throw Error("canvas bounds missing");
|
||||
await page.mouse.move(box.x + p.x, box.y + p.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + p.x + dx, box.y + p.y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
const PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACAQMAAABIeJ9nAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABlBMVEUzZpn////xDcYdAAAAAWJLR0QB/wIt3gAAAAd0SU1FB+oHFQw4FzO6KaEAAAAMSURBVAjXY2BgYAAAAAQAASc0JwoAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjYtMDctMjFUMTI6NTY6MjMrMDA6MDC80CO3AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI2LTA3LTIxVDEyOjU2OjIzKzAwOjAwzY2bCwAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
async function chooseImage(page: Page, canvas: ReturnType<Page["locator"]>, position: Point, name: string) {
|
||||
const pending = page.waitForEvent("filechooser");
|
||||
await canvas.click({ position });
|
||||
const chooser = await pending;
|
||||
await chooser.setFiles({ name, mimeType: "image/png", buffer: PNG });
|
||||
}
|
||||
|
||||
test("Image Texture conditional blend and persisted texture modes", async ({ page }) => {
|
||||
const canvas = await open(page);
|
||||
let a = await saved(page);
|
||||
const emptyPreview = await pixels(page, 130, 448, 260, 62);
|
||||
await chooseImage(page, canvas, P.image.resource, "texture.png");
|
||||
await expect
|
||||
.poll(async () => (parameter(await saved(page), "image-texture", "image") as { value: string }).value)
|
||||
.toContain("texture.png");
|
||||
await page.evaluate(() => window.parityExample!.view.whenRendered());
|
||||
const uploaded = await saved(page);
|
||||
paired(a, uploaded);
|
||||
expect(await pixels(page, 130, 448, 260, 62)).not.toBe(emptyPreview);
|
||||
a = uploaded;
|
||||
const flatPixels = await pixels(page, 110, 410, 300, 300);
|
||||
expect(node(a, "image-texture").sockets.map((s) => [s.key, s.direction, s.dataType])).toEqual([
|
||||
["vector", "input", "vector"],
|
||||
["color", "output", "color"],
|
||||
["alpha", "output", "float"],
|
||||
]);
|
||||
await canvas.click({ position: P.image.projection });
|
||||
await page.evaluate(() => window.parityExample!.view.whenRendered());
|
||||
const box = await saved(page);
|
||||
paired(a, box);
|
||||
expect(parameter(box, "image-texture", "projection")).toEqual({ kind: "string", value: "Box" });
|
||||
expect(await pixels(page, 110, 410, 300, 300)).not.toBe(flatPixels);
|
||||
await scrub(page, P.image.boxBlend);
|
||||
const blended = await saved(page);
|
||||
paired(box, blended);
|
||||
expect((parameter(blended, "image-texture", "blend") as { value: number }).value).toBeGreaterThan(0);
|
||||
await canvas.hover({ position: P.image.boxBlend });
|
||||
await canvas.press("Backspace");
|
||||
const reset = await saved(page);
|
||||
paired(blended, reset);
|
||||
expect(parameter(reset, "image-texture", "blend")).toEqual({ kind: "number", value: 0 });
|
||||
await scrub(page, P.image.boxBlend, 15);
|
||||
await canvas.click({ position: P.image.projection });
|
||||
const sphere = await saved(page);
|
||||
expect(parameter(sphere, "image-texture", "projection")).toEqual({ kind: "string", value: "Sphere" });
|
||||
expect((parameter(sphere, "image-texture", "blend") as { value: number }).value).toBeGreaterThan(0);
|
||||
// The old Blend coordinate is now Extension: hit behavior proves that no
|
||||
// Blend control remains there, while the saved Blend value stays authored.
|
||||
await canvas.click({ position: P.image.boxBlend });
|
||||
let hidden = await saved(page);
|
||||
expect(parameter(hidden, "image-texture", "extension")).toEqual({ kind: "string", value: "Extend" });
|
||||
expect((parameter(hidden, "image-texture", "blend") as { value: number }).value).toBeGreaterThan(0);
|
||||
await canvas.click({ position: P.image.interpolation });
|
||||
const changed = await saved(page);
|
||||
expect(parameter(changed, "image-texture", "interpolation")).toEqual({ kind: "string", value: "Closest" });
|
||||
expect(parameter(changed, "image-texture", "extension")).toEqual({ kind: "string", value: "Extend" });
|
||||
await page.evaluate(async () => {
|
||||
const x = await window.parityExample!.root.save();
|
||||
await window.parityExample!.root.load(x);
|
||||
});
|
||||
const loaded = await saved(page);
|
||||
expect(node(loaded, "image-texture").parameters).toEqual(node(changed, "image-texture").parameters);
|
||||
});
|
||||
|
||||
test("Compositor Image movie rows, resource editing and output contract", async ({ page }) => {
|
||||
const canvas = await open(page),
|
||||
a = await saved(page);
|
||||
const n = node(a, "compositor-image");
|
||||
expect(n.sockets.map((s) => [s.key, s.direction, s.dataType])).toEqual([
|
||||
["image", "output", "color"],
|
||||
["alpha", "output", "float"],
|
||||
["z", "output", "float"],
|
||||
]);
|
||||
expect(Object.keys(n.parameters)).not.toEqual(expect.arrayContaining(["projection", "interpolation", "vector"]));
|
||||
await chooseImage(page, canvas, P.compositor.resource, "plate.png");
|
||||
await expect
|
||||
.poll(async () => (parameter(await saved(page), "compositor-image", "image") as { value: string }).value)
|
||||
.toContain("plate.png");
|
||||
let b = await saved(page);
|
||||
paired(a, b);
|
||||
await canvas.click({ position: P.compositor.source });
|
||||
b = await saved(page);
|
||||
expect(parameter(b, "compositor-image", "source")).toEqual({ kind: "string", value: "Movie" });
|
||||
await scrub(page, P.compositor.frames);
|
||||
await scrub(page, P.compositor.start);
|
||||
await scrub(page, P.compositor.offset);
|
||||
await canvas.click({ position: P.compositor.cyclic });
|
||||
await canvas.click({ position: P.compositor.refresh });
|
||||
const edited = await saved(page);
|
||||
expect((parameter(edited, "compositor-image", "frames") as { value: number }).value).toBeGreaterThan(1);
|
||||
await canvas.hover({ position: { x: 10, y: 10 } });
|
||||
await canvas.hover({ position: P.compositor.frames });
|
||||
await page.keyboard.press("Backspace");
|
||||
expect(parameter(await saved(page), "compositor-image", "frames")).toEqual({ kind: "number", value: 1 });
|
||||
// Movie -> Sequence retains the same rows; cycling through Multilayer,
|
||||
// Generated and File hides them without deleting their authored values.
|
||||
await canvas.click({ position: P.compositor.source });
|
||||
expect(parameter(await saved(page), "compositor-image", "source")).toEqual({ kind: "string", value: "Sequence" });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await canvas.click({ position: P.compositor.source });
|
||||
await page.evaluate(() => window.parityExample!.view.whenRendered());
|
||||
}
|
||||
const file = await saved(page);
|
||||
expect(parameter(file, "compositor-image", "source")).toEqual({ kind: "string", value: "File" });
|
||||
expect(parameter(file, "compositor-image", "offset")).toEqual(parameter(edited, "compositor-image", "offset"));
|
||||
const v = file.snapshot.version;
|
||||
await canvas.click({ position: P.compositor.frames });
|
||||
expect((await saved(page)).snapshot.version).toBe(v);
|
||||
});
|
||||
|
||||
test("Noise dimensions/types use conditional hit rows and preserve hidden values", async ({ page }) => {
|
||||
const canvas = await open(page);
|
||||
let prior = await saved(page),
|
||||
hash = await pixels(page, 410, 410, 220, 380);
|
||||
// 3D -> 4D -> 1D, then 1D -> 2D -> 3D -> 4D (actual enum events).
|
||||
await canvas.click({ position: P.noise.dimensions });
|
||||
await canvas.click({ position: P.noise.dimensions });
|
||||
let one = await saved(page);
|
||||
paired(prior, one, 2);
|
||||
expect(parameter(one, "noise-3d", "dimensions")).toEqual({ kind: "string", value: "1d" });
|
||||
expect(await pixels(page, 410, 410, 220, 380)).not.toBe(hash);
|
||||
// W is the first socket row in 1D (y=528).
|
||||
await scrub(page, { x: 653, y: 528 });
|
||||
const wEdited = await saved(page);
|
||||
expect(
|
||||
(node(wEdited, "noise-3d").sockets.find((s) => s.key === "w")!.defaultValue as { value: number }).value,
|
||||
).toBeGreaterThan(0);
|
||||
for (let i = 0; i < 3; i++) await canvas.click({ position: P.noise.dimensions });
|
||||
let four = await saved(page);
|
||||
expect(parameter(four, "noise-3d", "dimensions")).toEqual({ kind: "string", value: "4d" });
|
||||
await canvas.click({ position: P.noise.type });
|
||||
await canvas.click({ position: P.noise.type });
|
||||
let hybrid = await saved(page);
|
||||
expect(parameter(hybrid, "noise-3d", "noiseType")).toEqual({ kind: "string", value: "hybrid-multifractal" });
|
||||
// In 4D hybrid: vector,w,scale,detail,roughness,lacunarity,offset,gain.
|
||||
await scrub(page, { x: 653, y: 648 });
|
||||
await scrub(page, { x: 653, y: 672 });
|
||||
const conditional = await saved(page);
|
||||
for (const key of ["offset", "gain"])
|
||||
expect(
|
||||
(node(conditional, "noise-3d").sockets.find((s) => s.key === key)!.defaultValue as { value: number }).value,
|
||||
).toBeGreaterThan(0);
|
||||
await canvas.click({ position: P.noise.type });
|
||||
expect(parameter(await saved(page), "noise-3d", "noiseType")).toEqual({
|
||||
kind: "string",
|
||||
value: "ridged-multifractal",
|
||||
});
|
||||
await canvas.click({ position: P.noise.type });
|
||||
const hetero = await saved(page);
|
||||
expect(parameter(hetero, "noise-3d", "noiseType")).toEqual({ kind: "string", value: "hetero-terrain" });
|
||||
expect(node(hetero, "noise-3d").sockets.find((s) => s.key === "gain")!.defaultValue).toEqual(
|
||||
node(conditional, "noise-3d").sockets.find((s) => s.key === "gain")!.defaultValue,
|
||||
);
|
||||
expect(parameter(hetero, "noise-3d", "normalize")).toEqual({ kind: "boolean", value: false });
|
||||
await canvas.hover({ position: P.noise.type });
|
||||
await canvas.press("Backspace");
|
||||
await page.evaluate(() => window.parityExample!.view.whenRendered());
|
||||
await canvas.hover({ position: { x: 10, y: 10 } });
|
||||
await canvas.hover({ position: P.noise.dimensions });
|
||||
await canvas.press("Backspace");
|
||||
const reset = await saved(page);
|
||||
expect(parameter(reset, "noise-3d", "noiseType")).toEqual({ kind: "string", value: "fbm" });
|
||||
expect(parameter(reset, "noise-3d", "dimensions")).toEqual({ kind: "string", value: "3d" });
|
||||
});
|
||||
|
||||
test("Master Color Grading modes edit, persist, load and undo", async ({ page }) => {
|
||||
const canvas = await open(page),
|
||||
initial = await saved(page);
|
||||
expect(node(initial, "master").label).toBe("Master Color Grading");
|
||||
const lgg = await pixels(page, 650, 850, 420, 350);
|
||||
await scrub(page, P.master.scalar);
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw Error("canvas bounds missing");
|
||||
await page.mouse.move(box.x + P.master.liftWheel.x, box.y + P.master.liftWheel.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + P.master.liftWheel.x + 25, box.y + P.master.liftWheel.y, { steps: 4 });
|
||||
paired(initial, await saved(page), 1);
|
||||
await page.mouse.up();
|
||||
const lift = await saved(page);
|
||||
paired(initial, lift, 2);
|
||||
expect((parameter(lift, "master", "lift") as { value: number }).value).toBeGreaterThan(0);
|
||||
expect(parameter(lift, "master", "liftColor")).not.toEqual(parameter(initial, "master", "liftColor"));
|
||||
await canvas.click({ position: P.master.mode });
|
||||
let ops = await saved(page);
|
||||
expect(parameter(ops, "master", "mode")).toEqual({ kind: "string", value: "Offset/Power/Slope" });
|
||||
expect(await pixels(page, 650, 850, 420, 350)).not.toBe(lgg);
|
||||
await scrub(page, P.master.scalar);
|
||||
ops = await saved(page);
|
||||
expect((parameter(ops, "master", "offset") as { value: number }).value).toBeGreaterThan(0);
|
||||
expect(parameter(ops, "master", "lift")).toEqual(parameter(lift, "master", "lift"));
|
||||
await canvas.click({ position: P.master.mode });
|
||||
let white = await saved(page);
|
||||
expect(parameter(white, "master", "mode")).toEqual({ kind: "string", value: "White Point" });
|
||||
await scrub(page, P.master.whiteTemperature);
|
||||
white = await saved(page);
|
||||
expect((parameter(white, "master", "inputTemperature") as { value: number }).value).toBeGreaterThan(6500);
|
||||
const beforeEye = white.snapshot.version;
|
||||
await canvas.click({ position: P.master.eye });
|
||||
expect((await saved(page)).snapshot.version).toBe(beforeEye);
|
||||
const persisted = await page.evaluate(async () => {
|
||||
const x = await window.parityExample!.root.save();
|
||||
await window.parityExample!.root.load(x);
|
||||
return window.parityExample!.root.save();
|
||||
});
|
||||
expect(persisted.nodes.find((n) => n.id === "master")!.label).toBe("Master Color Grading");
|
||||
expect(persisted.nodes.find((n) => n.id === "master")!.parameters).toEqual(node(white, "master").parameters);
|
||||
await canvas.click({ position: P.master.mode });
|
||||
expect(parameter(await saved(page), "master", "mode")).toEqual({ kind: "string", value: "Lift/Gamma/Gain" });
|
||||
await page.evaluate(() => window.parityExample!.root.undo());
|
||||
expect(parameter(await saved(page), "master", "mode")).toEqual({ kind: "string", value: "White Point" });
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
bddbb2638889d825407600a55ddd427a04fa26cf624a718dde13bb3429e8b3aa test/browser/all-supported.spec.ts-snapshots/all-supported-linux.png
|
||||
66021d791bacd650a5c0d983d0a2c5ba377aba23c598dd022de7fc18f9ad7f68 test/browser/parity.visual.spec.ts-snapshots/parity-image-grading-linux.png
|
||||
6d24bc2f6783a4bbad5d416b72442c02e0b05260700fb06d6aaaa1e61aff53dc test/browser/parity.visual.spec.ts-snapshots/parity-ramp-noise-linux.png
|
||||
acd64dbf5847551a9403517be7c7f94ca6c7691fc14c641b82b687ce7b16a919 test/browser/parity.visual.spec.ts-snapshots/parity-structural-linux.png
|
||||
b22233380b3509b79769cc1dd9dca809360e097c337c258638871867f4442673 test/browser/visual.spec.ts-snapshots/color-picker-linux.png
|
||||
db27823fafd21ef361dfa12e13d9424972e111d13babfc4fee53614bfdde37e4 test/browser/visual.spec.ts-snapshots/numeric-editing-linux.png
|
||||
83a60b97d9dd423e614d52e0427e3a17515f92ec94a467cd9bd3fe1723157fc5 test/browser/visual.spec.ts-snapshots/numeric-fields-linux.png
|
||||
5e578055b7ade004c9252ca785491dd39211b92ceb68d6eb8a37421e1c2b30fd test/browser/visual.spec.ts-snapshots/phase-4-example-linux.png
|
||||
0ba342da7237709e47ee607726fc131f5dcdbb84650aed5d6ac3e2d1ced8b727 test/browser/visual.spec.ts-snapshots/zoomed-out-lod-linux.png
|
||||
@@ -0,0 +1,82 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("@visual deterministic example canvas", async ({ page }) => {
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
await page.evaluate(() => window.fxnodeExample.rendered);
|
||||
|
||||
const canvas = page.locator("#graph");
|
||||
await expect(canvas).toHaveAttribute("width", "1200");
|
||||
await expect(canvas).toHaveAttribute("height", "640");
|
||||
const evidence = await canvas.evaluate((element) => {
|
||||
const context = (element as HTMLCanvasElement).getContext("2d");
|
||||
if (!context) throw new Error("Canvas context missing");
|
||||
const data = context.getImageData(0, 0, 1200, 640).data;
|
||||
let background = 0,
|
||||
nodePixels = 0,
|
||||
linkPixels = 0;
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
const r = data[index],
|
||||
g = data[index + 1],
|
||||
b = data[index + 2],
|
||||
a = data[index + 3];
|
||||
if (r === 29 && g === 31 && b === 35 && a === 255) background++;
|
||||
if (r === 53 && g === 56 && b === 62 && a === 255) nodePixels++;
|
||||
if ((r === 168 && g === 168 && b === 168) || (r === 98 && g === 179 && b === 79)) linkPixels++;
|
||||
}
|
||||
return { background, nodePixels, linkPixels };
|
||||
});
|
||||
expect(evidence.background).toBeGreaterThan(300_000);
|
||||
expect(evidence.nodePixels).toBeGreaterThan(20_000);
|
||||
expect(evidence.linkPixels).toBeGreaterThan(100);
|
||||
|
||||
const first = await canvas.screenshot();
|
||||
const second = await canvas.screenshot();
|
||||
expect(second.equals(first)).toBe(true);
|
||||
await expect(canvas).toHaveScreenshot("phase-4-example.png", { animations: "disabled" });
|
||||
|
||||
await canvas.click({ position: { x: 380, y: 160 } });
|
||||
await canvas.click({ position: { x: 560, y: 140 }, modifiers: ["Shift"] });
|
||||
await page.evaluate(() => window.fxnodeExample.view!.whenRendered());
|
||||
const selection = await canvas.evaluate((element) => {
|
||||
const context = (element as HTMLCanvasElement).getContext("2d")!;
|
||||
const pixels = context.getImageData(0, 0, 1200, 640).data;
|
||||
let selected = 0,
|
||||
expanded = 0;
|
||||
for (let y = 150; y < 260; y++)
|
||||
for (let x = 295; x < 485; x++) {
|
||||
const index = (y * 1200 + x) * 4;
|
||||
if (pixels[index] === 237 && pixels[index + 1] === 87 && pixels[index + 2] === 0 && pixels[index + 3] === 255) {
|
||||
selected++;
|
||||
if (x < 299) expanded++;
|
||||
}
|
||||
}
|
||||
const socketIndex = (234 * 1200 + 300) * 4;
|
||||
return { selected, expanded, socket: Array.from(pixels.slice(socketIndex, socketIndex + 4)) };
|
||||
});
|
||||
expect(selection.selected).toBeGreaterThan(100);
|
||||
expect(selection.expanded).toBe(0);
|
||||
expect(selection.socket).toEqual([168, 168, 168, 255]);
|
||||
});
|
||||
|
||||
test("@visual distant zoom keeps text inside scaled nodes", async ({ page }) => {
|
||||
await page.goto("/examples/blender/");
|
||||
await page.evaluate(() => window.fxnodeExample.ready);
|
||||
const canvas = page.locator("#graph");
|
||||
await canvas.hover({ position: { x: 600, y: 320 } });
|
||||
await page.mouse.wheel(0, 900);
|
||||
await page.evaluate(() => window.fxnodeExample.view!.whenRendered());
|
||||
await expect(canvas).toHaveScreenshot("zoomed-out-lod.png", { animations: "disabled" });
|
||||
});
|
||||
|
||||
test("@visual Blender-style numeric fields and text editing", async ({ page }) => {
|
||||
await page.goto("/examples/blender/control-test/");
|
||||
await page.evaluate(() => window.controlTest.ready);
|
||||
const canvas = page.locator("#controls");
|
||||
await expect(canvas).toHaveScreenshot("numeric-fields.png", { animations: "disabled" });
|
||||
await canvas.click({ position: { x: 196, y: 147 } });
|
||||
await expect(canvas).toHaveScreenshot("numeric-editing.png", { animations: "disabled" });
|
||||
await canvas.press("Escape");
|
||||
await canvas.click({ position: { x: 1003, y: 147 } });
|
||||
await expect(canvas).toHaveScreenshot("color-picker.png", { animations: "disabled" });
|
||||
});
|
||||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="importmap">
|
||||
{ "imports": { "@lib/": "/src/" } }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="application-a" width="360" height="220" style="width: 360px; height: 220px"></canvas>
|
||||
<canvas id="application-b" width="360" height="220" style="width: 360px; height: 220px"></canvas>
|
||||
<template id="add-node-menu-template">
|
||||
<div data-fxnode-add-menu style="position: fixed; visibility: hidden">
|
||||
<section role="dialog" aria-label="Add node">
|
||||
<input data-fxnode-menu-search type="search" role="combobox" aria-label="Search nodes" />
|
||||
<div data-fxnode-menu-results role="listbox">
|
||||
<section data-fxnode-menu-group role="group">
|
||||
<h2 data-fxnode-menu-heading>Test</h2>
|
||||
<button data-fxnode-menu-option data-type-id="alpha-node" type="button" role="option">Alpha Node</button>
|
||||
</section>
|
||||
<div data-fxnode-menu-empty role="status" hidden>No nodes found</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script type="module" src="./worker-composition.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,372 @@
|
||||
import { createFxNode, type FxNode, type FxNodeCompositionData, type FxNodeView } from "@lib/index.js";
|
||||
import { prepareFxNodeBrowserHost, type PreparedFxNodeBrowserHost } from "../../examples/shared/browser-host.js";
|
||||
import type { FxNodeResourceOpenRequest } from "@lib/index.js";
|
||||
import type { GraphState } from "@lib/core/types.js";
|
||||
|
||||
const color = "#202124" as const;
|
||||
const theme = {
|
||||
background: color,
|
||||
grid: color,
|
||||
frame: color,
|
||||
frameHeader: color,
|
||||
body: "#35383e",
|
||||
control: color,
|
||||
controlFill: color,
|
||||
controlEditing: color,
|
||||
textSelection: color,
|
||||
outline: "#101114",
|
||||
text: "#eeeeee",
|
||||
muted: "#999999",
|
||||
shadow: "#000000",
|
||||
nodeSelected: "#ed5700",
|
||||
nodeActive: "#ed5700",
|
||||
unknownHeader: "#555555",
|
||||
unknownSocket: "#777777",
|
||||
linkMuted: "#d94b4b",
|
||||
knifeMuted: "#e85b5b",
|
||||
emphasis: "#ffffff",
|
||||
focus: "#f5a623",
|
||||
editOutline: "#666a70",
|
||||
resize: "#8b8e95",
|
||||
muteOverlay: "#14141459",
|
||||
boxSelectionFill: "#f5a6231f",
|
||||
checkerLight: "#aaaaaa",
|
||||
checkerDark: "#777777",
|
||||
widgetBorder: "#111216",
|
||||
rampBorder: "#111111",
|
||||
resourceBackground: "#202228",
|
||||
} as const;
|
||||
const composition = (
|
||||
id: string,
|
||||
version: number,
|
||||
nodeType: string,
|
||||
title: string,
|
||||
header: `#${string}`,
|
||||
maxBytes: number,
|
||||
maxWidth: number,
|
||||
accept: string,
|
||||
) =>
|
||||
({
|
||||
schemaVersion: 2,
|
||||
id,
|
||||
version,
|
||||
compatibility: { wildcardInputTypes: [] },
|
||||
theme,
|
||||
socketTypes: { signal: { title: "Signal", color: header, acceptsFrom: ["signal"] } },
|
||||
nodeStyles: { application: { header } },
|
||||
resources: {
|
||||
image: {
|
||||
kind: "image",
|
||||
title: "Image",
|
||||
openTitle: "Open",
|
||||
accept: [accept],
|
||||
referencePrefix: `${id}-image:`,
|
||||
maxBytes,
|
||||
maxWidth,
|
||||
maxHeight: 4,
|
||||
maxPixels: maxWidth * 4,
|
||||
},
|
||||
strictImage: {
|
||||
kind: "image",
|
||||
title: "Strict Image",
|
||||
openTitle: "Open",
|
||||
accept: [accept],
|
||||
referencePrefix: `${id}-image:`,
|
||||
maxBytes,
|
||||
maxWidth: 1,
|
||||
maxHeight: 1,
|
||||
maxPixels: 1,
|
||||
},
|
||||
},
|
||||
nodes: {
|
||||
[nodeType]: {
|
||||
version: 1,
|
||||
title,
|
||||
behavior: "standard",
|
||||
style: "application",
|
||||
parameters: { image: { type: "string", default: { kind: "string", value: "" } } },
|
||||
sockets: {
|
||||
output: {
|
||||
title: "Output",
|
||||
direction: "output",
|
||||
type: "signal",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{ kind: "resource", resource: "image", parameter: "image" },
|
||||
{ kind: "socket", socket: "output" },
|
||||
],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
},
|
||||
[`${nodeType}-strict`]: {
|
||||
version: 1,
|
||||
title: `${title} Strict`,
|
||||
behavior: "standard",
|
||||
style: "application",
|
||||
parameters: { image: { type: "string", default: { kind: "string", value: "" } } },
|
||||
sockets: {
|
||||
output: {
|
||||
title: "Output",
|
||||
direction: "output",
|
||||
type: "signal",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{ kind: "resource", resource: "strictImage", parameter: "image" },
|
||||
{ kind: "socket", socket: "output" },
|
||||
],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
},
|
||||
},
|
||||
}) as unknown as FxNodeCompositionData;
|
||||
|
||||
export const compositionA = composition(
|
||||
"application-a",
|
||||
11,
|
||||
"alpha-node",
|
||||
"Alpha Node",
|
||||
"#9b59b6",
|
||||
512,
|
||||
1,
|
||||
"image/png",
|
||||
);
|
||||
export const compositionB = composition(
|
||||
"application-b",
|
||||
22,
|
||||
"beta-node",
|
||||
"Beta Node",
|
||||
"#2e86de",
|
||||
2048,
|
||||
4,
|
||||
"image/webp",
|
||||
);
|
||||
export const migrationComposition = {
|
||||
...compositionA,
|
||||
id: "worker-migration-custom",
|
||||
version: 73,
|
||||
nodes: {
|
||||
"odd-migrator": {
|
||||
...compositionA.nodes["alpha-node"]!,
|
||||
version: 2,
|
||||
parameters: { level: { type: "number", default: { kind: "number", value: 5 } } },
|
||||
sockets: {
|
||||
source: {
|
||||
title: "Source",
|
||||
direction: "output",
|
||||
type: "signal",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
sink: {
|
||||
title: "Sink",
|
||||
direction: "input",
|
||||
type: "signal",
|
||||
maxIncomingLinks: 4,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{ kind: "parameter", parameter: "level" },
|
||||
{ kind: "socket", socket: "source" },
|
||||
{ kind: "socket", socket: "sink" },
|
||||
],
|
||||
migrations: [
|
||||
{
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
steps: [
|
||||
{ kind: "rename-parameter", from: "oldLevel", to: "level" },
|
||||
{ kind: "rename-socket", from: "oldSource", to: "source" },
|
||||
{ kind: "rename-socket", from: "oldSink", to: "sink" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as FxNodeCompositionData;
|
||||
const layout = (id: string, version: number) => ({
|
||||
schemaVersion: 2 as const,
|
||||
graphId: id,
|
||||
catalogVersion: version,
|
||||
nodes: [],
|
||||
links: [],
|
||||
metadata: {},
|
||||
});
|
||||
const migrationLayout = {
|
||||
schemaVersion: 2 as const,
|
||||
graphId: "worker-historical",
|
||||
catalogVersion: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: "left",
|
||||
typeId: "odd-migrator",
|
||||
typeVersion: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
size: { x: 180, y: 190 },
|
||||
label: "",
|
||||
parameters: { oldLevel: { kind: "number", value: 9 } },
|
||||
sockets: [
|
||||
{
|
||||
id: "left:oldSource",
|
||||
key: "oldSource",
|
||||
label: "Source",
|
||||
direction: "output",
|
||||
dataType: "signal",
|
||||
accepts: [],
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "left:oldSink",
|
||||
key: "oldSink",
|
||||
label: "Sink",
|
||||
direction: "input",
|
||||
dataType: "signal",
|
||||
accepts: ["signal"],
|
||||
maxIncomingLinks: 4,
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
muted: false,
|
||||
collapsed: false,
|
||||
extensions: {},
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
typeId: "odd-migrator",
|
||||
typeVersion: 1,
|
||||
position: { x: 300, y: 0 },
|
||||
size: { x: 180, y: 190 },
|
||||
label: "",
|
||||
parameters: { oldLevel: { kind: "number", value: 3 } },
|
||||
sockets: [
|
||||
{
|
||||
id: "right:oldSource",
|
||||
key: "oldSource",
|
||||
label: "Source",
|
||||
direction: "output",
|
||||
dataType: "signal",
|
||||
accepts: [],
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "right:oldSink",
|
||||
key: "oldSink",
|
||||
label: "Sink",
|
||||
direction: "input",
|
||||
dataType: "signal",
|
||||
accepts: ["signal"],
|
||||
maxIncomingLinks: 4,
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
muted: false,
|
||||
collapsed: false,
|
||||
extensions: {},
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: "historical-link",
|
||||
fromNodeId: "left",
|
||||
fromSocketId: "left:oldSource",
|
||||
toNodeId: "right",
|
||||
toSocketId: "right:oldSink",
|
||||
muted: false,
|
||||
extensions: { kept: true },
|
||||
},
|
||||
],
|
||||
metadata: { fixture: "migration" },
|
||||
};
|
||||
const hosts = new WeakMap<FxNode, PreparedFxNodeBrowserHost>(),
|
||||
views = new WeakMap<FxNode, FxNodeView>();
|
||||
const create = async <C extends FxNodeCompositionData>(
|
||||
canvas: HTMLCanvasElement,
|
||||
value: C,
|
||||
initialLayout: unknown = layout(value.id, value.version),
|
||||
activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void,
|
||||
) => {
|
||||
const host = prepareFxNodeBrowserHost({
|
||||
canvas,
|
||||
addNodeMenuTemplate: document.querySelector<HTMLTemplateElement>("#add-node-menu-template")!,
|
||||
...(activateResourcePicker ? { activateResourcePicker } : {}),
|
||||
});
|
||||
let api: FxNode | undefined;
|
||||
const { id, version, resources } = value;
|
||||
try {
|
||||
api = await createFxNode({
|
||||
applicationId: id,
|
||||
applicationVersion: version,
|
||||
resources,
|
||||
});
|
||||
await api.loadComposition(value as never, { expectedRevision: 0 });
|
||||
if (initialLayout === migrationLayout) await api.load(initialLayout);
|
||||
const view = await api.attachView({ canvas, viewport: host.initialViewport });
|
||||
host.attach(api, view);
|
||||
hosts.set(api, host);
|
||||
views.set(api, view);
|
||||
return api;
|
||||
} catch (error) {
|
||||
host.destroy();
|
||||
api?.destroy();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const destroy = (api: FxNode) => {
|
||||
hosts.get(api)?.destroy();
|
||||
hosts.delete(api);
|
||||
views.delete(api);
|
||||
api.destroy();
|
||||
};
|
||||
|
||||
type Harness = {
|
||||
readonly compositionA: typeof compositionA;
|
||||
readonly compositionB: typeof compositionB;
|
||||
createA(activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void): Promise<FxNode>;
|
||||
createB(activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void): Promise<FxNode>;
|
||||
createMigration(): Promise<FxNode>;
|
||||
createRaw(canvas: HTMLCanvasElement, value: FxNodeCompositionData): Promise<FxNode>;
|
||||
view(api: FxNode): FxNodeView;
|
||||
destroy(api: FxNode): void;
|
||||
};
|
||||
(window as unknown as { workerCompositionTest: Harness }).workerCompositionTest = {
|
||||
compositionA,
|
||||
compositionB,
|
||||
createA: (activate) =>
|
||||
create(document.querySelector<HTMLCanvasElement>("#application-a")!, compositionA, undefined, activate),
|
||||
createB: (activate) =>
|
||||
create(document.querySelector<HTMLCanvasElement>("#application-b")!, compositionB, undefined, activate),
|
||||
createMigration: () =>
|
||||
create(document.querySelector<HTMLCanvasElement>("#application-a")!, migrationComposition, migrationLayout),
|
||||
createRaw: async (canvas, value) => {
|
||||
const api = await create(canvas, compositionA);
|
||||
try {
|
||||
await api.loadComposition(value, { expectedRevision: 1 });
|
||||
return api;
|
||||
} catch (error) {
|
||||
destroy(api);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
view: (api) => {
|
||||
const view = views.get(api);
|
||||
if (!view) throw new Error("FxNode test view is unavailable");
|
||||
return view;
|
||||
},
|
||||
destroy,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("real worker supports a browser root with no attached views", async ({ page }) => {
|
||||
await page.goto("/test/browser/client-runtime.html");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createFxNode } = (await import("/src/index.ts" as string)) as typeof import("@lib/index.js");
|
||||
const { minimalStyles, numberSocket, valueNode } = (await import(
|
||||
"../../examples/minimal/definition.js"
|
||||
)) as typeof import("../../examples/minimal/definition.js");
|
||||
const root = await createFxNode({ applicationId: "worker-headless", applicationVersion: 1, resources: {} });
|
||||
await root.setHeaderStyles(minimalStyles);
|
||||
await root.composeSocket(...numberSocket);
|
||||
await root.composeNode(...valueNode);
|
||||
const added = await root.dispatch({
|
||||
type: "node.add",
|
||||
nodeId: "headless-node" as never,
|
||||
nodeType: valueNode[0],
|
||||
position: { x: 12, y: 34 },
|
||||
});
|
||||
const state = await root.getState(),
|
||||
saved = await root.getSaveData(),
|
||||
undone = await root.undo(),
|
||||
empty = await root.getState();
|
||||
root.destroy();
|
||||
return {
|
||||
addedVersion: added.version,
|
||||
undoneVersion: undone.version,
|
||||
node: state.nodes[0],
|
||||
journalLength: saved.commands.length,
|
||||
emptyCount: empty.nodes.length,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.addedVersion).toBe(1);
|
||||
expect(result.undoneVersion).toBe(2);
|
||||
expect(result.node).toMatchObject({ id: "headless-node", position: { x: 12, y: 34 } });
|
||||
expect(result.journalLength).toBe(1);
|
||||
expect(result.emptyCount).toBe(0);
|
||||
});
|
||||
|
||||
test("real worker shares graph history while views keep camera and selection", async ({ page }) => {
|
||||
await page.goto("/test/browser/client-runtime.html");
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createFxNode } = (await import("/src/index.ts" as string)) as typeof import("@lib/index.js");
|
||||
const { minimalStyles, numberSocket, valueNode } = (await import(
|
||||
"../../examples/minimal/definition.js"
|
||||
)) as typeof import("../../examples/minimal/definition.js");
|
||||
const api = await createFxNode({ applicationId: "worker-multiview", applicationVersion: 1, resources: {} });
|
||||
await api.setHeaderStyles(minimalStyles);
|
||||
await api.composeSocket(...numberSocket);
|
||||
await api.composeNode(...valueNode);
|
||||
|
||||
const makeCanvas = () => {
|
||||
const canvas = document.createElement("canvas"),
|
||||
context = canvas.getContext("2d")!;
|
||||
canvas.width = 400;
|
||||
canvas.height = 200;
|
||||
let frames = 0;
|
||||
const drawImage = context.drawImage.bind(context);
|
||||
context.drawImage = ((...args: Parameters<CanvasRenderingContext2D["drawImage"]>) => {
|
||||
frames++;
|
||||
Reflect.apply(drawImage, context, args);
|
||||
}) as CanvasRenderingContext2D["drawImage"];
|
||||
return { canvas, frames: () => frames };
|
||||
};
|
||||
const firstCanvas = makeCanvas(),
|
||||
secondCanvas = makeCanvas(),
|
||||
first = await api.attachView({
|
||||
canvas: firstCanvas.canvas,
|
||||
viewport: { width: 400, height: 200, dpr: 1 },
|
||||
initialCamera: { center: { x: 1_000, y: -200 }, zoom: 2 },
|
||||
}),
|
||||
second = await api.attachView({
|
||||
canvas: secondCanvas.canvas,
|
||||
viewport: { width: 400, height: 200, dpr: 1 },
|
||||
});
|
||||
await Promise.all([first.whenRendered(), second.whenRendered()]);
|
||||
|
||||
const beforeRootFrames = [firstCanvas.frames(), secondCanvas.frames()];
|
||||
const rootAdd = await api.dispatch({
|
||||
type: "node.add",
|
||||
nodeId: "root-node" as never,
|
||||
nodeType: valueNode[0],
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const deadline = performance.now() + 2_000;
|
||||
while (
|
||||
performance.now() < deadline &&
|
||||
(firstCanvas.frames() <= beforeRootFrames[0]! || secondCanvas.frames() <= beforeRootFrames[1]!)
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 16));
|
||||
const rootSelections = [first.getHostSnapshot().selection.nodeCount, second.getHostSnapshot().selection.nodeCount];
|
||||
|
||||
const firstAdd = await first.addNode({
|
||||
typeId: valueNode[0],
|
||||
nodeId: "first-node",
|
||||
viewPosition: { x: 300, y: 50 },
|
||||
});
|
||||
const afterFirstSelection = [
|
||||
first.getHostSnapshot().selection.nodeCount,
|
||||
second.getHostSnapshot().selection.nodeCount,
|
||||
];
|
||||
const secondAdd = await second.addNode({
|
||||
typeId: valueNode[0],
|
||||
nodeId: "second-node",
|
||||
viewPosition: { x: 200, y: 100 },
|
||||
});
|
||||
const afterSecondSelection = [
|
||||
first.getHostSnapshot().selection.nodeCount,
|
||||
second.getHostSnapshot().selection.nodeCount,
|
||||
];
|
||||
const mute = await first.setSelectedMuted(true),
|
||||
remove = await second.removeSelected(),
|
||||
afterActions = await api.getState(),
|
||||
undo = await api.undo(),
|
||||
afterUndo = await api.getState(),
|
||||
redo = await api.redo(),
|
||||
afterRedo = await api.getState();
|
||||
const firstNode = afterActions.nodes.find((node) => node.id === "first-node");
|
||||
|
||||
await first.detach();
|
||||
await second.detach();
|
||||
api.destroy();
|
||||
return {
|
||||
rootSelections,
|
||||
afterFirstSelection,
|
||||
afterSecondSelection,
|
||||
rootRenderedBoth: firstCanvas.frames() > beforeRootFrames[0]! && secondCanvas.frames() > beforeRootFrames[1]!,
|
||||
firstPosition: firstNode?.position,
|
||||
firstMuted: firstNode?.muted,
|
||||
secondRemoved: !afterActions.nodes.some((node) => node.id === "second-node"),
|
||||
undoRestoredSecond: afterUndo.nodes.some((node) => node.id === "second-node"),
|
||||
redoRemovedSecond: !afterRedo.nodes.some((node) => node.id === "second-node"),
|
||||
versions: [
|
||||
rootAdd.version,
|
||||
firstAdd.version,
|
||||
secondAdd.version,
|
||||
mute.version,
|
||||
remove.version,
|
||||
undo.version,
|
||||
redo.version,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.rootSelections).toEqual([0, 0]);
|
||||
expect(result.afterFirstSelection).toEqual([1, 0]);
|
||||
expect(result.afterSecondSelection).toEqual([1, 1]);
|
||||
expect(result.rootRenderedBoth).toBe(true);
|
||||
expect(result.firstPosition).toEqual({ x: 1_050, y: -175 });
|
||||
expect(result.firstMuted).toBe(true);
|
||||
expect(result.secondRemoved).toBe(true);
|
||||
expect(result.undoRestoredSecond).toBe(true);
|
||||
expect(result.redoRemovedSecond).toBe(true);
|
||||
expect(result.versions).toEqual([1, 2, 3, 4, 5, 6, 7]);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
addRampMidpoint,
|
||||
addRampStop,
|
||||
distributeColorRamp,
|
||||
flipColorRamp,
|
||||
isColorRamp,
|
||||
migrateColorRamp,
|
||||
moveRampStop,
|
||||
removeRampStop,
|
||||
sampleColorRamp,
|
||||
setRampColor,
|
||||
type ColorRamp,
|
||||
} from "@lib/widgets/color-ramp.js";
|
||||
const DEFAULT_COLOR_RAMP: ColorRamp = {
|
||||
colorMode: "rgb",
|
||||
interpolation: "linear",
|
||||
hueInterpolation: "near",
|
||||
stops: [
|
||||
{ id: "stop-0", position: 0, color: [0, 0, 0, 1] },
|
||||
{ id: "stop-1", position: 1, color: [1, 1, 1, 1] },
|
||||
],
|
||||
};
|
||||
import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js";
|
||||
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
|
||||
const layoutGraph = (
|
||||
document: Parameters<typeof genericLayoutGraph>[1],
|
||||
transform: Parameters<typeof genericLayoutGraph>[2],
|
||||
) => genericLayoutGraph(APPLICATION_COMPILED, document, transform);
|
||||
const { materializeNode } = APPLICATION_HEADLESS;
|
||||
|
||||
test("Color Ramp migration, validation and stable pure operations", () => {
|
||||
const migrated = migrateColorRamp([
|
||||
{ position: 0, color: [0, 0, 0, 1] },
|
||||
{ position: 0.5, color: [1, 0, 0, 1] },
|
||||
{ position: 1, color: [1, 1, 1, 1] },
|
||||
])!;
|
||||
assert.deepEqual(
|
||||
migrated.stops.map((s) => s.id),
|
||||
["stop-0", "stop-1", "stop-2"],
|
||||
);
|
||||
assert.equal(isColorRamp(migrated), true);
|
||||
const added = addRampStop(migrated, 0.25, "worker-1");
|
||||
assert.equal(added.stops.find((s) => s.id === "worker-1")?.color[0], 0.5);
|
||||
assert.equal(addRampMidpoint(migrated, "stop-1", "worker-2").stops.find((s) => s.id === "worker-2")?.position, 0.25);
|
||||
assert.deepEqual(
|
||||
moveRampStop(migrated, "stop-0", 0.75).stops.map((s) => s.id),
|
||||
["stop-1", "stop-0", "stop-2"],
|
||||
);
|
||||
assert.equal(removeRampStop(DEFAULT_COLOR_RAMP, "stop-0"), DEFAULT_COLOR_RAMP);
|
||||
assert.deepEqual(
|
||||
distributeColorRamp(migrated).stops.map((s) => s.position),
|
||||
[0, 0.5, 1],
|
||||
);
|
||||
assert.deepEqual(
|
||||
flipColorRamp(migrated).stops.map((s) => s.position),
|
||||
[0, 0.5, 1],
|
||||
);
|
||||
assert.deepEqual(sampleColorRamp({ ...DEFAULT_COLOR_RAMP, interpolation: "constant" }, 0.5), [0, 0, 0, 1]);
|
||||
});
|
||||
|
||||
test("Color Ramp public operations preserve validity for empty IDs and non-finite numbers", () => {
|
||||
for (const position of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
|
||||
assert.equal(addRampStop(DEFAULT_COLOR_RAMP, position, "new"), DEFAULT_COLOR_RAMP);
|
||||
assert.equal(moveRampStop(DEFAULT_COLOR_RAMP, "stop-0", position), DEFAULT_COLOR_RAMP);
|
||||
assert.deepEqual(sampleColorRamp(DEFAULT_COLOR_RAMP, position), DEFAULT_COLOR_RAMP.stops[0]!.color);
|
||||
}
|
||||
assert.equal(addRampStop(DEFAULT_COLOR_RAMP, 0.5, ""), DEFAULT_COLOR_RAMP);
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
const color = [0, 0, 0, 1] as number[];
|
||||
color[channel] = Number.NaN;
|
||||
assert.equal(
|
||||
setRampColor(DEFAULT_COLOR_RAMP, "stop-0", color as [number, number, number, number]),
|
||||
DEFAULT_COLOR_RAMP,
|
||||
);
|
||||
}
|
||||
assert.equal(isColorRamp(DEFAULT_COLOR_RAMP), true);
|
||||
});
|
||||
|
||||
test("Noise Texture exhaustive Blender 4.5 visibility matrix and immediate height", () => {
|
||||
const dimensions = ["1d", "2d", "3d", "4d"],
|
||||
types = ["fbm", "multifractal", "hybrid-multifractal", "ridged-multifractal", "hetero-terrain"];
|
||||
for (const dimension of dimensions)
|
||||
for (const noiseType of types) {
|
||||
const base = materializeNode("noise", "fxnode.shader.noise-texture");
|
||||
const node = {
|
||||
...base,
|
||||
parameters: {
|
||||
...base.parameters,
|
||||
dimensions: { kind: "string" as const, value: dimension },
|
||||
noiseType: { kind: "string" as const, value: noiseType },
|
||||
},
|
||||
};
|
||||
const document = {
|
||||
schemaVersion: 2 as const,
|
||||
graphId: "g" as never,
|
||||
catalogVersion: 2,
|
||||
nodes: { noise: node },
|
||||
links: {},
|
||||
metadata: {},
|
||||
};
|
||||
const layout = layoutGraph(document, { center: { x: 0, y: 0 }, zoom: 1, viewport: { x: 800, y: 600 }, dpr: 1 });
|
||||
const keys = new Set([...layout.sockets.values()].map((s) => s.id.split(":").at(-1)));
|
||||
assert.equal(keys.has("vector"), dimension !== "1d");
|
||||
assert.equal(keys.has("w"), dimension === "1d" || dimension === "4d");
|
||||
assert.equal(
|
||||
keys.has("offset"),
|
||||
["hybrid-multifractal", "ridged-multifractal", "hetero-terrain"].includes(noiseType),
|
||||
);
|
||||
assert.equal(keys.has("gain"), ["hybrid-multifractal", "ridged-multifractal"].includes(noiseType));
|
||||
const hasNormalize = layout.controls.has("noise:parameter:normalize");
|
||||
assert.equal(hasNormalize, noiseType === "fbm");
|
||||
assert.ok(layout.nodes.get("noise" as never)!.bounds.height > 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { validCommand, validFxNodeReplayCommand } from "@lib/commands/validate.js";
|
||||
import { commandId, linkId, nodeId, socketId } from "@lib/core/types.js";
|
||||
import { saveCompositionCompatibility } from "@lib/composition/save-compatibility.js";
|
||||
import { createFxNodeHeadless } from "@lib/headless-runtime.js";
|
||||
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
|
||||
import type { FxNodeCompositionData } from "@lib/composition/index.js";
|
||||
|
||||
const composition = {
|
||||
...APPLICATION_COMPILED.source,
|
||||
id: "command-log-test",
|
||||
version: 7,
|
||||
nodes: { value: APPLICATION_COMPILED.source.nodes["fxnode.shader.value"]! },
|
||||
};
|
||||
const runtime = createFxNodeHeadless(composition);
|
||||
const save = (commands: readonly unknown[], baseline: unknown = runtime.save(runtime.emptyDocument())) => ({
|
||||
kind: "fxnode.command-log",
|
||||
schemaVersion: 2,
|
||||
composition,
|
||||
baseline,
|
||||
commands,
|
||||
});
|
||||
|
||||
test("command validators are total and replay is forward-only", () => {
|
||||
const hostile = new Proxy(
|
||||
{},
|
||||
{
|
||||
ownKeys() {
|
||||
throw new Error("hostile");
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.doesNotThrow(() => validCommand(hostile));
|
||||
assert.equal(validCommand(hostile), false);
|
||||
assert.equal(validCommand({ type: "undo" }), true);
|
||||
assert.equal(validFxNodeReplayCommand({ type: "undo" }), false);
|
||||
assert.equal(
|
||||
validFxNodeReplayCommand({ type: "batch", commands: [{ type: "node.move", id: "n", position: { x: 1, y: 2 } }] }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay commits atomically once, preserves bounded staged history, and accepts empty logs", () => {
|
||||
const original = runtime.createEngine(runtime.emptyDocument(), 1),
|
||||
commands = [
|
||||
{ type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 0, y: 0 } } as const,
|
||||
{ type: "node.move", id: nodeId("n"), position: { x: 2, y: 3 } } as const,
|
||||
];
|
||||
const result = runtime.replaySaveData(original, save(commands), 0, commandId("restore"));
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
assert.equal(result.status, "committed");
|
||||
if (result.status !== "committed") return;
|
||||
assert.equal(Object.isFrozen(result.saveData), true);
|
||||
assert.notEqual(result.saveData, save(commands));
|
||||
assert.equal(result.state.version, 1);
|
||||
assert.deepEqual(result.state.document.nodes.n!.position, { x: 2, y: 3 });
|
||||
assert.equal(result.state.undo.length, 1);
|
||||
assert.equal(result.state.redo.length, 0);
|
||||
assert.equal(result.mutationEnvelope.cause, "load");
|
||||
assert.equal(result.mutationEnvelope.mutations.length, 1);
|
||||
assert.equal(result.mutationEnvelope.mutations[0]?.kind, "document.replaced");
|
||||
const empty = runtime.replaySaveData(result.state, save([]));
|
||||
assert.equal(empty.ok, true);
|
||||
if (empty.ok) {
|
||||
assert.equal(empty.state.version, 2);
|
||||
assert.equal(empty.state.undo.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("replay rejection, noop, composition mismatch, and stale source inspection are atomic", () => {
|
||||
const state = runtime.createEngine(runtime.emptyDocument());
|
||||
for (const data of [
|
||||
save([{ type: "node.remove", id: "missing" }]),
|
||||
save([{ type: "batch", commands: [] }]),
|
||||
{ ...save([]), composition: { ...composition, id: "other" } },
|
||||
]) {
|
||||
const result = runtime.replaySaveData(state, data);
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.equal(result.state, state);
|
||||
}
|
||||
const noop = runtime.replaySaveData(state, save([{ type: "batch", commands: [] }]));
|
||||
assert.equal(noop.ok, false);
|
||||
if (!noop.ok) {
|
||||
assert.equal(noop.issues[0]?.code, "replay.noop");
|
||||
assert.equal(noop.issues[0]?.path, "/commands/0");
|
||||
}
|
||||
let inspected = false;
|
||||
const hostile = new Proxy(
|
||||
{},
|
||||
{
|
||||
ownKeys() {
|
||||
inspected = true;
|
||||
throw new Error("inspected");
|
||||
},
|
||||
},
|
||||
);
|
||||
const stale = runtime.replaySaveData(state, hostile, 1);
|
||||
assert.equal(stale.ok, false);
|
||||
assert.equal(inspected, false);
|
||||
if (!stale.ok) {
|
||||
assert.equal(stale.state, state);
|
||||
assert.equal(stale.issues[0]?.code, "version.stale");
|
||||
}
|
||||
const unchanged = runtime.replaySaveData(state, save([]));
|
||||
assert.equal(unchanged.ok, true);
|
||||
if (unchanged.ok) {
|
||||
assert.equal(unchanged.status, "noop");
|
||||
assert.equal(unchanged.state.version, state.version);
|
||||
assert.equal(unchanged.state.document, state.document);
|
||||
}
|
||||
});
|
||||
|
||||
test("persisted commands reject malformed durable values and links atomically", () => {
|
||||
const state = runtime.createEngine(runtime.emptyDocument());
|
||||
const link = {
|
||||
id: "l",
|
||||
fromNodeId: "a",
|
||||
fromSocketId: "a:o",
|
||||
toNodeId: "b",
|
||||
toSocketId: "b:i",
|
||||
muted: false,
|
||||
extensions: {},
|
||||
};
|
||||
const malformed = [
|
||||
{ type: "link.add", link: { ...link, muted: 0 } },
|
||||
{ type: "link.add", link: { ...link, extensions: { bad: undefined } } },
|
||||
{ type: "node.parameter", id: "n", key: "value", value: { kind: "number", value: "1" } },
|
||||
{ type: "node.socket-default", id: "n", socketId: "n:o", value: { kind: "vector", value: [1, 2] } },
|
||||
{ type: "node.add", nodeId: "n", nodeType: "not-bound", position: { x: 0, y: 0 } },
|
||||
];
|
||||
for (const command of malformed) {
|
||||
const result = runtime.replaySaveData(state, save([command]));
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) {
|
||||
assert.equal(result.state, state);
|
||||
assert.match(result.issues[0]?.path ?? "", /^\/commands\/0/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("command and baseline admissions have independent budgets and require V2", () => {
|
||||
const state = runtime.createEngine(runtime.emptyDocument()),
|
||||
baseline = runtime.save(runtime.emptyDocument());
|
||||
const large = { ...baseline, metadata: { values: Array.from({ length: 100_001 }, () => 0) } };
|
||||
assert.equal(runtime.replaySaveData(state, save([], large)).ok, true);
|
||||
const commands = Array.from({ length: 1_001 }, () => ({ type: "node.remove", id: "n" }));
|
||||
const over = runtime.replaySaveData(state, save(commands, baseline));
|
||||
assert.equal(over.ok, false);
|
||||
if (!over.ok) assert.equal(over.state, state);
|
||||
const v1 = runtime.replaySaveData(state, save([], { ...baseline, schemaVersion: 1 }));
|
||||
assert.equal(v1.ok, false);
|
||||
if (!v1.ok) {
|
||||
assert.equal(v1.state, state);
|
||||
assert.equal(v1.issues[0]?.code, "baseline.schema");
|
||||
}
|
||||
});
|
||||
|
||||
test("save schema v1 and future versions have distinct structured errors", () => {
|
||||
const state = runtime.createEngine(runtime.emptyDocument());
|
||||
for (const [schemaVersion, code] of [
|
||||
[1, "save.schema.unsupported"],
|
||||
[3, "save.schema.future"],
|
||||
] as const) {
|
||||
const result = runtime.replaySaveData(state, { ...save([]), schemaVersion });
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) {
|
||||
assert.equal(result.issues[0]?.code, code);
|
||||
assert.equal(result.issues[0]?.path, "/schemaVersion");
|
||||
assert.equal(result.state, state);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("save decode alone normalizes embedded schema-1 composition menus", () => {
|
||||
const state = runtime.createEngine(runtime.emptyDocument());
|
||||
const legacyComposition = {
|
||||
...composition,
|
||||
schemaVersion: 1,
|
||||
menuGroups: { legacy: { title: "Legacy", order: 0 } },
|
||||
nodes: Object.fromEntries(
|
||||
Object.entries(composition.nodes).map(([id, node]) => [
|
||||
id,
|
||||
{ ...node, menu: { kind: "entry", group: "legacy", order: 0, keywords: [] } },
|
||||
]),
|
||||
),
|
||||
};
|
||||
const result = runtime.replaySaveData(state, { ...save([]), composition: legacyComposition });
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
assert.equal(result.saveData.composition.schemaVersion, 2);
|
||||
assert.equal("menuGroups" in result.saveData.composition, false);
|
||||
assert.equal("menu" in result.saveData.composition.nodes.value!, false);
|
||||
assert.equal(validateLegacyRaw(legacyComposition), false);
|
||||
|
||||
for (const invalid of [
|
||||
{ ...legacyComposition, menuGroups: undefined },
|
||||
{ ...legacyComposition, nodes: { ...legacyComposition.nodes, value: composition.nodes.value } },
|
||||
{
|
||||
...legacyComposition,
|
||||
nodes: {
|
||||
...legacyComposition.nodes,
|
||||
value: { ...legacyComposition.nodes.value, menu: { kind: "entry", group: "missing", order: 0, keywords: [] } },
|
||||
},
|
||||
},
|
||||
{ ...legacyComposition, unexpected: true },
|
||||
]) {
|
||||
const rejected = runtime.replaySaveData(state, { ...save([]), composition: invalid });
|
||||
assert.equal(rejected.ok, false);
|
||||
}
|
||||
});
|
||||
|
||||
const validateLegacyRaw = (value: unknown) => {
|
||||
try {
|
||||
createFxNodeHeadless(value as FxNodeCompositionData);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
test("composition compatibility accepts true supersets and rejects changed replay semantics", () => {
|
||||
const extra = APPLICATION_COMPILED.source.nodes["fxnode.shader.color"]!,
|
||||
presentation = {
|
||||
...composition,
|
||||
version: 8,
|
||||
theme: { ...composition.theme, background: "#123456" as const },
|
||||
nodes: { ...composition.nodes, extra },
|
||||
};
|
||||
const reordered = {
|
||||
...presentation,
|
||||
nodes: {
|
||||
...presentation.nodes,
|
||||
value: { ...presentation.nodes.value!, ui: [...presentation.nodes.value!.ui].reverse() },
|
||||
},
|
||||
};
|
||||
const baseline = runtime.save(runtime.emptyDocument());
|
||||
assert.deepEqual(saveCompositionCompatibility(composition, reordered, baseline), []);
|
||||
const supersetRuntime = createFxNodeHeadless(reordered),
|
||||
accepted = supersetRuntime.replaySaveData(supersetRuntime.createEngine(supersetRuntime.emptyDocument()), save([]));
|
||||
assert.equal(accepted.ok, true);
|
||||
const changed = {
|
||||
...presentation,
|
||||
nodes: {
|
||||
...presentation.nodes,
|
||||
value: {
|
||||
...presentation.nodes.value!,
|
||||
parameters: {
|
||||
value: { ...presentation.nodes.value!.parameters.value!, default: { kind: "number" as const, value: 42 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const semantic = saveCompositionCompatibility(composition, changed as FxNodeCompositionData, baseline);
|
||||
assert.equal(semantic[0]?.code, "composition.incompatible");
|
||||
assert.equal(semantic[1]?.code, "composition.node-semantic");
|
||||
assert.equal(semantic[1]?.path, "/composition/nodes/value");
|
||||
const missing = { ...presentation, nodes: { extra } };
|
||||
const absent = saveCompositionCompatibility(composition, missing, baseline);
|
||||
assert.equal(absent[0]?.code, "composition.incompatible");
|
||||
assert.equal(absent[1]?.code, "composition.definition-missing");
|
||||
const forged = {
|
||||
...save([{ type: "node.add", nodeId: "x", nodeType: "extra", position: { x: 0, y: 0 } }]),
|
||||
composition,
|
||||
};
|
||||
const rejected = supersetRuntime.replaySaveData(
|
||||
supersetRuntime.createEngine(supersetRuntime.emptyDocument()),
|
||||
forged,
|
||||
);
|
||||
assert.equal(rejected.ok, false);
|
||||
if (!rejected.ok) assert.equal(rejected.issues[0]?.code, "command.invalid");
|
||||
});
|
||||
|
||||
test("compatibility guards opaque promotion and caps structured issues", () => {
|
||||
const extra = APPLICATION_COMPILED.source.nodes["fxnode.shader.color"]!,
|
||||
current = { ...composition, nodes: { ...composition.nodes, opaque: extra } };
|
||||
const opaque = { ...runtime.save(runtime.emptyDocument()), nodes: [{ id: "o", typeId: "opaque" }] as never };
|
||||
const promoted = saveCompositionCompatibility(composition, current, opaque);
|
||||
assert.equal(promoted[0]?.code, "composition.incompatible");
|
||||
assert.equal(promoted[1]?.code, "composition.opaque-promotion");
|
||||
assert.equal(promoted[1]?.path, "/baseline/nodes/0/typeId");
|
||||
const many = Object.fromEntries(
|
||||
Array.from({ length: 150 }, (_, i) => [`style-${i}`, { header: "#000000" }]),
|
||||
) as unknown as typeof composition.nodeStyles,
|
||||
withMany = { ...composition, nodeStyles: many };
|
||||
const capped = saveCompositionCompatibility(
|
||||
withMany,
|
||||
{ ...composition, nodeStyles: {} },
|
||||
runtime.save(runtime.emptyDocument()),
|
||||
);
|
||||
assert.equal(capped.length, 100);
|
||||
});
|
||||
|
||||
test("trusted journal validation rejects executable commands outside the durable schema", () => {
|
||||
const full = createFxNodeHeadless(APPLICATION_COMPILED.source),
|
||||
from = full.materializeNode("from", "fxnode.shader.value"),
|
||||
to = full.materializeNode("to", "fxnode.shader.math"),
|
||||
baseline = full.save({ ...full.emptyDocument(), nodes: { from, to } }),
|
||||
link = {
|
||||
id: linkId("l"),
|
||||
fromNodeId: nodeId("from"),
|
||||
fromSocketId: socketId("from:value"),
|
||||
toNodeId: nodeId("to"),
|
||||
toSocketId: socketId("to:a"),
|
||||
muted: false,
|
||||
extensions: {},
|
||||
};
|
||||
assert.equal(full.validateReplayJournal(baseline, [{ type: "link.add", link }]), true);
|
||||
assert.equal(
|
||||
full.validateReplayJournal(baseline, [{ type: "link.add", link: { ...link, transient: "not durable" } as never }]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay rejects a command whose resulting aggregate exceeds persistence closure", () => {
|
||||
const node = runtime.materializeNode("n", "value"),
|
||||
baseline = runtime.save({ ...runtime.emptyDocument(), nodes: { n: node } });
|
||||
const baselineText = "b".repeat(8_388_350),
|
||||
commandText = "c".repeat(1_048_554);
|
||||
const result = runtime.replaySaveData(
|
||||
runtime.createEngine(runtime.emptyDocument()),
|
||||
save([{ type: "node.label", id: "n", label: commandText }], { ...baseline, metadata: { text: baselineText } }),
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.equal(result.state.document.nodes.n, undefined);
|
||||
});
|
||||
@@ -0,0 +1,578 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type {
|
||||
createFxNode as CreateFxNode,
|
||||
FxNode,
|
||||
FxNodeView,
|
||||
FxNodeViewOptions,
|
||||
FxNodeCompositionSeed,
|
||||
NodeParameterId,
|
||||
NodeSocketId,
|
||||
NodeStyleId,
|
||||
NodeTypeId,
|
||||
ResourceId,
|
||||
SocketTypeId,
|
||||
Themed,
|
||||
RemovedNode,
|
||||
RemovedSocket,
|
||||
} from "@lib/index.js";
|
||||
import { compileFxNodeComposition } from "@lib/composition/compile.js";
|
||||
import {
|
||||
composeNode,
|
||||
composeSocket,
|
||||
removeNode,
|
||||
removeSocket,
|
||||
setHeaderStyles,
|
||||
setTheme,
|
||||
} from "@lib/composition/compose.js";
|
||||
import { APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION } from "../examples/blender/nodes/application.js";
|
||||
import type {
|
||||
createApplicationFxNode as CreateApplicationFxNode,
|
||||
ApplicationFxNode,
|
||||
} from "../examples/blender/application-browser.js";
|
||||
|
||||
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
||||
type Expect<T extends true> = T;
|
||||
const theme = {
|
||||
background: "#000",
|
||||
grid: "#000",
|
||||
frame: "#000",
|
||||
frameHeader: "#000",
|
||||
body: "#000",
|
||||
control: "#000",
|
||||
controlFill: "#000",
|
||||
controlEditing: "#000",
|
||||
textSelection: "#000",
|
||||
outline: "#000",
|
||||
text: "#000",
|
||||
muted: "#000",
|
||||
shadow: "#000",
|
||||
nodeSelected: "#000",
|
||||
nodeActive: "#000",
|
||||
unknownHeader: "#000",
|
||||
unknownSocket: "#000",
|
||||
linkMuted: "#000",
|
||||
knifeMuted: "#000",
|
||||
emphasis: "#000",
|
||||
focus: "#000",
|
||||
editOutline: "#000",
|
||||
resize: "#000",
|
||||
muteOverlay: "#000",
|
||||
boxSelectionFill: "#000",
|
||||
checkerLight: "#000",
|
||||
checkerDark: "#000",
|
||||
widgetBorder: "#000",
|
||||
rampBorder: "#000",
|
||||
resourceBackground: "#000",
|
||||
} as const;
|
||||
const valid = {
|
||||
schemaVersion: 2,
|
||||
id: "types",
|
||||
version: 1,
|
||||
compatibility: { wildcardInputTypes: [] },
|
||||
theme,
|
||||
socketTypes: { number: { title: "Number", color: "#abc", acceptsFrom: ["number"] } },
|
||||
nodeStyles: { basic: { header: "#abc" } },
|
||||
resources: {
|
||||
preview: {
|
||||
kind: "image",
|
||||
title: "Preview",
|
||||
openTitle: "Open",
|
||||
accept: ["image/png"],
|
||||
referencePrefix: "test-image:",
|
||||
maxBytes: 100_000_000,
|
||||
maxWidth: 10_000,
|
||||
maxHeight: 3,
|
||||
maxPixels: 100_000_000,
|
||||
},
|
||||
},
|
||||
nodes: {
|
||||
math: {
|
||||
version: 2,
|
||||
title: "Math",
|
||||
behavior: "standard",
|
||||
style: "basic",
|
||||
parameters: {
|
||||
amount: { type: "number", default: { kind: "number", value: 1 }, precision: 2 },
|
||||
ramp: { type: "json", codec: "color-ramp/v1", default: { kind: "json", value: null } },
|
||||
},
|
||||
sockets: {
|
||||
input: {
|
||||
title: "In",
|
||||
direction: "input",
|
||||
type: "number",
|
||||
maxIncomingLinks: 1,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
output: {
|
||||
title: "Out",
|
||||
direction: "output",
|
||||
type: "number",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{ kind: "parameter", parameter: "amount", visibleWhen: { all: [{ parameter: "amount", equals: 1 }] } },
|
||||
{ kind: "widget", widget: "color-ramp", parameter: "ramp" },
|
||||
{ kind: "resource", resource: "preview", parameter: "amount" },
|
||||
],
|
||||
muteBypass: [["input", "output"]],
|
||||
migrations: [
|
||||
{
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
steps: [
|
||||
{ kind: "materialize-missing", target: "parameter", key: "amount" },
|
||||
{ kind: "rename-socket", from: "old", to: "input" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
other: {
|
||||
version: 1,
|
||||
title: "Other",
|
||||
behavior: "standard",
|
||||
style: "basic",
|
||||
parameters: { label: { type: "string", default: { kind: "string", value: "" } } },
|
||||
sockets: {
|
||||
result: {
|
||||
title: "Result",
|
||||
direction: "output",
|
||||
type: "number",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{ kind: "parameter", parameter: "label" },
|
||||
{ kind: "socket", socket: "result" },
|
||||
],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
type _N = Expect<Equal<NodeTypeId<typeof valid>, "math" | "other">>;
|
||||
type _T = Expect<Equal<SocketTypeId<typeof valid>, "number">>;
|
||||
type _S = Expect<Equal<NodeStyleId<typeof valid>, "basic">>;
|
||||
type _R = Expect<Equal<ResourceId<typeof valid>, "preview">>;
|
||||
type _P = Expect<Equal<NodeParameterId<typeof valid, "math">, "amount" | "ramp">>;
|
||||
type _K = Expect<Equal<NodeSocketId<typeof valid, "math">, "input" | "output">>;
|
||||
type _OP = Expect<Equal<NodeParameterId<typeof valid, "other">, "label">>;
|
||||
type _OK = Expect<Equal<NodeSocketId<typeof valid, "other">, "result">>;
|
||||
if (false) {
|
||||
const createFxNode = null as unknown as typeof CreateFxNode;
|
||||
const compiled = compileFxNodeComposition(valid);
|
||||
const restyled = setHeaderStyles(valid, { alternate: { header: "#123456" } });
|
||||
type _HeaderStyle = Expect<Equal<NodeStyleId<typeof restyled>, "alternate">>;
|
||||
void (null as _HeaderStyle | null);
|
||||
const typeId = compiled.nodes.get("math")?.typeId;
|
||||
type _CN = Expect<Equal<Exclude<typeof typeId, undefined>, "math" | "other">>;
|
||||
void (null as _CN | null);
|
||||
const requestedBytes = compiled.source.resources.preview.maxBytes;
|
||||
type _RequestedBytes = Expect<Equal<typeof requestedBytes, 100_000_000>>;
|
||||
void (null as _RequestedBytes | null);
|
||||
const effectiveBytes = compiled.resources.get("preview")!.maxBytes;
|
||||
type _EffectiveBytes = Expect<Equal<typeof effectiveBytes, number>>;
|
||||
void (null as _EffectiveBytes | null);
|
||||
// @ts-expect-error exact compiled node keys
|
||||
compiled.nodes.get("unknown");
|
||||
const canvas = null as unknown as HTMLCanvasElement;
|
||||
const viewport = { width: 1, height: 1, dpr: 1 };
|
||||
type _ApplicationApi = Expect<Equal<Awaited<ReturnType<typeof CreateApplicationFxNode>>, ApplicationFxNode>>;
|
||||
void (null as _ApplicationApi | null);
|
||||
const options = {
|
||||
applicationId: APPLICATION_ID,
|
||||
applicationVersion: APPLICATION_VERSION,
|
||||
resources: APPLICATION_RESOURCES,
|
||||
};
|
||||
const nodeless = createFxNode(options);
|
||||
void nodeless;
|
||||
// @ts-expect-error application identity, version, and resources are mandatory
|
||||
void createFxNode({});
|
||||
// @ts-expect-error constructor composition was removed
|
||||
void createFxNode({ ...options, composition: valid });
|
||||
// @ts-expect-error constructor graph layout was removed
|
||||
void createFxNode({ ...options, layout: {} });
|
||||
// @ts-expect-error canvases belong to attached views, not root construction
|
||||
void createFxNode({ ...options, canvas });
|
||||
// @ts-expect-error viewports belong to attached views, not root construction
|
||||
void createFxNode({ ...options, viewport });
|
||||
const apiPromise = createFxNode(options);
|
||||
type _Api = Expect<Equal<Awaited<typeof apiPromise>, FxNode>>;
|
||||
void (null as _Api | null);
|
||||
void apiPromise.then((api) => {
|
||||
const viewOptions: FxNodeViewOptions = {
|
||||
canvas,
|
||||
viewport,
|
||||
initialCamera: { center: { x: 0, y: 0 }, zoom: 1 },
|
||||
};
|
||||
api.attachView(viewOptions).then((view) => {
|
||||
type _View = Expect<Equal<typeof view, FxNodeView>>;
|
||||
void (null as _View | null);
|
||||
view.feedInput({ kind: "focus", phase: "focus" });
|
||||
view.setViewport(viewport);
|
||||
view.getHostSnapshot().selection.nodeCount;
|
||||
const unsubscribeHost = view.subscribeHost(() => view.getHostSnapshot());
|
||||
const unsubscribeRequests = view.onHostRequests((request) => void request);
|
||||
void view.addNode({ typeId: "math", viewPosition: { x: 0, y: 0 } });
|
||||
void view.removeSelected();
|
||||
void view.setSelectedMuted(true);
|
||||
void view.provideResource(
|
||||
{ viewId: view.id, token: "token", graphVersion: 0, compositionRevision: 0 },
|
||||
{ name: "image.png", mime: "image/png", bytes: new ArrayBuffer(1) },
|
||||
);
|
||||
unsubscribeHost();
|
||||
unsubscribeRequests();
|
||||
void view.whenRendered();
|
||||
void view.detach();
|
||||
view.id.length;
|
||||
// @ts-expect-error a view ID cannot be replaced by consumers
|
||||
view.id = "replacement";
|
||||
// @ts-expect-error graph authority remains on the root
|
||||
view.dispatch({ type: "undo" });
|
||||
// @ts-expect-error graph snapshots remain on the root
|
||||
view.getState();
|
||||
// @ts-expect-error composition authority remains on the root
|
||||
view.setTheme(theme);
|
||||
// @ts-expect-error composition authority remains on the root
|
||||
view.composeNode("other", valid.nodes.other);
|
||||
});
|
||||
// @ts-expect-error canvas is required
|
||||
api.attachView({ viewport });
|
||||
// @ts-expect-error viewport is required
|
||||
api.attachView({ canvas });
|
||||
// @ts-expect-error view IDs are internal
|
||||
api.attachView({ canvas, viewport, viewId: "caller-owned" });
|
||||
// @ts-expect-error view-only operation
|
||||
api.feedInput({ kind: "focus", phase: "focus" });
|
||||
// @ts-expect-error old copy presentation API was removed
|
||||
api.copyTo(canvas);
|
||||
// @ts-expect-error old mirror presentation API was removed
|
||||
api.addMirror(canvas);
|
||||
// @ts-expect-error old mirror presentation API was removed
|
||||
api.removeMirror(canvas);
|
||||
// @ts-expect-error rendering barriers belong to views
|
||||
api.whenRendered();
|
||||
api.setCompatibility({ wildcardInputTypes: ["any"] });
|
||||
api.dispatch({ type: "node.add", nodeType: "math", position: { x: 0, y: 0 } });
|
||||
api.getState().then((snapshot) => {
|
||||
const known = snapshot.nodes.find((node) => node.known);
|
||||
if (known) {
|
||||
type _Known = Expect<Equal<typeof known.typeId, string>>;
|
||||
void (null as _Known | null);
|
||||
}
|
||||
});
|
||||
api.onSnapshots((event) => {
|
||||
const known = event.snapshot.nodes.find((node) => node.known);
|
||||
if (known) {
|
||||
type _Known = Expect<Equal<typeof known.typeId, string>>;
|
||||
void (null as _Known | null);
|
||||
}
|
||||
});
|
||||
api.onMutations((event) => {
|
||||
for (const mutation of event.mutations)
|
||||
if (mutation.kind === "node.set" && mutation.after?.known) {
|
||||
type _Known = Expect<Equal<typeof mutation.after.typeId, string>>;
|
||||
void (null as _Known | null);
|
||||
}
|
||||
});
|
||||
// @ts-expect-error live composition validates definition-local parameter references
|
||||
void api.composeNode("bad-live-node", { ...valid.nodes.other, ui: [{ kind: "parameter", parameter: "missing" }] });
|
||||
void api
|
||||
.composeSocket("dynamic", { title: "Dynamic", color: "#abcdef", acceptsFrom: ["dynamic"] } as const)
|
||||
.then(async (receipt) => {
|
||||
await api.composeNode("dynamic-node", {
|
||||
version: 1,
|
||||
title: "Dynamic Node",
|
||||
behavior: "standard",
|
||||
style: "basic",
|
||||
parameters: {},
|
||||
sockets: {
|
||||
out: {
|
||||
title: "Out",
|
||||
direction: "output",
|
||||
type: "dynamic",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [{ kind: "socket", socket: "out" }],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
});
|
||||
api.dispatch({ type: "node.add", nodeType: "dynamic-node", position: { x: 0, y: 0 } });
|
||||
await api.removeNode("dynamic-node");
|
||||
void receipt.revision;
|
||||
});
|
||||
api.onCompositionChanges((event) => {
|
||||
event.change.kind;
|
||||
event.revision;
|
||||
event.graphVersion;
|
||||
// @ts-expect-error composition events never expose definitions
|
||||
event.change.definition;
|
||||
});
|
||||
});
|
||||
}
|
||||
test("public composition definition preserves identity", () => assert.equal(valid.id, "types"));
|
||||
|
||||
const seed = {
|
||||
schemaVersion: 2,
|
||||
id: "composed-types",
|
||||
version: 1,
|
||||
compatibility: { wildcardInputTypes: [] },
|
||||
nodeStyles: { basic: { header: "#abc" } },
|
||||
resources: {},
|
||||
socketTypes: {},
|
||||
nodes: {},
|
||||
} as const satisfies FxNodeCompositionSeed;
|
||||
const themed = setTheme(seed, theme);
|
||||
const scalar = composeSocket(themed, "scalar", { title: "Scalar", color: "#abc", acceptsFrom: ["scalar"] });
|
||||
const vector = composeSocket(scalar, "vector", { title: "Vector", color: "#def", acceptsFrom: ["vector", "scalar"] });
|
||||
const composed = composeNode(vector, "constant", {
|
||||
version: 1,
|
||||
title: "Constant",
|
||||
behavior: "standard",
|
||||
style: "basic",
|
||||
parameters: { value: { type: "number", default: { kind: "number", value: 1 } } },
|
||||
sockets: {
|
||||
out: {
|
||||
title: "Out",
|
||||
direction: "output",
|
||||
type: "scalar",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{ kind: "parameter", parameter: "value" },
|
||||
{ kind: "socket", socket: "out" },
|
||||
],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
});
|
||||
const overridden = composeNode(composed, "constant", {
|
||||
...composed.nodes.constant,
|
||||
title: "Replacement",
|
||||
sockets: {
|
||||
result: {
|
||||
title: "Result",
|
||||
direction: "output",
|
||||
type: "vector",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{ kind: "parameter", parameter: "value" },
|
||||
{ kind: "socket", socket: "result" },
|
||||
],
|
||||
muteBypass: [],
|
||||
});
|
||||
const noNode = removeNode(overridden, "constant"),
|
||||
noVector = removeSocket(vector, "vector");
|
||||
type _ComposedSockets = Expect<Equal<SocketTypeId<typeof vector>, "scalar" | "vector">>;
|
||||
type _ComposedNodes = Expect<Equal<NodeTypeId<typeof composed>, "constant">>;
|
||||
type _OverriddenSocket = Expect<Equal<NodeSocketId<typeof overridden, "constant">, "result">>;
|
||||
type _RemovedNode = Expect<Equal<NodeTypeId<typeof noNode>, never>>;
|
||||
type _RemovedSocket = Expect<Equal<SocketTypeId<typeof noVector>, "scalar">>;
|
||||
type _ThemedExport = Expect<Equal<typeof themed, Themed<typeof seed, typeof theme>>>;
|
||||
type _RemovedNodeExport = Expect<Equal<typeof noNode, RemovedNode<typeof overridden, "constant">>>;
|
||||
type _RemovedSocketExport = Expect<Equal<typeof noVector, RemovedSocket<typeof vector, "vector">>>;
|
||||
void (null as
|
||||
| _ComposedSockets
|
||||
| _ComposedNodes
|
||||
| _OverriddenSocket
|
||||
| _RemovedNode
|
||||
| _RemovedSocket
|
||||
| _ThemedExport
|
||||
| _RemovedNodeExport
|
||||
| _RemovedSocketExport
|
||||
| null);
|
||||
if (false) {
|
||||
// @ts-expect-error acceptsFrom may only reference itself or a composed socket type
|
||||
composeSocket(scalar, "bad", { title: "Bad", color: "#000", acceptsFrom: ["missing"] });
|
||||
// @ts-expect-error removeNode only accepts a currently composed node ID
|
||||
removeNode(composed, "missing");
|
||||
// @ts-expect-error removeSocket only accepts a currently composed socket ID
|
||||
removeSocket(vector, "missing");
|
||||
// @ts-expect-error composeNode validates style references against the current composition
|
||||
composeNode(vector, "bad", { ...composed.nodes.constant, style: "missing" });
|
||||
composeNode(vector, "bad-size", {
|
||||
...composed.nodes.constant,
|
||||
// @ts-expect-error node dimensions are calculated internally
|
||||
defaultSize: { x: 1, y: 1 },
|
||||
});
|
||||
compileFxNodeComposition(composed);
|
||||
|
||||
// Every representable reference and finite schema discriminator is checked below.
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
socketTypes: {
|
||||
number: {
|
||||
...valid.socketTypes.number,
|
||||
// @ts-expect-error unknown acceptsFrom socket type
|
||||
acceptsFrom: ["missing"],
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
// @ts-expect-error unknown node socket type
|
||||
sockets: { ...valid.nodes.math.sockets, input: { ...valid.nodes.math.sockets.input, type: "missing" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
// @ts-expect-error unknown style
|
||||
style: "missing",
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
// @ts-expect-error unknown resource
|
||||
ui: [{ kind: "resource", resource: "missing", parameter: "amount" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
// @ts-expect-error unknown UI parameter
|
||||
ui: [{ kind: "parameter", parameter: "missing" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
// @ts-expect-error unknown UI socket
|
||||
ui: [{ kind: "socket", socket: "missing" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
// @ts-expect-error unknown nested visibility parameter
|
||||
ui: [{ kind: "parameter", parameter: "amount", visibleWhen: { any: [{ parameter: "missing", equals: 1 }] } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
// @ts-expect-error unknown grading scalar/color references
|
||||
ui: [
|
||||
{
|
||||
kind: "widget",
|
||||
widget: "grading-wheels",
|
||||
bindings: [
|
||||
{ title: "A", scalar: "missing", color: "amount" },
|
||||
{ title: "B", scalar: "amount", color: "amount" },
|
||||
{ title: "C", scalar: "amount", color: "amount" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
// @ts-expect-error unknown mute bypass socket
|
||||
muteBypass: [["missing", "output"]],
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
// @ts-expect-error unknown migration current-side parameter
|
||||
migrations: [
|
||||
{ fromVersion: 1, toVersion: 2, steps: [{ kind: "rename-parameter", from: "old", to: "missing" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
// @ts-expect-error unknown migration current-side socket
|
||||
migrations: [
|
||||
{ fromVersion: 1, toVersion: 2, steps: [{ kind: "materialize-missing", target: "socket", key: "missing" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
// @ts-expect-error unsupported widget
|
||||
ui: [{ kind: "widget", widget: "dial", parameter: "amount" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
compileFxNodeComposition({
|
||||
...valid,
|
||||
nodes: {
|
||||
math: {
|
||||
...valid.nodes.math,
|
||||
migrations: [
|
||||
{
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
steps: [
|
||||
{
|
||||
kind: "migrate-parameter",
|
||||
parameter: "amount",
|
||||
// @ts-expect-error unsupported migration codec
|
||||
codec: "unknown",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
compileFxNodeComposition,
|
||||
composeNode,
|
||||
composeSocket,
|
||||
FXNODE_COMPOSITION_LIMITS,
|
||||
FxNodeCompositionError,
|
||||
removeNode,
|
||||
removeSocket,
|
||||
setTheme,
|
||||
validateFxNodeComposition,
|
||||
type FxNodeCompositionSeed,
|
||||
} from "@lib/composition/index.js";
|
||||
|
||||
const theme = {
|
||||
background: "#112233",
|
||||
grid: "#112233",
|
||||
frame: "#112233",
|
||||
frameHeader: "#112233",
|
||||
body: "#112233",
|
||||
control: "#112233",
|
||||
controlFill: "#112233",
|
||||
controlEditing: "#112233",
|
||||
textSelection: "#112233",
|
||||
outline: "#112233",
|
||||
text: "#112233",
|
||||
muted: "#112233",
|
||||
shadow: "#112233",
|
||||
nodeSelected: "#112233",
|
||||
nodeActive: "#112233",
|
||||
unknownHeader: "#112233",
|
||||
unknownSocket: "#112233",
|
||||
linkMuted: "#112233",
|
||||
knifeMuted: "#112233",
|
||||
emphasis: "#112233",
|
||||
focus: "#112233",
|
||||
editOutline: "#112233",
|
||||
resize: "#112233",
|
||||
muteOverlay: "#112233",
|
||||
boxSelectionFill: "#112233",
|
||||
checkerLight: "#112233",
|
||||
checkerDark: "#112233",
|
||||
widgetBorder: "#112233",
|
||||
rampBorder: "#112233",
|
||||
resourceBackground: "#112233",
|
||||
} as const;
|
||||
const socket = (
|
||||
direction: "input" | "output",
|
||||
type: "scalar" | "wide",
|
||||
value: null | { type: "number"; default: { kind: "number"; value: number } } = null,
|
||||
) => ({
|
||||
title: direction,
|
||||
direction,
|
||||
type,
|
||||
maxIncomingLinks: direction === "input" ? 1 : 0,
|
||||
visible: true,
|
||||
value: direction === "input" ? value : null,
|
||||
showValue: direction === "input" && value !== null,
|
||||
});
|
||||
const valid = () =>
|
||||
({
|
||||
schemaVersion: 2,
|
||||
id: "studio",
|
||||
version: 4,
|
||||
compatibility: { wildcardInputTypes: [] },
|
||||
theme,
|
||||
socketTypes: {
|
||||
scalar: { title: "Scalar", color: "#abcdef", acceptsFrom: ["scalar"] },
|
||||
wide: { title: "Wide", color: "#fedcba", acceptsFrom: ["wide", "scalar"] },
|
||||
},
|
||||
nodeStyles: { grade: { header: "#123456" }, utility: { header: "#654321" } },
|
||||
resources: {
|
||||
image: {
|
||||
kind: "image",
|
||||
title: "Image",
|
||||
openTitle: "Open",
|
||||
accept: ["image/png", "image/jpeg"],
|
||||
referencePrefix: "test-image:",
|
||||
maxBytes: 1024,
|
||||
maxWidth: 64,
|
||||
maxHeight: 64,
|
||||
maxPixels: 4096,
|
||||
},
|
||||
},
|
||||
nodes: {
|
||||
grade: {
|
||||
version: 3,
|
||||
title: "Grade",
|
||||
behavior: "standard",
|
||||
style: "grade",
|
||||
parameters: {
|
||||
exposure: {
|
||||
type: "number",
|
||||
default: { kind: "number", value: 0 },
|
||||
minimum: -10,
|
||||
maximum: 10,
|
||||
step: 0.1,
|
||||
precision: 2,
|
||||
},
|
||||
mode: { type: "string", default: { kind: "string", value: "film" }, enum: ["film", "raw"] },
|
||||
file: { type: "string", default: { kind: "string", value: "" } },
|
||||
tint: { type: "color", default: { kind: "color", value: [1, 1, 1, 1] }, minimum: 0, maximum: 1 },
|
||||
ramp: {
|
||||
type: "json",
|
||||
codec: "color-ramp/v1",
|
||||
default: {
|
||||
kind: "json",
|
||||
value: {
|
||||
colorMode: "rgb",
|
||||
interpolation: "linear",
|
||||
hueInterpolation: "near",
|
||||
stops: [
|
||||
{ id: "a", position: 0, color: [0, 0, 0, 1] },
|
||||
{ id: "b", position: 1, color: [1, 1, 1, 1] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
lift: { type: "number", default: { kind: "number", value: 0 } },
|
||||
gamma: { type: "number", default: { kind: "number", value: 1 } },
|
||||
gain: { type: "number", default: { kind: "number", value: 1 } },
|
||||
liftColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] } },
|
||||
gammaColor: { type: "color", default: { kind: "color", value: [0.5, 0.5, 0.5, 1] } },
|
||||
gainColor: { type: "color", default: { kind: "color", value: [1, 1, 1, 1] } },
|
||||
},
|
||||
sockets: {
|
||||
input: socket("input", "wide", { type: "number", default: { kind: "number", value: 0 } }),
|
||||
output: socket("output", "scalar"),
|
||||
secret: socket("input", "scalar"),
|
||||
},
|
||||
ui: [
|
||||
{
|
||||
kind: "text",
|
||||
variant: "header",
|
||||
title: "Color",
|
||||
visibleWhen: {
|
||||
all: [
|
||||
{ parameter: "mode", equals: "film" },
|
||||
{
|
||||
any: [
|
||||
{ parameter: "exposure", in: [0, 1] },
|
||||
{ parameter: "mode", equals: "raw" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: "parameter", parameter: "exposure" },
|
||||
{ kind: "parameter", parameter: "mode" },
|
||||
{ kind: "resource", resource: "image", parameter: "file" },
|
||||
{ kind: "parameter", parameter: "tint" },
|
||||
{ kind: "widget", widget: "color-ramp", parameter: "ramp" },
|
||||
{
|
||||
kind: "widget",
|
||||
widget: "grading-wheels",
|
||||
bindings: [
|
||||
{ title: "Lift", scalar: "lift", color: "liftColor" },
|
||||
{ title: "Gamma", scalar: "gamma", color: "gammaColor" },
|
||||
{ title: "Gain", scalar: "gain", color: "gainColor" },
|
||||
],
|
||||
},
|
||||
{ kind: "socket", socket: "input" },
|
||||
{ kind: "socket", socket: "output" },
|
||||
{ kind: "hidden", target: "socket", socket: "secret" },
|
||||
],
|
||||
muteBypass: [["input", "output"]],
|
||||
migrations: [
|
||||
{
|
||||
fromVersion: 1,
|
||||
toVersion: 3,
|
||||
steps: [
|
||||
{ kind: "materialize-missing", target: "parameter", key: "exposure" },
|
||||
{ kind: "migrate-parameter", parameter: "ramp", codec: "color-ramp/legacy-stops" },
|
||||
{ kind: "rename-socket", from: "source", to: "input" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
constant: {
|
||||
version: 1,
|
||||
title: "Constant",
|
||||
behavior: "standard",
|
||||
style: "utility",
|
||||
parameters: { value: { type: "number", default: { kind: "number", value: 1 }, integer: true } },
|
||||
sockets: { out: socket("output", "scalar") },
|
||||
ui: [
|
||||
{ kind: "parameter", parameter: "value" },
|
||||
{ kind: "socket", socket: "out" },
|
||||
],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
},
|
||||
internal: {
|
||||
version: 1,
|
||||
title: "Internal",
|
||||
behavior: "reroute",
|
||||
style: "utility",
|
||||
parameters: {},
|
||||
sockets: {},
|
||||
ui: [],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
},
|
||||
},
|
||||
}) as const;
|
||||
|
||||
const clone = () => structuredClone(valid());
|
||||
function issue(value: unknown, code: string, path: string) {
|
||||
const r = validateFxNodeComposition(value);
|
||||
assert.equal(r.ok, false);
|
||||
if (!r.ok)
|
||||
assert(
|
||||
r.issues.some((x) => x.code === code && x.path === path),
|
||||
`${code} ${path}\n${JSON.stringify(r.issues)}`,
|
||||
);
|
||||
}
|
||||
|
||||
test("valid fixture compiles without menu projection", () => {
|
||||
const v = valid();
|
||||
assert.equal(validateFxNodeComposition(v).ok, true);
|
||||
const c = compileFxNodeComposition(v);
|
||||
assert.equal("menuEntries" in c, false);
|
||||
});
|
||||
|
||||
test("composition authoring helpers are immutable, replaceable, removable and clone-safe", () => {
|
||||
const seed = {
|
||||
schemaVersion: 2,
|
||||
id: "helpers",
|
||||
version: 1,
|
||||
compatibility: { wildcardInputTypes: [] },
|
||||
nodeStyles: { utility: { header: "#654321" } },
|
||||
resources: {},
|
||||
socketTypes: {},
|
||||
nodes: {},
|
||||
} as const satisfies FxNodeCompositionSeed;
|
||||
const themed = setTheme(seed, theme);
|
||||
assert.equal("theme" in seed, false);
|
||||
assert.notEqual(themed, seed);
|
||||
const rethemed = setTheme(themed, { ...theme, grid: "#abcdef" });
|
||||
assert.equal(themed.theme.grid, "#112233");
|
||||
assert.equal(rethemed.theme.grid, "#abcdef");
|
||||
const sockets = composeSocket(themed, "scalar", { title: "Scalar", color: "#abcdef", acceptsFrom: ["scalar"] });
|
||||
assert.deepEqual(seed.socketTypes, {});
|
||||
assert.notEqual(sockets.socketTypes, seed.socketTypes);
|
||||
const definition = {
|
||||
version: 1,
|
||||
title: "Constant",
|
||||
behavior: "standard",
|
||||
style: "utility",
|
||||
parameters: {},
|
||||
sockets: {
|
||||
out: {
|
||||
title: "Out",
|
||||
direction: "output",
|
||||
type: "scalar",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [{ kind: "socket", socket: "out" }],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
} as const;
|
||||
const nodes = composeNode(sockets, "constant", definition),
|
||||
replacement = composeNode(nodes, "constant", { ...definition, title: "Replacement" });
|
||||
assert.equal(nodes.nodes.constant.title, "Constant");
|
||||
assert.equal(replacement.nodes.constant.title, "Replacement");
|
||||
assert.deepEqual(sockets.nodes, {});
|
||||
assert.doesNotThrow(() => structuredClone(replacement));
|
||||
assert.equal(validateFxNodeComposition(replacement).ok, true);
|
||||
const withoutNode = removeNode(replacement, "constant");
|
||||
assert.equal("constant" in withoutNode.nodes, false);
|
||||
assert.equal("constant" in replacement.nodes, true);
|
||||
const dangling = removeSocket(replacement, "scalar"),
|
||||
checked = validateFxNodeComposition(dangling);
|
||||
assert.equal(checked.ok, false);
|
||||
if (!checked.ok) assert(checked.issues.some((issue) => issue.code === "reference.socketType"));
|
||||
const withSize: any = structuredClone(replacement);
|
||||
withSize.nodes.constant.defaultSize = { x: 1, y: 1 };
|
||||
issue(withSize, "shape.unknown", "/nodes/constant/defaultSize");
|
||||
});
|
||||
|
||||
test("compilations are independent immutable clones and expose readonly facades", () => {
|
||||
const a: any = clone(),
|
||||
b: any = clone();
|
||||
a.id = "same";
|
||||
b.id = "same";
|
||||
const ca = compileFxNodeComposition(a),
|
||||
cb = compileFxNodeComposition(b);
|
||||
assert.notEqual(ca.source, cb.source);
|
||||
a.nodes.grade.parameters.exposure.default.value = 9;
|
||||
assert.equal(ca.source.nodes.grade.parameters.exposure.default.value, 0);
|
||||
assert.equal(ca.nodes.get("grade")?.typeId, "grade");
|
||||
assert(Object.isFrozen(ca));
|
||||
assert(Object.isFrozen(ca.source));
|
||||
assert(Object.isFrozen(ca.source.nodes.grade.parameters));
|
||||
for (const facade of [ca.nodes, ca.socketTypes, ca.styles, ca.resources])
|
||||
for (const method of ["set", "delete", "clear"]) assert.equal(method in facade, false);
|
||||
let leaked: unknown = "unset";
|
||||
const size = ca.nodes.size;
|
||||
ca.nodes.forEach(function () {
|
||||
leaked = arguments[2];
|
||||
});
|
||||
assert.equal(leaked, undefined);
|
||||
assert.equal(ca.nodes.size, size);
|
||||
});
|
||||
|
||||
test("compiled image policies preserve requested source limits and expose effective ceilings", () => {
|
||||
const source: any = clone();
|
||||
source.resources.image = {
|
||||
...source.resources.image,
|
||||
maxBytes: 100_000_000,
|
||||
maxWidth: 10_000,
|
||||
maxHeight: 3,
|
||||
maxPixels: 100_000_000,
|
||||
};
|
||||
const compiled = compileFxNodeComposition(source);
|
||||
assert.deepEqual(
|
||||
{
|
||||
maxBytes: compiled.source.resources.image.maxBytes,
|
||||
maxWidth: compiled.source.resources.image.maxWidth,
|
||||
maxHeight: compiled.source.resources.image.maxHeight,
|
||||
maxPixels: compiled.source.resources.image.maxPixels,
|
||||
},
|
||||
{ maxBytes: 100_000_000, maxWidth: 10_000, maxHeight: 3, maxPixels: 100_000_000 },
|
||||
);
|
||||
const effective = compiled.resources.get("image")!;
|
||||
assert.deepEqual(
|
||||
{
|
||||
maxBytes: effective.maxBytes,
|
||||
maxWidth: effective.maxWidth,
|
||||
maxHeight: effective.maxHeight,
|
||||
maxPixels: effective.maxPixels,
|
||||
},
|
||||
{ maxBytes: 33_554_432, maxWidth: 8192, maxHeight: 3, maxPixels: 24_576 },
|
||||
);
|
||||
});
|
||||
|
||||
type Mutator = (x: any) => void;
|
||||
const cases: [string, string, Mutator][] = [
|
||||
["reference.style", "/nodes/grade/style", (x) => (x.nodes.grade.style = "no")],
|
||||
["reference.socketType", "/socketTypes/wide/acceptsFrom/0", (x) => (x.socketTypes.wide.acceptsFrom[0] = "no")],
|
||||
["reference.socketType", "/nodes/grade/sockets/input/type", (x) => (x.nodes.grade.sockets.input.type = "no")],
|
||||
["reference.resource", "/nodes/grade/ui/3/resource", (x) => (x.nodes.grade.ui[3].resource = "no")],
|
||||
["reference.parameter", "/nodes/grade/ui/1/parameter", (x) => (x.nodes.grade.ui[1].parameter = "no")],
|
||||
["reference.socket", "/nodes/grade/ui/7/socket", (x) => (x.nodes.grade.ui[7].socket = "no")],
|
||||
[
|
||||
"reference.parameter",
|
||||
"/nodes/grade/ui/0/visibleWhen/all/0/parameter",
|
||||
(x) => (x.nodes.grade.ui[0].visibleWhen.all[0].parameter = "no"),
|
||||
],
|
||||
[
|
||||
"schema.bounds",
|
||||
"/nodes/grade/parameters/exposure/minimum",
|
||||
(x) => (x.nodes.grade.parameters.exposure.minimum = 20),
|
||||
],
|
||||
[
|
||||
"schema.integer",
|
||||
"/nodes/constant/parameters/value/integer",
|
||||
(x) => (x.nodes.constant.parameters.value.integer = "yes"),
|
||||
],
|
||||
[
|
||||
"schema.precision",
|
||||
"/nodes/grade/parameters/exposure/precision",
|
||||
(x) => (x.nodes.grade.parameters.exposure.precision = 21),
|
||||
],
|
||||
[
|
||||
"value.duplicate",
|
||||
"/nodes/grade/parameters/mode/enum",
|
||||
(x) => (x.nodes.grade.parameters.mode.enum = ["film", "film"]),
|
||||
],
|
||||
[
|
||||
"schema.enum",
|
||||
"/nodes/grade/parameters/mode/default/value",
|
||||
(x) => (x.nodes.grade.parameters.mode.default.value = "no"),
|
||||
],
|
||||
[
|
||||
"schema.bounds",
|
||||
"/nodes/grade/parameters/tint/default/value",
|
||||
(x) => (x.nodes.grade.parameters.tint.default.value[0] = 2),
|
||||
],
|
||||
[
|
||||
"schema.bounds",
|
||||
"/nodes/grade/parameters/exposure/default/value",
|
||||
(x) => (x.nodes.grade.parameters.exposure.default.value = 20),
|
||||
],
|
||||
[
|
||||
"socket.links",
|
||||
"/nodes/grade/sockets/input/maxIncomingLinks",
|
||||
(x) => (x.nodes.grade.sockets.input.maxIncomingLinks = 0),
|
||||
],
|
||||
["socket.output", "/nodes/grade/sockets/output", (x) => (x.nodes.grade.sockets.output.showValue = true)],
|
||||
[
|
||||
"socket.value",
|
||||
"/nodes/grade/sockets/input/value",
|
||||
(x) => {
|
||||
x.nodes.grade.sockets.input.value = null;
|
||||
x.nodes.grade.sockets.input.showValue = true;
|
||||
},
|
||||
],
|
||||
["ui.widget", "/nodes/grade/ui/5/widget", (x) => (x.nodes.grade.ui[5].widget = "dial")],
|
||||
["ui.widget", "/nodes/grade/ui/5/parameter", (x) => (x.nodes.grade.ui[5].parameter = "exposure")],
|
||||
["ui.widget", "/nodes/grade/ui/6/bindings/0/scalar", (x) => (x.nodes.grade.ui[6].bindings[0].scalar = "liftColor")],
|
||||
["ui.widget", "/nodes/grade/ui/6/bindings/0/color", (x) => (x.nodes.grade.ui[6].bindings[0].color = "lift")],
|
||||
["ui.resource", "/nodes/grade/ui/3/parameter", (x) => (x.nodes.grade.ui[3].parameter = "exposure")],
|
||||
["ui.placement", "/nodes/grade/parameters/exposure", (x) => x.nodes.grade.ui.splice(1, 1)],
|
||||
["ui.placement", "/nodes/grade/ui/2", (x) => (x.nodes.grade.ui[2] = { kind: "parameter", parameter: "exposure" })],
|
||||
["socket.bypass", "/nodes/grade/muteBypass/0", (x) => (x.nodes.grade.muteBypass = [["output", "input"]])],
|
||||
[
|
||||
"socket.compatibility",
|
||||
"/nodes/grade/muteBypass/0",
|
||||
(x) => {
|
||||
x.nodes.grade.sockets.input.type = "scalar";
|
||||
x.nodes.grade.sockets.output.type = "wide";
|
||||
},
|
||||
],
|
||||
["value.duplicate", "/nodes/grade/muteBypass/1", (x) => x.nodes.grade.muteBypass.push(["input", "output"])],
|
||||
["limit.resource", "/resources/image/maxWidth", (x) => (x.resources.image.maxWidth = 0)],
|
||||
["shape.string", "/resources/image/referencePrefix", (x) => (x.resources.image.referencePrefix = "bad")],
|
||||
["migration.edge", "/nodes/grade/migrations/0", (x) => (x.nodes.grade.migrations[0].toVersion = 4)],
|
||||
[
|
||||
"migration.outgoing",
|
||||
"/nodes/grade/migrations/1/fromVersion",
|
||||
(x) => x.nodes.grade.migrations.push({ fromVersion: 1, toVersion: 2, steps: [] }),
|
||||
],
|
||||
[
|
||||
"reference.migrationTarget",
|
||||
"/nodes/grade/migrations/0/steps/0/key",
|
||||
(x) => (x.nodes.grade.migrations[0].steps[0].key = "no"),
|
||||
],
|
||||
[
|
||||
"migration.codec",
|
||||
"/nodes/grade/migrations/0/steps/1/codec",
|
||||
(x) => (x.nodes.grade.migrations[0].steps[1].codec = "no"),
|
||||
],
|
||||
[
|
||||
"migration.rename",
|
||||
"/nodes/grade/migrations/0/steps/3/from",
|
||||
(x) => x.nodes.grade.migrations[0].steps.push({ kind: "rename-socket", from: "source", to: "output" }),
|
||||
],
|
||||
["shape.literal", "/nodes/grade/behavior", (x) => (x.nodes.grade.behavior = ["standard"])],
|
||||
[
|
||||
"socket.direction",
|
||||
"/nodes/grade/sockets/input/direction",
|
||||
(x) => (x.nodes.grade.sockets.input.direction = ["input"]),
|
||||
],
|
||||
["ui.text", "/nodes/grade/ui/0/variant", (x) => (x.nodes.grade.ui[0].variant = ["header"])],
|
||||
["shape.string", "/nodes/grade/ui/1/parameter", (x) => (x.nodes.grade.ui[1].parameter = 0)],
|
||||
["shape.string", "/nodes/grade/ui/3/resource", (x) => (x.nodes.grade.ui[3].resource = 0)],
|
||||
["ui.target", "/nodes/grade/ui/9/target", (x) => (x.nodes.grade.ui[9].target = "bogus")],
|
||||
["ui.visibility", "/nodes/grade/ui/0/visibleWhen/all", (x) => (x.nodes.grade.ui[0].visibleWhen = { all: [] })],
|
||||
["ui.visibility", "/nodes/grade/ui/0/visibleWhen/any", (x) => (x.nodes.grade.ui[0].visibleWhen = { any: [] })],
|
||||
[
|
||||
"migration.codec",
|
||||
"/nodes/grade/migrations/0/steps/1/parameter",
|
||||
(x) => (x.nodes.grade.migrations[0].steps[1].parameter = "exposure"),
|
||||
],
|
||||
];
|
||||
test("semantic invalid cases report precise codes and paths", () => {
|
||||
for (const [code, path, mutate] of cases) {
|
||||
const x: any = clone();
|
||||
mutate(x);
|
||||
issue(x, code, path);
|
||||
}
|
||||
});
|
||||
|
||||
test("hostile structures fail without executing code or throwing", () => {
|
||||
for (const array of [false, true]) {
|
||||
let calls = 0;
|
||||
const x: any = clone();
|
||||
if (array)
|
||||
Object.defineProperty(x.nodes.grade.ui, 0, {
|
||||
get() {
|
||||
calls++;
|
||||
return {};
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
else
|
||||
Object.defineProperty(x, "id", {
|
||||
get() {
|
||||
calls++;
|
||||
return "x";
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
issue(x, "data.inspect", array ? "/nodes/grade/ui/0" : "/id");
|
||||
assert.equal(calls, 0);
|
||||
}
|
||||
const hostile: [unknown, string, string][] = [
|
||||
[Object.assign(clone(), { [Symbol("x")]: 1 }), "data.symbol", ""],
|
||||
[Object.assign(clone(), { id: new (class X {})() }), "data.type", "/id"],
|
||||
[Object.assign(clone(), { id: new Date() }), "data.type", "/id"],
|
||||
[Object.assign(clone(), { id: new Map() }), "data.type", "/id"],
|
||||
[Object.assign(clone(), { id: new ArrayBuffer(1) }), "data.type", "/id"],
|
||||
[Object.assign(clone(), { id: 1n }), "data.type", "/id"],
|
||||
[Object.assign(clone(), { id: undefined }), "data.type", "/id"],
|
||||
];
|
||||
for (const [x, c, p] of hostile) issue(x, c, p);
|
||||
const sparse: any = clone();
|
||||
sparse.nodes.grade.ui.length++;
|
||||
issue(sparse, "data.array", "/nodes/grade/ui/10");
|
||||
const keyed: any = [];
|
||||
Object.defineProperty(keyed, "x".repeat(FXNODE_COMPOSITION_LIMITS.maxStringCodeUnits + 1), {
|
||||
value: null,
|
||||
enumerable: true,
|
||||
});
|
||||
issue(keyed, "limit.strings", "");
|
||||
const shared: any = clone();
|
||||
shared.extra = shared.theme;
|
||||
issue(shared, "data.identity", "/extra");
|
||||
const cycle: any = clone();
|
||||
cycle.extra = cycle;
|
||||
issue(cycle, "data.identity", "/extra");
|
||||
const revoked = Proxy.revocable({}, {});
|
||||
revoked.revoke();
|
||||
for (const proxy of [
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
ownKeys() {
|
||||
throw Error("no");
|
||||
},
|
||||
},
|
||||
),
|
||||
revoked.proxy,
|
||||
])
|
||||
assert.doesNotThrow(() => issue(proxy, "data.inspect", ""));
|
||||
const np = Object.assign(Object.create(null), clone());
|
||||
const r = validateFxNodeComposition(np);
|
||||
assert.equal(r.ok, true);
|
||||
if (r.ok) assert.equal(r.value.id, "studio");
|
||||
});
|
||||
|
||||
test("collection, visibility, and issue caps", () => {
|
||||
const limits: [string, string, (x: any) => void][] = [
|
||||
[
|
||||
"limit.collection",
|
||||
"/nodes",
|
||||
(x) => {
|
||||
const n = x.nodes.internal;
|
||||
x.nodes = {};
|
||||
for (let i = 0; i <= FXNODE_COMPOSITION_LIMITS.maxNodes; i++) x.nodes[`n${i}`] = structuredClone(n);
|
||||
},
|
||||
],
|
||||
[
|
||||
"limit.collection",
|
||||
"/socketTypes",
|
||||
(x) => {
|
||||
for (let i = 0; i <= FXNODE_COMPOSITION_LIMITS.maxSocketTypes; i++)
|
||||
x.socketTypes[`s${i}`] = { title: "S", color: "#abcdef", acceptsFrom: [] };
|
||||
},
|
||||
],
|
||||
[
|
||||
"limit.ui",
|
||||
"/nodes/grade/ui",
|
||||
(x) => {
|
||||
x.nodes.grade.ui = Array.from({ length: 257 }, () => ({ kind: "text", variant: "header", title: "x" }));
|
||||
},
|
||||
],
|
||||
[
|
||||
"limit.enum",
|
||||
"/nodes/grade/parameters/mode/enum",
|
||||
(x) => (x.nodes.grade.parameters.mode.enum = Array.from({ length: 257 }, (_, i) => `v${i}`)),
|
||||
],
|
||||
[
|
||||
"limit.migrations",
|
||||
"/nodes/grade/migrations",
|
||||
(x) =>
|
||||
(x.nodes.grade.migrations = Array.from({ length: 65 }, (_, i) => ({
|
||||
fromVersion: i + 1,
|
||||
toVersion: i + 2,
|
||||
steps: [],
|
||||
}))),
|
||||
],
|
||||
[
|
||||
"limit.migrations",
|
||||
"/nodes/grade/migrations/0/steps",
|
||||
(x) =>
|
||||
(x.nodes.grade.migrations[0].steps = Array.from({ length: 129 }, () => ({
|
||||
kind: "materialize-missing",
|
||||
target: "parameter",
|
||||
key: "exposure",
|
||||
}))),
|
||||
],
|
||||
[
|
||||
"limit.visibility",
|
||||
"/nodes/grade/ui/0/visibleWhen/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0",
|
||||
(x) => {
|
||||
let v: any = { parameter: "mode", equals: "film" };
|
||||
for (let i = 0; i < 17; i++) v = { all: [v] };
|
||||
x.nodes.grade.ui[0].visibleWhen = v;
|
||||
},
|
||||
],
|
||||
];
|
||||
for (const [c, p, m] of limits) {
|
||||
const x: any = clone();
|
||||
m(x);
|
||||
issue(x, c, p);
|
||||
}
|
||||
for (const target of ["top", "node"]) {
|
||||
const x: any = clone();
|
||||
const o = target === "top" ? x : x.nodes.grade;
|
||||
for (let i = 0; i < 110; i++) o[`unknown${i}`] = i;
|
||||
const r = validateFxNodeComposition(x);
|
||||
assert.equal(r.ok, false);
|
||||
if (!r.ok) {
|
||||
assert.equal(r.issues.length, FXNODE_COMPOSITION_LIMITS.maxIssues);
|
||||
assert(r.issues.every((i) => i.code === "shape.unknown"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("compile error carries frozen issues", () => {
|
||||
try {
|
||||
compileFxNodeComposition({ ...valid(), schemaVersion: 1 } as unknown as ReturnType<typeof valid>);
|
||||
assert.fail("expected error");
|
||||
} catch (e) {
|
||||
assert(e instanceof FxNodeCompositionError);
|
||||
assert(Object.isFrozen(e.issues));
|
||||
assert(Object.isFrozen(e.issues[0]));
|
||||
assert(e.issues.some((i) => i.path === "/schemaVersion"));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
clampNumber,
|
||||
cycleEnum,
|
||||
numericStep,
|
||||
scrubValue,
|
||||
setNumericComponent,
|
||||
snapNumber,
|
||||
} from "@lib/worker/control-edit.js";
|
||||
import type { LayoutControl } from "@lib/layout/types.js";
|
||||
|
||||
const numberControl: LayoutControl = {
|
||||
id: "number",
|
||||
nodeId: "node" as LayoutControl["nodeId"],
|
||||
source: "parameter",
|
||||
key: "value",
|
||||
label: "Value",
|
||||
kind: "number",
|
||||
value: { kind: "number", value: 0 },
|
||||
schema: { type: "number", default: { kind: "number", value: 0 }, minimum: -1, maximum: 1, step: 0.25 },
|
||||
linked: false,
|
||||
bounds: { x: 0, y: 0, width: 10, height: 10 },
|
||||
subfields: [],
|
||||
numericFields: [],
|
||||
};
|
||||
|
||||
test("control edit helpers clamp, snap and apply fine scrub", () => {
|
||||
assert.equal(clampNumber(2, numberControl.schema), 1);
|
||||
assert.equal(snapNumber(0.38, numberControl.schema), 0.5);
|
||||
assert.deepEqual(scrubValue(numberControl, { kind: "number", value: 0 }, 0, 5, true, false), {
|
||||
kind: "number",
|
||||
value: 0.05,
|
||||
});
|
||||
assert.deepEqual(scrubValue(numberControl, { kind: "number", value: 0 }, 0, 3.8, false, true), {
|
||||
kind: "number",
|
||||
value: 0.5,
|
||||
});
|
||||
assert.deepEqual(setNumericComponent(numberControl, { kind: "number", value: 0 }, 0, 2), {
|
||||
kind: "number",
|
||||
value: 1,
|
||||
});
|
||||
assert.equal(numericStep(numberControl, false), 0.25);
|
||||
assert.equal(numericStep(numberControl, true), 0.025);
|
||||
});
|
||||
|
||||
test("vector/color edits preserve other components and enum cycling wraps", () => {
|
||||
const vector = {
|
||||
...numberControl,
|
||||
kind: "vector" as const,
|
||||
schema: { type: "vector" as const, default: { kind: "vector" as const, value: [0, 0, 0] as const } },
|
||||
};
|
||||
assert.deepEqual(scrubValue(vector, { kind: "vector", value: [1, 2, 3] }, 1, 10, false, false), {
|
||||
kind: "vector",
|
||||
value: [1, 3, 3],
|
||||
});
|
||||
assert.deepEqual(setNumericComponent(vector, { kind: "vector", value: [1, 2, 3] }, 1, 4), {
|
||||
kind: "vector",
|
||||
value: [1, 4, 3],
|
||||
});
|
||||
const color = {
|
||||
...numberControl,
|
||||
kind: "color" as const,
|
||||
schema: { type: "color" as const, default: { kind: "color" as const, value: [0, 0, 0, 1] as const } },
|
||||
};
|
||||
assert.deepEqual(scrubValue(color, { kind: "color", value: [0, 0.5, 0, 1] }, 1, 10, false, false), {
|
||||
kind: "color",
|
||||
value: [0, 1, 0, 1],
|
||||
});
|
||||
assert.equal(cycleEnum(["a", "b"], "b", 1), "a");
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
|
||||
import { commandId, linkId, nodeId, socketId } from "@lib/core/types.js";
|
||||
import { reduceMutations } from "@lib/engine/reducer.js";
|
||||
import { minimumNodeSize } from "@lib/layout/node-dimensions.js";
|
||||
import { validCommand } from "@lib/commands/validate.js";
|
||||
import { planSelectionMute, planSelectionRemoval } from "@lib/worker/selection-actions.js";
|
||||
const {
|
||||
createEngine,
|
||||
decodeGraphDocument,
|
||||
emptyDocument,
|
||||
materializeNode,
|
||||
save,
|
||||
serializeGraphDocument,
|
||||
getState,
|
||||
transition,
|
||||
} = APPLICATION_HEADLESS;
|
||||
const BUILTIN_DESCRIPTORS = Object.freeze([...APPLICATION_COMPILED.nodes.values()]);
|
||||
const CATALOG_NODE_IDS = [...APPLICATION_COMPILED.nodes.keys()];
|
||||
import type { EngineState, Command, CommandRequest } from "@lib/headless.js";
|
||||
|
||||
type ApplicationEngineState = EngineState<typeof APPLICATION_COMPILED.source>;
|
||||
|
||||
let sequence = 0;
|
||||
function run(state: ApplicationEngineState, command: Command) {
|
||||
const request: CommandRequest = {
|
||||
commandId: commandId(`command-${++sequence}`),
|
||||
expectedVersion: state.version,
|
||||
source: "api",
|
||||
command,
|
||||
};
|
||||
return transition(state, request);
|
||||
}
|
||||
function committed(state: ApplicationEngineState, command: Command): ApplicationEngineState {
|
||||
const result = run(state, command);
|
||||
assert.equal(result.status, "committed");
|
||||
if (result.status !== "committed") throw new Error("expected commit");
|
||||
return result.state;
|
||||
}
|
||||
|
||||
test("catalog has exact, frozen coverage and every built-in materializes generic initial state", () => {
|
||||
const expectedTypeIds = [
|
||||
"fxnode.common.frame",
|
||||
"fxnode.common.reroute",
|
||||
"fxnode.common.group-input",
|
||||
"fxnode.common.group-output",
|
||||
"fxnode.shader.value",
|
||||
"fxnode.shader.color",
|
||||
"fxnode.shader.math",
|
||||
"fxnode.shader.vector-math",
|
||||
"fxnode.shader.mix",
|
||||
"fxnode.shader.color-ramp",
|
||||
"fxnode.shader.texture-coordinate",
|
||||
"fxnode.shader.noise-texture",
|
||||
"fxnode.shader.image-texture",
|
||||
"fxnode.shader.principled-bsdf",
|
||||
"fxnode.shader.material-output",
|
||||
"fxnode.geometry.position",
|
||||
"fxnode.geometry.mesh-cube",
|
||||
"fxnode.geometry.set-position",
|
||||
"fxnode.geometry.transform-geometry",
|
||||
"fxnode.geometry.join-geometry",
|
||||
"fxnode.compositor.image",
|
||||
"fxnode.compositor.color-balance",
|
||||
] as const;
|
||||
assert.deepEqual(
|
||||
BUILTIN_DESCRIPTORS.map((item) => item.typeId),
|
||||
expectedTypeIds,
|
||||
);
|
||||
assert.deepEqual(CATALOG_NODE_IDS, expectedTypeIds);
|
||||
assert.equal(BUILTIN_DESCRIPTORS.length, 22);
|
||||
assert.ok(BUILTIN_DESCRIPTORS.every(Object.isFrozen));
|
||||
assert.ok(Object.isFrozen(BUILTIN_DESCRIPTORS));
|
||||
assert.ok(Object.isFrozen(BUILTIN_DESCRIPTORS[0]!.sockets));
|
||||
assert.ok(Object.isFrozen(BUILTIN_DESCRIPTORS[4]!.parameters));
|
||||
for (const [index, typeId] of expectedTypeIds.entries()) {
|
||||
const node = materializeNode(`catalog-${index}`, typeId, { x: index, y: -index });
|
||||
assert.equal(node.typeId, typeId);
|
||||
assert.deepEqual(node.position, { x: index, y: -index });
|
||||
assert.equal(
|
||||
node.size.y,
|
||||
typeId === "fxnode.common.reroute" ? 10 : Math.max(100, minimumNodeSize(BUILTIN_DESCRIPTORS[index]!, node).y),
|
||||
);
|
||||
assert.equal(node.known, true);
|
||||
assert.equal(node.muted, false);
|
||||
assert.equal(node.collapsed, false);
|
||||
assert.equal(node.parentId, undefined);
|
||||
assert.deepEqual(Object.keys(node.extensions), []);
|
||||
assert.ok(Object.isFrozen(node));
|
||||
assert.ok(Object.isFrozen(node.parameters));
|
||||
assert.ok(Object.isFrozen(node.sockets));
|
||||
}
|
||||
});
|
||||
|
||||
test("commit envelopes carry versions, command id, cause and explicit null", () => {
|
||||
const state = createEngine(emptyDocument());
|
||||
const id = commandId("add-1");
|
||||
const result = transition(state, {
|
||||
commandId: id,
|
||||
expectedVersion: 0,
|
||||
source: "gesture",
|
||||
command: { type: "node.add", nodeId: nodeId("n"), nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } },
|
||||
});
|
||||
assert.equal(result.status, "committed");
|
||||
if (result.status !== "committed") return;
|
||||
assert.deepEqual(
|
||||
{
|
||||
base: result.mutationEnvelope.baseVersion,
|
||||
version: result.mutationEnvelope.version,
|
||||
cause: result.mutationEnvelope.cause,
|
||||
},
|
||||
{ base: 0, version: 1, cause: "gesture" },
|
||||
);
|
||||
assert.equal(result.mutationEnvelope.commandId, id);
|
||||
assert.equal(result.mutationEnvelope.mutations[0]?.before, null);
|
||||
assert.equal(result.snapshotEnvelope.version, 1);
|
||||
});
|
||||
|
||||
test("stale, duplicate and missing commands reject with exact state identity", () => {
|
||||
let state = createEngine(emptyDocument());
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("n"),
|
||||
nodeType: "fxnode.shader.value",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
for (const command of [
|
||||
{ type: "node.add", nodeId: nodeId("n"), nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } },
|
||||
{ type: "node.remove", id: nodeId("missing") },
|
||||
] as const) {
|
||||
const result = run(state, command);
|
||||
assert.equal(result.status, "rejected");
|
||||
assert.equal(result.state, state);
|
||||
}
|
||||
const stale = transition(state, {
|
||||
commandId: commandId("stale"),
|
||||
expectedVersion: 0,
|
||||
source: "api",
|
||||
command: { type: "undo" },
|
||||
});
|
||||
assert.equal(stale.status, "rejected");
|
||||
assert.equal(stale.state, state);
|
||||
});
|
||||
|
||||
test("same-value editing is a no-op and untouched records are shared", () => {
|
||||
let state = createEngine(emptyDocument());
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("a"),
|
||||
nodeType: "fxnode.shader.value",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("b"),
|
||||
nodeType: "fxnode.shader.value",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const noop = run(state, { type: "node.move", id: nodeId("a"), position: { x: 0, y: 0 } });
|
||||
assert.equal(noop.status, "noop");
|
||||
assert.equal(noop.state, state);
|
||||
const before = state.document.nodes.b;
|
||||
state = committed(state, { type: "node.move", id: nodeId("a"), position: { x: 5, y: 6 } });
|
||||
assert.equal(state.document.nodes.b, before);
|
||||
assert.ok(Object.isFrozen(state.document.nodes.a?.parameters));
|
||||
});
|
||||
|
||||
test("undo and redo restore remove cascades and frame children", () => {
|
||||
let state = createEngine(emptyDocument());
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("frame"),
|
||||
nodeType: "fxnode.common.frame",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("child"),
|
||||
nodeType: "fxnode.shader.value",
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: nodeId("frame"),
|
||||
});
|
||||
const beforeRemoval = state,
|
||||
removal = run(state, { type: "node.remove", id: nodeId("frame") });
|
||||
assert.equal(removal.status, "committed");
|
||||
if (removal.status !== "committed") return;
|
||||
state = removal.state;
|
||||
assert.equal(state.document.nodes.child?.parentId, undefined);
|
||||
assert.equal(Object.hasOwn(state.document.nodes.child!, "parentId"), false);
|
||||
assert.deepEqual(reduceMutations(beforeRemoval.document, removal.mutationEnvelope.mutations), state.document);
|
||||
const unparented = save(state.document),
|
||||
savedChild = unparented.nodes.find((node) => node.id === "child");
|
||||
assert.equal(Object.hasOwn(savedChild!, "parentId"), false);
|
||||
assert.equal(decodeGraphDocument(unparented).ok, true);
|
||||
state = committed(state, { type: "undo" });
|
||||
assert.equal(state.document.nodes.child?.parentId, "frame");
|
||||
state = committed(state, { type: "redo" });
|
||||
assert.equal(state.document.nodes.frame, undefined);
|
||||
});
|
||||
|
||||
test("history limits 0, 1 and 2 bound undo and new edits clear redo", () => {
|
||||
for (const limit of [0, 1, 2]) {
|
||||
let state = createEngine(emptyDocument(), limit);
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId(`n${limit}`),
|
||||
nodeType: "fxnode.shader.value",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
assert.equal(state.undo.length, limit === 0 ? 0 : 1);
|
||||
}
|
||||
assert.throws(() => createEngine(emptyDocument(), 1.5), RangeError);
|
||||
});
|
||||
|
||||
test("frame cycles reject atomically", () => {
|
||||
let state = createEngine(emptyDocument());
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("a"),
|
||||
nodeType: "fxnode.common.frame",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("b"),
|
||||
nodeType: "fxnode.common.frame",
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: nodeId("a"),
|
||||
});
|
||||
const result = run(state, { type: "node.parent", id: nodeId("a"), parentId: nodeId("b") });
|
||||
assert.equal(result.status, "rejected");
|
||||
assert.equal(result.state, state);
|
||||
});
|
||||
|
||||
test("save, decode and save is canonical; snapshot arrays sort by id", () => {
|
||||
let state = createEngine(emptyDocument("canonical"));
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("z"),
|
||||
nodeType: "fxnode.shader.value",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("a"),
|
||||
nodeType: "fxnode.shader.value",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const layout = save(state.document);
|
||||
assert.deepEqual(
|
||||
layout.nodes.map((item) => item.id),
|
||||
["a", "z"],
|
||||
);
|
||||
const decoded = decodeGraphDocument(structuredClone(layout));
|
||||
assert.equal(decoded.ok, true);
|
||||
if (decoded.ok) assert.equal(serializeGraphDocument(decoded.value), serializeGraphDocument(state.document));
|
||||
assert.deepEqual(
|
||||
getState(state).nodes.map((item) => item.id),
|
||||
["a", "z"],
|
||||
);
|
||||
assert.equal("known" in layout.nodes[0]!, false);
|
||||
});
|
||||
|
||||
test("transient fields are rejected even through extension escape hatches", () => {
|
||||
const raw = { ...save(emptyDocument()), extensions: { selection: [] } };
|
||||
assert.equal(decodeGraphDocument(raw).ok, false);
|
||||
const nested = { ...save(emptyDocument()), metadata: { extension: { history: [] } } };
|
||||
assert.equal(decodeGraphDocument(nested).ok, false);
|
||||
});
|
||||
|
||||
test("bound persistence normalizes v1 and rejects future document schemas", () => {
|
||||
const v1 = { schemaVersion: 1, graphId: "old", catalogVersion: 1, nodes: [], links: [], metadata: {} };
|
||||
const migrated = decodeGraphDocument(v1);
|
||||
assert.equal(migrated.ok, true);
|
||||
if (migrated.ok) assert.equal(migrated.value.catalogVersion, emptyDocument().catalogVersion);
|
||||
const future = decodeGraphDocument({ ...v1, schemaVersion: 3 });
|
||||
assert.equal(future.ok, false);
|
||||
if (!future.ok) assert.equal(future.issues.at(-1)?.code, "schema.future");
|
||||
});
|
||||
|
||||
test("parameter reset resolves descriptor default and is one-step undoable", () => {
|
||||
let state = createEngine(emptyDocument());
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("v"),
|
||||
nodeType: "fxnode.shader.value",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
state = committed(state, {
|
||||
type: "node.parameter",
|
||||
id: nodeId("v"),
|
||||
key: "value",
|
||||
value: { kind: "number", value: 9 },
|
||||
});
|
||||
state = committed(state, { type: "node.parameter-reset", id: nodeId("v"), key: "value" });
|
||||
assert.deepEqual(state.document.nodes.v?.parameters.value, { kind: "number", value: 0 });
|
||||
state = committed(state, { type: "undo" });
|
||||
assert.deepEqual(state.document.nodes.v?.parameters.value, { kind: "number", value: 9 });
|
||||
});
|
||||
|
||||
test("batch is atomic, increments once, and undoes as one entry", () => {
|
||||
let state = createEngine(emptyDocument());
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("a"),
|
||||
nodeType: "fxnode.shader.value",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
state = committed(state, {
|
||||
type: "node.add",
|
||||
nodeId: nodeId("b"),
|
||||
nodeType: "fxnode.shader.value",
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const before = state,
|
||||
result = run(state, {
|
||||
type: "batch",
|
||||
commands: [
|
||||
{ type: "node.move", id: nodeId("a"), position: { x: 3, y: 4 } },
|
||||
{ type: "node.move", id: nodeId("b"), position: { x: 5, y: 6 } },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, "committed");
|
||||
if (result.status !== "committed") return;
|
||||
assert.equal(result.state.version, before.version + 1);
|
||||
assert.equal(result.state.undo.length, before.undo.length + 1);
|
||||
state = committed(result.state, { type: "undo" });
|
||||
assert.deepEqual(state.document.nodes.a?.position, { x: 0, y: 0 });
|
||||
assert.deepEqual(state.document.nodes.b?.position, { x: 0, y: 0 });
|
||||
const rejected = run(state, {
|
||||
type: "batch",
|
||||
commands: [
|
||||
{ type: "node.move", id: nodeId("a"), position: { x: 9, y: 9 } },
|
||||
{ type: "node.remove", id: nodeId("missing") },
|
||||
],
|
||||
});
|
||||
assert.equal(rejected.status, "rejected");
|
||||
assert.equal(rejected.state, state);
|
||||
});
|
||||
|
||||
test("selection planners are complete, deterministic, and enforce the shared atomic limit", () => {
|
||||
const a = materializeNode("a", "fxnode.shader.value", { x: 0, y: 0 })!,
|
||||
b = materializeNode("b", "fxnode.shader.value", { x: 1, y: 1 })!;
|
||||
const incident = {
|
||||
id: linkId("incident"),
|
||||
fromNodeId: nodeId("a"),
|
||||
fromSocketId: socketId("a:out"),
|
||||
toNodeId: nodeId("b"),
|
||||
toSocketId: socketId("b:in"),
|
||||
muted: false,
|
||||
extensions: {},
|
||||
},
|
||||
standalone = { ...incident, id: linkId("standalone"), fromNodeId: nodeId("b") };
|
||||
const document = { ...emptyDocument(), nodes: { a, b }, links: { incident, standalone } };
|
||||
const removal = planSelectionRemoval(
|
||||
document,
|
||||
new Set([nodeId("a")]),
|
||||
new Set([linkId("incident"), linkId("standalone")]),
|
||||
)!;
|
||||
assert.deepEqual(removal, {
|
||||
type: "batch",
|
||||
commands: [
|
||||
{ type: "link.remove", id: linkId("standalone") },
|
||||
{ type: "node.remove", id: nodeId("a") },
|
||||
],
|
||||
});
|
||||
const mixed = { ...document, nodes: { a: { ...a, muted: true }, b } };
|
||||
assert.deepEqual(
|
||||
planSelectionMute(mixed, new Set([nodeId("b"), nodeId("a")]), () => true),
|
||||
{ type: "batch", commands: [{ type: "node.mute", id: nodeId("b"), value: true }] },
|
||||
);
|
||||
const nodes = Object.fromEntries(
|
||||
Array.from({ length: 257 }, (_, index) => {
|
||||
const id = `n-${String(index).padStart(3, "0")}`;
|
||||
return [id, materializeNode(id, "fxnode.shader.value", { x: index, y: 0 })!];
|
||||
}),
|
||||
);
|
||||
const overflow = planSelectionRemoval(
|
||||
{ ...emptyDocument(), nodes },
|
||||
new Set(Object.keys(nodes).map(nodeId)),
|
||||
new Set(),
|
||||
)!;
|
||||
assert.equal(overflow.type, "batch");
|
||||
if (overflow.type !== "batch") return;
|
||||
assert.equal(overflow.commands.length, 257);
|
||||
assert.equal(validCommand(overflow), false);
|
||||
const overflowState = createEngine({ ...emptyDocument(), nodes }),
|
||||
rejected = run(overflowState, overflow);
|
||||
assert.equal(rejected.status, "rejected");
|
||||
assert.equal(rejected.state, overflowState);
|
||||
assert.equal(overflowState.undo.length, 0);
|
||||
assert.deepEqual(
|
||||
planSelectionMute(mixed, new Set([nodeId("b"), nodeId("a")]), (id) => id === nodeId("a"), false),
|
||||
{ type: "batch", commands: [{ type: "node.mute", id: nodeId("a"), value: false }] },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,494 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createFxNodeHeadless } from "@lib/headless-runtime.js";
|
||||
import { commandId, nodeId, type GraphState } from "@lib/core/types.js";
|
||||
import { rebindBoundEngineAuthority } from "@lib/composition/bound-engine.js";
|
||||
import type { FxNodeCompositionData } from "@lib/composition/types.js";
|
||||
import { canonicalJsonEqual } from "@lib/core/json.js";
|
||||
import { admitStructuredData } from "@lib/core/json.js";
|
||||
import { PERSISTENCE_LIMITS } from "@lib/composition/bound-document.js";
|
||||
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
|
||||
|
||||
const composition = {
|
||||
...APPLICATION_COMPILED.source,
|
||||
id: "runtime-test",
|
||||
version: 91,
|
||||
nodes: {
|
||||
value: APPLICATION_COMPILED.source.nodes["fxnode.shader.value"]!,
|
||||
frame: APPLICATION_COMPILED.source.nodes["fxnode.common.frame"]!,
|
||||
},
|
||||
};
|
||||
const runtime = createFxNodeHeadless(composition);
|
||||
const request = (version: number, command: Parameters<typeof runtime.transition>[1]["command"]) => ({
|
||||
commandId: commandId(`c${version}`),
|
||||
expectedVersion: version,
|
||||
source: "api" as const,
|
||||
command,
|
||||
});
|
||||
|
||||
test("bound runtime materializes, edits, resets, undoes, redoes, saves and loads", () => {
|
||||
assert.equal(runtime.emptyDocument().catalogVersion, 91);
|
||||
const materialized = runtime.materializeNode("direct", "value");
|
||||
assert.equal(materialized.typeId, "value");
|
||||
let state = runtime.createEngine(runtime.emptyDocument());
|
||||
let result = runtime.transition(
|
||||
state,
|
||||
request(0, { type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 1, y: 2 } }),
|
||||
);
|
||||
assert.equal(result.status, "committed");
|
||||
if (result.status !== "committed") return;
|
||||
state = result.state;
|
||||
const key = Object.keys(state.document.nodes.n!.parameters)[0]!;
|
||||
result = runtime.transition(
|
||||
state,
|
||||
request(1, { type: "node.parameter", id: nodeId("n"), key, value: { kind: "number", value: 4 } }),
|
||||
);
|
||||
assert.equal(result.status, "committed");
|
||||
if (result.status !== "committed") return;
|
||||
state = result.state;
|
||||
result = runtime.transition(state, request(2, { type: "node.parameter-reset", id: nodeId("n"), key }));
|
||||
assert.equal(result.status, "committed");
|
||||
if (result.status !== "committed") return;
|
||||
state = result.state;
|
||||
result = runtime.transition(state, request(3, { type: "undo" }));
|
||||
assert.equal(result.status, "committed");
|
||||
if (result.status !== "committed") return;
|
||||
result = runtime.transition(result.state, request(4, { type: "redo" }));
|
||||
assert.equal(result.status, "committed");
|
||||
if (result.status !== "committed") return;
|
||||
const saved = runtime.save(result.state.document),
|
||||
decoded = runtime.decodeGraphDocument(saved);
|
||||
assert.equal(decoded.ok, true);
|
||||
const loaded = runtime.load(result.state, saved);
|
||||
assert.equal(loaded.ok, true);
|
||||
assert.equal(
|
||||
runtime.serializeGraphDocument(result.state.document),
|
||||
runtime.serializeGraphDocument(decoded.ok ? decoded.value : result.state.document),
|
||||
);
|
||||
});
|
||||
|
||||
test("bound runtime rejects unknown add and invalid initial engine", () => {
|
||||
const state = runtime.createEngine(runtime.emptyDocument());
|
||||
const bad = { type: "node.add", nodeId: nodeId("x"), nodeType: "missing", position: { x: 0, y: 0 } } as never;
|
||||
const result = runtime.transition(state, request(0, bad));
|
||||
assert.equal(result.status, "rejected");
|
||||
if (result.status === "rejected") assert.equal(result.error.code, "node.type-unknown");
|
||||
assert.throws(() =>
|
||||
runtime.createEngine({
|
||||
...runtime.emptyDocument(),
|
||||
nodes: { x: { ...runtime.materializeNode("x", "value"), size: { x: 0, y: 1 } } },
|
||||
}),
|
||||
);
|
||||
assert.throws(() => runtime.createEngine({ ...runtime.emptyDocument(), catalogVersion: 999 }));
|
||||
assert.throws(() =>
|
||||
runtime.createEngine({
|
||||
...runtime.emptyDocument(),
|
||||
links: {
|
||||
wrong: {
|
||||
id: "actual",
|
||||
fromNodeId: "a",
|
||||
fromSocketId: "a:out",
|
||||
toNodeId: "b",
|
||||
toSocketId: "b:in",
|
||||
muted: false,
|
||||
extensions: {},
|
||||
},
|
||||
},
|
||||
} as never),
|
||||
);
|
||||
});
|
||||
|
||||
test("graph state decoding is exact for current composition and ignores only observational version", () => {
|
||||
const document = {
|
||||
...runtime.emptyDocument("state-source"),
|
||||
nodes: { n: runtime.materializeNode("n", "value", { x: 4, y: 5 }) },
|
||||
},
|
||||
state = runtime.createEngine(document),
|
||||
snapshot = runtime.getState(state),
|
||||
input = structuredClone(snapshot),
|
||||
nested = input.nodes[0]!;
|
||||
const decoded = runtime.decodeGraphState(input);
|
||||
assert.equal(decoded.ok, true);
|
||||
assert.deepEqual(input, snapshot);
|
||||
assert.equal(Object.isFrozen(input), false);
|
||||
assert.equal(Object.isFrozen(nested), false);
|
||||
if (!decoded.ok) return;
|
||||
assert.equal(decoded.value.nodes.n?.known, true);
|
||||
assert.ok(Object.isFrozen(decoded.value));
|
||||
const spoofed = runtime.decodeGraphState({ ...snapshot, version: Number.MAX_SAFE_INTEGER });
|
||||
assert.equal(spoofed.ok, true);
|
||||
const missingKnown = { ...snapshot, nodes: snapshot.nodes.map(({ known: _, ...node }) => node) },
|
||||
wrongKnown = { ...snapshot, nodes: snapshot.nodes.map((node) => ({ ...node, known: false })) },
|
||||
foreign = { ...snapshot, catalogVersion: 999 };
|
||||
for (const value of [missingKnown, wrongKnown, foreign]) {
|
||||
const result = runtime.decodeGraphState(value);
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.equal(result.issues[0]?.code, "state.inexact");
|
||||
}
|
||||
const malformed = runtime.decodeGraphState({ ...snapshot, extra: true });
|
||||
assert.equal(malformed.ok, false);
|
||||
if (!malformed.ok) assert.equal(malformed.issues[0]?.code, "state.shape");
|
||||
const failed = structuredClone(wrongKnown),
|
||||
failedNested = failed.nodes[0]!;
|
||||
runtime.decodeGraphState(failed);
|
||||
assert.equal(Object.isFrozen(failed), false);
|
||||
assert.equal(Object.isFrozen(failedNested), false);
|
||||
assert.deepEqual(failed, wrongKnown);
|
||||
const cycle: any = {};
|
||||
cycle.self = cycle;
|
||||
const sparse: any = structuredClone(snapshot);
|
||||
sparse.nodes.length = 2;
|
||||
const nonfinite: any = structuredClone(snapshot);
|
||||
nonfinite.nodes[0].position.x = Infinity;
|
||||
const overLimit: any = { ...snapshot, nodes: Array(100_001).fill(null) };
|
||||
for (const value of [cycle, sparse, nonfinite, overLimit])
|
||||
assert.doesNotThrow(() => assert.equal(runtime.decodeGraphState(value).ok, false));
|
||||
});
|
||||
|
||||
test("maximum admitted durable layout round-trips through getState and state decoding", () => {
|
||||
const base = runtime.save({
|
||||
...runtime.emptyDocument("state-limit"),
|
||||
nodes: { n: runtime.materializeNode("n", "value") },
|
||||
}),
|
||||
metrics = admitStructuredData(base, PERSISTENCE_LIMITS);
|
||||
assert.equal(metrics.ok, true);
|
||||
if (!metrics.ok) return;
|
||||
const paddingLength = PERSISTENCE_LIMITS.maxValues - metrics.metrics.values - 1,
|
||||
layout = { ...base, metadata: { padding: Array(paddingLength).fill(null) } } as typeof base,
|
||||
admitted = admitStructuredData(layout, PERSISTENCE_LIMITS);
|
||||
assert.equal(admitted.ok, true);
|
||||
if (!admitted.ok) return;
|
||||
assert.equal(admitted.metrics.values, PERSISTENCE_LIMITS.maxValues);
|
||||
const document = runtime.decodeGraphDocument(layout);
|
||||
assert.equal(document.ok, true);
|
||||
if (!document.ok) return;
|
||||
const state = runtime.getState(runtime.createEngine(document.value)),
|
||||
decoded = runtime.decodeGraphState(state);
|
||||
assert.equal(decoded.ok, true);
|
||||
if (decoded.ok) assert.equal((decoded.value.metadata.padding as readonly unknown[]).length, paddingLength);
|
||||
});
|
||||
|
||||
test("state replacement commits once and is one-step undoable and redoable", () => {
|
||||
const targetDocument = {
|
||||
...runtime.emptyDocument("replacement"),
|
||||
nodes: { n: runtime.materializeNode("n", "value", { x: 7, y: 8 }) },
|
||||
},
|
||||
target = runtime.getState(runtime.createEngine(targetDocument));
|
||||
let state = runtime.createEngine(runtime.emptyDocument("before"), 2);
|
||||
const replaced = runtime.replaceState(state, { commandId: commandId("replace"), expectedVersion: 0, target });
|
||||
assert.equal(replaced.status, "committed");
|
||||
if (replaced.status !== "committed") return;
|
||||
assert.equal(replaced.state.version, 1);
|
||||
assert.equal(replaced.state.document.graphId, "replacement");
|
||||
assert.equal(replaced.state.undo.length, 1);
|
||||
assert.equal(replaced.mutationEnvelope.cause, "api");
|
||||
assert.deepEqual(
|
||||
replaced.mutationEnvelope.mutations.map((m) => m.kind),
|
||||
["document.replaced"],
|
||||
);
|
||||
state = replaced.state;
|
||||
const undo = runtime.transition(state, request(1, { type: "undo" }));
|
||||
assert.equal(undo.status, "committed");
|
||||
if (undo.status !== "committed") return;
|
||||
assert.equal(undo.state.version, 2);
|
||||
assert.equal(undo.state.document.graphId, "before");
|
||||
assert.equal(undo.state.redo.length, 1);
|
||||
const redo = runtime.transition(undo.state, request(2, { type: "redo" }));
|
||||
assert.equal(redo.status, "committed");
|
||||
if (redo.status !== "committed") return;
|
||||
assert.equal(redo.state.version, 3);
|
||||
assert.equal(redo.state.document.graphId, "replacement");
|
||||
const noop = runtime.replaceState(redo.state, {
|
||||
commandId: commandId("noop"),
|
||||
expectedVersion: 3,
|
||||
target: runtime.getState(redo.state),
|
||||
});
|
||||
assert.equal(noop.status, "noop");
|
||||
assert.equal(noop.state, redo.state);
|
||||
const withoutHistory = runtime.createEngine(runtime.emptyDocument("zero"), 0),
|
||||
zero = runtime.replaceState(withoutHistory, { commandId: commandId("zero"), expectedVersion: 0, target });
|
||||
assert.equal(zero.status, "committed");
|
||||
if (zero.status === "committed") assert.equal(zero.state.undo.length, 0);
|
||||
});
|
||||
|
||||
test("state replacement checks staleness before inspecting hostile input and noops before overflow", () => {
|
||||
const state = runtime.createEngine(runtime.emptyDocument());
|
||||
let inspections = 0;
|
||||
const hostile = Object.defineProperty({}, "graphId", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
inspections++;
|
||||
throw new Error("must not inspect");
|
||||
},
|
||||
}) as GraphState<typeof composition>;
|
||||
const stale = runtime.replaceState(state, {
|
||||
commandId: commandId("stale-state"),
|
||||
expectedVersion: 1,
|
||||
target: hostile,
|
||||
});
|
||||
assert.equal(stale.status, "rejected");
|
||||
assert.equal(stale.state, state);
|
||||
assert.equal(inspections, 0);
|
||||
if (stale.status === "rejected") assert.equal(stale.error.code, "version.stale");
|
||||
const current = runtime.replaceState(state, {
|
||||
commandId: commandId("hostile-state"),
|
||||
expectedVersion: 0,
|
||||
target: hostile,
|
||||
});
|
||||
assert.equal(current.status, "rejected");
|
||||
assert.equal(inspections, 0);
|
||||
if (current.status === "rejected") assert.equal(current.error.code, "data.inspect");
|
||||
const maximum = { ...state, version: Number.MAX_SAFE_INTEGER } as typeof state,
|
||||
target = runtime.getState(state),
|
||||
noop = runtime.replaceState(maximum, {
|
||||
commandId: commandId("maximum-noop"),
|
||||
expectedVersion: Number.MAX_SAFE_INTEGER,
|
||||
target,
|
||||
});
|
||||
assert.equal(noop.status, "noop");
|
||||
assert.equal(noop.state, maximum);
|
||||
const changed = { ...target, graphId: "changed" as typeof target.graphId },
|
||||
overflow = runtime.replaceState(maximum, {
|
||||
commandId: commandId("maximum-change"),
|
||||
expectedVersion: Number.MAX_SAFE_INTEGER,
|
||||
target: changed,
|
||||
});
|
||||
assert.equal(overflow.status, "rejected");
|
||||
assert.equal(overflow.state, maximum);
|
||||
if (overflow.status === "rejected") assert.equal(overflow.error.code, "version.overflow");
|
||||
});
|
||||
|
||||
test("state replacement preserves bounded prior history, clears redo, and ignores spoofed target version", () => {
|
||||
const target = runtime.getState(
|
||||
runtime.createEngine({
|
||||
...runtime.emptyDocument("target"),
|
||||
nodes: { target: runtime.materializeNode("target", "value") },
|
||||
}),
|
||||
);
|
||||
let state = runtime.createEngine(runtime.emptyDocument("history"), 2),
|
||||
first = runtime.transition(
|
||||
state,
|
||||
request(0, { type: "node.add", nodeId: nodeId("a"), nodeType: "value", position: { x: 0, y: 0 } }),
|
||||
);
|
||||
assert.equal(first.status, "committed");
|
||||
if (first.status !== "committed") return;
|
||||
state = first.state;
|
||||
const second = runtime.transition(
|
||||
state,
|
||||
request(1, { type: "node.add", nodeId: nodeId("b"), nodeType: "value", position: { x: 0, y: 0 } }),
|
||||
);
|
||||
assert.equal(second.status, "committed");
|
||||
if (second.status !== "committed") return;
|
||||
const undone = runtime.transition(second.state, request(2, { type: "undo" }));
|
||||
assert.equal(undone.status, "committed");
|
||||
if (undone.status !== "committed") return;
|
||||
assert.equal(undone.state.redo.length, 1);
|
||||
const replaced = runtime.replaceState(undone.state, {
|
||||
commandId: commandId("history-replace"),
|
||||
expectedVersion: 3,
|
||||
target: { ...target, version: 999 } as GraphState<typeof composition>,
|
||||
});
|
||||
assert.equal(replaced.status, "committed");
|
||||
if (replaced.status !== "committed") return;
|
||||
assert.equal(replaced.state.version, 4);
|
||||
assert.equal(replaced.state.redo.length, 0);
|
||||
assert.equal(replaced.state.undo.length, 2);
|
||||
assert.deepEqual(
|
||||
replaced.state.undo.at(-1)?.forward.map((m) => m.kind),
|
||||
["document.replaced"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
replaced.state.undo.at(-1)?.inverse.map((m) => m.kind),
|
||||
["document.replaced"],
|
||||
);
|
||||
const undoIdentity = replaced.state.undo,
|
||||
redoIdentity = replaced.state.redo,
|
||||
entryIdentity = replaced.state.undo[0],
|
||||
noop = runtime.replaceState(replaced.state, {
|
||||
commandId: commandId("history-noop"),
|
||||
expectedVersion: 4,
|
||||
target: runtime.getState(replaced.state),
|
||||
});
|
||||
assert.equal(noop.status, "noop");
|
||||
assert.equal(noop.state.undo, undoIdentity);
|
||||
assert.equal(noop.state.redo, redoIdentity);
|
||||
assert.equal(noop.state.undo[0], entryIdentity);
|
||||
const limited = runtime.createEngine(runtime.emptyDocument("limited"), 1),
|
||||
added = runtime.transition(
|
||||
limited,
|
||||
request(0, { type: "node.add", nodeId: nodeId("old"), nodeType: "value", position: { x: 0, y: 0 } }),
|
||||
);
|
||||
assert.equal(added.status, "committed");
|
||||
if (added.status !== "committed") return;
|
||||
const one = runtime.replaceState(added.state, {
|
||||
commandId: commandId("limited-replace"),
|
||||
expectedVersion: 1,
|
||||
target,
|
||||
});
|
||||
assert.equal(one.status, "committed");
|
||||
if (one.status === "committed") {
|
||||
assert.equal(one.state.undo.length, 1);
|
||||
assert.deepEqual(
|
||||
one.state.undo[0]?.forward.map((m) => m.kind),
|
||||
["document.replaced"],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("composition runtimes isolate overlapping IDs; unknown versions roundtrip read-only", () => {
|
||||
const other = createFxNodeHeadless({
|
||||
...composition,
|
||||
id: "other",
|
||||
version: 92,
|
||||
nodes: { ...composition.nodes, value: { ...composition.nodes.value, title: "Other" } },
|
||||
});
|
||||
assert.equal(other.materializeNode("a", "value").label, "Other");
|
||||
const raw = runtime.save({ ...runtime.emptyDocument(), nodes: { a: runtime.materializeNode("a", "value") } });
|
||||
const unknownVersion = { ...raw, nodes: raw.nodes.map((n) => ({ ...n, typeVersion: n.typeVersion + 1 })) };
|
||||
const decoded = runtime.decodeGraphDocument(unknownVersion);
|
||||
assert.equal(decoded.ok, true);
|
||||
if (!decoded.ok) return;
|
||||
assert.equal(decoded.value.nodes.a!.known, false);
|
||||
assert.equal(runtime.parseGraphDocument(runtime.serializeGraphDocument(decoded.value)).ok, true);
|
||||
let state = runtime.createEngine(decoded.value);
|
||||
const key = Object.keys(state.document.nodes.a!.parameters)[0]!;
|
||||
const edit = runtime.transition(state, request(0, { type: "node.parameter-reset", id: nodeId("a"), key }));
|
||||
assert.equal(edit.status, "rejected");
|
||||
const malformed = { ...raw, nodes: raw.nodes.map((n) => ({ ...n, parameters: {} })) };
|
||||
assert.equal(runtime.decodeGraphDocument(malformed).ok, false);
|
||||
});
|
||||
|
||||
test("foreign catalog versions normalize and future node payloads remain opaque", () => {
|
||||
const raw = runtime.save({ ...runtime.emptyDocument(), nodes: { a: runtime.materializeNode("a", "value") } });
|
||||
const foreign = { ...raw, catalogVersion: 999 };
|
||||
const normalized = runtime.decodeGraphDocument(foreign);
|
||||
assert.equal(normalized.ok, true);
|
||||
if (!normalized.ok) return;
|
||||
assert.equal(normalized.value.catalogVersion, 91);
|
||||
assert.equal(runtime.save(normalized.value).catalogVersion, 91);
|
||||
const loaded = runtime.load(runtime.createEngine(runtime.emptyDocument()), foreign);
|
||||
assert.equal(loaded.ok, true);
|
||||
if (loaded.ok) assert.equal(loaded.snapshotEnvelope.snapshot.catalogVersion, 91);
|
||||
const future = {
|
||||
...raw,
|
||||
nodes: raw.nodes.map((n) => ({
|
||||
...n,
|
||||
typeId: "future.node",
|
||||
typeVersion: 12,
|
||||
parameters: { future: { history: [] } },
|
||||
extensions: { session: { selection: [1] } },
|
||||
futureField: { hovered: true },
|
||||
sockets: [
|
||||
{
|
||||
...n.sockets[0]!,
|
||||
dataType: "future-socket",
|
||||
accepts: ["future-source"],
|
||||
defaultValue: null,
|
||||
metadata: { runtimeVersion: 7 },
|
||||
},
|
||||
],
|
||||
})),
|
||||
};
|
||||
const decoded = runtime.decodeGraphDocument(future);
|
||||
assert.equal(decoded.ok, true);
|
||||
if (!decoded.ok) return;
|
||||
assert.equal(decoded.value.nodes.a!.known, false);
|
||||
assert.equal(JSON.stringify(runtime.save(decoded.value)), JSON.stringify(future));
|
||||
});
|
||||
|
||||
test("authority rebind preserves graph version for presentation changes and resets history", () => {
|
||||
const source: FxNodeCompositionData = composition,
|
||||
candidateSource: FxNodeCompositionData = { ...source, theme: { ...source.theme, background: "#123456" } },
|
||||
current = createFxNodeHeadless(source),
|
||||
candidate = createFxNodeHeadless(candidateSource);
|
||||
let state = current.createEngine(current.emptyDocument(), 7),
|
||||
result = current.transition(state, {
|
||||
commandId: commandId("add"),
|
||||
expectedVersion: 0,
|
||||
source: "api",
|
||||
command: { type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 0, y: 0 } },
|
||||
});
|
||||
assert.equal(result.status, "committed");
|
||||
if (result.status !== "committed") return;
|
||||
state = result.state;
|
||||
assert.equal(state.undo.length, 1);
|
||||
const rebound = rebindBoundEngineAuthority(state, current, candidate, { commandId: commandId("rebind") });
|
||||
assert.equal(rebound.ok, true);
|
||||
if (!rebound.ok) return;
|
||||
assert.equal(rebound.graphChanged, false);
|
||||
assert.equal(rebound.state.version, state.version);
|
||||
assert.equal(rebound.state.historyLimit, 7);
|
||||
assert.equal(rebound.state.undo.length, 0);
|
||||
assert.equal(rebound.state.redo.length, 0);
|
||||
assert.equal(rebound.state.document.nodes.n!.known, true);
|
||||
assert.equal(canonicalJsonEqual({ b: 1, a: 2 }, { a: 2, b: 1 }), true);
|
||||
const left = Object.create(null) as Record<string, unknown>,
|
||||
right = Object.create(null) as Record<string, unknown>;
|
||||
left.__proto__ = { value: 1 };
|
||||
right.__proto__ = { value: 2 };
|
||||
assert.equal(canonicalJsonEqual(left, right), false);
|
||||
});
|
||||
|
||||
test("authority rebind promotes opaque nodes and explicitly removed definitions remain opaque", () => {
|
||||
const full: FxNodeCompositionData = composition,
|
||||
withoutFrame: FxNodeCompositionData = { ...full, nodes: { value: full.nodes.value! } },
|
||||
old = createFxNodeHeadless(withoutFrame),
|
||||
candidate = createFxNodeHeadless(full);
|
||||
const authored = candidate.save({
|
||||
...candidate.emptyDocument(),
|
||||
nodes: { f: candidate.materializeNode("f", "frame") },
|
||||
}),
|
||||
opaque = old.decodeGraphDocument(authored);
|
||||
assert.equal(opaque.ok, true);
|
||||
if (!opaque.ok) return;
|
||||
assert.equal(opaque.value.nodes.f!.known, false);
|
||||
const promoted = rebindBoundEngineAuthority(old.createEngine(opaque.value), old, candidate, {
|
||||
commandId: commandId("promote"),
|
||||
});
|
||||
assert.equal(promoted.ok, true);
|
||||
if (!promoted.ok || !promoted.graphChanged) return;
|
||||
assert.equal(promoted.state.version, 1);
|
||||
assert.equal(promoted.state.document.nodes.f!.known, true);
|
||||
assert.equal(promoted.mutationEnvelope.cause, "composition");
|
||||
assert.equal(promoted.mutationEnvelope.mutations[0]?.kind, "document.replaced");
|
||||
assert.deepEqual(old.save(opaque.value), candidate.save(promoted.state.document));
|
||||
|
||||
const current = createFxNodeHeadless(full),
|
||||
removed = createFxNodeHeadless(withoutFrame),
|
||||
known = current.createEngine({ ...current.emptyDocument(), nodes: { f: current.materializeNode("f", "frame") } });
|
||||
const rejected = rebindBoundEngineAuthority(known, current, removed, { commandId: commandId("reject") });
|
||||
assert.equal(rejected.ok, false);
|
||||
if (rejected.ok) return;
|
||||
assert.equal(rejected.state, known);
|
||||
assert.equal(rejected.issues[0]?.code, "composition.node-demotion");
|
||||
const accepted = rebindBoundEngineAuthority(known, current, removed, {
|
||||
commandId: commandId("remove"),
|
||||
removedNodeTypes: new Set(["frame"]),
|
||||
});
|
||||
assert.equal(accepted.ok, true);
|
||||
if (!accepted.ok) return;
|
||||
assert.equal(accepted.graphChanged, true);
|
||||
assert.equal(accepted.state.document.nodes.f!.known, false);
|
||||
assert.equal(accepted.state.document.nodes.f!.id, "f");
|
||||
});
|
||||
|
||||
test("graph-changing authority rebind rejects version overflow atomically", () => {
|
||||
const full: FxNodeCompositionData = composition,
|
||||
removedSource: FxNodeCompositionData = { ...full, nodes: { value: full.nodes.value! } },
|
||||
current = createFxNodeHeadless(full),
|
||||
removed = createFxNodeHeadless(removedSource);
|
||||
const normal = current.createEngine({
|
||||
...current.emptyDocument(),
|
||||
nodes: { f: current.materializeNode("f", "frame") },
|
||||
}),
|
||||
state = { ...normal, version: Number.MAX_SAFE_INTEGER, undo: [{ forward: [], inverse: [] }] } as typeof normal;
|
||||
const result = rebindBoundEngineAuthority(state, current, removed, {
|
||||
commandId: commandId("overflow"),
|
||||
removedNodeTypes: new Set(["frame"]),
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) return;
|
||||
assert.equal(result.state, state);
|
||||
assert.equal(result.issues[0]?.code, "version.overflow");
|
||||
assert.equal(result.state.undo.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
test("browser entry does not import the engine runtime", async () => {
|
||||
const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(source, /headless|engine\/engine|core\/document/);
|
||||
assert.match(source, /browser\/client/);
|
||||
});
|
||||
|
||||
test("examples server explicitly loads the repository Vite config", async () => {
|
||||
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as {
|
||||
scripts?: { example?: string; examples?: string };
|
||||
};
|
||||
assert.equal(packageJson.scripts?.example, "npm run examples");
|
||||
assert.match(packageJson.scripts?.examples ?? "", /vite\s+--config\s+vite\.config\.ts\s+examples(?:\s|$)/);
|
||||
});
|
||||
|
||||
test("standalone examples import library types and values only through the public entrypoint", async () => {
|
||||
const root = new URL("../examples/", import.meta.url);
|
||||
const directories = ["shared", "minimal", "color-balance", "live-composition"];
|
||||
const files: string[] = [];
|
||||
async function collect(directory: string): Promise<void> {
|
||||
for (const entry of await readdir(new URL(directory, root), { withFileTypes: true })) {
|
||||
const relative = join(directory, entry.name);
|
||||
if (entry.isDirectory()) await collect(`${relative}/`);
|
||||
else if ([".ts", ".tsx", ".js", ".mjs"].includes(extname(entry.name))) files.push(relative);
|
||||
}
|
||||
}
|
||||
await Promise.all(directories.map((directory) => collect(`${directory}/`)));
|
||||
for (const file of files) {
|
||||
const source = await readFile(new URL(file, root), "utf8");
|
||||
const imports = source.matchAll(/(?:from\s*|import\s*)["'](@lib\/[^"']+)["']/g);
|
||||
for (const match of imports) assert.equal(match[1], "@lib/index.js", `${file} bypasses the public entrypoint`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { appendKnifePoint, crossedLinks, MAX_KNIFE_POINTS, segmentsIntersect } from "@lib/worker/knife-path.js";
|
||||
import { effectivelyMutedLinks } from "@lib/layout/link-mute.js";
|
||||
import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js";
|
||||
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
|
||||
const layoutGraph = (
|
||||
document: Parameters<typeof genericLayoutGraph>[1],
|
||||
transform: Parameters<typeof genericLayoutGraph>[2],
|
||||
) => genericLayoutGraph(APPLICATION_COMPILED, document, transform);
|
||||
import { worldToView } from "@lib/layout/geometry.js";
|
||||
const { emptyDocument, materializeNode } = APPLICATION_HEADLESS;
|
||||
|
||||
test("knife intersection handles directions, dedupes links, candidates, mute and point cap", () => {
|
||||
assert.equal(segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 10, y: 0 }), true);
|
||||
assert.equal(segmentsIntersect({ x: 10, y: 10 }, { x: 0, y: 0 }, { x: 0, y: 10 }, { x: 10, y: 0 }), true);
|
||||
assert.equal(segmentsIntersect({ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 3, y: 0 }, { x: 5, y: 0 }), false);
|
||||
let points: readonly { x: number; y: number }[] = [];
|
||||
for (let i = 0; i < 300; i++) points = appendKnifePoint(points, { x: i * 3, y: 0 });
|
||||
assert.equal(points.length, MAX_KNIFE_POINTS);
|
||||
const source = materializeNode("source", "fxnode.shader.value", { x: -200, y: 50 }),
|
||||
target = materializeNode("target", "fxnode.shader.math", { x: 100, y: 50 });
|
||||
const base = emptyDocument();
|
||||
const document = {
|
||||
...base,
|
||||
nodes: { source, target },
|
||||
links: {
|
||||
wire: {
|
||||
id: "wire",
|
||||
fromNodeId: "source",
|
||||
fromSocketId: source.sockets[0]!.id,
|
||||
toNodeId: "target",
|
||||
toSocketId: target.sockets[0]!.id,
|
||||
muted: false,
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
const layout = layoutGraph(document, { center: { x: 0, y: 0 }, zoom: 1, viewport: { x: 1200, y: 640 }, dpr: 1 });
|
||||
const link = [...layout.links.values()].find((item) => item.visible && !item.muted)!;
|
||||
const middle = link.points[Math.floor(link.points.length / 2)]!;
|
||||
const view = worldToView(middle, layout.transform);
|
||||
const found = crossedLinks(layout, [
|
||||
{ x: view.x, y: view.y - 100 },
|
||||
{ x: view.x, y: view.y + 100 },
|
||||
]);
|
||||
assert.equal(found.has(link.id), true);
|
||||
assert.equal(found.size, 1);
|
||||
});
|
||||
|
||||
test("reroute effective mute propagates down chains and branches without changing authored flags", () => {
|
||||
const doc = {
|
||||
nodes: {
|
||||
a: { id: "a", typeId: "x", known: false, sockets: [] },
|
||||
r: { id: "r", typeId: "fxnode.common.reroute", known: true, sockets: [] },
|
||||
b: { id: "b", typeId: "x", known: false, sockets: [] },
|
||||
},
|
||||
links: {
|
||||
one: { id: "one", fromNodeId: "a", toNodeId: "r", muted: true },
|
||||
two: { id: "two", fromNodeId: "r", toNodeId: "b", muted: false },
|
||||
three: { id: "three", fromNodeId: "r", toNodeId: "a", muted: false },
|
||||
},
|
||||
} as never;
|
||||
assert.deepEqual([...effectivelyMutedLinks(APPLICATION_COMPILED, doc)].sort(), ["one", "three", "two"]);
|
||||
assert.equal((doc as any).links.two.muted, false);
|
||||
});
|
||||
@@ -0,0 +1,807 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
|
||||
import { commandId, nodeId } from "@lib/core/types.js";
|
||||
import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js";
|
||||
import { viewToWorld, worldToView } from "@lib/layout/geometry.js";
|
||||
const layoutGraph = (document: any, transform: Parameters<typeof genericLayoutGraph>[2]) =>
|
||||
genericLayoutGraph(APPLICATION_COMPILED, document as never, transform);
|
||||
const { createEngine, emptyDocument, materializeNode, transition, socketsCompatible } = APPLICATION_HEADLESS;
|
||||
const BUILTIN_DESCRIPTORS = [...APPLICATION_COMPILED.nodes.values()];
|
||||
import type { GraphDocument } from "@lib/headless.js";
|
||||
import { applyNodeOrder } from "@lib/layout/layout-graph.js";
|
||||
import {
|
||||
boxNodes,
|
||||
clampResize,
|
||||
compatibleTargets,
|
||||
frameDropCandidate,
|
||||
groupRoots,
|
||||
hitRamp,
|
||||
hitTest,
|
||||
planLink,
|
||||
zoomAt,
|
||||
} from "@lib/worker/interaction.js";
|
||||
|
||||
const transform = { center: { x: 0, y: 0 }, zoom: 2, viewport: { x: 800, y: 600 }, dpr: 1 };
|
||||
test("world/view transforms round trip with +Y up", () => {
|
||||
const point = { x: 17, y: -23 };
|
||||
assert.deepEqual(viewToWorld(worldToView(point, transform), transform), point);
|
||||
assert.ok(worldToView({ x: 0, y: 1 }, transform).y < worldToView({ x: 0, y: 0 }, transform).y);
|
||||
});
|
||||
test("all catalog types lay out deterministically", () => {
|
||||
let state = createEngine(emptyDocument());
|
||||
for (const [index, descriptor] of BUILTIN_DESCRIPTORS.entries()) {
|
||||
const result = transition(state, {
|
||||
commandId: commandId(String(index)),
|
||||
expectedVersion: state.version,
|
||||
source: "api",
|
||||
command: {
|
||||
type: "node.add",
|
||||
nodeId: nodeId(`n${index}`),
|
||||
nodeType: descriptor.typeId,
|
||||
position: { x: index * 10, y: 0 },
|
||||
},
|
||||
});
|
||||
assert.notEqual(result.status, "rejected");
|
||||
if (result.status === "committed") state = result.state;
|
||||
}
|
||||
const a = layoutGraph(state.document, transform),
|
||||
b = layoutGraph(state.document, transform);
|
||||
assert.equal(a.nodes.size, BUILTIN_DESCRIPTORS.length);
|
||||
assert.deepEqual([...a.drawOrder], [...b.drawOrder]);
|
||||
const expected = [
|
||||
["fxnode.common.frame", "frame", "common", [], [100, 100], [300, 100]],
|
||||
["fxnode.common.reroute", "reroute", "common", ["socket:input:input:1"], [10, 10], [10, 10]],
|
||||
[
|
||||
"fxnode.shader.texture-coordinate",
|
||||
"node",
|
||||
"shader",
|
||||
["socket:output:generated:1", "socket:output:normal:1", "socket:output:uv:1", "socket:output:object:1"],
|
||||
[192, 124],
|
||||
[192, 124],
|
||||
],
|
||||
[
|
||||
"fxnode.shader.noise-texture",
|
||||
"node",
|
||||
"texture",
|
||||
[
|
||||
"control:enum:dimensions:1",
|
||||
"control:enum:noiseType:1",
|
||||
"control:boolean:normalize:1",
|
||||
"socket:input:vector:1",
|
||||
"socket:input:scale:1",
|
||||
"socket:input:detail:1",
|
||||
"socket:input:roughness:1",
|
||||
"socket:input:lacunarity:1",
|
||||
"socket:input:distortion:1",
|
||||
"socket:output:factor:1",
|
||||
"socket:output:color:1",
|
||||
],
|
||||
[286, 292],
|
||||
[286, 292],
|
||||
],
|
||||
[
|
||||
"fxnode.shader.image-texture",
|
||||
"node",
|
||||
"input",
|
||||
[
|
||||
"control:resource:image:4",
|
||||
"control:enum:interpolation:1",
|
||||
"control:enum:projection:1",
|
||||
"control:enum:extension:1",
|
||||
"control:enum:colorSpace:1",
|
||||
"control:enum:alphaMode:1",
|
||||
"socket:input:vector:1",
|
||||
"socket:output:color:1",
|
||||
"socket:output:alpha:1",
|
||||
],
|
||||
[257, 316],
|
||||
[257, 316],
|
||||
],
|
||||
[
|
||||
"fxnode.shader.principled-bsdf",
|
||||
"node",
|
||||
"shader",
|
||||
[
|
||||
"socket:input:base-color:1",
|
||||
"socket:input:metallic:1",
|
||||
"socket:input:roughness:1",
|
||||
"socket:input:ior:1",
|
||||
"socket:input:alpha:1",
|
||||
"socket:input:normal:1",
|
||||
"socket:output:bsdf:1",
|
||||
],
|
||||
[208, 196],
|
||||
[208, 196],
|
||||
],
|
||||
[
|
||||
"fxnode.shader.material-output",
|
||||
"node",
|
||||
"output",
|
||||
["socket:input:surface:1", "socket:input:volume:1", "socket:input:displacement:1"],
|
||||
[340, 100],
|
||||
[340, 100],
|
||||
],
|
||||
["fxnode.geometry.position", "node", "geometry", ["socket:output:position:1"], [175, 52], [175, 100]],
|
||||
[
|
||||
"fxnode.geometry.mesh-cube",
|
||||
"node",
|
||||
"geometry",
|
||||
[
|
||||
"socket:input:size:1",
|
||||
"socket:input:vertices-x:1",
|
||||
"socket:input:vertices-y:1",
|
||||
"socket:input:vertices-z:1",
|
||||
"socket:output:mesh:1",
|
||||
],
|
||||
[340, 148],
|
||||
[340, 148],
|
||||
],
|
||||
[
|
||||
"fxnode.geometry.set-position",
|
||||
"node",
|
||||
"geometry",
|
||||
["socket:input:geometry:1", "socket:input:position:1", "socket:input:offset:1", "socket:output:result:1"],
|
||||
[340, 124],
|
||||
[340, 124],
|
||||
],
|
||||
[
|
||||
"fxnode.geometry.transform-geometry",
|
||||
"node",
|
||||
"geometry",
|
||||
[
|
||||
"socket:input:geometry:1",
|
||||
"socket:input:translation:1",
|
||||
"socket:input:rotation:1",
|
||||
"socket:input:scale:1",
|
||||
"socket:output:result:1",
|
||||
],
|
||||
[340, 148],
|
||||
[340, 148],
|
||||
],
|
||||
[
|
||||
"fxnode.geometry.join-geometry",
|
||||
"node",
|
||||
"geometry",
|
||||
["socket:input:geometry:1", "socket:output:result:1"],
|
||||
[175, 76],
|
||||
[175, 100],
|
||||
],
|
||||
[
|
||||
"fxnode.common.group-input",
|
||||
"node",
|
||||
"input",
|
||||
["control:string:interfaceName:1", "socket:output:output:1"],
|
||||
[257, 76],
|
||||
[257, 100],
|
||||
],
|
||||
[
|
||||
"fxnode.compositor.image",
|
||||
"node",
|
||||
"compositorInput",
|
||||
[
|
||||
"control:resource:image:4",
|
||||
"control:enum:source:1",
|
||||
"socket:output:image:1",
|
||||
"socket:output:alpha:1",
|
||||
"socket:output:z:1",
|
||||
],
|
||||
[176, 220],
|
||||
[176, 220],
|
||||
],
|
||||
[
|
||||
"fxnode.compositor.color-balance",
|
||||
"node",
|
||||
"compositorColor",
|
||||
[
|
||||
"control:enum:mode:1",
|
||||
"grading-wheels:Lift:number:lift:color:liftColor,Gamma:number:gamma:color:gammaColor,Gain:number:gain:color:gainColor:7",
|
||||
"socket:input:image:1",
|
||||
"socket:input:factor:1",
|
||||
"socket:output:result:1",
|
||||
],
|
||||
[400, 292],
|
||||
[400, 292],
|
||||
],
|
||||
[
|
||||
"fxnode.common.group-output",
|
||||
"node",
|
||||
"output",
|
||||
["control:string:interfaceName:1", "socket:input:input:1"],
|
||||
[257, 76],
|
||||
[257, 100],
|
||||
],
|
||||
[
|
||||
"fxnode.shader.value",
|
||||
"node",
|
||||
"shader",
|
||||
["control:number:value:1", "socket:output:value:1"],
|
||||
[151, 76],
|
||||
[151, 100],
|
||||
],
|
||||
[
|
||||
"fxnode.shader.color",
|
||||
"node",
|
||||
"shader",
|
||||
["control:color:color:1", "socket:output:color:1"],
|
||||
[151, 76],
|
||||
[151, 100],
|
||||
],
|
||||
[
|
||||
"fxnode.shader.math",
|
||||
"node",
|
||||
"converter",
|
||||
[
|
||||
"control:enum:operation:1",
|
||||
"control:boolean:clamp:1",
|
||||
"socket:input:a:1",
|
||||
"socket:input:b:1",
|
||||
"socket:output:value:1",
|
||||
],
|
||||
[192, 148],
|
||||
[192, 148],
|
||||
],
|
||||
[
|
||||
"fxnode.shader.vector-math",
|
||||
"node",
|
||||
"converter",
|
||||
[
|
||||
"control:enum:operation:1",
|
||||
"socket:input:a:1",
|
||||
"socket:input:b:1",
|
||||
"socket:output:vector:1",
|
||||
"socket:output:value:1",
|
||||
],
|
||||
[340, 148],
|
||||
[340, 148],
|
||||
],
|
||||
[
|
||||
"fxnode.shader.mix",
|
||||
"node",
|
||||
"shader",
|
||||
[
|
||||
"control:enum:blend:1",
|
||||
"control:boolean:clamp:1",
|
||||
"socket:input:factor:1",
|
||||
"socket:input:a:1",
|
||||
"socket:input:b:1",
|
||||
"socket:output:result:1",
|
||||
],
|
||||
[151, 172],
|
||||
[151, 172],
|
||||
],
|
||||
[
|
||||
"fxnode.shader.color-ramp",
|
||||
"node",
|
||||
"shader",
|
||||
["socket:output:color:1", "socket:output:alpha:1", "control:color-ramp:ramp:8", "socket:input:factor:1"],
|
||||
[320, 292],
|
||||
[320, 292],
|
||||
],
|
||||
] as Array<[string, string, string, string[], [number, number], [number, number]]>;
|
||||
const layoutByType = new Map([...a.nodes.values()].map((node) => [node.typeId, node]));
|
||||
const aggregate = expected.map(([typeId]) => {
|
||||
const node = layoutByType.get(typeId)!;
|
||||
const sourceNode = state.document.nodes[node.id]!;
|
||||
return {
|
||||
typeId: node.typeId,
|
||||
kind: node.kind,
|
||||
category: node.styleId,
|
||||
rows: node.rows.map((row) => {
|
||||
if (row.kind === "control") {
|
||||
const control = a.controls.get(row.controlId)!;
|
||||
return `control:${control.kind}:${control.key}:${row.units}`;
|
||||
}
|
||||
if (row.kind === "socket") {
|
||||
const socket = sourceNode.sockets.find((item) => item.id === row.socketId)!;
|
||||
return `socket:${socket.direction}:${socket.key}:${row.units}`;
|
||||
}
|
||||
if (row.kind === "grading-wheels") {
|
||||
const wheels = row.wheels.map((wheel) => {
|
||||
const scalar = a.controls.get(wheel.scalarControlId)!;
|
||||
const color = a.controls.get(wheel.colorControlId)!;
|
||||
return `${wheel.label}:${scalar.kind}:${scalar.key}:${color.kind}:${color.key}`;
|
||||
});
|
||||
return `grading-wheels:${wheels.join(",")}:${row.units}`;
|
||||
}
|
||||
return `${row.kind}:${row.label}:${row.units}`;
|
||||
}),
|
||||
minimum: node.minimumSize,
|
||||
effective: { x: node.bounds.width, y: node.bounds.height },
|
||||
};
|
||||
});
|
||||
const expectedLayout = expected.map(
|
||||
([typeId, kind, category, rows, [minimumX, minimumY], [effectiveX, effectiveY]]) => ({
|
||||
typeId,
|
||||
kind,
|
||||
category,
|
||||
rows,
|
||||
minimum: { x: minimumX, y: minimumY },
|
||||
effective: { x: effectiveX, y: effectiveY },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(aggregate, expectedLayout);
|
||||
});
|
||||
|
||||
test("socket compatibility and exact theme palettes are stable", () => {
|
||||
const types = ["float", "vector", "color", "shader", "geometry", "any"] as const;
|
||||
const matrix = types.map((output) =>
|
||||
types.map((input) =>
|
||||
socketsCompatible(
|
||||
{ direction: "output", dataType: output },
|
||||
{ direction: "input", dataType: input, accepts: input === "any" ? ["any"] : [input, "any"] },
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(matrix, [
|
||||
[true, false, false, false, false, true],
|
||||
[false, true, false, false, false, true],
|
||||
[false, false, true, false, false, true],
|
||||
[false, false, false, true, false, true],
|
||||
[false, false, false, false, true, true],
|
||||
[true, true, true, true, true, true],
|
||||
]);
|
||||
assert.equal(
|
||||
socketsCompatible(
|
||||
{ direction: "input", dataType: "float" },
|
||||
{ direction: "input", dataType: "float", accepts: ["float"] },
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
socketsCompatible(
|
||||
{ direction: "output", dataType: "float" },
|
||||
{ direction: "output", dataType: "float", accepts: [] },
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(Object.fromEntries([...APPLICATION_COMPILED.styles].map(([id, style]) => [id, style.header])), {
|
||||
input: "#8b3f72",
|
||||
converter: "#4f5964",
|
||||
texture: "#a36b34",
|
||||
shader: "#3b7551",
|
||||
output: "#963d3d",
|
||||
geometry: "#2c7a75",
|
||||
common: "#555b64",
|
||||
compositorInput: "#8b3f72",
|
||||
compositorColor: "#4f5964",
|
||||
});
|
||||
assert.deepEqual(Object.fromEntries([...APPLICATION_COMPILED.socketTypes].map(([id, type]) => [id, type.color])), {
|
||||
float: "#a8a8a8",
|
||||
vector: "#6476dc",
|
||||
color: "#d7ca63",
|
||||
shader: "#62b34f",
|
||||
geometry: "#00bfa5",
|
||||
any: "#999999",
|
||||
});
|
||||
});
|
||||
|
||||
test("mute bypass map and endpoint anchors are exact", () => {
|
||||
const expected = [
|
||||
["fxnode.common.reroute", [["input", "output"]]],
|
||||
["fxnode.shader.math", [["a", "value"]]],
|
||||
["fxnode.shader.vector-math", [["a", "vector"]]],
|
||||
["fxnode.shader.mix", [["a", "result"]]],
|
||||
["fxnode.geometry.set-position", [["geometry", "result"]]],
|
||||
["fxnode.geometry.transform-geometry", [["geometry", "result"]]],
|
||||
["fxnode.compositor.color-balance", [["image", "result"]]],
|
||||
] as const;
|
||||
assert.deepEqual(
|
||||
BUILTIN_DESCRIPTORS.filter((item) => item.muteBypass.length).map((item) => [item.typeId, item.muteBypass]),
|
||||
expected,
|
||||
);
|
||||
for (const [typeId, pairs] of expected) {
|
||||
const node = { ...materializeNode("muted", typeId, { x: 0, y: 0 }), muted: true };
|
||||
const document: GraphDocument = { ...emptyDocument("mute"), nodes: { muted: node } };
|
||||
const layout = layoutGraph(document, transform),
|
||||
placed = layout.nodes.get(nodeId("muted"))!;
|
||||
assert.equal(placed.bypasses.length, pairs.length);
|
||||
for (const [index, [from, to]] of pairs.entries()) {
|
||||
const fromSocket = node.sockets.find((socket) => socket.key === from)!;
|
||||
const toSocket = node.sockets.find((socket) => socket.key === to)!;
|
||||
assert.deepEqual(placed.bypasses[index], {
|
||||
from: layout.sockets.get(fromSocket.id)!.anchor,
|
||||
to: layout.sockets.get(toSocket.id)!.anchor,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
test("expanded, collapsed, reroute and links have pinned geometry", () => {
|
||||
const value = materializeNode("value", "fxnode.shader.value", { x: -100, y: 20 });
|
||||
const math = materializeNode("math", "fxnode.shader.math", { x: 100, y: 20 });
|
||||
const link = {
|
||||
id: "link",
|
||||
fromNodeId: "value",
|
||||
fromSocketId: "value:value",
|
||||
toNodeId: "math",
|
||||
toSocketId: "math:a",
|
||||
extensions: {},
|
||||
};
|
||||
const raw = {
|
||||
schemaVersion: 1,
|
||||
graphId: "layout",
|
||||
catalogVersion: 1,
|
||||
nodes: { value, math },
|
||||
links: { link },
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0];
|
||||
const snapshot = layoutGraph(raw, transform);
|
||||
assert.equal(snapshot.links.values().next().value?.points.length, 13);
|
||||
const valueLayout = snapshot.nodes.get(nodeId("value"))!;
|
||||
assert.equal(valueLayout.bounds.width, valueLayout.minimumSize.x);
|
||||
assert.ok(valueLayout.bounds.width >= 140);
|
||||
assert.equal(snapshot.sockets.size, 4);
|
||||
assert.equal((snapshot.controls.get("value:parameter:value")?.value as { value: number }).value, 0);
|
||||
assert.equal((snapshot.controls.get("math:parameter:operation")?.value as { value: string }).value, "add");
|
||||
const mathLayout = snapshot.nodes.get(nodeId("math"))!;
|
||||
assert.ok(mathLayout.bounds.height >= 126);
|
||||
assert.equal(mathLayout.collapseHitRect.x, mathLayout.bounds.x);
|
||||
assert.equal(mathLayout.collapseHitRect.width, 14);
|
||||
assert.deepEqual(
|
||||
hitTest(snapshot, worldToView({ x: mathLayout.bounds.x + 10, y: mathLayout.bounds.y - 12 }, snapshot.transform)),
|
||||
{ kind: "collapse", id: nodeId("math") },
|
||||
);
|
||||
assert.deepEqual(
|
||||
hitTest(snapshot, worldToView({ x: mathLayout.bounds.x + 22, y: mathLayout.bounds.y - 12 }, snapshot.transform)),
|
||||
{ kind: "node", id: nodeId("math") },
|
||||
);
|
||||
assert.equal(snapshot.controls.get("math:socket:math:a")?.linked, true, "linked inputs hide controls");
|
||||
});
|
||||
|
||||
test("frames have labelled fitted bounds around parent-local children", () => {
|
||||
const frame = {
|
||||
...materializeNode("frame", "fxnode.common.frame", { x: -200, y: 200 }),
|
||||
label: "Surface Controls",
|
||||
size: { x: 100, y: 100 },
|
||||
};
|
||||
const child = { ...materializeNode("child", "fxnode.shader.value", { x: 30, y: -50 }), parentId: "frame" };
|
||||
const raw = {
|
||||
schemaVersion: 1,
|
||||
graphId: "frame-layout",
|
||||
catalogVersion: 1,
|
||||
nodes: { frame, child },
|
||||
links: {},
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0];
|
||||
const snapshot = layoutGraph(raw, transform),
|
||||
frameLayout = snapshot.nodes.get(nodeId("frame"))!,
|
||||
childLayout = snapshot.nodes.get(nodeId("child"))!;
|
||||
assert.equal(frameLayout.label, "Surface Controls");
|
||||
assert.ok(frameLayout.bounds.x <= childLayout.bounds.x - 30);
|
||||
assert.ok(frameLayout.bounds.x + frameLayout.bounds.width >= childLayout.bounds.x + childLayout.bounds.width + 30);
|
||||
assert.ok(frameLayout.bounds.y >= childLayout.bounds.y + 30);
|
||||
assert.ok(frameLayout.bounds.y - frameLayout.bounds.height <= childLayout.bounds.y - childLayout.bounds.height - 30);
|
||||
});
|
||||
test("socket compatibility does not treat accepts any as wildcard", () => {
|
||||
const output = { direction: "output" as const, dataType: "float" as const };
|
||||
assert.equal(socketsCompatible(output, { direction: "input", dataType: "vector", accepts: ["any"] }), false);
|
||||
assert.equal(socketsCompatible(output, { direction: "input", dataType: "any", accepts: [] }), true);
|
||||
});
|
||||
test("wheel zoom preserves world point and group roots omit selected descendants", () => {
|
||||
const cursor = { x: 123, y: 234 },
|
||||
before = viewToWorld(cursor, transform),
|
||||
next = zoomAt(transform, cursor, 120),
|
||||
after = viewToWorld(cursor, { ...transform, ...next });
|
||||
assert.ok(Math.abs(before.x - after.x) < 1e-9);
|
||||
assert.ok(Math.abs(before.y - after.y) < 1e-9);
|
||||
const frame = { ...materializeNode("f", "fxnode.common.frame", { x: 0, y: 0 }) },
|
||||
child = { ...materializeNode("c", "fxnode.shader.value", { x: 10, y: -10 }), parentId: nodeId("f") };
|
||||
const doc = {
|
||||
schemaVersion: 1,
|
||||
graphId: "g",
|
||||
catalogVersion: 1,
|
||||
nodes: { f: frame, c: child },
|
||||
links: {},
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0];
|
||||
assert.deepEqual(groupRoots(new Set([nodeId("f"), nodeId("c")]), layoutGraph(doc, transform)), [nodeId("f")]);
|
||||
});
|
||||
test("transient node order controls overlapping paint and hit order", () => {
|
||||
const a = materializeNode("a", "fxnode.shader.value", { x: 0, y: 0 }),
|
||||
b = materializeNode("b", "fxnode.shader.value", { x: 0, y: 0 }),
|
||||
doc = {
|
||||
schemaVersion: 1,
|
||||
graphId: "z",
|
||||
catalogVersion: 1,
|
||||
nodes: { a, b },
|
||||
links: {},
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0];
|
||||
const original = layoutGraph(doc, { ...transform, zoom: 1 }),
|
||||
raised = applyNodeOrder(original, [nodeId("b"), nodeId("a")]),
|
||||
point = worldToView({ x: 40, y: -20 }, raised.transform);
|
||||
assert.deepEqual(raised.drawOrder.slice(-2), [nodeId("b"), nodeId("a")]);
|
||||
assert.deepEqual(hitTest(raised, point), { kind: "node", id: nodeId("a") });
|
||||
const shifted = { ...b, position: { x: 30, y: 0 } },
|
||||
overlap = applyNodeOrder(layoutGraph({ ...doc, nodes: { a, b: shifted } }, { ...transform, zoom: 1 }), [
|
||||
nodeId("a"),
|
||||
nodeId("b"),
|
||||
]);
|
||||
assert.deepEqual(hitTest(overlap, worldToView({ x: 35, y: -36 }, overlap.transform)), {
|
||||
kind: "node",
|
||||
id: nodeId("b"),
|
||||
});
|
||||
});
|
||||
test("reroute core starts links while its outer halo selects the node", () => {
|
||||
const reroute = materializeNode("r", "fxnode.common.reroute", { x: 0, y: 0 }),
|
||||
doc = {
|
||||
schemaVersion: 1,
|
||||
graphId: "reroute",
|
||||
catalogVersion: 1,
|
||||
nodes: { r: reroute },
|
||||
links: {},
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0],
|
||||
layout = layoutGraph(doc, { ...transform, zoom: 1 }),
|
||||
node = layout.nodes.get(nodeId("r"))!,
|
||||
center = worldToView({ x: node.bounds.x + 5, y: node.bounds.y - 5 }, layout.transform);
|
||||
assert.equal(hitTest(layout, center, "output").kind, "socket");
|
||||
assert.deepEqual(hitTest(layout, { x: center.x + 11, y: center.y }), { kind: "node", id: nodeId("r") });
|
||||
});
|
||||
test("interaction helpers hit sampled links, box nodes, plan replacement and clamp resize", () => {
|
||||
const value = materializeNode("v", "fxnode.shader.value", { x: -100, y: 50 }),
|
||||
math = materializeNode("m", "fxnode.shader.math", { x: 100, y: 50 });
|
||||
const old = { id: "old", fromNodeId: "v", fromSocketId: "v:value", toNodeId: "m", toSocketId: "m:a", extensions: {} };
|
||||
const doc = {
|
||||
schemaVersion: 1,
|
||||
graphId: "i",
|
||||
catalogVersion: 1,
|
||||
nodes: { v: value, m: math },
|
||||
links: { old },
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0],
|
||||
layout = layoutGraph(doc, { ...transform, zoom: 1 });
|
||||
const link = layout.links.values().next().value!,
|
||||
mid = worldToView(link.points[6]!, layout.transform);
|
||||
assert.deepEqual(hitTest(layout, mid), { kind: "link", id: "old" });
|
||||
const v = layout.nodes.get(nodeId("v"))!;
|
||||
assert.deepEqual(
|
||||
boxNodes(
|
||||
layout,
|
||||
worldToView({ x: v.bounds.x - 1, y: v.bounds.y + 1 }, layout.transform),
|
||||
worldToView({ x: v.bounds.x + v.bounds.width + 1, y: v.bounds.y - v.bounds.height - 1 }, layout.transform),
|
||||
),
|
||||
[nodeId("v")],
|
||||
);
|
||||
assert.equal(
|
||||
compatibleTargets(layout, "v:value" as never).some((s) => s.id === "m:a"),
|
||||
true,
|
||||
);
|
||||
assert.equal(planLink(layout, "v:value" as never, "m:a" as never)?.type, "link.replace");
|
||||
assert.deepEqual(clampResize(layout, nodeId("m"), { x: 10000, y: 10000 }), {
|
||||
x: 700,
|
||||
y: layout.nodes.get(nodeId("m"))!.minimumSize.y,
|
||||
});
|
||||
});
|
||||
test("frame drop picks containing frame and rejects self", () => {
|
||||
const frame = materializeNode("f", "fxnode.common.frame", { x: -200, y: 200 }),
|
||||
node = materializeNode("n", "fxnode.shader.value", { x: 0, y: 0 });
|
||||
const doc = {
|
||||
schemaVersion: 1,
|
||||
graphId: "d",
|
||||
catalogVersion: 1,
|
||||
nodes: { f: frame, n: node },
|
||||
links: {},
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0],
|
||||
layout = layoutGraph(doc, transform);
|
||||
assert.equal(frameDropCandidate(layout, nodeId("n"), { x: -100, y: 100 }), nodeId("f"));
|
||||
assert.equal(frameDropCandidate(layout, nodeId("f"), { x: -100, y: 100 }), undefined);
|
||||
});
|
||||
test("Color Ramp authoritative bounds resolve every interaction region", () => {
|
||||
const node = materializeNode("r", "fxnode.shader.color-ramp", { x: 0, y: 0 }),
|
||||
doc = {
|
||||
schemaVersion: 1,
|
||||
graphId: "r",
|
||||
catalogVersion: 1,
|
||||
nodes: { r: node },
|
||||
links: {},
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0],
|
||||
layout = layoutGraph(doc, { ...transform, zoom: 1 }),
|
||||
control = layout.controls.get("r:parameter:ramp")!,
|
||||
b = control.rampBounds!,
|
||||
center = (r: typeof b.toolbar) => ({ x: r.x + r.width / 2, y: r.y - r.height / 2 });
|
||||
assert.equal(hitRamp(control, { x: b.toolbar.x + 1, y: b.toolbar.y - 10 })?.target, "add");
|
||||
assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.18, y: b.toolbar.y - 10 })?.target, "remove");
|
||||
assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.4, y: b.toolbar.y - 10 })?.target, "flip");
|
||||
assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.8, y: b.toolbar.y - 10 })?.target, "distribute");
|
||||
assert.equal(hitRamp(control, center(b.mode))?.target, "mode");
|
||||
assert.equal(hitRamp(control, center(b.interpolation))?.target, "interpolation");
|
||||
assert.equal(hitRamp(control, center(b.hue))?.target, "hue");
|
||||
assert.equal(hitRamp(control, center(b.gradient))?.target, "gradient");
|
||||
assert.equal(hitRamp(control, center(b.selector))?.target, "selector");
|
||||
assert.equal(hitRamp(control, center(b.position))?.target, "position");
|
||||
for (const x of [0.05, 0.5, 0.95])
|
||||
assert.equal(hitRamp(control, { x: b.color.x + b.color.width * x, y: b.color.y - 10 })?.target, "swatch");
|
||||
});
|
||||
|
||||
test("Color Balance owns three disjoint Blender-style grading wheels", () => {
|
||||
const node = materializeNode("balance", "fxnode.compositor.color-balance", { x: 0, y: 0 }),
|
||||
doc = {
|
||||
schemaVersion: 1,
|
||||
graphId: "balance",
|
||||
catalogVersion: 1,
|
||||
nodes: { balance: node },
|
||||
links: {},
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0],
|
||||
layout = layoutGraph(doc, { ...transform, zoom: 1 }),
|
||||
placed = layout.nodes.get(nodeId("balance"))!,
|
||||
row = placed.rows.find((item) => item.kind === "grading-wheels");
|
||||
assert.ok(row && row.kind === "grading-wheels");
|
||||
assert.deepEqual(
|
||||
row.wheels.map((wheel) => wheel.label),
|
||||
["Lift", "Gamma", "Gain"],
|
||||
);
|
||||
assert.ok(placed.minimumSize.x >= 400);
|
||||
assert.equal(row.units, 7);
|
||||
for (const wheel of row.wheels) {
|
||||
const color = layout.controls.get(wheel.colorControlId)!,
|
||||
scalar = layout.controls.get(wheel.scalarControlId)!,
|
||||
bounds = color.colorWheelBounds!;
|
||||
assert.equal(bounds.plane.width, bounds.plane.height);
|
||||
assert.ok(bounds.lightness.x >= bounds.plane.x + bounds.plane.width);
|
||||
for (const [region, rect] of Object.entries(bounds) as ["plane" | "lightness", typeof bounds.plane][])
|
||||
assert.deepEqual(
|
||||
hitTest(layout, worldToView({ x: rect.x + rect.width / 2, y: rect.y - rect.height / 2 }, layout.transform)),
|
||||
{ kind: "color-wheel", id: color.id, region },
|
||||
);
|
||||
assert.equal(
|
||||
hitTest(
|
||||
layout,
|
||||
worldToView(
|
||||
{ x: scalar.bounds.x + scalar.bounds.width / 2, y: scalar.bounds.y - scalar.bounds.height / 2 },
|
||||
layout.transform,
|
||||
),
|
||||
).kind,
|
||||
"control",
|
||||
);
|
||||
}
|
||||
for (let i = 1; i < row.wheels.length; i++) {
|
||||
const prior = layout.controls.get(row.wheels[i - 1]!.colorControlId)!.bounds,
|
||||
next = layout.controls.get(row.wheels[i]!.colorControlId)!.bounds;
|
||||
assert.ok(next.x >= prior.x + prior.width);
|
||||
}
|
||||
const collapsed = layoutGraph(
|
||||
{ ...doc, nodes: { balance: { ...node, collapsed: true } } },
|
||||
{ ...transform, zoom: 1 },
|
||||
).nodes.get(nodeId("balance"))!;
|
||||
assert.equal(collapsed.bounds.width, placed.bounds.width);
|
||||
assert.equal(collapsed.bounds.height, 24);
|
||||
});
|
||||
|
||||
test("compound and component controls have bounded, disjoint layout cells", () => {
|
||||
const types = [
|
||||
["r", "fxnode.shader.color-ramp"],
|
||||
["i", "fxnode.shader.image-texture"],
|
||||
["g", "fxnode.compositor.color-balance"],
|
||||
] as const;
|
||||
const nodes = Object.fromEntries(
|
||||
types.map(([id, type], index) => [id, materializeNode(id, type, { x: index * 500, y: 0 })]),
|
||||
);
|
||||
const doc = {
|
||||
schemaVersion: 1,
|
||||
graphId: "cells",
|
||||
catalogVersion: 1,
|
||||
nodes,
|
||||
links: {},
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0],
|
||||
layout = layoutGraph(doc, transform);
|
||||
const inside = (
|
||||
outer: { x: number; y: number; width: number; height: number },
|
||||
inner: { x: number; y: number; width: number; height: number },
|
||||
) =>
|
||||
inner.x >= outer.x &&
|
||||
inner.x + inner.width <= outer.x + outer.width &&
|
||||
inner.y <= outer.y &&
|
||||
inner.y - inner.height >= outer.y - outer.height;
|
||||
for (const control of layout.controls.values()) {
|
||||
const node = layout.nodes.get(control.nodeId)!;
|
||||
assert.ok(inside(node.bounds, control.bounds), `${control.id} is inside its node`);
|
||||
for (let index = 1; index < control.subfields.length; index++)
|
||||
assert.ok(
|
||||
control.subfields[index]!.bounds.x -
|
||||
(control.subfields[index - 1]!.bounds.x + control.subfields[index - 1]!.bounds.width) >=
|
||||
2,
|
||||
"component gutter",
|
||||
);
|
||||
}
|
||||
const ramp = layout.controls.get("r:parameter:ramp")!,
|
||||
rampRow = layout.nodes.get(nodeId("r"))!.rows.find((row) => row.kind === "control")!;
|
||||
for (const row of layout.nodes.get(nodeId("r"))!.rows.filter((row) => row.kind === "socket"))
|
||||
assert.ok(
|
||||
row.bounds.y - row.bounds.height >= rampRow.bounds.y || rampRow.bounds.y - rampRow.bounds.height >= row.bounds.y,
|
||||
"ramp and sockets disjoint",
|
||||
);
|
||||
const ordinary = [layout.controls.get("i:parameter:interpolation")!, layout.controls.get("i:parameter:projection")!],
|
||||
factor = layout.controls.get("g:socket:g:factor")!;
|
||||
assert.ok([...ordinary, factor].every((control) => control.bounds.height === 18));
|
||||
assert.ok(
|
||||
ordinary.every(
|
||||
(control) =>
|
||||
Math.abs(
|
||||
(control.bounds.x - layout.nodes.get(control.nodeId)!.bounds.x) /
|
||||
layout.nodes.get(control.nodeId)!.bounds.width -
|
||||
0.42,
|
||||
) < 1e-9,
|
||||
),
|
||||
);
|
||||
assert.equal(factor.bounds.x - layout.nodes.get(factor.nodeId)!.bounds.x, 12);
|
||||
});
|
||||
|
||||
test("numeric fields expose Blender-style range fill and step geometry", () => {
|
||||
const principled = materializeNode("p", "fxnode.shader.principled-bsdf", { x: 0, y: 0 }),
|
||||
valueNode = materializeNode("v", "fxnode.shader.value", { x: 500, y: 0 }),
|
||||
doc = {
|
||||
schemaVersion: 1,
|
||||
graphId: "numeric-fields",
|
||||
catalogVersion: 1,
|
||||
nodes: { p: principled, v: valueNode },
|
||||
links: {},
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0],
|
||||
layout = layoutGraph(doc, transform),
|
||||
roughness = layout.controls.get("p:socket:p:roughness")!.numericFields[0]!,
|
||||
unbounded = layout.controls.get("v:parameter:value")!.numericFields[0]!;
|
||||
assert.deepEqual(roughness.range, { minimum: 0, maximum: 1 });
|
||||
assert.equal(unbounded.range, undefined);
|
||||
assert.equal(roughness.decrement.width, 7);
|
||||
assert.equal(roughness.increment.width, 7);
|
||||
assert.ok(roughness.value.x >= roughness.decrement.x + roughness.decrement.width);
|
||||
assert.ok(roughness.increment.x >= roughness.value.x + roughness.value.width);
|
||||
});
|
||||
|
||||
test("color controls are compact swatches rather than RGBA fields", () => {
|
||||
const colorNode = materializeNode("c", "fxnode.shader.color", { x: 0, y: 0 }),
|
||||
principled = materializeNode("p", "fxnode.shader.principled-bsdf", { x: 300, y: 0 }),
|
||||
doc = {
|
||||
schemaVersion: 1,
|
||||
graphId: "swatches",
|
||||
catalogVersion: 1,
|
||||
nodes: { c: colorNode, p: principled },
|
||||
links: {},
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0],
|
||||
layout = layoutGraph(doc, transform),
|
||||
parameter = layout.controls.get("c:parameter:color")!,
|
||||
socket = layout.controls.get("p:socket:p:base-color")!;
|
||||
for (const control of [parameter, socket]) {
|
||||
assert.equal(control.kind, "color");
|
||||
assert.equal(control.subfields.length, 0);
|
||||
assert.equal(control.numericFields.length, 0);
|
||||
assert.equal(
|
||||
hitTest(
|
||||
layout,
|
||||
worldToView(
|
||||
{ x: control.bounds.x + control.bounds.width / 2, y: control.bounds.y - control.bounds.height / 2 },
|
||||
layout.transform,
|
||||
),
|
||||
).kind,
|
||||
"control",
|
||||
);
|
||||
}
|
||||
assert.equal(layout.nodes.get(nodeId("c"))!.minimumSize.x, 151);
|
||||
assert.equal(layout.nodes.get(nodeId("p"))!.bounds.width, 208);
|
||||
});
|
||||
|
||||
test("resource previews and open buttons are authoritative worker hit targets", () => {
|
||||
const image = materializeNode("i", "fxnode.shader.image-texture", { x: 0, y: 0 }),
|
||||
document = {
|
||||
schemaVersion: 1,
|
||||
graphId: "resource-hits",
|
||||
catalogVersion: 1,
|
||||
nodes: { i: image },
|
||||
links: {},
|
||||
metadata: {},
|
||||
} as unknown as Parameters<typeof layoutGraph>[0],
|
||||
layout = layoutGraph(document, transform),
|
||||
control = layout.controls.get("i:parameter:image")!,
|
||||
bounds = control.resourceBounds!,
|
||||
center = (rect: typeof bounds.preview) =>
|
||||
worldToView({ x: rect.x + rect.width / 2, y: rect.y - rect.height / 2 }, layout.transform);
|
||||
assert.equal(control.kind, "resource");
|
||||
assert.deepEqual(hitTest(layout, center(bounds.preview)), { kind: "resource", id: control.id });
|
||||
assert.deepEqual(hitTest(layout, center(bounds.open)), { kind: "resource", id: control.id });
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isInSrgbGamut, mapOklchToSrgb, maxSrgbChroma, oklabToOklch, srgbToOklab } from "@lib/color/oklab.js";
|
||||
test("Oklab conversion matches reference primaries", () => {
|
||||
const red = srgbToOklab([1, 0, 0]);
|
||||
assert.ok(Math.abs(red.l - 0.627955) < 1e-5);
|
||||
assert.ok(Math.abs(red.a - 0.224863) < 1e-5);
|
||||
assert.ok(Math.abs(red.b - 0.125846) < 1e-5);
|
||||
const white = srgbToOklab([1, 1, 1]);
|
||||
assert.ok(Math.abs(white.l - 1) < 1e-6);
|
||||
});
|
||||
test("Oklch gamut mapping preserves lightness and hue by reducing chroma", () => {
|
||||
const requested = { l: 0.65, c: 0.6, h: 1.2 },
|
||||
limit = maxSrgbChroma(requested.l, requested.h);
|
||||
assert.ok(limit < requested.c);
|
||||
assert.equal(isInSrgbGamut({ ...requested, c: limit }), true);
|
||||
const rgb = mapOklchToSrgb(requested);
|
||||
assert.ok(rgb.every((value) => value >= 0 && value <= 1));
|
||||
const mapped = oklabToOklch(srgbToOklab(rgb));
|
||||
assert.ok(Math.abs(mapped.l - requested.l) < 2e-5);
|
||||
assert.ok(Math.abs(mapped.h - requested.h) < 2e-4);
|
||||
});
|
||||
@@ -0,0 +1,536 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { PERSISTENCE_LIMITS } from "@lib/composition/bound-document.js";
|
||||
import { createFxNodeHeadless } from "@lib/headless-runtime.js";
|
||||
|
||||
const numberValue = (value: number) => ({ kind: "number" as const, value });
|
||||
const rampDefault = {
|
||||
kind: "json" as const,
|
||||
value: {
|
||||
colorMode: "rgb",
|
||||
interpolation: "linear",
|
||||
hueInterpolation: "near",
|
||||
stops: [
|
||||
{ id: "a", position: 0, color: [0, 0, 0, 1] },
|
||||
{ id: "b", position: 1, color: [1, 1, 1, 1] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const node = (migrations: readonly any[], version = 3) => ({
|
||||
version,
|
||||
title: "Arbitrary Migrating Thing",
|
||||
behavior: "standard" as const,
|
||||
style: "common",
|
||||
parameters: {
|
||||
gain: { type: "number" as const, default: numberValue(7) },
|
||||
bonus: { type: "number" as const, default: numberValue(11) },
|
||||
spectrum: { type: "json" as const, codec: "color-ramp/v1" as const, default: rampDefault },
|
||||
},
|
||||
sockets: {
|
||||
source: {
|
||||
title: "Source",
|
||||
direction: "output" as const,
|
||||
type: "float",
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
sink: {
|
||||
title: "Sink",
|
||||
direction: "input" as const,
|
||||
type: "float",
|
||||
maxIncomingLinks: 8,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
},
|
||||
},
|
||||
ui: [
|
||||
{ kind: "parameter" as const, parameter: "gain" },
|
||||
{ kind: "parameter" as const, parameter: "bonus" },
|
||||
{ kind: "widget" as const, widget: "color-ramp" as const, parameter: "spectrum" },
|
||||
{ kind: "socket" as const, socket: "source" },
|
||||
{ kind: "socket" as const, socket: "sink" },
|
||||
],
|
||||
muteBypass: [],
|
||||
migrations,
|
||||
});
|
||||
const edges = [
|
||||
{
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
steps: [
|
||||
{ kind: "rename-parameter", from: "strength", to: "gain" },
|
||||
{ kind: "rename-socket", from: "send", to: "source" },
|
||||
{ kind: "rename-socket", from: "receive", to: "sink" },
|
||||
],
|
||||
},
|
||||
{
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
steps: [
|
||||
{ kind: "materialize-missing", target: "parameter", key: "bonus" },
|
||||
{ kind: "migrate-parameter", parameter: "spectrum", codec: "color-ramp/legacy-stops" },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
const color = "#000000" as const;
|
||||
const theme = Object.fromEntries(
|
||||
[
|
||||
"background",
|
||||
"grid",
|
||||
"frame",
|
||||
"frameHeader",
|
||||
"body",
|
||||
"control",
|
||||
"controlFill",
|
||||
"controlEditing",
|
||||
"textSelection",
|
||||
"outline",
|
||||
"text",
|
||||
"muted",
|
||||
"shadow",
|
||||
"nodeSelected",
|
||||
"nodeActive",
|
||||
"unknownHeader",
|
||||
"unknownSocket",
|
||||
"linkMuted",
|
||||
"knifeMuted",
|
||||
"emphasis",
|
||||
"focus",
|
||||
"editOutline",
|
||||
"resize",
|
||||
"muteOverlay",
|
||||
"boxSelectionFill",
|
||||
"checkerLight",
|
||||
"checkerDark",
|
||||
"widgetBorder",
|
||||
"rampBorder",
|
||||
"resourceBackground",
|
||||
].map((key) => [key, color]),
|
||||
);
|
||||
const make = (migrations: readonly any[] = edges, version = 3) =>
|
||||
createFxNodeHeadless({
|
||||
schemaVersion: 2,
|
||||
id: "peculiar-migration-suite",
|
||||
version: 47,
|
||||
compatibility: { wildcardInputTypes: [] },
|
||||
theme,
|
||||
socketTypes: { float: { title: "Float", color, acceptsFrom: ["float"] } },
|
||||
nodeStyles: { common: { header: color } },
|
||||
resources: {},
|
||||
nodes: { "acme.odd-unit": node(migrations, version) },
|
||||
} as any);
|
||||
const current = (runtime = make(), id = "n") =>
|
||||
runtime.save({ ...runtime.emptyDocument(), nodes: { [id]: runtime.materializeNode(id, "acme.odd-unit") } } as any);
|
||||
const historical = (runtime = make(), id = "n") => {
|
||||
const raw = structuredClone(current(runtime, id));
|
||||
const n: any = raw.nodes[0];
|
||||
n.typeVersion = 1;
|
||||
n.parameters.strength = numberValue(23);
|
||||
delete n.parameters.gain;
|
||||
delete n.parameters.bonus;
|
||||
n.parameters.spectrum = [
|
||||
{ position: 0, color: [0, 0, 0, 1] },
|
||||
{ position: 1, color: [1, 0, 0, 1] },
|
||||
];
|
||||
for (const s of n.sockets) {
|
||||
if (s.key === "source") {
|
||||
s.key = "send";
|
||||
s.id = `${id}:send`;
|
||||
} else {
|
||||
s.key = "receive";
|
||||
s.id = `${id}:receive`;
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
const decode = (runtime: ReturnType<typeof make>, raw: unknown) => {
|
||||
const result = runtime.decodeGraphDocument(raw);
|
||||
assert.equal(result.ok, true, result.ok ? undefined : JSON.stringify(result.issues));
|
||||
return result.ok ? result.value : runtime.emptyDocument();
|
||||
};
|
||||
|
||||
test("custom parameter rename and missing default materialize", () => {
|
||||
const r = make(),
|
||||
d = decode(r, historical(r)),
|
||||
n: any = d.nodes.n;
|
||||
assert.deepEqual(n.parameters.gain, numberValue(23));
|
||||
assert.deepEqual(n.parameters.bonus, numberValue(11));
|
||||
assert.equal("strength" in n.parameters, false);
|
||||
});
|
||||
test("socket rename rewrites output/input endpoints and preserves link identity and extensions", () => {
|
||||
const r = make(),
|
||||
raw: any = historical(r);
|
||||
raw.nodes.push(structuredClone(raw.nodes[0]));
|
||||
raw.nodes[1].id = "m";
|
||||
for (const s of raw.nodes[1].sockets) s.id = s.id.replace(/^n:/, "m:");
|
||||
raw.links = [
|
||||
{
|
||||
id: "out",
|
||||
fromNodeId: "n",
|
||||
fromSocketId: "n:send",
|
||||
toNodeId: "m",
|
||||
toSocketId: "m:receive",
|
||||
muted: false,
|
||||
extensions: { custom: { x: 1 } },
|
||||
},
|
||||
{
|
||||
id: "in",
|
||||
fromNodeId: "m",
|
||||
fromSocketId: "m:send",
|
||||
toNodeId: "n",
|
||||
toSocketId: "n:receive",
|
||||
muted: true,
|
||||
extensions: { tag: "kept" },
|
||||
},
|
||||
];
|
||||
const saved = r.save(decode(r, raw));
|
||||
assert.deepEqual(
|
||||
saved.links.map((x) => [x.id, x.fromSocketId, x.toSocketId, x.extensions]),
|
||||
[
|
||||
["in", "m:source", "n:sink", { tag: "kept" }],
|
||||
["out", "n:source", "m:sink", { custom: { x: 1 } }],
|
||||
],
|
||||
);
|
||||
});
|
||||
test("graph state decoding never performs persistence migrations, materialization, rewrites, or reordering", () => {
|
||||
const r = make(),
|
||||
raw: any = historical(r);
|
||||
raw.nodes.push(structuredClone(raw.nodes[0]));
|
||||
raw.nodes[1].id = "m";
|
||||
for (const s of raw.nodes[1].sockets) s.id = s.id.replace(/^n:/, "m:");
|
||||
raw.links = [
|
||||
{
|
||||
id: "out",
|
||||
fromNodeId: "n",
|
||||
fromSocketId: "n:send",
|
||||
toNodeId: "m",
|
||||
toSocketId: "m:receive",
|
||||
muted: false,
|
||||
extensions: {},
|
||||
},
|
||||
{
|
||||
id: "in",
|
||||
fromNodeId: "m",
|
||||
fromSocketId: "m:send",
|
||||
toNodeId: "n",
|
||||
toSocketId: "n:receive",
|
||||
muted: false,
|
||||
extensions: {},
|
||||
},
|
||||
];
|
||||
assert.equal(r.decodeGraphDocument(raw).ok, true);
|
||||
const state = {
|
||||
graphId: raw.graphId,
|
||||
catalogVersion: raw.catalogVersion,
|
||||
nodes: raw.nodes.map((node: any) => ({ ...node, known: true })),
|
||||
links: raw.links,
|
||||
metadata: raw.metadata,
|
||||
},
|
||||
migrating = r.decodeGraphState(state);
|
||||
assert.equal(migrating.ok, false);
|
||||
if (!migrating.ok) assert.equal(migrating.issues[0]?.code, "state.inexact");
|
||||
const currentState = r.getState(r.createEngine(decode(r, current(r)))),
|
||||
reordered = { ...currentState, nodes: [...currentState.nodes].reverse() };
|
||||
assert.equal(r.decodeGraphState(reordered).ok, currentState.nodes.length < 2);
|
||||
const promoted = { ...currentState, nodes: currentState.nodes.map((node: any) => ({ ...node, known: false })) },
|
||||
promotion = r.decodeGraphState(promoted);
|
||||
assert.equal(promotion.ok, false);
|
||||
if (!promotion.ok) assert.equal(promotion.issues[0]?.code, "state.inexact");
|
||||
const currentTwo = decode(r, { ...current(r), nodes: [...current(r, "a").nodes, ...current(r, "b").nodes] } as any),
|
||||
twoState = r.getState(r.createEngine(currentTwo)),
|
||||
reorderedNodes = { ...twoState, nodes: [...twoState.nodes].reverse() };
|
||||
assert.equal(r.decodeGraphState(reorderedNodes).ok, false);
|
||||
const currentLinks = decode(
|
||||
r,
|
||||
r.save(
|
||||
decode(r, {
|
||||
...raw,
|
||||
nodes: raw.nodes.map((node: any) => ({
|
||||
...node,
|
||||
typeVersion: 3,
|
||||
parameters: { gain: numberValue(7), bonus: numberValue(11), spectrum: structuredClone(rampDefault) },
|
||||
sockets: node.sockets.map((socket: any) => ({
|
||||
...socket,
|
||||
key: socket.key === "send" ? "source" : "sink",
|
||||
id: socket.id.replace(/:(send|receive)$/, (_: string, key: string) =>
|
||||
key === "send" ? ":source" : ":sink",
|
||||
),
|
||||
})),
|
||||
})),
|
||||
links: raw.links.map((link: any) => ({
|
||||
...link,
|
||||
fromSocketId: link.fromSocketId.replace(":send", ":source"),
|
||||
toSocketId: link.toSocketId.replace(":receive", ":sink"),
|
||||
})),
|
||||
} as any),
|
||||
),
|
||||
),
|
||||
linkState = r.getState(r.createEngine(currentLinks)),
|
||||
reorderedLinks = { ...linkState, links: [...linkState.links].reverse() };
|
||||
assert.equal(r.decodeGraphState(reorderedLinks).ok, false);
|
||||
});
|
||||
test("legacy color-ramp codec works on arbitrary node and parameter", () => {
|
||||
const r = make(),
|
||||
n: any = decode(r, historical(r)).nodes.n;
|
||||
assert.equal(n.parameters.spectrum.kind, "json");
|
||||
assert.deepEqual(
|
||||
n.parameters.spectrum.value.stops.map((x: any) => x.id),
|
||||
["stop-0", "stop-1"],
|
||||
);
|
||||
});
|
||||
test("direct and multi-edge migration paths execute", () => {
|
||||
const direct = make([
|
||||
{ fromVersion: 2, toVersion: 3, steps: [{ kind: "materialize-missing", target: "parameter", key: "bonus" }] },
|
||||
]);
|
||||
const raw: any = structuredClone(current(direct));
|
||||
raw.nodes[0].typeVersion = 2;
|
||||
delete raw.nodes[0].parameters.bonus;
|
||||
assert.equal((decode(direct, raw).nodes.n as any).known, true);
|
||||
assert.equal((decode(make(), historical()).nodes.n as any).typeVersion, 3);
|
||||
});
|
||||
test("catalogVersion mismatch is irrelevant and output is normalized", () => {
|
||||
const r = make(),
|
||||
raw = historical(r) as any;
|
||||
raw.catalogVersion = 999;
|
||||
const d = decode(r, raw);
|
||||
assert.equal(d.catalogVersion, 47);
|
||||
assert.equal(r.save(d).catalogVersion, 47);
|
||||
});
|
||||
test("missing route variants and unknown types preserve original save payload", () => {
|
||||
const cases: any[] = [];
|
||||
const noEdge: any = historical(make([]));
|
||||
cases.push([make([]), noEdge]);
|
||||
const gap: any = historical(make([edges[0]]));
|
||||
cases.push([make([edges[0]]), gap]);
|
||||
const future: any = structuredClone(current());
|
||||
future.nodes[0].typeVersion = 99;
|
||||
cases.push([make(), future]);
|
||||
const absent: any = structuredClone(current());
|
||||
absent.nodes[0].typeId = "vendor.absent";
|
||||
cases.push([make(), absent]);
|
||||
for (const [r, raw] of cases) {
|
||||
const d = decode(r, raw);
|
||||
assert.equal((Object.values(d.nodes)[0] as any).known, false);
|
||||
assert.deepEqual(r.save(d).nodes[0], raw.nodes[0]);
|
||||
}
|
||||
});
|
||||
test("failed migration steps and obsolete final fields remain original unknown", () => {
|
||||
const variants: any[] = [];
|
||||
const malformed: any = historical();
|
||||
malformed.nodes[0].parameters.spectrum = [{ bad: true }];
|
||||
variants.push(malformed);
|
||||
const missing: any = historical();
|
||||
delete missing.nodes[0].parameters.strength;
|
||||
variants.push(missing);
|
||||
const parameterCollision: any = historical();
|
||||
parameterCollision.nodes[0].parameters.gain = numberValue(4);
|
||||
variants.push(parameterCollision);
|
||||
const socketCollision: any = historical();
|
||||
socketCollision.nodes[0].sockets.push({
|
||||
...structuredClone(socketCollision.nodes[0].sockets[0]),
|
||||
id: "n:source",
|
||||
key: "source",
|
||||
});
|
||||
variants.push(socketCollision);
|
||||
const obsolete: any = historical();
|
||||
obsolete.nodes[0].parameters.obsolete = numberValue(1);
|
||||
variants.push(obsolete);
|
||||
for (const raw of variants) {
|
||||
const r = make(),
|
||||
d = decode(r, raw);
|
||||
assert.equal((d.nodes.n as any).known, false);
|
||||
assert.deepEqual(r.save(d).nodes[0], raw.nodes[0]);
|
||||
}
|
||||
});
|
||||
test("malformed current-version payload hard fails catalog.invalid", () => {
|
||||
const r = make(),
|
||||
raw: any = structuredClone(current(r));
|
||||
delete raw.nodes[0].parameters.gain;
|
||||
const d = r.decodeGraphDocument(raw);
|
||||
assert.equal(d.ok, false);
|
||||
if (!d.ok) assert.ok(d.issues.some((x) => x.code === "catalog.invalid"));
|
||||
});
|
||||
test("current known nodes reject extra durable fields at every exactness level", () => {
|
||||
const variants = [
|
||||
(n: any) => {
|
||||
n.obsolete = true;
|
||||
},
|
||||
(n: any) => {
|
||||
n.parameters.gain.obsolete = true;
|
||||
},
|
||||
(n: any) => {
|
||||
n.sockets[0].obsolete = true;
|
||||
},
|
||||
(n: any) => {
|
||||
n.position.obsolete = true;
|
||||
},
|
||||
];
|
||||
for (const mutate of variants) {
|
||||
const r = make(),
|
||||
raw: any = structuredClone(current(r));
|
||||
mutate(raw.nodes[0]);
|
||||
const decoded = r.decodeGraphDocument(raw);
|
||||
assert.equal(decoded.ok, false);
|
||||
if (!decoded.ok) assert.ok(decoded.issues.some((x) => x.code === "catalog.invalid"));
|
||||
}
|
||||
});
|
||||
test("historical extras abort migration and preserve the original unknown payload", () => {
|
||||
const variants = [
|
||||
(n: any) => {
|
||||
n.obsolete = true;
|
||||
},
|
||||
(n: any) => {
|
||||
n.parameters.strength.obsolete = true;
|
||||
},
|
||||
(n: any) => {
|
||||
n.sockets[0].obsolete = true;
|
||||
},
|
||||
];
|
||||
for (const mutate of variants) {
|
||||
const r = make(),
|
||||
raw: any = historical(r);
|
||||
mutate(raw.nodes[0]);
|
||||
const decoded = decode(r, raw);
|
||||
assert.equal((decoded.nodes.n as any).known, false);
|
||||
assert.deepEqual(r.save(decoded).nodes[0], raw.nodes[0]);
|
||||
}
|
||||
});
|
||||
test("persisted known is rejected rather than silently stripped from opaque nodes", () => {
|
||||
const r = make(),
|
||||
raw: any = structuredClone(current(r));
|
||||
raw.nodes[0].typeId = "vendor.opaque";
|
||||
raw.nodes[0].known = false;
|
||||
const decoded = r.decodeGraphDocument(raw);
|
||||
assert.equal(decoded.ok, false);
|
||||
if (!decoded.ok) assert.ok(decoded.issues.some((x) => x.code === "decode.node"));
|
||||
});
|
||||
test("materialized defaults are independent clones", () => {
|
||||
const r = make(),
|
||||
a: any = r.materializeNode("a", "acme.odd-unit"),
|
||||
b: any = r.materializeNode("b", "acme.odd-unit");
|
||||
assert.notEqual(a.parameters.spectrum, b.parameters.spectrum);
|
||||
assert.notEqual(a.parameters.spectrum.value, b.parameters.spectrum.value);
|
||||
});
|
||||
test("materialized socket compatibility arrays are independently clone-safe", () => {
|
||||
const r = make(),
|
||||
a: any = r.materializeNode("a", "acme.odd-unit"),
|
||||
b: any = r.materializeNode("b", "acme.odd-unit"),
|
||||
raw = r.save({ ...r.emptyDocument(), nodes: { a, b } } as any);
|
||||
assert.notEqual(a.sockets[1].accepts, b.sockets[1].accepts);
|
||||
assert.equal(r.decodeGraphDocument(raw).ok, true);
|
||||
});
|
||||
test("link destination collision causes no partial node or link migration", () => {
|
||||
const r = make(),
|
||||
raw: any = historical(r);
|
||||
raw.links = [
|
||||
{
|
||||
id: "collision",
|
||||
fromNodeId: "n",
|
||||
fromSocketId: "n:send",
|
||||
toNodeId: "n",
|
||||
toSocketId: "n:sink",
|
||||
muted: false,
|
||||
extensions: { x: 1 },
|
||||
},
|
||||
];
|
||||
const copy = structuredClone(raw),
|
||||
d = r.decodeGraphDocument(raw);
|
||||
assert.equal(d.ok, false);
|
||||
assert.deepEqual(raw, copy);
|
||||
});
|
||||
test("opaque unknown custom socket fields roundtrip", () => {
|
||||
const r = make(),
|
||||
raw: any = structuredClone(current(r));
|
||||
raw.nodes[0].typeId = "opaque.widget";
|
||||
raw.nodes[0].sockets[0] = {
|
||||
...raw.nodes[0].sockets[0],
|
||||
dataType: "quark",
|
||||
accepts: ["boson"],
|
||||
defaultValue: null,
|
||||
metadata: { private: { v: 1 } },
|
||||
extensions: { socketExtra: true },
|
||||
};
|
||||
assert.deepEqual(r.save(decode(r, raw)), raw);
|
||||
});
|
||||
test("caller input is unchanged after successful and failed decode", () => {
|
||||
const r = make();
|
||||
for (const raw of [
|
||||
historical(r),
|
||||
(() => {
|
||||
const x: any = structuredClone(current(r));
|
||||
delete x.nodes[0].parameters.gain;
|
||||
return x;
|
||||
})(),
|
||||
]) {
|
||||
const copy = structuredClone(raw);
|
||||
r.decodeGraphDocument(raw);
|
||||
assert.deepEqual(raw, copy);
|
||||
}
|
||||
});
|
||||
test("decode-save-decode stabilizes", () => {
|
||||
const r = make(),
|
||||
first = r.save(decode(r, historical(r))),
|
||||
second = r.save(decode(r, first));
|
||||
assert.deepEqual(second, first);
|
||||
});
|
||||
test("getter, cycle, sparse and nonfinite inputs error without throwing or executing getter", () => {
|
||||
const r = make();
|
||||
let calls = 0;
|
||||
const getter: any = {
|
||||
get schemaVersion() {
|
||||
calls++;
|
||||
throw Error("no");
|
||||
},
|
||||
};
|
||||
const cycle: any = {};
|
||||
cycle.self = cycle;
|
||||
const sparse: any = structuredClone(current(r));
|
||||
sparse.nodes.length = 2;
|
||||
const hugeSparse = new Array(4_000_000_000);
|
||||
const nonfinite: any = structuredClone(current(r));
|
||||
nonfinite.nodes[0].position.x = Infinity;
|
||||
for (const value of [getter, cycle, sparse, hugeSparse, nonfinite])
|
||||
assert.doesNotThrow(() => assert.equal(r.decodeGraphDocument(value).ok, false));
|
||||
const huge = r.decodeGraphDocument(hugeSparse);
|
||||
assert.equal(huge.ok, false);
|
||||
if (!huge.ok) assert.equal(huge.issues[0]?.code, "limit.values");
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
test("node and link collection limits reject over-limit and admit at-limit", () => {
|
||||
const r = make(),
|
||||
base: any = { schemaVersion: 2, graphId: "limits", catalogVersion: 1, nodes: [], links: [], metadata: {} };
|
||||
base.nodes = Array(PERSISTENCE_LIMITS.maxNodes + 1).fill(null);
|
||||
let d = r.decodeGraphDocument(base);
|
||||
assert.equal(d.ok, false);
|
||||
if (!d.ok) assert.equal(d.issues[0]?.path, "/nodes");
|
||||
base.nodes = [];
|
||||
base.links = Array(PERSISTENCE_LIMITS.maxLinks + 1).fill(null);
|
||||
d = r.decodeGraphDocument(base);
|
||||
assert.equal(d.ok, false);
|
||||
if (!d.ok) assert.equal(d.issues[0]?.path, "/links");
|
||||
base.links = [];
|
||||
assert.equal(r.decodeGraphDocument(base).ok, true);
|
||||
});
|
||||
test("bound runtime failed load returns the identical state object with version and history", () => {
|
||||
const r = make();
|
||||
let state: any = r.createEngine(r.emptyDocument());
|
||||
const request: any = {
|
||||
commandId: "x",
|
||||
expectedVersion: 0,
|
||||
source: "api",
|
||||
command: { type: "node.add", nodeId: "n", nodeType: "acme.odd-unit", position: { x: 0, y: 0 } },
|
||||
};
|
||||
const added = r.transition(state, request);
|
||||
assert.equal(added.status, "committed");
|
||||
if (added.status === "committed") state = added.state;
|
||||
const bad: any = structuredClone(current(r));
|
||||
delete bad.nodes[0].parameters.gain;
|
||||
const result = r.load(state, bad);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.state, state);
|
||||
assert.equal(result.state.version, 1);
|
||||
assert.equal(result.state.undo, state.undo);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
advancePointerLaneFence,
|
||||
createPointerLane,
|
||||
pointerLaneFence,
|
||||
publishPointerMove,
|
||||
readPointerMove,
|
||||
type PointerMoveWire,
|
||||
} from "@lib/browser/pointer-lane.js";
|
||||
|
||||
const move = (x: number, y: number): PointerMoveWire => ({
|
||||
kind: "pointer",
|
||||
phase: "move",
|
||||
pointerId: 7,
|
||||
pointerType: "pen",
|
||||
position: { x, y },
|
||||
button: -1,
|
||||
buttons: 1,
|
||||
modifiers: 8,
|
||||
});
|
||||
|
||||
test("shared pointer lane publishes exact latest coordinates and fences", () => {
|
||||
const lane = createPointerLane();
|
||||
assert.equal(pointerLaneFence(lane), 0);
|
||||
const generation = 0x1_0000_0007;
|
||||
const sequence = publishPointerMove(lane, move(-12.25, 987.125), generation);
|
||||
assert.equal(sequence, 1);
|
||||
assert.deepEqual(readPointerMove(lane), { sequence: 1, hostGeneration: generation, event: move(-12.25, 987.125) });
|
||||
assert.equal(advancePointerLaneFence(lane), 1);
|
||||
assert.equal(pointerLaneFence(lane), 1);
|
||||
assert.equal(publishPointerMove(lane, { ...move(4, 5), pointerType: "unknown" }, generation + 1), undefined);
|
||||
assert.deepEqual(readPointerMove(lane), { sequence: 1, hostGeneration: generation, event: move(-12.25, 987.125) });
|
||||
});
|
||||
|
||||
test("shared pointer lane rejects an in-progress publication", () => {
|
||||
const lane = createPointerLane();
|
||||
publishPointerMove(lane, move(1, 2), 3);
|
||||
const words = new Int32Array(lane);
|
||||
Atomics.store(words, 0, Atomics.load(words, 0) | 1);
|
||||
assert.equal(readPointerMove(lane), undefined);
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { compileFxNodeComposition } from "@lib/composition/index.js";
|
||||
import { bindFxNodeHeadless } from "@lib/headless-runtime.js";
|
||||
import { layoutGraph } from "@lib/layout/layout-graph.js";
|
||||
import { effectivelyMutedLinks } from "@lib/layout/link-mute.js";
|
||||
import { IndexedLayoutStore } from "@lib/layout/indexed-layout-store.js";
|
||||
import { layoutSocketsCompatible } from "@lib/layout/types.js";
|
||||
import { compatibleTargets } from "@lib/worker/interaction.js";
|
||||
|
||||
const theme = {
|
||||
background: "#010203",
|
||||
grid: "#040506",
|
||||
frame: "#070809",
|
||||
frameHeader: "#0a0b0c",
|
||||
body: "#0d0e0f",
|
||||
control: "#101112",
|
||||
controlFill: "#131415",
|
||||
controlEditing: "#161718",
|
||||
textSelection: "#191a1b",
|
||||
outline: "#1c1d1e",
|
||||
text: "#1f2021",
|
||||
muted: "#222324",
|
||||
shadow: "#252627",
|
||||
nodeSelected: "#28292a",
|
||||
nodeActive: "#2b2c2d",
|
||||
unknownHeader: "#2e2f30",
|
||||
unknownSocket: "#313233",
|
||||
linkMuted: "#343536",
|
||||
knifeMuted: "#373839",
|
||||
emphasis: "#3a3b3c",
|
||||
focus: "#3d3e3f",
|
||||
editOutline: "#404142",
|
||||
resize: "#434445",
|
||||
muteOverlay: "#464748",
|
||||
boxSelectionFill: "#494a4b",
|
||||
checkerLight: "#4c4d4e",
|
||||
checkerDark: "#4f5051",
|
||||
widgetBorder: "#525354",
|
||||
rampBorder: "#555657",
|
||||
resourceBackground: "#58595a",
|
||||
} as const;
|
||||
const ramp = () => ({
|
||||
kind: "json" as const,
|
||||
value: {
|
||||
colorMode: "rgb",
|
||||
interpolation: "linear",
|
||||
hueInterpolation: "near",
|
||||
stops: [
|
||||
{ id: "dark", position: 0, color: [0, 0, 0, 1] },
|
||||
{ id: "light", position: 1, color: [1, 1, 1, 1] },
|
||||
],
|
||||
},
|
||||
});
|
||||
const input = <const T extends string>(type: T, title = "Input", visible = true) => ({
|
||||
title,
|
||||
direction: "input" as const,
|
||||
type,
|
||||
maxIncomingLinks: 1,
|
||||
visible,
|
||||
value: null,
|
||||
showValue: false,
|
||||
});
|
||||
const output = <const T extends string>(type: T, title = "Output") => ({
|
||||
title,
|
||||
direction: "output" as const,
|
||||
type,
|
||||
maxIncomingLinks: 0,
|
||||
visible: true,
|
||||
value: null,
|
||||
showValue: false,
|
||||
});
|
||||
const base = (behavior: "standard" | "frame" | "reroute", style: "violet" | "frameStyle" = "violet") => ({
|
||||
version: 1,
|
||||
title: "Node",
|
||||
behavior,
|
||||
style,
|
||||
parameters: {},
|
||||
sockets: {},
|
||||
ui: [],
|
||||
muteBypass: [],
|
||||
migrations: [],
|
||||
});
|
||||
const compositionSource = {
|
||||
schemaVersion: 2,
|
||||
id: "adversarial-presentation",
|
||||
version: 73,
|
||||
compatibility: { wildcardInputTypes: ["universal-destination"] },
|
||||
theme,
|
||||
socketTypes: {
|
||||
"source-signal": { title: "Signal", color: "#a1b2c3", acceptsFrom: ["source-signal"] },
|
||||
"universal-destination": { title: "Everything", color: "#d4e5f6", acceptsFrom: [] },
|
||||
},
|
||||
nodeStyles: { violet: { header: "#7654ab" }, frameStyle: { header: "#abcdef" } },
|
||||
resources: {
|
||||
photo: {
|
||||
kind: "image",
|
||||
title: "Default image title",
|
||||
openTitle: "Default open",
|
||||
accept: ["image/png"],
|
||||
referencePrefix: "test-image:",
|
||||
maxBytes: 1024,
|
||||
maxWidth: 64,
|
||||
maxHeight: 64,
|
||||
maxPixels: 4096,
|
||||
},
|
||||
},
|
||||
nodes: {
|
||||
"fxnode.common.frame": { ...base("standard"), title: "Frame-looking ordinary" },
|
||||
"fxnode.common.reroute": { ...base("standard"), title: "Reroute-looking ordinary" },
|
||||
"totally-unrelated-container": { ...base("frame", "frameStyle"), title: "Actual frame" },
|
||||
"odd-junction": {
|
||||
...base("reroute"),
|
||||
title: "Actual reroute",
|
||||
sockets: { in: input("source-signal"), out: output("source-signal") },
|
||||
ui: [
|
||||
{ kind: "socket", socket: "in" },
|
||||
{ kind: "socket", socket: "out" },
|
||||
],
|
||||
muteBypass: [["in", "out"]],
|
||||
},
|
||||
source: { ...base("standard"), sockets: { out: output("source-signal") }, ui: [{ kind: "socket", socket: "out" }] },
|
||||
destination: {
|
||||
...base("standard"),
|
||||
parameters: { mode: { type: "string", default: { kind: "string", value: "show" }, enum: ["show", "hide"] } },
|
||||
sockets: {
|
||||
wild: input("universal-destination", "Schema label"),
|
||||
hidden: input("source-signal", "Hidden socket"),
|
||||
conditional: input("source-signal", "Conditional socket"),
|
||||
},
|
||||
ui: [
|
||||
{ kind: "parameter", parameter: "mode" },
|
||||
{ kind: "socket", socket: "wild", title: "Overridden socket title" },
|
||||
{ kind: "hidden", target: "socket", socket: "hidden" },
|
||||
{ kind: "socket", socket: "conditional", visibleWhen: { parameter: "mode", equals: "show" } },
|
||||
],
|
||||
},
|
||||
controls: {
|
||||
...base("standard"),
|
||||
parameters: {
|
||||
ramp: { type: "json", codec: "color-ramp/v1", default: ramp() },
|
||||
ordinaryRampJson: { type: "json", codec: "color-ramp/v1", default: ramp() },
|
||||
imageRef: { type: "string", default: { kind: "string", value: "" } },
|
||||
lift: { type: "number", default: { kind: "number", value: 0 } },
|
||||
gamma: { type: "number", default: { kind: "number", value: 1 } },
|
||||
gain: { type: "number", default: { kind: "number", value: 1 } },
|
||||
liftColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] } },
|
||||
gammaColor: { type: "color", default: { kind: "color", value: [0.5, 0.5, 0.5, 1] } },
|
||||
gainColor: { type: "color", default: { kind: "color", value: [1, 1, 1, 1] } },
|
||||
},
|
||||
sockets: {},
|
||||
ui: [
|
||||
{ kind: "widget", widget: "color-ramp", parameter: "ramp" },
|
||||
{ kind: "parameter", parameter: "ordinaryRampJson", title: "Plain JSON" },
|
||||
{ kind: "resource", resource: "photo", parameter: "imageRef", title: "Plate", openTitle: "Choose plate" },
|
||||
{
|
||||
kind: "widget",
|
||||
widget: "grading-wheels",
|
||||
bindings: [
|
||||
{ title: "Shadows", scalar: "lift", color: "liftColor" },
|
||||
{ title: "Midtones", scalar: "gamma", color: "gammaColor" },
|
||||
{ title: "Highlights", scalar: "gain", color: "gainColor" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
const compiled = compileFxNodeComposition(structuredClone(compositionSource));
|
||||
const runtime = bindFxNodeHeadless(compiled);
|
||||
const transform = { center: { x: 200, y: 0 }, zoom: 1, viewport: { x: 1600, y: 1000 }, dpr: 1 };
|
||||
const link = (
|
||||
id: string,
|
||||
fromNodeId: string,
|
||||
fromSocketId: string,
|
||||
toNodeId: string,
|
||||
toSocketId: string,
|
||||
muted = false,
|
||||
) => ({ id, fromNodeId, fromSocketId, toNodeId, toSocketId, muted, extensions: {} });
|
||||
|
||||
test("custom behavior metadata, UI placement, widgets, resources, and colors are authoritative", () => {
|
||||
const lookFrame = runtime.materializeNode("look-frame", "fxnode.common.frame", { x: 0, y: 0 });
|
||||
const lookReroute = runtime.materializeNode("look-reroute", "fxnode.common.reroute", { x: 220, y: 0 });
|
||||
const frame = runtime.materializeNode("container", "totally-unrelated-container", { x: 0, y: 300 });
|
||||
const child = runtime.materializeNode("child", "source", { x: 40, y: -40 }, "container");
|
||||
const junction = runtime.materializeNode("junction", "odd-junction", { x: 400, y: 100 });
|
||||
const controls = runtime.materializeNode("arbitrary", "controls", { x: 600, y: 300 });
|
||||
const destination = runtime.materializeNode("dest", "destination", { x: 1100, y: 200 });
|
||||
const document = {
|
||||
...runtime.emptyDocument("custom"),
|
||||
nodes: {
|
||||
"look-frame": lookFrame,
|
||||
"look-reroute": lookReroute,
|
||||
container: frame,
|
||||
child,
|
||||
junction,
|
||||
arbitrary: controls,
|
||||
dest: destination,
|
||||
},
|
||||
links: {},
|
||||
} as any;
|
||||
const layout = layoutGraph(compiled, document, transform);
|
||||
assert.equal(layout.nodes.get("look-frame" as never)?.kind, "node");
|
||||
assert.equal(layout.nodes.get("look-reroute" as never)?.kind, "node");
|
||||
assert.equal(layout.nodes.get("container" as never)?.kind, "frame");
|
||||
assert.equal(layout.nodes.get("junction" as never)?.kind, "reroute");
|
||||
assert.equal(layout.drawOrder[0], "container");
|
||||
const f = layout.nodes.get("container" as never)!,
|
||||
c = layout.nodes.get("child" as never)!;
|
||||
assert.ok(f.bounds.width > 100 && f.bounds.x <= c.bounds.x - 30, "custom frame fits its child and paints behind it");
|
||||
assert.equal(layout.sockets.has("dest:hidden" as never), false);
|
||||
assert.equal(layout.sockets.get("dest:wild" as never)?.label, "Overridden socket title");
|
||||
assert.equal(layout.sockets.has("dest:conditional" as never), true);
|
||||
const hiddenMode = {
|
||||
...destination,
|
||||
parameters: { ...destination.parameters, mode: { kind: "string", value: "hide" } },
|
||||
};
|
||||
const conditional = layoutGraph(compiled, { ...document, nodes: { ...document.nodes, dest: hiddenMode } }, transform);
|
||||
assert.equal(conditional.sockets.has("dest:conditional" as never), false);
|
||||
assert.equal(
|
||||
conditional.nodes.get("dest" as never)?.rows.some((r: any) => r.socketId === "dest:conditional"),
|
||||
false,
|
||||
);
|
||||
const explicit = layout.controls.get("arbitrary:parameter:ramp")!,
|
||||
plain = layout.controls.get("arbitrary:parameter:ordinaryRampJson")!,
|
||||
resource = layout.controls.get("arbitrary:parameter:imageRef")!;
|
||||
assert.equal(explicit.kind, "color-ramp");
|
||||
assert.ok(explicit.rampBounds);
|
||||
assert.equal(plain.kind, "readonly-json");
|
||||
assert.equal(plain.rampBounds, undefined);
|
||||
assert.equal(resource.kind, "resource");
|
||||
assert.ok(resource.resourceBounds);
|
||||
assert.equal(resource.label, "Plate");
|
||||
assert.equal(resource.openTitle, "Choose plate");
|
||||
const wheels = layout.nodes.get("arbitrary" as never)?.rows.find((r) => r.kind === "grading-wheels");
|
||||
assert.ok(wheels?.kind === "grading-wheels");
|
||||
assert.deepEqual(
|
||||
wheels.wheels.map((w) => w.label),
|
||||
["Shadows", "Midtones", "Highlights"],
|
||||
);
|
||||
assert.ok(wheels.wheels.every((w) => layout.controls.get(w.colorControlId)?.colorWheelBounds));
|
||||
assert.equal(layout.nodes.get("arbitrary" as never)?.headerColor, "#7654ab");
|
||||
assert.equal(layout.sockets.get("junction:out" as never)?.color, "#a1b2c3");
|
||||
const unknown = {
|
||||
...junction,
|
||||
known: false,
|
||||
typeId: "odd-junction",
|
||||
id: "opaque",
|
||||
sockets: junction.sockets.map((s) => ({ ...s, id: `opaque:${s.key}` })),
|
||||
};
|
||||
const opaqueLayout = layoutGraph(compiled, { ...document, nodes: { opaque: unknown }, links: {} }, transform);
|
||||
assert.equal(opaqueLayout.nodes.get("opaque" as never)?.kind, "node");
|
||||
assert.equal(opaqueLayout.nodes.get("opaque" as never)?.headerColor, theme.unknownHeader);
|
||||
assert.ok([...opaqueLayout.sockets.values()].every((s) => s.color === theme.unknownSocket));
|
||||
});
|
||||
|
||||
test("custom wildcard compatibility agrees across layout, interaction, and bound runtime", () => {
|
||||
const source = runtime.materializeNode("s", "source"),
|
||||
destination = runtime.materializeNode("d", "destination", { x: 300, y: 0 }),
|
||||
document = { ...runtime.emptyDocument("compat"), nodes: { s: source, d: destination }, links: {} } as any,
|
||||
layout = layoutGraph(compiled, document, transform),
|
||||
from = layout.sockets.get("s:out" as never)!,
|
||||
to = layout.sockets.get("d:wild" as never)!;
|
||||
assert.equal(to.dataType, "universal-destination");
|
||||
assert.equal(to.wildcardInput, true);
|
||||
assert.equal(layoutSocketsCompatible(from, to), true);
|
||||
assert.equal(
|
||||
compatibleTargets(layout, from.id).some((x) => x.id === to.id),
|
||||
true,
|
||||
);
|
||||
assert.equal(runtime.socketsCompatible(source.sockets[0]!, destination.sockets[0]!), true);
|
||||
});
|
||||
|
||||
test("custom reroute mute propagation excludes opaque lookalikes and survives indexed rebuild authority", () => {
|
||||
const source = runtime.materializeNode("s", "source"),
|
||||
reroute = runtime.materializeNode("r", "odd-junction"),
|
||||
destination = runtime.materializeNode("d", "destination"),
|
||||
unknown = {
|
||||
...reroute,
|
||||
known: false,
|
||||
id: "u",
|
||||
typeId: "odd-junction",
|
||||
sockets: reroute.sockets.map((s) => ({ ...s, id: `u:${s.key}` })),
|
||||
};
|
||||
const links = {
|
||||
a: link("a", "s", "s:out", "r", "r:in", true),
|
||||
b: link("b", "r", "r:out", "d", "d:wild"),
|
||||
c: link("c", "s", "s:out", "u", "u:in", true),
|
||||
d: link("d", "u", "u:out", "d", "d:wild"),
|
||||
};
|
||||
const document = {
|
||||
...runtime.emptyDocument("mute"),
|
||||
nodes: { s: source, r: reroute, d: destination, u: unknown },
|
||||
links,
|
||||
} as any,
|
||||
muted = effectivelyMutedLinks(compiled, document);
|
||||
assert.deepEqual([...muted].sort(), ["a", "b", "c"]);
|
||||
const store = new IndexedLayoutStore(compiled, document),
|
||||
authority = store.compiled;
|
||||
store.rebuild({ ...document, links: {} });
|
||||
assert.equal(store.compiled, authority);
|
||||
assert.equal(store.compiled, compiled);
|
||||
assert.equal(store.scene.nodes.get("r" as never)?.kind, "reroute");
|
||||
});
|
||||
@@ -0,0 +1,879 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
validCommandReceipt,
|
||||
validCompositionReceipt,
|
||||
validRequest,
|
||||
validWorkerMessage,
|
||||
} from "@lib/browser/protocol.js";
|
||||
import {
|
||||
decodeFxNodeActionOptions,
|
||||
decodeFxNodeAddNodeParams,
|
||||
decodeFxNodeCamera,
|
||||
decodeFxNodeInput,
|
||||
decodeFxNodeResourceAuthorization,
|
||||
decodeFxNodeResourceData,
|
||||
decodeFxNodeViewport,
|
||||
} from "@lib/browser/host-decode.js";
|
||||
import { fxNodeDevicePixels } from "@lib/browser/view-limits.js";
|
||||
|
||||
const viewport = { width: 1, height: 1, dpr: 1 };
|
||||
const viewId = "internal-view";
|
||||
|
||||
test("protocol v2 messages are rejected", () => {
|
||||
assert.equal(validRequest({ protocol: 2, type: "state.get", id: "request" }), false);
|
||||
assert.equal(
|
||||
validWorkerMessage({ protocol: 2, type: "view.node-menu.result", viewId, requestId: "request", open: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("old unprefixed view message names are rejected", () => {
|
||||
for (const type of [
|
||||
"viewport",
|
||||
"input",
|
||||
"node.add",
|
||||
"selection.remove",
|
||||
"selection.mute",
|
||||
"resource.set",
|
||||
"pointer.flush",
|
||||
"frame.consumed",
|
||||
])
|
||||
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type, viewId }), false);
|
||||
for (const type of ["frame", "selection.host", "node-menu.result", "resource.open"])
|
||||
assert.equal(validWorkerMessage({ protocol: PROTOCOL_VERSION, type, viewId }), false);
|
||||
});
|
||||
|
||||
test("view protocol is internally scoped and camera decoding is hostile-safe", () => {
|
||||
const attach = {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.attach",
|
||||
id: "request",
|
||||
viewId: "internal-view",
|
||||
viewport,
|
||||
camera: { center: { x: 3, y: -4 }, zoom: 2 },
|
||||
};
|
||||
assert.equal(validRequest(attach), true);
|
||||
assert.equal(validRequest({ ...attach, viewId: undefined }), false);
|
||||
assert.equal(validRequest({ ...attach, camera: undefined }), false);
|
||||
assert.equal(validRequest({ ...attach, callerViewId: "public" }), false);
|
||||
for (const id of [undefined, "", 1, "x".repeat(513)]) assert.equal(validRequest({ ...attach, viewId: id }), false);
|
||||
assert.equal(validRequest({ ...attach, extra: true }), false);
|
||||
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "view.detach", id: "request", viewId }), true);
|
||||
assert.equal(
|
||||
validRequest({ protocol: PROTOCOL_VERSION, type: "view.detach", id: "request", viewId, extra: true }),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(decodeFxNodeCamera(attach.camera), attach.camera);
|
||||
const frozen = Object.freeze({ center: Object.freeze({ x: 3, y: -4 }), zoom: 0.1 });
|
||||
assert.deepEqual(decodeFxNodeCamera(frozen), frozen);
|
||||
assert.deepEqual(decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: 4 }), {
|
||||
center: { x: 0, y: 0 },
|
||||
zoom: 4,
|
||||
});
|
||||
for (const value of [NaN, Infinity, -Infinity]) {
|
||||
assert.throws(() => decodeFxNodeCamera({ center: { x: value, y: 0 }, zoom: 1 }), TypeError);
|
||||
assert.throws(() => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: value }), TypeError);
|
||||
}
|
||||
assert.throws(
|
||||
() => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: 1, extra: true } as never),
|
||||
new TypeError("Invalid FxNode camera"),
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: 1, [Symbol()]: true } as never),
|
||||
new TypeError("Invalid FxNode camera"),
|
||||
);
|
||||
let cameraReads = 0;
|
||||
const accessorCamera = Object.defineProperty({ center: { x: 0, y: 0 } }, "zoom", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
cameraReads++;
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
assert.throws(() => decodeFxNodeCamera(accessorCamera as never), new TypeError("Invalid FxNode camera"));
|
||||
assert.equal(cameraReads, 0);
|
||||
const center = { x: 7, y: 8 };
|
||||
const detachedCamera = decodeFxNodeCamera({ center, zoom: 1 });
|
||||
center.x = 99;
|
||||
assert.deepEqual(detachedCamera, { center: { x: 7, y: 8 }, zoom: 1 });
|
||||
for (const zoom of [0.099, 4.001])
|
||||
assert.throws(() => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom }), RangeError);
|
||||
const nested = Proxy.revocable({ x: 0, y: 0 }, {});
|
||||
nested.revoke();
|
||||
assert.throws(() => decodeFxNodeCamera({ center: nested.proxy, zoom: 1 }), new TypeError("Invalid FxNode camera"));
|
||||
const revoked = Proxy.revocable({}, {});
|
||||
revoked.revoke();
|
||||
assert.throws(() => decodeFxNodeCamera(revoked.proxy as never), new TypeError("Invalid FxNode camera"));
|
||||
});
|
||||
|
||||
test("view.frame worker messages are exact and view IDs are bounded", () => {
|
||||
const previous = globalThis.ImageBitmap;
|
||||
class TestImageBitmap {
|
||||
readonly width = 1;
|
||||
readonly height = 1;
|
||||
}
|
||||
Object.defineProperty(globalThis, "ImageBitmap", { configurable: true, value: TestImageBitmap });
|
||||
try {
|
||||
const frame = {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.frame",
|
||||
viewId,
|
||||
bitmap: new TestImageBitmap(),
|
||||
renderId: 0,
|
||||
frameId: 0,
|
||||
hostGeneration: 0,
|
||||
surfaceGeneration: 0,
|
||||
deviceWidth: 1,
|
||||
deviceHeight: 1,
|
||||
host: { colorPickerOpen: false },
|
||||
};
|
||||
assert.equal(validWorkerMessage(frame), true);
|
||||
for (const candidate of [
|
||||
{ ...frame, bitmap: {} },
|
||||
{ ...frame, frameId: -1 },
|
||||
{ ...frame, renderId: 0.5 },
|
||||
{ ...frame, hostGeneration: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...frame, surfaceGeneration: -1 },
|
||||
{ ...frame, deviceWidth: 0 },
|
||||
{ ...frame, deviceHeight: 8193 },
|
||||
{ ...frame, host: { colorPickerOpen: false, extra: true } },
|
||||
{ ...frame, extra: true },
|
||||
])
|
||||
assert.equal(validWorkerMessage(candidate), false);
|
||||
for (const invalidViewId of [undefined, "", 1, "x".repeat(513)]) {
|
||||
const candidate = { ...frame, viewId: invalidViewId };
|
||||
if (invalidViewId === undefined) delete (candidate as { viewId?: unknown }).viewId;
|
||||
assert.equal(validWorkerMessage(candidate), false);
|
||||
}
|
||||
} finally {
|
||||
if (previous === undefined) delete (globalThis as { ImageBitmap?: unknown }).ImageBitmap;
|
||||
else Object.defineProperty(globalThis, "ImageBitmap", { configurable: true, value: previous });
|
||||
}
|
||||
});
|
||||
|
||||
test("input and viewport decoder tables cover every variant and boundary", () => {
|
||||
const m0 = { alt: false, control: false, meta: false, shift: false },
|
||||
m15 = { alt: true, control: true, meta: true, shift: true };
|
||||
const cases = [
|
||||
[
|
||||
{ kind: "focus", phase: "focus" },
|
||||
{ kind: "focus", phase: "focus" },
|
||||
],
|
||||
[
|
||||
{ kind: "outside-pointer", button: -1 },
|
||||
{ kind: "outside-pointer", button: -1 },
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: "pointer",
|
||||
phase: "move",
|
||||
pointerId: 1,
|
||||
pointerType: "pen",
|
||||
position: { x: -2, y: -3 },
|
||||
button: -1,
|
||||
buttons: 0,
|
||||
modifiers: m0,
|
||||
},
|
||||
{
|
||||
kind: "pointer",
|
||||
phase: "move",
|
||||
pointerId: 1,
|
||||
pointerType: "pen",
|
||||
position: { x: -2, y: -3 },
|
||||
button: -1,
|
||||
buttons: 0,
|
||||
modifiers: 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ kind: "wheel", position: { x: -1, y: -2 }, delta: { x: -3, y: 4 }, modifiers: m15 },
|
||||
{ kind: "wheel", position: { x: -1, y: -2 }, delta: { x: -3, y: 4 }, modifiers: 15 },
|
||||
],
|
||||
[
|
||||
{ kind: "key", phase: "up", key: "A", code: "KeyA", repeat: false, modifiers: m15 },
|
||||
{ kind: "key", phase: "up", key: "A", code: "KeyA", repeat: false, modifiers: 15 },
|
||||
],
|
||||
] as const;
|
||||
for (const [input, expected] of cases) assert.deepEqual(decodeFxNodeInput(input as never), expected);
|
||||
for (const value of [
|
||||
{ width: 0, height: 8192, dpr: Number.MIN_VALUE },
|
||||
{ width: 8192, height: 0, dpr: 1 },
|
||||
{ width: 4096, height: 4096, dpr: 1 },
|
||||
])
|
||||
assert.deepEqual(decodeFxNodeViewport(value), value);
|
||||
assert.deepEqual(decodeFxNodeViewport({ width: 0, height: 0, dpr: 4 }), { width: 0, height: 0, dpr: 4 });
|
||||
assert.deepEqual(fxNodeDevicePixels(0, 0, 1), { width: 1, height: 1 });
|
||||
assert.deepEqual(fxNodeDevicePixels(0.49, 0.5, 1), { width: 1, height: 1 });
|
||||
assert.deepEqual(fxNodeDevicePixels(1.5, 2.5, 1), { width: 2, height: 3 });
|
||||
assert.equal(fxNodeDevicePixels(4097, 4096, 1), undefined);
|
||||
for (const value of [
|
||||
{ width: -1, height: 1, dpr: 1 },
|
||||
{ width: 8193, height: 0, dpr: 1 },
|
||||
{ width: 4097, height: 4096, dpr: 1 },
|
||||
{ width: 1, height: 1, dpr: 0 },
|
||||
])
|
||||
assert.throws(() => decodeFxNodeViewport(value), RangeError);
|
||||
});
|
||||
|
||||
test("protocol tables reject malformed pointer fences", () => {
|
||||
const base = { protocol: PROTOCOL_VERSION, type: "view.pointer.flush", viewId },
|
||||
move = {
|
||||
kind: "pointer",
|
||||
phase: "move",
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
position: { x: 0, y: 0 },
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
modifiers: 0,
|
||||
};
|
||||
assert.equal(
|
||||
validRequest({ ...base, pointerFence: { generation: 0, before: { sequence: 1, hostGeneration: 2, event: move } } }),
|
||||
true,
|
||||
);
|
||||
for (const pointerFence of [
|
||||
{},
|
||||
{ generation: 0.5 },
|
||||
{ generation: 0, extra: true },
|
||||
{ generation: 0, before: {} },
|
||||
{ generation: 0, before: { sequence: 1, event: move } },
|
||||
{ generation: 0, before: { sequence: 1, hostGeneration: -1, event: move } },
|
||||
{ generation: 0, before: { sequence: 1, hostGeneration: 0, event: { ...move, phase: "down" } } },
|
||||
{ generation: 0, before: { sequence: 1, hostGeneration: 0, event: { ...move, position: { x: Infinity, y: 0 } } } },
|
||||
])
|
||||
assert.equal(validRequest({ ...base, pointerFence }), false);
|
||||
});
|
||||
test("host generations are required, exact, and nonnegative", () => {
|
||||
const event = { kind: "focus", phase: "focus" },
|
||||
input = { protocol: PROTOCOL_VERSION, type: "view.input", viewId, event, hostGeneration: 0 },
|
||||
viewportRequest = {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.viewport",
|
||||
id: "resize",
|
||||
viewId,
|
||||
viewport,
|
||||
expectedSurfaceGeneration: 1,
|
||||
hostGeneration: 0,
|
||||
};
|
||||
for (const value of [input, viewportRequest]) assert.equal(validRequest(value), true);
|
||||
for (const generation of [undefined, -1, 0.5]) {
|
||||
const candidate = { ...input, ...(generation === undefined ? {} : { hostGeneration: generation }) };
|
||||
if (generation === undefined) delete (candidate as { hostGeneration?: unknown }).hostGeneration;
|
||||
assert.equal(validRequest(candidate), false);
|
||||
}
|
||||
assert.equal(validRequest({ ...input, extra: true }), false);
|
||||
assert.equal(validRequest({ ...viewportRequest, hostGeneration: -1 }), false);
|
||||
assert.equal(validRequest({ ...viewportRequest, pointerFence: { generation: 1 } }), true);
|
||||
assert.equal(validRequest({ ...viewportRequest, pointerFence: { generation: 0.5 } }), false);
|
||||
for (const expectedSurfaceGeneration of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1])
|
||||
assert.equal(validRequest({ ...viewportRequest, expectedSurfaceGeneration }), false);
|
||||
const render = { protocol: PROTOCOL_VERSION, type: "view.render", viewId, renderId: 1, hostGeneration: 0 };
|
||||
assert.equal(validRequest(render), true);
|
||||
for (const renderId of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1])
|
||||
assert.equal(validRequest({ ...render, renderId }), false);
|
||||
const consumed = { protocol: PROTOCOL_VERSION, type: "view.frame.consumed", viewId, frameId: 0 };
|
||||
assert.equal(validRequest(consumed), true);
|
||||
assert.equal(validRequest({ ...consumed, extra: true }), false);
|
||||
for (const frameId of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1])
|
||||
assert.equal(validRequest({ ...consumed, frameId }), false);
|
||||
});
|
||||
test("resource open correlation is exact on input and worker messages", () => {
|
||||
const event = {
|
||||
kind: "pointer",
|
||||
phase: "down",
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
position: { x: 2, y: 3 },
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
modifiers: 0,
|
||||
},
|
||||
input = {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.input",
|
||||
viewId,
|
||||
event,
|
||||
hostGeneration: 0,
|
||||
resourceOpenRequestId: "open-1",
|
||||
},
|
||||
resource = {
|
||||
id: "image",
|
||||
kind: "image",
|
||||
title: "Image",
|
||||
openTitle: "Open image",
|
||||
accept: ["image/png"],
|
||||
maxBytes: 1024,
|
||||
maxWidth: 4,
|
||||
maxHeight: 4,
|
||||
maxPixels: 16,
|
||||
},
|
||||
open = {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.resource.open",
|
||||
viewId,
|
||||
requestId: "open-1",
|
||||
authorization: { viewId, token: "node:parameter:image", graphVersion: 2, compositionRevision: 3 },
|
||||
resource,
|
||||
};
|
||||
assert.equal(validRequest(input), true);
|
||||
assert.equal(validWorkerMessage(open), true);
|
||||
assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, viewId: "other" } }), true);
|
||||
assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, extra: true } }), false);
|
||||
assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, viewId: "" } }), false);
|
||||
for (const requestId of ["", "x".repeat(513), 1]) {
|
||||
assert.equal(validRequest({ ...input, resourceOpenRequestId: requestId }), false);
|
||||
assert.equal(validWorkerMessage({ ...open, requestId }), false);
|
||||
}
|
||||
assert.equal(validRequest({ ...input, extra: true }), false);
|
||||
assert.equal(validWorkerMessage({ ...open, bounds: { x: 0, y: 0, width: 1, height: 1 } }), false);
|
||||
assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, extra: true } }), false);
|
||||
assert.equal(validWorkerMessage({ ...open, resource: { ...resource, accept: ["image/png"], extra: true } }), false);
|
||||
});
|
||||
test("public host values decode once without getters and detach nested input", () => {
|
||||
let reads = 0;
|
||||
const getter = Object.defineProperty({ kind: "focus" }, "phase", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads++;
|
||||
return "focus";
|
||||
},
|
||||
});
|
||||
assert.throws(() => decodeFxNodeInput(getter as never), new TypeError("Invalid FxNode input"));
|
||||
assert.equal(reads, 0);
|
||||
const position = { x: 1, y: 2 },
|
||||
mods = { alt: true, control: false, meta: true, shift: false },
|
||||
input = {
|
||||
kind: "pointer",
|
||||
phase: "down",
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
position,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
modifiers: mods,
|
||||
} as const,
|
||||
wire = decodeFxNodeInput(input);
|
||||
position.x = 9;
|
||||
mods.control = true;
|
||||
assert.deepEqual(wire, {
|
||||
kind: "pointer",
|
||||
phase: "down",
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
position: { x: 1, y: 2 },
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
modifiers: 5,
|
||||
});
|
||||
assert.throws(
|
||||
() => decodeFxNodeInput({ ...input, [Symbol()]: true } as never),
|
||||
new TypeError("Invalid FxNode input"),
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeFxNodeViewport({ width: Infinity, height: 1, dpr: 1 }),
|
||||
new TypeError("Invalid FxNode viewport"),
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeFxNodeViewport({ width: 8192, height: 8192, dpr: 1 }),
|
||||
new RangeError("FxNode viewport is outside supported bounds"),
|
||||
);
|
||||
});
|
||||
|
||||
test("public action values reject accessors, symbols, and hostile proxies", () => {
|
||||
assert.deepEqual(decodeFxNodeActionOptions({}), { kind: "current" });
|
||||
let reads = 0;
|
||||
const options = Object.defineProperty({}, "expectedVersion", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads++;
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
const params = Object.defineProperty({ typeId: "test", viewPosition: { x: 1, y: 2 } }, "nodeId", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads++;
|
||||
return "node";
|
||||
},
|
||||
});
|
||||
assert.throws(() => decodeFxNodeActionOptions(options), new TypeError("Invalid action options"));
|
||||
assert.throws(() => decodeFxNodeAddNodeParams(params), new TypeError("Invalid add-node parameters"));
|
||||
assert.equal(reads, 0);
|
||||
assert.throws(
|
||||
() => decodeFxNodeAddNodeParams({ typeId: "test", viewPosition: { x: 1, y: 2 }, [Symbol()]: true }),
|
||||
new TypeError("Invalid add-node parameters"),
|
||||
);
|
||||
const revoked = Proxy.revocable({}, {});
|
||||
revoked.revoke();
|
||||
assert.throws(() => decodeFxNodeActionOptions(revoked.proxy), new TypeError("Invalid action options"));
|
||||
assert.throws(() => decodeFxNodeAddNodeParams(revoked.proxy), new TypeError("Invalid add-node parameters"));
|
||||
});
|
||||
test("protocol validators are total and bound request and node type ids", () => {
|
||||
const throwing = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("hostile");
|
||||
},
|
||||
ownKeys() {
|
||||
throw new Error("hostile");
|
||||
},
|
||||
},
|
||||
);
|
||||
const target = {};
|
||||
const revoked = Proxy.revocable(target, {});
|
||||
revoked.revoke();
|
||||
for (const value of [throwing, revoked.proxy]) {
|
||||
assert.doesNotThrow(() => validRequest(value));
|
||||
assert.equal(validRequest(value), false);
|
||||
assert.doesNotThrow(() => validWorkerMessage(value));
|
||||
assert.equal(validWorkerMessage(value), false);
|
||||
}
|
||||
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "state.get", id: "x".repeat(513) }), false);
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "node.add-at-view",
|
||||
id: "r",
|
||||
nodeId: "n",
|
||||
nodeType: "x".repeat(129),
|
||||
viewPosition: { x: 0, y: 0 },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// The live command wire format retains its historical 512-code-unit node type bound.
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "command",
|
||||
id: "r",
|
||||
expected: { kind: "current" },
|
||||
command: { type: "node.add", nodeId: "n", nodeType: "x".repeat(129), position: { x: 0, y: 0 } },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "command",
|
||||
id: "r",
|
||||
expected: { kind: "current" },
|
||||
command: { type: "node.add", nodeId: "n", nodeType: "x".repeat(513), position: { x: 0, y: 0 } },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "command",
|
||||
id: "r",
|
||||
expected: { kind: "current" },
|
||||
command: Object.create({ type: "undo" }),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validRequest({ protocol: PROTOCOL_VERSION, type: "load", id: "r", data: {}, expected: { kind: "current" } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validRequest({ protocol: PROTOCOL_VERSION, type: "load", id: "r", layout: {}, expected: { kind: "current" } }),
|
||||
false,
|
||||
);
|
||||
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "save.data", id: "r" }), true);
|
||||
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "save.data", id: "r", extra: true }), false);
|
||||
assert.equal(validRequest(Object.create({ protocol: PROTOCOL_VERSION, type: "state.get", id: "r" })), false);
|
||||
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "snapshot", id: "r" }), false);
|
||||
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "state.get", id: "r" }), true);
|
||||
let inspected = 0;
|
||||
const opaque = Object.defineProperty({}, "nodes", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
inspected++;
|
||||
throw new Error("payload inspected");
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "state.set",
|
||||
id: "r",
|
||||
state: opaque,
|
||||
expected: { kind: "current" },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(inspected, 0);
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "state.set",
|
||||
id: "r",
|
||||
state: {},
|
||||
expected: { kind: "current" },
|
||||
extra: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.resource.set",
|
||||
viewId,
|
||||
id: "r",
|
||||
authorization: { viewId, token: "t", graphVersion: 2, compositionRevision: 0 },
|
||||
resource: { name: "x.png", mime: "image/png", bytes: new ArrayBuffer(1) },
|
||||
expected: { kind: "current" },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validRequest(
|
||||
Object.assign(Object.create({ event: { kind: "focus", phase: "focus" } }), {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.input",
|
||||
viewId,
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.pointer.flush",
|
||||
viewId,
|
||||
pointerFence: Object.create({ generation: 0 }),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("action requests, selection projections, menu results, and receipts are exact", () => {
|
||||
const current = { kind: "current" as const };
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.node.add",
|
||||
viewId,
|
||||
id: "r",
|
||||
nodeId: "n",
|
||||
typeId: "t",
|
||||
viewPosition: { x: 1, y: 2 },
|
||||
expected: current,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validRequest({ protocol: PROTOCOL_VERSION, type: "view.selection.remove", viewId, id: "r", expected: current }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.selection.remove",
|
||||
viewId,
|
||||
id: "r",
|
||||
expected: current,
|
||||
value: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.selection.mute",
|
||||
viewId,
|
||||
id: "r",
|
||||
expected: current,
|
||||
value: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validRequest({ protocol: PROTOCOL_VERSION, type: "view.selection.mute", viewId, id: "r", expected: current }),
|
||||
false,
|
||||
);
|
||||
const selection = (nodeCount: number, linkCount: number, canRemove: boolean) => ({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.selection.host",
|
||||
viewId,
|
||||
projection: { nodeCount, linkCount, canRemove, mute: { enabled: false } },
|
||||
});
|
||||
assert.equal(validWorkerMessage(selection(0, 0, false)), true);
|
||||
assert.equal(validWorkerMessage(selection(1, 0, true)), true);
|
||||
assert.equal(validWorkerMessage(selection(0, 0, true)), false);
|
||||
assert.equal(validWorkerMessage(selection(1, 0, false)), false);
|
||||
assert.equal(
|
||||
validWorkerMessage({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.node-menu.result",
|
||||
viewId,
|
||||
requestId: "r",
|
||||
open: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validWorkerMessage({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.node-menu.result",
|
||||
viewId,
|
||||
requestId: "r",
|
||||
open: true,
|
||||
compositionRevision: 0,
|
||||
viewPosition: { x: 1, y: 2 },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validWorkerMessage({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.node-menu.result",
|
||||
viewId,
|
||||
requestId: "r",
|
||||
open: false,
|
||||
viewPosition: { x: 1, y: 2 },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(validCommandReceipt({ status: "noop", version: 0 }), true);
|
||||
assert.equal(validCommandReceipt({ status: "committed", version: 1 }), true);
|
||||
assert.equal(validCommandReceipt({ status: "noop", version: 0, extra: true }), false);
|
||||
});
|
||||
|
||||
test("resource DTO and wire decoding is exact, bounded, and hostile-safe", () => {
|
||||
const authorization = { viewId, token: "control", graphVersion: 2, compositionRevision: 3 },
|
||||
bytes = new ArrayBuffer(4),
|
||||
resource = { name: "pixel.png", mime: "image/png", bytes };
|
||||
assert.deepEqual(decodeFxNodeResourceAuthorization(authorization), authorization);
|
||||
assert.throws(
|
||||
() => decodeFxNodeResourceAuthorization({ ...authorization, viewId: "" }),
|
||||
new TypeError("Invalid resource authorization"),
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeFxNodeResourceAuthorization({ ...authorization, extra: true }),
|
||||
new TypeError("Invalid resource authorization"),
|
||||
);
|
||||
assert.equal(decodeFxNodeResourceData(resource).bytes, bytes);
|
||||
let reads = 0;
|
||||
const accessor = Object.defineProperty({ token: "control", graphVersion: 2 }, "compositionRevision", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads++;
|
||||
return 3;
|
||||
},
|
||||
});
|
||||
assert.throws(() => decodeFxNodeResourceAuthorization(accessor), new TypeError("Invalid resource authorization"));
|
||||
assert.equal(reads, 0);
|
||||
assert.throws(
|
||||
() => decodeFxNodeResourceData({ ...resource, [Symbol()]: true }),
|
||||
new TypeError("Invalid resource data"),
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeFxNodeResourceData({ ...resource, bytes: new ArrayBuffer(0) }),
|
||||
new RangeError("Resource data is outside supported bounds"),
|
||||
);
|
||||
const request = {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "view.resource.set",
|
||||
viewId,
|
||||
id: "r",
|
||||
authorization,
|
||||
resource,
|
||||
expected: { kind: "exact", version: 2 },
|
||||
pointerFence: { generation: 1 },
|
||||
};
|
||||
assert.equal(validRequest(request), true);
|
||||
assert.equal(validRequest({ ...request, authorization: { ...authorization, extra: true } }), false);
|
||||
assert.equal(validRequest({ ...request, resource: { ...resource, name: "bad\nname" } }), false);
|
||||
assert.equal(validRequest({ ...request, expected: { kind: "exact", version: -1 } }), false);
|
||||
const nested = Proxy.revocable({ x: 1, y: 2 }, {});
|
||||
nested.revoke();
|
||||
assert.throws(
|
||||
() => decodeFxNodeAddNodeParams({ typeId: "test", viewPosition: nested.proxy }),
|
||||
new TypeError("Invalid add-node parameters"),
|
||||
);
|
||||
const bytesProxy = Proxy.revocable(new ArrayBuffer(4), {});
|
||||
bytesProxy.revoke();
|
||||
assert.throws(
|
||||
() => decodeFxNodeResourceData({ ...resource, bytes: bytesProxy.proxy }),
|
||||
new TypeError("Invalid resource data"),
|
||||
);
|
||||
});
|
||||
|
||||
test("malformed composition init remains recognizable protocol", () => {
|
||||
const init = {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "init",
|
||||
id: "init",
|
||||
applicationId: "app",
|
||||
applicationVersion: 1,
|
||||
resources: {},
|
||||
historyLimit: 0,
|
||||
};
|
||||
assert.equal(validRequest(init), true);
|
||||
for (const presentation of [
|
||||
{ viewport },
|
||||
{ camera: { center: { x: 0, y: 0 }, zoom: 1 } },
|
||||
{ pointerLane: new SharedArrayBuffer(32) },
|
||||
])
|
||||
assert.equal(validRequest({ ...init, ...presentation }), false);
|
||||
assert.equal(
|
||||
validRequest({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "init",
|
||||
id: "init",
|
||||
composition: {},
|
||||
layout: null,
|
||||
historyLimit: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validWorkerMessage({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "response",
|
||||
id: "init",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "composition.invalid",
|
||||
message: "Invalid composition",
|
||||
issues: [{ code: "composition.schema", path: "/", message: "invalid" }],
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validWorkerMessage({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "response",
|
||||
id: "init",
|
||||
ok: false,
|
||||
error: { code: "link.endpoint", message: "Missing endpoint", path: "/links/stale" },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validWorkerMessage({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "response",
|
||||
id: "init",
|
||||
ok: false,
|
||||
error: { code: "link.endpoint", message: "Missing endpoint", path: "/" + "x".repeat(513) },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validWorkerMessage(Object.create({ protocol: PROTOCOL_VERSION, type: "response", id: "init", ok: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validWorkerMessage({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "response",
|
||||
id: "init",
|
||||
ok: false,
|
||||
error: Object.create({ code: "worker.error", message: "inherited" }),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("live composition protocol validates updates, revisions, receipts and events", () => {
|
||||
const request = (update: unknown, expected: unknown = { kind: "current" }) => ({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "composition.update",
|
||||
id: "request",
|
||||
expected,
|
||||
update,
|
||||
});
|
||||
for (const update of [
|
||||
{ kind: "composition.load", composition: {} },
|
||||
{ kind: "theme.set", theme: {} },
|
||||
{ kind: "compatibility.set", compatibility: { wildcardInputTypes: [] } },
|
||||
{ kind: "socket.compose", id: "signal", definition: {} },
|
||||
{ kind: "socket.remove", id: "signal" },
|
||||
{ kind: "node.compose", id: "source", definition: {} },
|
||||
{ kind: "node.remove", id: "source" },
|
||||
])
|
||||
assert.equal(validRequest(request(update)), true);
|
||||
assert.equal(validRequest(request({ kind: "composition.load", composition: {}, extra: true })), false);
|
||||
for (const expected of [
|
||||
{ kind: "exact", revision: -1 },
|
||||
{ kind: "exact", revision: 0.5 },
|
||||
{ kind: "exact", revision: Number.MAX_SAFE_INTEGER + 1 },
|
||||
])
|
||||
assert.equal(validRequest(request({ kind: "node.remove", id: "source" }, expected)), false);
|
||||
for (const id of ["", "x".repeat(129), "bad\nvalue", "__proto__", "prototype", "constructor"])
|
||||
assert.equal(validRequest(request({ kind: "node.remove", id })), false);
|
||||
assert.equal(validRequest({ ...request({ kind: "node.remove", id: "source" }), extra: true }), false);
|
||||
assert.equal(validRequest(request({ kind: "node.remove", id: "source", extra: true })), false);
|
||||
assert.equal(
|
||||
validCompositionReceipt({
|
||||
status: "committed",
|
||||
revision: 1,
|
||||
graphVersion: 0,
|
||||
graphChanged: false,
|
||||
historyReset: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validCompositionReceipt({ status: "noop", revision: 0, graphVersion: 0, graphChanged: false, historyReset: false }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validCompositionReceipt(
|
||||
Object.create({ status: "noop", revision: 0, graphVersion: 0, graphChanged: false, historyReset: false }),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validCompositionReceipt({ status: "noop", revision: 0, graphVersion: 0, graphChanged: true, historyReset: false }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validCompositionReceipt({
|
||||
status: "committed",
|
||||
revision: 1,
|
||||
graphVersion: 0,
|
||||
graphChanged: false,
|
||||
historyReset: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
validWorkerMessage({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "composition.event",
|
||||
envelope: {
|
||||
baseRevision: 0,
|
||||
revision: 1,
|
||||
change: { kind: "node.compose", id: "source" },
|
||||
baseGraphVersion: 2,
|
||||
graphVersion: 3,
|
||||
graphChanged: true,
|
||||
historyReset: true,
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validWorkerMessage({
|
||||
protocol: PROTOCOL_VERSION,
|
||||
type: "composition.event",
|
||||
envelope: {
|
||||
baseRevision: 0,
|
||||
revision: 2,
|
||||
change: { kind: "theme.set" },
|
||||
baseGraphVersion: 2,
|
||||
graphVersion: 2,
|
||||
graphChanged: false,
|
||||
historyReset: true,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Compile-only mirror of README composition snippets; check:readme guards their key syntax. */
|
||||
import type { FxNode, FxNodeDefinition, FxNodeSocketTypeDefinition, FxNodeStyleDefinition } from "@lib/index.js";
|
||||
import { createFxNodeHeadless } from "@lib/headless.js";
|
||||
import { minimalStyles, numberSocket, valueNode } from "../examples/minimal/definition.js";
|
||||
import { colorBalanceNode } from "../examples/shared/nodes/color-balance.js";
|
||||
import { exampleTheme } from "../examples/shared/theme.js";
|
||||
|
||||
const floatSocket = [
|
||||
"float",
|
||||
{ title: "Float", color: "#a8a8a8", acceptsFrom: ["float"] },
|
||||
] as const satisfies readonly [string, FxNodeSocketTypeDefinition];
|
||||
const styles = { compositorColor: { header: "#8c5cc4" } } as const satisfies Readonly<
|
||||
Record<string, FxNodeStyleDefinition>
|
||||
>;
|
||||
export async function installColorBalance(api: FxNode) {
|
||||
await api.setTheme(exampleTheme);
|
||||
await api.setHeaderStyles(styles);
|
||||
await api.composeSocket(...floatSocket);
|
||||
await api.composeNode(...colorBalanceNode);
|
||||
}
|
||||
export const gradingWheelsRow = {
|
||||
kind: "widget",
|
||||
widget: "grading-wheels",
|
||||
bindings: [
|
||||
{ title: "Lift", scalar: "lift", color: "liftColor" },
|
||||
{ title: "Gamma", scalar: "gamma", color: "gammaColor" },
|
||||
{ title: "Gain", scalar: "gain", color: "gainColor" },
|
||||
],
|
||||
visibleWhen: { parameter: "mode", equals: "Lift/Gamma/Gain" },
|
||||
} satisfies FxNodeDefinition["ui"][number];
|
||||
export const readmeHeadless = createFxNodeHeadless({
|
||||
schemaVersion: 2,
|
||||
id: "fxnode.example.minimal",
|
||||
version: 1,
|
||||
compatibility: { wildcardInputTypes: [] },
|
||||
theme: exampleTheme,
|
||||
socketTypes: { [numberSocket[0]]: numberSocket[1] },
|
||||
nodeStyles: minimalStyles,
|
||||
resources: {},
|
||||
nodes: { [valueNode[0]]: valueNode[1] },
|
||||
} as const);
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { DirtyReason, RenderScheduler, requestViewInvalidations } from "@lib/worker/render-scheduler.js";
|
||||
|
||||
test("atlas cleanup invalidations request worker viewport repaints", () => {
|
||||
const requests: Array<readonly [number, DirtyReason]> = [];
|
||||
const views = new Map([
|
||||
["a", { scheduler: { request: (id: number, reason: DirtyReason) => requests.push([id, reason]) } }],
|
||||
]);
|
||||
requestViewInvalidations(["missing", "a"], views);
|
||||
assert.deepEqual(requests, [[0, DirtyReason.Viewport]]);
|
||||
});
|
||||
|
||||
test("continuous RAF polls while idle and preserves one-frame backpressure", () => {
|
||||
const callbacks: Array<() => void> = [];
|
||||
const draws: Array<readonly [number, number, number]> = [];
|
||||
let polls = 0;
|
||||
const scheduler = new RenderScheduler(
|
||||
(...args) => draws.push(args),
|
||||
(callback) => callbacks.push(callback),
|
||||
);
|
||||
const tick = () => {
|
||||
const callback = callbacks.shift();
|
||||
assert.ok(callback);
|
||||
callback();
|
||||
};
|
||||
|
||||
scheduler.start(() => polls++);
|
||||
assert.equal(callbacks.length, 1);
|
||||
tick();
|
||||
assert.equal(polls, 1);
|
||||
assert.equal(draws.length, 0);
|
||||
assert.equal(callbacks.length, 1);
|
||||
|
||||
scheduler.request(4, DirtyReason.Preview);
|
||||
tick();
|
||||
assert.deepEqual(draws, [[1, 4, DirtyReason.Preview]]);
|
||||
scheduler.request(5, DirtyReason.Scene);
|
||||
tick();
|
||||
assert.equal(draws.length, 1);
|
||||
scheduler.consumed(99);
|
||||
tick();
|
||||
assert.equal(draws.length, 1);
|
||||
scheduler.consumed(1);
|
||||
tick();
|
||||
assert.deepEqual(draws[1], [2, 5, DirtyReason.Scene]);
|
||||
assert.equal(scheduler.metrics.staleAcks, 1);
|
||||
assert.equal(scheduler.metrics.maxInFlight, 1);
|
||||
|
||||
scheduler.stop();
|
||||
tick();
|
||||
assert.equal(callbacks.length, 0);
|
||||
});
|
||||
|
||||
test("requests coalesce and only a matching defer retries the same reasons", () => {
|
||||
const callbacks: Array<() => void> = [],
|
||||
draws: Array<readonly [number, number, number]> = [];
|
||||
const scheduler = new RenderScheduler(
|
||||
(...args) => draws.push(args),
|
||||
(callback) => callbacks.push(callback),
|
||||
);
|
||||
const tick = () => callbacks.shift()!();
|
||||
scheduler.start();
|
||||
scheduler.request(2, DirtyReason.Scene);
|
||||
scheduler.request(7, DirtyReason.Selection);
|
||||
tick();
|
||||
assert.deepEqual(draws, [[1, 7, DirtyReason.Scene | DirtyReason.Selection]]);
|
||||
|
||||
scheduler.defer(99, DirtyReason.Camera);
|
||||
tick();
|
||||
assert.equal(draws.length, 1, "a stale defer must not clear the in-flight frame");
|
||||
scheduler.defer(1, DirtyReason.Scene | DirtyReason.Selection);
|
||||
tick();
|
||||
assert.deepEqual(draws[1], [2, 7, DirtyReason.Scene | DirtyReason.Selection]);
|
||||
|
||||
scheduler.consumed(1);
|
||||
tick();
|
||||
assert.equal(draws.length, 2, "a stale ack must not consume the retried frame");
|
||||
scheduler.consumed(2);
|
||||
tick();
|
||||
assert.equal(draws.length, 2);
|
||||
scheduler.stop();
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ViewAtlasError, ViewAtlasManager, type ViewAtlasPlatform } from "@lib/worker/view-atlas.js";
|
||||
|
||||
class FakeBitmap {
|
||||
width = 1;
|
||||
height = 1;
|
||||
closed = false;
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
class FakeCanvas extends EventTarget {
|
||||
context = {} as OffscreenCanvasRenderingContext2D;
|
||||
constructor(
|
||||
public width: number,
|
||||
public height: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
getContext() {
|
||||
return this.context;
|
||||
}
|
||||
}
|
||||
function platform(options: { failCrop?: boolean; deferred?: { resolve: (bitmap: FakeBitmap) => void } } = {}) {
|
||||
const canvases: FakeCanvas[] = [],
|
||||
bitmaps: FakeBitmap[] = [];
|
||||
const value: ViewAtlasPlatform = {
|
||||
createCanvas(width, height) {
|
||||
const canvas = new FakeCanvas(width, height);
|
||||
canvases.push(canvas);
|
||||
return canvas as unknown as OffscreenCanvas;
|
||||
},
|
||||
async createBitmap() {
|
||||
if (options.failCrop) throw new Error("crop");
|
||||
if (options.deferred)
|
||||
return new Promise((resolve) => (options.deferred!.resolve = resolve)) as Promise<ImageBitmap>;
|
||||
const bitmap = new FakeBitmap();
|
||||
bitmaps.push(bitmap);
|
||||
return bitmap as unknown as ImageBitmap;
|
||||
},
|
||||
};
|
||||
return { value, canvases, bitmaps };
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test("atlas manager is lazy, serialized, and releases its only canvas after last detach", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
const [a, b] = await Promise.all([
|
||||
atlas.attach("a", { width: 100, height: 80 }),
|
||||
atlas.attach("b", { width: 90, height: 70 }),
|
||||
]);
|
||||
assert.equal(fake.canvases.length, 1);
|
||||
assert.equal(a.slot.viewId, "a");
|
||||
assert.equal(b.slot.viewId, "b");
|
||||
assert.deepEqual(await atlas.detach("a"), { invalidatedViewIds: [] });
|
||||
assert(atlas.surface());
|
||||
assert.deepEqual(await atlas.detach("b"), { invalidatedViewIds: [] });
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
await atlas.attach("c", { width: 20, height: 20 });
|
||||
assert.equal(fake.canvases.length, 2);
|
||||
});
|
||||
|
||||
test("detach reports only survivors invalidated by successful compaction", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
await atlas.attach("large", { width: 1000, height: 1000 });
|
||||
await atlas.attach("small", { width: 100, height: 100 });
|
||||
assert.deepEqual(await atlas.detach("large"), { invalidatedViewIds: ["small"] });
|
||||
assert(atlas.slot("small"));
|
||||
});
|
||||
|
||||
test("failed detach compaction with a successful rollback invalidates every survivor", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
await atlas.attach("large", { width: 1000, height: 1000 });
|
||||
await atlas.attach("small-a", { width: 100, height: 100 });
|
||||
await atlas.attach("small-b", { width: 90, height: 90 });
|
||||
let probes = 0;
|
||||
fake.value.createBitmap = async () => {
|
||||
if (probes++ === 0) throw new Error("compact probe failed");
|
||||
return new FakeBitmap() as unknown as ImageBitmap;
|
||||
};
|
||||
assert.deepEqual(await atlas.detach("large"), {
|
||||
invalidatedViewIds: ["small-a", "small-b"],
|
||||
});
|
||||
assert(atlas.slot("small-a"));
|
||||
assert(atlas.slot("small-b"));
|
||||
});
|
||||
|
||||
test("failed surface transition with successful rollback stales all previous slots", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
await atlas.attach("a", { width: 100, height: 100 });
|
||||
const generation = atlas.surface()!.atlasGeneration,
|
||||
slotGeneration = atlas.slot("a")!.slotGeneration;
|
||||
let probes = 0;
|
||||
fake.value.createBitmap = async () => {
|
||||
if (probes++ === 0) throw new Error("new surface probe failed");
|
||||
return new FakeBitmap() as unknown as ImageBitmap;
|
||||
};
|
||||
await assert.rejects(atlas.attach("b", { width: 1000, height: 1000 }), (error: unknown) => {
|
||||
assert(error instanceof ViewAtlasError);
|
||||
assert.equal(error.code, "atlas.crop");
|
||||
return true;
|
||||
});
|
||||
assert.equal(atlas.slot("b"), undefined);
|
||||
assert(atlas.slot("a")!.slotGeneration > slotGeneration);
|
||||
assert(atlas.surface()!.atlasGeneration > generation);
|
||||
});
|
||||
|
||||
test("failed first probe leaves the manager empty and retryable", async () => {
|
||||
const bad = platform({ failCrop: true }),
|
||||
atlas = new ViewAtlasManager(bad.value);
|
||||
await assert.rejects(atlas.attach("a", { width: 10, height: 10 }), (error: unknown) => {
|
||||
assert(error instanceof ViewAtlasError);
|
||||
assert.equal(error.code, "atlas.crop");
|
||||
return true;
|
||||
});
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
bad.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap;
|
||||
await atlas.attach("a", { width: 10, height: 10 });
|
||||
assert(atlas.surface());
|
||||
});
|
||||
|
||||
test("dispose during a pending probe cannot resurrect atlas state", async () => {
|
||||
const deferred = { resolve: (_bitmap: FakeBitmap) => {} },
|
||||
fake = platform({ deferred }),
|
||||
atlas = new ViewAtlasManager(fake.value),
|
||||
attached = atlas.attach("a", { width: 10, height: 10 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
atlas.dispose();
|
||||
const bitmap = new FakeBitmap();
|
||||
deferred.resolve(bitmap);
|
||||
await assert.rejects(attached, { name: "ViewAtlasError" });
|
||||
assert.equal(bitmap.closed, true);
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
});
|
||||
|
||||
test("context loss during initial probe cannot expose the lost candidate", async () => {
|
||||
const deferred = { resolve: (_bitmap: FakeBitmap) => {} },
|
||||
fake = platform({ deferred }),
|
||||
atlas = new ViewAtlasManager(fake.value),
|
||||
attached = atlas.attach("a", { width: 10, height: 10 });
|
||||
await tick();
|
||||
fake.canvases[0]!.dispatchEvent(new Event("contextlost", { cancelable: true }));
|
||||
deferred.resolve(new FakeBitmap());
|
||||
await assert.rejects(attached, (error: unknown) => {
|
||||
assert(error instanceof ViewAtlasError);
|
||||
assert.equal(error.code, "atlas.context-lost");
|
||||
return true;
|
||||
});
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
});
|
||||
|
||||
test("restoration events from a discarded candidate cannot affect its replacement", async () => {
|
||||
const deferred = { resolve: (_bitmap: FakeBitmap) => {} },
|
||||
fake = platform({ deferred }),
|
||||
fatals: ViewAtlasError[] = [],
|
||||
atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error)),
|
||||
first = atlas.attach("a", { width: 10, height: 10 });
|
||||
await tick();
|
||||
const discarded = fake.canvases[0]!;
|
||||
discarded.dispatchEvent(new Event("contextlost", { cancelable: true }));
|
||||
discarded.dispatchEvent(new Event("contextrestored"));
|
||||
deferred.resolve(new FakeBitmap());
|
||||
await assert.rejects(first, { name: "ViewAtlasError" });
|
||||
fake.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap;
|
||||
await atlas.attach("b", { width: 10, height: 10 });
|
||||
const generation = atlas.surface()!.atlasGeneration;
|
||||
discarded.dispatchEvent(new Event("contextrestored"));
|
||||
await tick();
|
||||
assert.equal(atlas.surface()!.atlasGeneration, generation);
|
||||
assert.deepEqual(fatals, []);
|
||||
});
|
||||
|
||||
test("dispose during an existing resize does not enter rollback or republish state", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
await atlas.attach("a", { width: 10, height: 10 });
|
||||
let resolve = (_bitmap: FakeBitmap) => {};
|
||||
fake.value.createBitmap = () => new Promise((done) => (resolve = done)) as Promise<ImageBitmap>;
|
||||
const resized = atlas.resize("a", { width: 400, height: 400 });
|
||||
await tick();
|
||||
atlas.dispose();
|
||||
resolve(new FakeBitmap());
|
||||
await assert.rejects(resized, { name: "ViewAtlasError" });
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
});
|
||||
|
||||
test("context loss hides the surface and restoration reprobes before exposing it", async () => {
|
||||
const fake = platform(),
|
||||
fatals: ViewAtlasError[] = [],
|
||||
atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error));
|
||||
await atlas.attach("a", { width: 10, height: 10 });
|
||||
const before = atlas.surface()!;
|
||||
fake.canvases[0]!.dispatchEvent(new Event("contextlost", { cancelable: true }));
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
fake.canvases[0]!.dispatchEvent(new Event("contextrestored"));
|
||||
for (let index = 0; index < 20 && !atlas.surface(); index++) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const restored = atlas.surface()!;
|
||||
assert(restored);
|
||||
assert(restored.atlasGeneration > before.atlasGeneration);
|
||||
assert.deepEqual(fatals, []);
|
||||
await atlas.detach("a");
|
||||
await atlas.attach("b", { width: 10, height: 10 });
|
||||
assert(atlas.surface());
|
||||
});
|
||||
|
||||
test("a stale restoration cannot expose or poison a newer context loss", async () => {
|
||||
const fake = platform(),
|
||||
fatals: ViewAtlasError[] = [],
|
||||
atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error));
|
||||
await atlas.attach("a", { width: 10, height: 10 });
|
||||
const canvas = fake.canvases[0]!;
|
||||
let rejectRestore = (_error: Error) => {};
|
||||
fake.value.createBitmap = () => new Promise((_resolve, reject) => (rejectRestore = reject));
|
||||
canvas.dispatchEvent(new Event("contextlost", { cancelable: true }));
|
||||
canvas.dispatchEvent(new Event("contextrestored"));
|
||||
await tick();
|
||||
canvas.dispatchEvent(new Event("contextlost", { cancelable: true }));
|
||||
rejectRestore(new Error("stale restoration"));
|
||||
await tick();
|
||||
assert.equal(atlas.surface(), undefined);
|
||||
assert.deepEqual(fatals, []);
|
||||
fake.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap;
|
||||
canvas.dispatchEvent(new Event("contextrestored"));
|
||||
for (let index = 0; index < 20 && !atlas.surface(); index++) await tick();
|
||||
assert(atlas.surface());
|
||||
assert.deepEqual(fatals, []);
|
||||
});
|
||||
|
||||
test("renderAndCrop clips, clears, paints, and crops a slot without yielding", async () => {
|
||||
const calls: unknown[][] = [],
|
||||
context = new Proxy(
|
||||
{},
|
||||
{
|
||||
get:
|
||||
(_target, property) =>
|
||||
(...args: unknown[]) =>
|
||||
calls.push([property, ...args]),
|
||||
},
|
||||
) as OffscreenCanvasRenderingContext2D,
|
||||
canvases: FakeCanvas[] = [];
|
||||
const crops: number[][] = [];
|
||||
const atlas = new ViewAtlasManager({
|
||||
createCanvas: (width, height) => {
|
||||
const canvas = new FakeCanvas(width, height);
|
||||
canvas.context = context;
|
||||
canvases.push(canvas);
|
||||
return canvas as unknown as OffscreenCanvas;
|
||||
},
|
||||
async createBitmap(_canvas, x, y, width, height) {
|
||||
crops.push([x, y, width, height]);
|
||||
return Object.assign(new FakeBitmap(), { width, height }) as unknown as ImageBitmap;
|
||||
},
|
||||
});
|
||||
await atlas.attach("a", { width: 12, height: 8 });
|
||||
calls.length = crops.length = 0;
|
||||
const bitmap = await atlas.renderAndCrop("a", { width: 12, height: 8 }, (target) => {
|
||||
calls.push(["paint", target.deviceX, target.deviceY, target.deviceWidth, target.deviceHeight]);
|
||||
});
|
||||
assert(bitmap);
|
||||
assert.deepEqual(crops, [[0, 0, 12, 8]]);
|
||||
assert.deepEqual(calls.slice(0, 6), [
|
||||
["save"],
|
||||
["setTransform", 1, 0, 0, 1, 0, 0],
|
||||
["beginPath"],
|
||||
["rect", 0, 0, 12, 8],
|
||||
["clip"],
|
||||
["clearRect", 0, 0, 12, 8],
|
||||
]);
|
||||
assert.deepEqual(calls.at(-2), ["paint", 0, 0, 12, 8]);
|
||||
assert.deepEqual(calls.at(-1), ["restore"]);
|
||||
});
|
||||
|
||||
test("detach waits for a delayed render crop and only then retires its slot", async () => {
|
||||
const fake = platform(),
|
||||
atlas = new ViewAtlasManager(fake.value);
|
||||
await atlas.attach("a", { width: 10, height: 10 });
|
||||
Object.assign(fake.canvases[0]!.context, {
|
||||
save() {},
|
||||
setTransform() {},
|
||||
beginPath() {},
|
||||
rect() {},
|
||||
clip() {},
|
||||
clearRect() {},
|
||||
restore() {},
|
||||
});
|
||||
let resolve = (_bitmap: FakeBitmap) => {},
|
||||
cropStarted = false;
|
||||
fake.value.createBitmap = () =>
|
||||
new Promise((done) => {
|
||||
cropStarted = true;
|
||||
resolve = done;
|
||||
}) as Promise<ImageBitmap>;
|
||||
const rendered = atlas.renderAndCrop("a", { width: 10, height: 10 }, () => {}),
|
||||
detached = atlas.detach("a");
|
||||
for (let index = 0; index < 10 && !cropStarted; index++) await tick();
|
||||
assert.equal(cropStarted, true);
|
||||
assert(atlas.slot("a"), "detach must wait behind the crop");
|
||||
const bitmap = Object.assign(new FakeBitmap(), { width: 10, height: 10 });
|
||||
resolve(bitmap);
|
||||
assert.equal(await rendered, bitmap, "the crop ticket remains valid until it is returned");
|
||||
assert.equal(bitmap.closed, false);
|
||||
assert.deepEqual(await detached, { invalidatedViewIds: [] });
|
||||
assert.equal(atlas.slot("a"), undefined);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { FxNodeSaveData } from "@lib/commands/types.js";
|
||||
import { createFxNodeHeadless } from "@lib/headless-runtime.js";
|
||||
import { nodeId } from "@lib/core/types.js";
|
||||
import { advanceJournal, checkpointJournal, importJournal, journalSaveData } from "@lib/worker/journal.js";
|
||||
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
|
||||
|
||||
const composition = {
|
||||
...APPLICATION_COMPILED.source,
|
||||
id: "journal",
|
||||
version: 1,
|
||||
nodes: { value: APPLICATION_COMPILED.source.nodes["fxnode.shader.value"]! },
|
||||
},
|
||||
runtime = createFxNodeHeadless(composition),
|
||||
baseline = runtime.save(runtime.emptyDocument()),
|
||||
authority = composition,
|
||||
valid = () => true;
|
||||
const add = { type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 0, y: 0 } } as const,
|
||||
move = { type: "node.move", id: nodeId("n"), position: { x: 1, y: 2 } } as const;
|
||||
|
||||
test("worker journal folds forward undo redo and clears redo on a new forward", () => {
|
||||
const empty = checkpointJournal(baseline),
|
||||
one = advanceJournal(empty, add, baseline, valid),
|
||||
two = advanceJournal(one, move, baseline, valid);
|
||||
assert.deepEqual(journalSaveData(two, authority).commands, [add, move]);
|
||||
const undone = advanceJournal(two, { type: "undo" }, baseline, valid);
|
||||
assert.deepEqual(journalSaveData(undone, authority).commands, [add]);
|
||||
assert.equal(undone.redo.length, 1);
|
||||
const redone = advanceJournal(undone, { type: "redo" }, baseline, valid);
|
||||
assert.deepEqual(journalSaveData(redone, authority).commands, [add, move]);
|
||||
const replaced = advanceJournal(undone, { ...move, position: { x: 3, y: 4 } }, baseline, valid);
|
||||
assert.equal(replaced.redo.length, 0);
|
||||
assert.deepEqual(journalSaveData(replaced, authority).commands.at(-1), { ...move, position: { x: 3, y: 4 } });
|
||||
assert.equal(Object.isFrozen(replaced), true);
|
||||
assert.equal(Object.isFrozen(journalSaveData(replaced, authority)), true);
|
||||
});
|
||||
|
||||
test("worker journal checkpoints on strict replay failure and imported journals append", () => {
|
||||
const one = advanceJournal(checkpointJournal(baseline), add, baseline, valid),
|
||||
saved = journalSaveData(one, authority),
|
||||
imported = importJournal(saved as unknown as FxNodeSaveData<typeof composition>),
|
||||
appended = advanceJournal(imported, move, baseline, valid);
|
||||
assert.deepEqual(journalSaveData(appended, authority).commands, [add, move]);
|
||||
const checkpointed = advanceJournal(one, move, baseline, () => false);
|
||||
assert.equal(checkpointed.applied.length, 0);
|
||||
assert.equal(JSON.stringify(checkpointed.baseline), JSON.stringify(baseline));
|
||||
const undone = advanceJournal(checkpointed, { type: "undo" }, { ...baseline, metadata: { after: "undo" } }, valid);
|
||||
assert.deepEqual(undone.baseline.metadata, { after: "undo" });
|
||||
assert.equal(undone.applied.length, 0);
|
||||
const oversized = advanceJournal(
|
||||
one,
|
||||
{ type: "node.label", id: nodeId("n"), label: "x".repeat(1_048_577) },
|
||||
{ ...baseline, metadata: { after: "large command" } },
|
||||
valid,
|
||||
);
|
||||
assert.deepEqual(oversized.baseline.metadata, { after: "large command" });
|
||||
assert.equal(oversized.applied.length, 0);
|
||||
});
|
||||
|
||||
test("strict callback receives only baseline and applied commands", () => {
|
||||
let received: readonly unknown[] = [];
|
||||
const next = advanceJournal(checkpointJournal(baseline), add, baseline, (candidate, commands) => {
|
||||
received = [candidate, commands];
|
||||
return true;
|
||||
});
|
||||
assert.equal(received[0], next.baseline);
|
||||
assert.deepEqual(received[1], [add]);
|
||||
assert.equal("composition" in (received[0] as object), false);
|
||||
});
|
||||