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>
This commit is contained in:
+536
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user