Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
537 lines
18 KiB
TypeScript
537 lines
18 KiB
TypeScript
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);
|
|
});
|