Preserve source values without extra wires, collect explicit per-item overrides, and expose ordered bundles through Get Value. Keep legacy sockets and no-override transport compatible.
191 lines
9.2 KiB
JavaScript
191 lines
9.2 KiB
JavaScript
import { readFileSync } from "node:fs";
|
|
import vm from "node:vm";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const source = readFileSync(new URL("../imageListValues.js", import.meta.url), "utf8")
|
|
.replace(/^import .*;\r?\n/gm, "");
|
|
const PIPE = "DISTRIBUTED_IMAGE_LIST_VALUES";
|
|
|
|
function setup() {
|
|
let extension;
|
|
let nextId = 1;
|
|
const tasks = [];
|
|
const graph = {
|
|
_nodes: [], links: new Map(),
|
|
getNodeById(id) { return this._nodes.find(node => node.id === id); },
|
|
getLink(id) { return this.links.get(id); },
|
|
};
|
|
vm.runInNewContext(source, {
|
|
app: { registerExtension: ext => { extension = ext; } },
|
|
setTimeout: callback => tasks.push(callback), console,
|
|
});
|
|
function node(comfyClass, inputs, outputs, widgets = []) {
|
|
const n = {
|
|
id: nextId++, comfyClass, graph, widgets,
|
|
inputs: inputs.map(name => ({ name, type: "*", link: null })),
|
|
outputs: outputs.map(([name, type]) => ({ name, type, links: [], shape: 6 })),
|
|
addWidget(type, name, value, callback, options) { this.widgets.push({ type, name, value, callback, options }); },
|
|
addInput(name, type) { this.inputs.push({ name, type, link: null }); },
|
|
addOutput(name, type, extra) { this.outputs.push({ name, type, links: [], ...extra }); },
|
|
removeInput(slot) {
|
|
if (this.inputs[slot].link != null) throw new Error("Removed wired input");
|
|
this.inputs.splice(slot, 1);
|
|
this.inputs.forEach((input, index) => {
|
|
if (input.link != null) graph.links.get(input.link).target_slot = index;
|
|
});
|
|
},
|
|
disconnectInput(slot) {
|
|
const input = this.inputs[slot];
|
|
const link = graph.links.get(input.link);
|
|
if (link) {
|
|
const out = graph.getNodeById(link.origin_id).outputs[link.origin_slot];
|
|
out.links = out.links.filter(id => id !== link.id);
|
|
graph.links.delete(link.id);
|
|
}
|
|
input.link = null;
|
|
this.onConnectionsChange?.();
|
|
},
|
|
setDirtyCanvas() {},
|
|
};
|
|
graph._nodes.push(n);
|
|
extension.nodeCreated(n);
|
|
return n;
|
|
}
|
|
const flush = () => { while (tasks.length) tasks.shift()(); };
|
|
const connect = (from, slot, to, name) => {
|
|
const targetSlot = to.inputs.findIndex(input => input.name === name);
|
|
if (targetSlot < 0) throw new Error(`Missing input ${name}`);
|
|
to.disconnectInput(targetSlot);
|
|
const id = nextId++;
|
|
graph.links.set(id, { id, origin_id: from.id, origin_slot: slot, target_id: to.id,
|
|
target_slot: targetSlot, type: from.outputs[slot].type });
|
|
from.outputs[slot].links.push(id);
|
|
to.inputs[targetSlot].link = id;
|
|
from.onConnectionsChange?.();
|
|
to.onConnectionsChange?.();
|
|
return id;
|
|
};
|
|
const start = node("DistributedImageListStart", ["images", "mask", "values.value_1", "values.value_2", "values.value_3"],
|
|
[["image", "IMAGE"], ["mask", "MASK"], ["item_index", "INT"], ["context", "DISTRIBUTED_IMAGE_LIST_CONTEXT"]]);
|
|
const end = node("DistributedImageListEnd", ["images", "masks", "texts", "context", ...Array.from({ length: 10 }, (_, i) => `value_${i + 1}`)],
|
|
[["image_list", "IMAGE"], ["masks", "MASK"], ["texts", "STRING"], ["values", PIPE]]);
|
|
const get = node("DistributedImageListGetValue", ["values"], [["value", "*"]], [{ name: "value_name", value: "value_1" }]);
|
|
connect(start, 3, end, "context");
|
|
connect(end, 3, get, "values");
|
|
flush();
|
|
return { node, graph, start, end, get, flush, connect, extension };
|
|
}
|
|
|
|
const valueNames = node => node.inputs.filter(input => input.name.startsWith("value_")).map(input => input.name);
|
|
const addOverride = h => { h.end.widgets.find(widget => widget.name === "Add value override").callback(); h.flush(); };
|
|
|
|
describe("Image List End context values", () => {
|
|
it("keeps original sockets and shows only the first optional override initially", () => {
|
|
const h = setup();
|
|
expect(h.end.inputs.map(input => input.name)).toEqual(["images", "masks", "texts", "context", "value_1"]);
|
|
expect(h.end.outputs.map(output => output.name)).toEqual(["image_list", "masks", "texts", "values"]);
|
|
expect(h.end.widgets[0].options.serialize).toBe(false);
|
|
});
|
|
|
|
it("appends the values output when loading an old three-output End", () => {
|
|
const h = setup();
|
|
const old = h.node("DistributedImageListEnd", ["images", "masks", "texts", "context"],
|
|
[["image_list", "IMAGE"], ["masks", "MASK"], ["texts", "STRING"]]);
|
|
h.flush();
|
|
expect(old.outputs[3]).toMatchObject({ name: "values", type: PIPE, shape: 6 });
|
|
expect(valueNames(old)).toEqual(["value_1"]);
|
|
});
|
|
|
|
it("grows overrides without compacting field identities or existing links", () => {
|
|
const h = setup();
|
|
addOverride(h);
|
|
addOverride(h);
|
|
const src = h.node("PrimitiveInt", [], [["INT", "INT"]]);
|
|
const thirdLink = h.connect(src, 0, h.end, "value_3");
|
|
h.connect(src, 0, h.end, "value_2");
|
|
h.flush();
|
|
h.end.disconnectInput(h.end.inputs.findIndex(input => input.name === "value_2"));
|
|
h.flush();
|
|
expect(valueNames(h.end)).toEqual(["value_1", "value_2", "value_3", "value_4"]);
|
|
const link = h.graph.links.get(thirdLink);
|
|
expect(h.end.inputs[link.target_slot].name).toBe("value_3");
|
|
for (let i = 0; i < 20; i++) addOverride(h);
|
|
expect(valueNames(h.end)).toHaveLength(10);
|
|
});
|
|
|
|
it.each(["IMAGE", "MASK", "STRING", "INT", "FLOAT", "BOOLEAN"])("infers %s from the original Start value through context", type => {
|
|
const h = setup();
|
|
const source = h.node("Source", [], [["value", type]]);
|
|
h.connect(source, 0, h.start, "values.value_1");
|
|
h.flush();
|
|
expect(h.get.outputs[0].type).toBe(type);
|
|
});
|
|
|
|
it("prefers the End override type, then returns to the original after disconnect", () => {
|
|
const h = setup();
|
|
h.connect(h.node("Source", [], [["value", "INT"]]), 0, h.start, "values.value_1");
|
|
h.connect(h.node("Source", [], [["value", "STRING"]]), 0, h.end, "value_1");
|
|
h.flush();
|
|
expect(h.get.outputs[0].type).toBe("STRING");
|
|
h.end.disconnectInput(h.end.inputs.findIndex(input => input.name === "value_1"));
|
|
h.flush();
|
|
expect(h.get.outputs[0].type).toBe("INT");
|
|
});
|
|
|
|
it("updates a selected field and disconnects incompatible consumers", () => {
|
|
const h = setup();
|
|
h.connect(h.node("Source", [], [["value", "STRING"]]), 0, h.start, "values.value_1");
|
|
h.connect(h.node("Source", [], [["value", "INT"]]), 0, h.start, "values.value_2");
|
|
h.flush();
|
|
const consumer = h.node("Save", ["filename"], []);
|
|
consumer.inputs[0].type = "STRING";
|
|
h.connect(h.get, 0, consumer, "filename");
|
|
h.get.widgets[0].value = "value_2";
|
|
h.get.widgets[0].callback();
|
|
h.flush();
|
|
expect(h.get.outputs[0].type).toBe("INT");
|
|
expect(consumer.inputs[0].link).toBe(null);
|
|
});
|
|
|
|
it("keeps compatible consumer links and uses wildcard for unavailable graph metadata", () => {
|
|
const h = setup();
|
|
h.connect(h.node("Source", [], [["value", "INT"]]), 0, h.start, "values.value_1");
|
|
h.flush();
|
|
const consumer = h.node("Consumer", ["value"], []);
|
|
consumer.inputs[0].type = "INT,FLOAT";
|
|
const id = h.connect(h.get, 0, consumer, "value");
|
|
h.get.disconnectInput(0);
|
|
h.flush();
|
|
expect(h.get.outputs[0].type).toBe("*");
|
|
expect(h.graph.links.has(id)).toBe(true);
|
|
});
|
|
|
|
it("follows reroutes for context and values, and terminates on a cycle", () => {
|
|
const h = setup();
|
|
h.connect(h.node("Source", [], [["value", "FLOAT"]]), 0, h.start, "values.value_1");
|
|
const reroute = h.node("Reroute", ["input"], [["output", "*"]]);
|
|
h.connect(h.end, 3, reroute, "input");
|
|
h.connect(reroute, 0, h.get, "values");
|
|
h.flush();
|
|
expect(h.get.outputs[0].type).toBe("FLOAT");
|
|
h.connect(reroute, 0, reroute, "input");
|
|
h.flush();
|
|
expect(h.get.outputs[0].type).toBe("*");
|
|
});
|
|
|
|
it("recomputes types across sequential regions", () => {
|
|
const h = setup();
|
|
const secondStart = h.node("DistributedImageListStart", ["values.value_1"],
|
|
[["image", "IMAGE"], ["mask", "MASK"], ["item_index", "INT"], ["context", "DISTRIBUTED_IMAGE_LIST_CONTEXT"]]);
|
|
const secondEnd = h.node("DistributedImageListEnd", ["images", "masks", "texts", "context"],
|
|
[["image_list", "IMAGE"], ["masks", "MASK"], ["texts", "STRING"], ["values", PIPE]]);
|
|
const secondGet = h.node("DistributedImageListGetValue", ["values"], [["value", "*"]], [{ name: "value_name", value: "value_1" }]);
|
|
h.connect(h.node("Source", [], [["value", "BOOLEAN"]]), 0, h.start, "values.value_1");
|
|
h.connect(h.get, 0, secondStart, "values.value_1");
|
|
h.connect(secondStart, 3, secondEnd, "context");
|
|
h.connect(secondEnd, 3, secondGet, "values");
|
|
h.flush();
|
|
expect(secondGet.outputs[0].type).toBe("BOOLEAN");
|
|
});
|
|
});
|