Files
yawn/vendor/fxnode/test/engine.test.ts
T

398 lines
14 KiB
TypeScript

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 }] },
);
});