Files
yawn/tests/add-node-menu.test.js
T

232 lines
6.1 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import {
addNodeItems,
moveAddNodeSelection,
searchAddNodeItems,
} from "../static/render-graph/add-node-menu.js";
import {
createNodeIdAllocator,
spawnRequestedNode,
} from "../static/render-graph/node-spawn.js";
test("add-node model contains all final catalog types in application groups", () => {
assert.equal(addNodeItems.length, 42);
assert.deepEqual(
[...new Set(addNodeItems.map((item) => item.group))],
["Source", "Expression", "Render / post", "Frame"],
);
assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 42);
assert.ok(addNodeItems.some((item) => item.typeId === "separate_u32_bits" && item.group === "Expression"));
assert.ok(!addNodeItems.some((item) => ["mesh_query", "pipeline_registry"].includes(item.typeId)));
assert.deepEqual(searchAddNodeItems("no such node"), []);
});
test("menu selection wraps and handles an empty search", () => {
assert.equal(moveAddNodeSelection(0, -1, 3), 2);
assert.equal(moveAddNodeSelection(2, 1, 3), 0);
assert.equal(moveAddNodeSelection(0, 1, 0), -1);
});
test("allocator avoids existing and session-reserved IDs and is bounded", () => {
const values = ["a-a", "a-a", "b-b"];
const allocate = createNodeIdAllocator(() => values.shift());
assert.equal(allocate(["node_aa"]), "node_bb");
assert.throws(
() => createNodeIdAllocator(() => "bad id")([]),
/Unable to allocate/,
);
});
test("all final types spawn with exact position, current version and generated ID", async () => {
let revision = 5,
expectedType;
const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } };
const root = {
getState: async () => ({ version: 91, nodes: [{ id: "existing" }] }),
};
const view = {
getHostSnapshot: () => ({ compositionRevision: revision }),
addNode: async (params, options) => {
assert.equal(params.typeId, expectedType);
assert.strictEqual(params.viewPosition, request.viewPosition);
assert.match(params.nodeId, /^node_/);
assert.deepEqual(options, { expectedVersion: 91 });
},
};
let id = 0;
const allocate = createNodeIdAllocator(
() => `00000000-0000-0000-0000-${String(++id).padStart(12, "0")}`,
);
for (const item of addNodeItems) {
expectedType = item.typeId;
assert.equal(
await spawnRequestedNode(root, view, request, item.typeId, allocate),
true,
);
}
revision = 6;
assert.equal(
await spawnRequestedNode(root, view, request, "tone_map", allocate),
false,
);
});
test("spawn rechecks composition after getState and propagates add errors", async () => {
let revision = 2;
const request = { compositionRevision: 2, viewPosition: { x: 1, y: 2 } };
const root = {
getState: async () => {
revision++;
return { version: 3, nodes: [] };
},
};
const allocate = createNodeIdAllocator(() => "a");
const view = {
getHostSnapshot: () => ({ compositionRevision: revision }),
addNode: async () => {
throw Error("must not add");
},
};
assert.equal(
await spawnRequestedNode(root, view, request, "fullscreen_copy", allocate),
false,
);
revision = 2;
root.getState = async () => ({ version: 3, nodes: [] });
await assert.rejects(
spawnRequestedNode(root, view, request, "fullscreen_copy", allocate),
/must not add/,
);
});
test("spawn cancels when a pending getState becomes mutated or dead", async () => {
const request = { compositionRevision: 2, viewPosition: { x: 1, y: 2 } };
let resolveState,
revision = 2,
alive = true,
adds = 0;
const root = {
getState: () =>
new Promise((resolve) => {
resolveState = resolve;
}),
};
const view = {
getHostSnapshot: () => ({ compositionRevision: revision }),
addNode: async () => {
adds++;
},
};
const pendingMutation = spawnRequestedNode(
root,
view,
request,
"fullscreen_copy",
() => "node_a",
() => alive,
);
revision++;
resolveState({ version: 1, nodes: [] });
assert.equal(await pendingMutation, false);
revision = 2;
const pendingDestroy = spawnRequestedNode(
root,
view,
request,
"fullscreen_copy",
() => "node_b",
() => alive,
);
alive = false;
resolveState({ version: 1, nodes: [] });
assert.equal(await pendingDestroy, false);
assert.equal(adds, 0);
});
test("spawn has a final liveness guard after ID allocation", async () => {
let alive = true,
adds = 0;
const request = { compositionRevision: 1, viewPosition: { x: 0, y: 0 } };
const root = { getState: async () => ({ version: 1, nodes: [] }) };
const view = {
getHostSnapshot: () => ({ compositionRevision: 1 }),
addNode: async () => {
adds++;
},
};
const result = await spawnRequestedNode(
root,
view,
request,
"fullscreen_copy",
() => {
alive = false;
return "node_reserved";
},
() => alive,
);
assert.equal(result, false);
assert.equal(adds, 0);
});
test("spawn suppresses teardown RPC rejections but propagates genuine live add errors", async () => {
const request = { compositionRevision: 1, viewPosition: { x: 0, y: 0 } };
let alive = true;
const view = {
getHostSnapshot: () => ({ compositionRevision: 1 }),
addNode: async () => {},
};
const root = {
getState: async () => {
alive = false;
throw Error("detached state");
},
};
assert.equal(
await spawnRequestedNode(
root,
view,
request,
"fullscreen_copy",
() => "node_a",
() => alive,
),
false,
);
alive = true;
root.getState = async () => ({ version: 1, nodes: [] });
view.addNode = async () => {
alive = false;
throw Error("detached add");
};
assert.equal(
await spawnRequestedNode(
root,
view,
request,
"fullscreen_copy",
() => "node_b",
() => alive,
),
false,
);
alive = true;
view.addNode = async () => {
throw Error("live add failure");
};
await assert.rejects(
spawnRequestedNode(
root,
view,
request,
"fullscreen_copy",
() => "node_c",
() => alive,
),
/live add failure/,
);
});