1014 lines
66 KiB
JavaScript
1014 lines
66 KiB
JavaScript
// Run with: node --test tests/js/
|
|
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { extractGenerationParams, extractGenerationFacts } = require("../../web/js/comfy-metadata.js");
|
|
|
|
const parsedFactGraph = value => ({ status: "parsed", value });
|
|
const absentFactGraph = () => ({ status: "absent", value: null });
|
|
const factFields = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
|
|
function factsFor(graph, extra = {}) {
|
|
assert.equal(typeof extractGenerationFacts, "function", "the conservative facts contract is exported");
|
|
return extractGenerationFacts({ prompt_graph: parsedFactGraph(graph), workflow_graph: absentFactGraph(), metadataStatus: "available", ...extra });
|
|
}
|
|
function factGraph(inputs = {}) {
|
|
return {
|
|
s: { class_type: "KSampler", inputs: { seed: "9007199254740993", steps: 28, cfg: "6.5000000000000000001", sampler_name: "dpmpp_2m", scheduler: "karras", model: ["model", 0], ...inputs } },
|
|
model: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
|
|
};
|
|
}
|
|
|
|
test("facts keep unsafe seed and decimal strings exact", () => {
|
|
const facts = factsFor(factGraph());
|
|
assert.deepEqual(Object.keys(facts), factFields);
|
|
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "dpmpp_2m", "karras", "model.safetensors"]);
|
|
for (const fact of Object.values(facts)) assert.deepEqual(fact, {
|
|
status: "known", value: fact.value, source: "api-graph", evidence: "configuration", reason: null, sampler_id: "s",
|
|
});
|
|
const zero = factsFor(factGraph({ seed: 0, cfg: "0", steps: "1" }));
|
|
assert.equal(zero.seed.value, "0"); assert.equal(zero.cfg.value, "0");
|
|
for (const inputs of [{ seed: 9007199254740992 }, { seed: "1.5" }, { steps: "0" }, { cfg: "NaN" }, { cfg: {} }, { cfg: true }]) {
|
|
const key = Object.keys(inputs)[0];
|
|
assert.equal(factsFor(factGraph(inputs))[key].status, "unresolved");
|
|
}
|
|
});
|
|
|
|
test("facts do not choose equal sampler branches or unrelated model", () => {
|
|
const graph = factGraph(); graph.other = structuredClone(graph.s);
|
|
const ambiguous = factsFor(graph);
|
|
assert.ok(Object.values(ambiguous).every(fact => fact.status === "ambiguous" && fact.value === null));
|
|
const selected = factsFor(graph, { resolution: { state: "resolved", sampler_id: "other" } });
|
|
assert.equal(selected.seed.sampler_id, "other");
|
|
const unrelated = factsFor(factGraph({ model: ["missing", 0] }));
|
|
assert.equal(unrelated.model.status, "unresolved");
|
|
assert.equal(unrelated.model.value, null);
|
|
assert.equal(factsFor({ only: graph.model }).seed.status, "unresolved");
|
|
});
|
|
|
|
test("facts respect named scalar outputs and custom scheduler paths", () => {
|
|
const graph = factGraph({ seed: ["primitive", 0], steps: ["steps", 0], cfg: ["cfg", 0], sampler_name: ["sampler", 0], scheduler: ["scheduler", 0], model: ["lora", 0] });
|
|
graph.primitive = { class_type: "PrimitiveInt", inputs: { value: "0" } };
|
|
graph.steps = { class_type: "PrimitiveInt", inputs: { value: "50" } };
|
|
graph.cfg = { class_type: "PrimitiveFloat", inputs: { value: "0" } };
|
|
graph.sampler = { class_type: "PrimitiveString", inputs: { value: "euler" } };
|
|
graph.scheduler = { class_type: "PrimitiveStringMultiline", inputs: { value: "normal" } };
|
|
graph.lora = { class_type: "LoraLoader", inputs: { lora_name: "style", model: ["model", 0] } };
|
|
assert.deepEqual(factFields.map(field => factsFor(graph)[field].value), ["0", "50", "0", "euler", "normal", "model.safetensors"]);
|
|
graph.settings = { class_type: "UnknownSettings", inputs: { first: 20, second: 30 } };
|
|
graph.s.inputs.steps = ["settings", 0];
|
|
assert.equal(factsFor(graph).steps.status, "unresolved");
|
|
const custom = {
|
|
s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
|
|
g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: "4.0000000000000001" } },
|
|
n: { class_type: "RandomNoise", inputs: { noise_seed: "9007199254740993" } },
|
|
k: { class_type: "KSamplerSelect", inputs: { sampler_name: "euler" } },
|
|
sigmas: { class_type: "BasicScheduler", inputs: { steps: 22, scheduler: "karras" } },
|
|
m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } },
|
|
};
|
|
assert.deepEqual(factFields.map(field => factsFor(custom)[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]);
|
|
custom.s = { class_type: "SamplerCustom", inputs: { model: ["m", 0], cfg: 0, noise_seed: 0, sampler: ["k", 0], sigmas: ["sigmas", 0] } };
|
|
assert.deepEqual([factsFor(custom).seed.value, factsFor(custom).steps.value, factsFor(custom).cfg.value], ["0", "22", "0"]);
|
|
custom.sigmas.inputs = { steps: 22 };
|
|
assert.equal(factsFor(custom).scheduler.status, "not-recorded");
|
|
assert.equal(factsFor(custom).sampler.value, "euler");
|
|
});
|
|
|
|
test("facts require supported scalar producer types and output slots", () => {
|
|
const cases = [
|
|
["PrimitiveInt", "seed", "42", true], ["PrimitiveFloat", "cfg", "6.5000000000000000001", true],
|
|
["PrimitiveString", "sampler_name", "euler", true], ["PrimitiveStringMultiline", "scheduler", "normal", true],
|
|
["PrimitiveString", "seed", "42", false], ["PrimitiveStringMultiline", "steps", "42", false],
|
|
["PrimitiveInt", "sampler_name", "euler", false], ["PrimitiveFloat", "scheduler", "normal", false],
|
|
["PrimitiveBoolean", "seed", "42", false], ["PrimitiveNode", "seed", "42", false],
|
|
["UnknownSettings", "seed", "42", false], ["SamplerCombo", "steps", "42", false],
|
|
];
|
|
for (const [class_type, input, value, supported] of cases) {
|
|
for (const slot of [0, 1, 999, -1, 0.5]) {
|
|
const graph = factGraph({ [input]: ["settings", slot] });
|
|
graph.settings = { class_type, inputs: { value, [input]: value } };
|
|
const fact = factsFor(graph)[input === "sampler_name" ? "sampler" : input];
|
|
assert.equal(fact.status, supported && slot === 0 ? "known" : "unresolved", `${class_type} output${slot} as ${input}`);
|
|
assert.equal(fact.value, supported && slot === 0 ? value : null);
|
|
}
|
|
}
|
|
const graph = factGraph({ seed: ["primitive", 0] });
|
|
graph.primitive = { class_type: "PrimitiveInt", inputs: { seed: "987" } };
|
|
assert.equal(factsFor(graph).seed.status, "unresolved", "a matching input is not the output's value");
|
|
graph.primitive.inputs = { value: "42", seed: "987" };
|
|
assert.equal(factsFor(graph).seed.value, "42");
|
|
for (const [input, class_type, inputs] of [
|
|
["seed", "RandomNoise", { noise_seed: "42", seed: "42" }],
|
|
["sampler_name", "KSamplerSelect", { sampler_name: "euler" }],
|
|
["steps", "BasicScheduler", { steps: "42" }],
|
|
["cfg", "CFGGuider", { cfg: "7" }],
|
|
]) {
|
|
const graph = factGraph({ [input]: ["provider", 0] }); graph.provider = { class_type, inputs };
|
|
assert.equal(factsFor(graph)[input === "sampler_name" ? "sampler" : input].status, "unresolved", `${class_type} is not a generic scalar output`);
|
|
}
|
|
});
|
|
|
|
function customFactGraph() {
|
|
return {
|
|
s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
|
|
g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: ["cfg", 0] } },
|
|
n: { class_type: "RandomNoise", inputs: { noise_seed: ["seed", 0] } },
|
|
k: { class_type: "KSamplerSelect", inputs: { sampler_name: ["sampler", 0] } },
|
|
sigmas: { class_type: "BasicScheduler", inputs: { steps: ["steps", 0], scheduler: ["scheduler", 0] } },
|
|
seed: { class_type: "PrimitiveInt", inputs: { value: "9007199254740993" } },
|
|
cfg: { class_type: "PrimitiveFloat", inputs: { value: "4.0000000000000001" } },
|
|
steps: { class_type: "PrimitiveInt", inputs: { value: 22 } },
|
|
sampler: { class_type: "PrimitiveString", inputs: { value: "euler" } },
|
|
scheduler: { class_type: "PrimitiveString", inputs: { value: "karras" } },
|
|
m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } },
|
|
};
|
|
}
|
|
|
|
test("facts require supported custom providers and guider outputs", () => {
|
|
assert.deepEqual(factFields.map(field => factsFor(customFactGraph())[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]);
|
|
for (const [input, nodeId, fields] of [["noise", "n", ["seed"]], ["sampler", "k", ["sampler"]], ["sigmas", "sigmas", ["steps", "scheduler"]], ["guider", "g", ["cfg", "model"]]]) {
|
|
for (const slot of [1, 999, -1, 0.5]) {
|
|
const graph = customFactGraph(); graph.s.inputs[input] = [nodeId, slot];
|
|
for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${input} output${slot} ${field}`);
|
|
}
|
|
for (const class_type of ["UnknownProvider", "PrimitiveInt", "PrimitiveString", "BasicGuider"]) {
|
|
if (input === "guider" && class_type === "BasicGuider") continue;
|
|
const graph = customFactGraph(); graph[nodeId].class_type = class_type;
|
|
for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${class_type} cannot provide ${input}`);
|
|
}
|
|
}
|
|
const basic = customFactGraph(); basic.g = { class_type: "BasicGuider", inputs: { model: ["m", 0] } };
|
|
assert.equal(factsFor(basic).model.value, "flux.safetensors");
|
|
assert.equal(factsFor(basic).cfg.status, "not-recorded");
|
|
});
|
|
|
|
test("facts require model output ancestry with supported loaders and passthroughs", () => {
|
|
for (const [class_type, key] of [["CheckpointLoader", "ckpt_name"], ["CheckpointLoaderSimple", "ckpt_name"], ["unCLIPCheckpointLoader", "ckpt_name"], ["UNETLoader", "unet_name"]]) {
|
|
for (const slot of [0, 1, 2, 999, -1, 0.5]) {
|
|
const graph = factGraph({ model: ["loader", slot] });
|
|
graph.loader = { class_type, inputs: { [key]: "selected.safetensors" } };
|
|
assert.equal(factsFor(graph).model.value, slot === 0 ? "selected.safetensors" : null, `${class_type} output${slot}`);
|
|
assert.equal(factsFor(graph).model.status, slot === 0 ? "known" : "unresolved");
|
|
}
|
|
}
|
|
for (const class_type of ["LoraLoader", "LoraLoaderModelOnly", "UnknownModelPassThrough"]) {
|
|
for (const slot of [0, 1, 2, 999]) {
|
|
const graph = factGraph({ model: ["lora", slot] }); graph.lora = { class_type, inputs: { model: ["model", 0] } };
|
|
assert.equal(factsFor(graph).model.status, class_type !== "UnknownModelPassThrough" && slot === 0 ? "known" : "unresolved", `${class_type} output${slot}`);
|
|
}
|
|
}
|
|
for (const [class_type, inputs] of [["UnknownLoader", { ckpt_name: "fake" }], ["PrimitiveString", { model_name: "fake", value: "fake" }], ["__proto__", { "[object Object]": "fake" }]]) {
|
|
const graph = factGraph({ model: ["unknown", 0] }); graph.unknown = { class_type, inputs };
|
|
assert.equal(factsFor(graph).model.status, "unresolved");
|
|
}
|
|
for (const [input, field] of [["sampler_name", "sampler"], ["scheduler", "scheduler"]]) assert.equal(factsFor(factGraph({ [input]: 42 }))[field].status, "unresolved");
|
|
const numericModel = factGraph(); numericModel.model.inputs.ckpt_name = 42;
|
|
assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
|
|
});
|
|
|
|
// D19: a FaceDetailer re-samples the KSampler's decoded image with its own settings.
|
|
function detailerFactGraph(detailer = {}) {
|
|
const graph = factGraph({ seed: "42", steps: 25, cfg: "6.5", sampler_name: "euler", scheduler: "normal" });
|
|
graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["model", 2] } };
|
|
graph.d = { class_type: "FaceDetailer", inputs: { image: ["decode", 0], model: ["model", 0], seed: "7", steps: 12, cfg: "6.5", sampler_name: "euler", scheduler: "normal", denoise: "0.4", ...detailer } };
|
|
return graph;
|
|
}
|
|
const statusValues = facts => Object.fromEntries(factFields.map(field => [field, [facts[field].status, facts[field].value]]));
|
|
|
|
test("facts use resolution.sampler_id for detailer-stage nodes", () => {
|
|
// Resolved (the stages' prompts agree): the final stage the server named supplies every fact.
|
|
const resolved = factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "d" } });
|
|
assert.deepEqual(statusValues(resolved), {
|
|
seed: ["known", "7"], steps: ["known", "12"], cfg: ["known", "6.5"],
|
|
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
|
});
|
|
assert.ok(factFields.every(field => resolved[field].sampler_id === "d" && resolved[field].evidence === "configuration"));
|
|
// The upstream KSampler is also accepted when it is the one resolved.
|
|
assert.equal(factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "s" } }).seed.value, "42");
|
|
// Ambiguous stages: merged per field, never the detailer's or the upstream value alone.
|
|
const ambiguous = factsFor(detailerFactGraph(), { resolution: { state: "ambiguous", sampler_id: "d", reason_code: "multistage-differs" } });
|
|
assert.deepEqual(statusValues(ambiguous), {
|
|
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
|
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
|
});
|
|
assert.ok(factFields.every(field => ambiguous[field].sampler_id === null), "a merged fact names no single sampler");
|
|
// A field one stage cannot read and the others agree on stays unresolved; not-recorded is not agreement.
|
|
const unreadable = factsFor(detailerFactGraph({ cfg: ["missing", 0] }), { resolution: { state: "ambiguous", sampler_id: "d" } });
|
|
assert.deepEqual([unreadable.cfg.status, unreadable.cfg.value], ["unresolved", null]);
|
|
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
|
|
assert.equal(factsFor(unrecorded, { resolution: { state: "ambiguous", sampler_id: "d" } }).scheduler.status, "unresolved");
|
|
// Two detailer stages over one KSampler: three stages merge.
|
|
const chained = detailerFactGraph({ seed: "42" });
|
|
chained.d2 = { class_type: "FaceDetailer", inputs: { image: ["d", 0], model: ["model", 0], seed: "42", steps: 25, cfg: "3", sampler_name: "euler", scheduler: "normal" } };
|
|
assert.deepEqual(statusValues(factsFor(chained, { resolution: { state: "ambiguous", sampler_id: "d2" } })), {
|
|
seed: ["known", "42"], steps: ["ambiguous", null], cfg: ["ambiguous", null],
|
|
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
|
});
|
|
// Without a resolution, two samplers stay ambiguous (today's rules, now over the samplers table).
|
|
assert.ok(Object.values(factsFor(detailerFactGraph())).every(fact => fact.status === "ambiguous"));
|
|
// A sampler_id the graph or the samplers table does not hold never falls back to a guess.
|
|
for (const sampler_id of ["missing", "decode", "model", "__proto__"]) {
|
|
for (const state of ["resolved", "ambiguous"]) {
|
|
const facts = factsFor(detailerFactGraph(), { resolution: { state, sampler_id } });
|
|
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null), `${state} ${sampler_id}`);
|
|
}
|
|
}
|
|
// Stage ancestry that cannot be walked is unresolved, not a partial merge.
|
|
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
|
const unlinked = detailerFactGraph({ image: "not a link" });
|
|
for (const graph of [cyclic, unlinked]) {
|
|
assert.ok(Object.values(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
|
|
}
|
|
const wide = detailerFactGraph();
|
|
let previous = ["s", 0];
|
|
for (let index = 0; index < 600; index++) { wide[`hop${index}`] = { class_type: "ImageScale", inputs: { image: previous } }; previous = [`hop${index}`, 0]; }
|
|
wide.decode.inputs.samples = ["s", 0]; wide.d.inputs.image = previous;
|
|
assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS");
|
|
});
|
|
|
|
test("ambiguous results without a sampler_id merge facts across every listed stage", () => {
|
|
const ambiguous = { resolution: { state: "ambiguous", sampler_id: null, reason: "Multiple sampler branches" } };
|
|
// f01b's shape: an upstream sampler and a detailer whose settings partly differ.
|
|
assert.deepEqual(statusValues(factsFor(detailerFactGraph(), ambiguous)), {
|
|
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
|
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
|
});
|
|
assert.ok(Object.values(factsFor(detailerFactGraph(), ambiguous)).every(fact => fact.sampler_id === null));
|
|
// Equal independent branches agree on every field once the server says ambiguous;
|
|
// without a resolution they stay ambiguous (today's rules, reversed in PR3).
|
|
const equal = factGraph(); equal.other = structuredClone(equal.s);
|
|
assert.ok(Object.values(factsFor(equal, ambiguous)).every(fact => fact.status === "known"));
|
|
assert.ok(Object.values(factsFor(equal)).every(fact => fact.status === "ambiguous"));
|
|
// A walk that fails keeps every field unresolved; so does a graph with no listed sampler.
|
|
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
|
const broken = detailerFactGraph(); broken.d.inputs.image = ["gone", 0];
|
|
for (const graph of [cyclic, broken, { model: factGraph().model }]) {
|
|
assert.ok(Object.values(factsFor(graph, ambiguous)).every(fact => fact.status === "unresolved" && fact.value === null));
|
|
}
|
|
const many = factGraph();
|
|
for (let index = 0; index < 40; index++) many[`s${index}`] = structuredClone(many.s);
|
|
assert.ok(Object.values(factsFor(many, ambiguous)).every(fact => fact.status === "unresolved"), "more stages than the candidate cap");
|
|
});
|
|
|
|
test("stage walks fail on unlisted sampler-shaped nodes like the resolver", () => {
|
|
const shaped = [
|
|
{ positive: ["model", 0] }, { latent_image: ["model", 0] }, { guider: ["model", 0] }, { negative: ["model", 0] },
|
|
{ pipe: ["model", 0] }, { basic_pipe: ["model", 0] }, { detailer_pipe: ["model", 0] }, { sampler: ["model", 0] },
|
|
{ sigmas: ["model", 0] }, { noise: ["model", 0] }, { text_embeds: ["model", 0] }, { hyvid_embeds: ["model", 0] },
|
|
{ model: ["model", 0], conditioning: ["model", 0] }, { model: ["model", 0], neg_conditioning: ["model", 0] },
|
|
{ model: ["model", 0], samples: ["model", 0] }, { model: ["model", 0], latent: ["model", 0] },
|
|
];
|
|
for (const inputs of shaped) {
|
|
const graph = detailerFactGraph();
|
|
graph.efficient = { class_type: "KSampler (Efficient)", inputs: { ...inputs, image: ["decode", 0] } };
|
|
graph.d.inputs.image = ["efficient", 0];
|
|
for (const sampler_id of ["d", null]) {
|
|
const facts = factsFor(graph, { resolution: { state: "ambiguous", sampler_id } });
|
|
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved"), `${JSON.stringify(inputs)} ${sampler_id}`);
|
|
}
|
|
}
|
|
// Model patches (model alone, or with clip) and listed table classes are walked through.
|
|
for (const [class_type, inputs] of [["ModelPatch", { model: ["model", 0] }], ["LoraPatch", { model: ["model", 0], clip: ["model", 1] }], ["ImageScale", { samples: ["model", 0] }], ["CFGGuider", { model: ["model", 0], positive: ["model", 0] }]]) {
|
|
const graph = detailerFactGraph();
|
|
graph.hop = { class_type, inputs: { ...inputs, image: ["decode", 0] } };
|
|
graph.d.inputs.image = ["hop", 0];
|
|
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", class_type);
|
|
}
|
|
});
|
|
|
|
test("unresolved and absent results over several stages merge facts per field", () => {
|
|
// Review I-1: the run's settings are facts even when its prompt is not, e.g. an
|
|
// upstream prompt behind an unsupported node (LoRAfromText) leaves the server unresolved.
|
|
const merged = {
|
|
seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"],
|
|
sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"],
|
|
};
|
|
for (const resolution of [
|
|
{ state: "unresolved", sampler_id: "d", reason_code: "unsupported-node" },
|
|
{ state: "unresolved", sampler_id: null, reason_code: "malformed" },
|
|
{ state: "absent", sampler_id: "d" },
|
|
]) {
|
|
const facts = factsFor(detailerFactGraph(), { resolution });
|
|
assert.deepEqual(statusValues(facts), merged, JSON.stringify(resolution));
|
|
assert.ok(factFields.every(field => facts[field].sampler_id === null));
|
|
}
|
|
// A stage missing a field leaves it unresolved; a failed walk leaves everything unresolved.
|
|
const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler;
|
|
assert.equal(factsFor(unrecorded, { resolution: { state: "unresolved", sampler_id: "d" } }).scheduler.status, "unresolved");
|
|
const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0];
|
|
assert.ok(Object.values(factsFor(cyclic, { resolution: { state: "unresolved", sampler_id: "d" } })).every(fact => fact.status === "unresolved"));
|
|
// One listed sampler keeps today's rule: its own facts, attributed to it.
|
|
const single = factsFor(factGraph(), { resolution: { state: "unresolved", sampler_id: "s" } });
|
|
assert.ok(factFields.every(field => single[field].status === "known" && single[field].sampler_id === "s"));
|
|
});
|
|
|
|
test("stage walks follow only the resolver's links", () => {
|
|
// Review M-2: `["eff", -1]` is not a link to the server, so it is not walked.
|
|
for (const slot of [-1, 0.5, "0"]) {
|
|
const graph = detailerFactGraph();
|
|
graph.eff = { class_type: "KSampler (Efficient)", inputs: { positive: ["model", 0], model: ["model", 0] } };
|
|
graph.decode.inputs.extra = ["eff", slot];
|
|
assert.equal(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", String(slot));
|
|
const shaped = detailerFactGraph();
|
|
shaped.eff = { class_type: "Unlisted", inputs: { positive: ["model", slot], image: ["decode", 0] } };
|
|
shaped.d.inputs.image = ["eff", 0];
|
|
assert.equal(factsFor(shaped, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "ambiguous", `shaped ${slot}`);
|
|
}
|
|
const linked = detailerFactGraph();
|
|
linked.eff = { class_type: "KSampler (Efficient)", inputs: { positive: ["model", 0], model: ["model", 0] } };
|
|
linked.decode.inputs.extra = ["eff", 0];
|
|
assert.equal(factsFor(linked, { resolution: { state: "ambiguous", sampler_id: "d" } }).seed.status, "unresolved");
|
|
});
|
|
|
|
test("Flux2Scheduler provides steps and scheduler is its node type", () => {
|
|
const graph = customFactGraph();
|
|
graph.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 4, width: 1024, height: 1024 } };
|
|
const facts = factsFor(graph);
|
|
assert.deepEqual([facts.steps.status, facts.steps.value], ["known", "4"]);
|
|
assert.deepEqual([facts.scheduler.status, facts.scheduler.value, facts.scheduler.evidence], ["known", "Flux2Scheduler", "configuration"]);
|
|
for (const slot of [1, -1]) {
|
|
const linked = customFactGraph(); linked.sigmas = graph.sigmas; linked.s.inputs.sigmas = ["sigmas", slot];
|
|
assert.equal(factsFor(linked).scheduler.status, "unresolved", `output${slot}`);
|
|
}
|
|
// Its own inputs cannot replace the node type, and steps keep exact-integer rules.
|
|
const named = customFactGraph(); named.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 0, scheduler: "karras" } };
|
|
assert.equal(factsFor(named).scheduler.value, "Flux2Scheduler");
|
|
assert.equal(factsFor(named).steps.status, "unresolved");
|
|
});
|
|
|
|
test("ModelSampling passthroughs reach UNETLoader", () => {
|
|
const graph = customFactGraph();
|
|
graph.g.inputs.model = ["flux", 0];
|
|
graph.flux = { class_type: "ModelSamplingFlux", inputs: { model: ["aura", 0], max_shift: "1.15", base_shift: "0.5" } };
|
|
graph.aura = { class_type: "ModelSamplingAuraFlow", inputs: { model: ["sd3", 0], shift: "1.73" } };
|
|
graph.sd3 = { class_type: "ModelSamplingSD3", inputs: { model: ["lora", 0], shift: "3" } };
|
|
graph.lora = { class_type: "LoraLoaderModelOnly", inputs: { model: ["m", 0], lora_name: "style", strength_model: "1" } };
|
|
assert.deepEqual([factsFor(graph).model.status, factsFor(graph).model.value], ["known", "flux.safetensors"]);
|
|
for (const node of ["flux", "aura", "sd3"]) {
|
|
const wrongSlot = structuredClone(graph);
|
|
const parent = { flux: "g", aura: "flux", sd3: "aura" }[node];
|
|
wrongSlot[parent].inputs.model = [node, 1];
|
|
assert.equal(factsFor(wrongSlot).model.status, "unresolved", `${node} output1`);
|
|
const unlisted = structuredClone(graph); unlisted[node].class_type = "ModelSamplingUnknown";
|
|
assert.equal(factsFor(unlisted).model.status, "unresolved", `${node} unlisted`);
|
|
}
|
|
});
|
|
|
|
test("vectors.json pins full facts for every inspection fixture", () => {
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "fixtures", "inspection_parity", "vectors.json"), "utf8"));
|
|
const inspection = vectors.filter(vector => vector.profile === "inspection");
|
|
assert.ok(inspection.length > 0);
|
|
const statuses = new Set(["known", "ambiguous", "unresolved", "not-recorded"]);
|
|
for (const vector of inspection) {
|
|
const facts = vector.expect.facts;
|
|
assert.deepEqual(Object.keys(facts), factFields, vector.name);
|
|
for (const field of factFields) {
|
|
assert.ok(statuses.has(facts[field].status), `${vector.name} ${field}`);
|
|
assert.equal(facts[field].value === null, facts[field].status !== "known", `${vector.name} ${field} value`);
|
|
}
|
|
}
|
|
// f19: KSampler seed 42 / steps 25 / cfg 6.5 then FaceDetailer seed 7 / steps 12 / cfg 4.5, different prompts.
|
|
const f19 = inspection.find(vector => vector.fixture === "f19");
|
|
assert.equal(f19.expect.state, "ambiguous");
|
|
assert.deepEqual(Object.fromEntries(factFields.map(field => [field, f19.expect.facts[field].status])), {
|
|
seed: "ambiguous", steps: "ambiguous", cfg: "ambiguous", sampler: "known", scheduler: "known", model: "known",
|
|
});
|
|
});
|
|
|
|
test("wide workflow lookups retain selected core sampler facts", () => {
|
|
const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = [];
|
|
let linkId = 1;
|
|
for (let index = 0; index < 20; index++) {
|
|
nodes.push({ id: `r${index}`, type: "Reroute", inputs: [{ name: "value", link: linkId }] });
|
|
links.push([linkId++, index ? `r${index - 1}` : "text", 0, `r${index}`, 0, "STRING"]);
|
|
}
|
|
for (let index = 0; index < 40; index++) {
|
|
nodes.push({ id: `encoder${index}`, type: "CLIPTextEncode", inputs: [{ name: "text", link: linkId }] });
|
|
links.push([linkId++, "r19", 0, `encoder${index}`, 0, "STRING"]);
|
|
}
|
|
nodes.push({ id: "model", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } },
|
|
{ id: "steps", type: "PrimitiveInt", widgets_values_named: { value: 28 } },
|
|
{ id: "s", type: "KSampler", widgets_values: ["9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal"], inputs: [{ name: "model", link: linkId }, { name: "steps", link: linkId + 1 }] });
|
|
links.push([linkId, "model", 0, "s", 0, "MODEL"], [linkId + 1, "steps", 0, "s", 1, "INT"]);
|
|
const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph({ nodes, links }) });
|
|
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "selected.safetensors"]);
|
|
});
|
|
|
|
test("workflow facts require supported passthrough output slots and bypass ancestry", () => {
|
|
function workflow(type, slot, mode) {
|
|
const pass = { id: "pass", type, ...(mode === undefined ? {} : { mode }), widgets_values: ["selected"], inputs: [{ name: type === "Reroute" ? "value" : "model", type: "MODEL", link: 1 }] };
|
|
const nodes = [{ id: "m", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } }, pass,
|
|
{ id: "s", type: "KSampler", widgets_values: [0, "fixed", 28, "0", "euler", "normal"], inputs: [{ name: "model", link: 2 }] }];
|
|
if (type === "GetNode") {
|
|
nodes.push({ id: "set", type: "SetNode", widgets_values: ["selected"], inputs: [{ name: "model", link: 1 }] });
|
|
pass.inputs = [];
|
|
}
|
|
return { nodes, links: [[1, "m", 0, "pass", 0, "MODEL"], [2, "pass", slot, "s", 0, "MODEL"]] };
|
|
}
|
|
const model = value => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(value) }).model;
|
|
for (const [type, mode] of [["Reroute", undefined], ["GetNode", undefined], ["SetNode", undefined], ["LoraLoader", 4], ["LoraLoaderModelOnly", 4]]) {
|
|
for (const slot of [0, 1, 999, -1, 0.5]) {
|
|
assert.equal(model(workflow(type, slot, mode)).status, slot === 0 ? "known" : "unresolved", `${type} output${slot}`);
|
|
}
|
|
}
|
|
assert.equal(model(workflow("UnknownBypass", 0, 4)).status, "unresolved");
|
|
const duplicate = workflow("LoraLoader", 0, 4);
|
|
duplicate.nodes[1].inputs.push({ name: "model", type: "MODEL", link: 1 });
|
|
assert.equal(model(duplicate).status, "unresolved", "bypass model input is uniquely named");
|
|
const wrongOrigin = workflow("Reroute", 0); wrongOrigin.links[0][2] = 1;
|
|
assert.equal(model(wrongOrigin).status, "unresolved", "passthrough cannot turn CLIP into MODEL");
|
|
const cycle = workflow("Reroute", 0); cycle.links[0][1] = "pass";
|
|
assert.equal(model(cycle).status, "unresolved");
|
|
const unnamed = workflow("GetNode", 0);
|
|
delete unnamed.nodes.find(node => node.id === "pass").widgets_values;
|
|
delete unnamed.nodes.find(node => node.id === "set").widgets_values;
|
|
assert.equal(model(unnamed).status, "unresolved", "missing Get/Set names are not a source association");
|
|
const scalar = factGraph({ seed: ["bypass", 0] });
|
|
scalar.bypass = { class_type: "LoraLoader", inputs: { model: ["primitive", 0] } };
|
|
scalar.primitive = { class_type: "PrimitiveInt", inputs: { value: 42 } };
|
|
const scalarWorkflow = factWorkflowFromGraph(scalar); scalarWorkflow.nodes.find(node => node.id === "bypass").mode = 4;
|
|
assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(scalarWorkflow) }).seed.status, "unresolved", "bypassed MODEL output cannot become a scalar");
|
|
for (const [input, field] of [["noise", "seed"], ["sampler", "sampler"], ["sigmas", "steps"], ["guider", "model"]]) {
|
|
const graph = customFactGraph(), target = graph.s.inputs[input];
|
|
graph.s.inputs[input] = ["bypass", 0]; graph.bypass = { class_type: "LoraLoaderModelOnly", inputs: { model: target } };
|
|
const workflow = factWorkflowFromGraph(graph); workflow.nodes.find(node => node.id === "bypass").mode = 4;
|
|
assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })[field].status, "unresolved", `MODEL output cannot become ${input}`);
|
|
}
|
|
});
|
|
|
|
function factWorkflowFromGraph(graph) {
|
|
const nodes = [], links = [];
|
|
for (const [id, node] of Object.entries(graph)) {
|
|
const inputs = [], widgets_values_named = {};
|
|
for (const [name, value] of Object.entries(node.inputs)) {
|
|
if (Array.isArray(value)) {
|
|
const link = links.length + 1;
|
|
links.push([link, value[0], value[1], id, inputs.length, "*"]);
|
|
inputs.push({ name, link });
|
|
} else widgets_values_named[name] = value;
|
|
}
|
|
nodes.push({ id, type: node.class_type, inputs, widgets_values_named });
|
|
}
|
|
return { nodes, links };
|
|
}
|
|
|
|
test("workflow facts count combined original ancestry once across passthroughs and providers", () => {
|
|
const inspect = graph => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(factWorkflowFromGraph(graph)) });
|
|
function chain(graph, reroutes, primitives, model = false) {
|
|
let link = model ? ["model", 0] : null;
|
|
for (let index = 0; index < primitives; index++) {
|
|
graph[`p${index}`] = { class_type: model ? "LoraLoader" : "PrimitiveInt", inputs: model ? { model: link } : { value: link || "42" } };
|
|
link = [`p${index}`, 0];
|
|
}
|
|
for (let index = 0; index < reroutes; index++) {
|
|
graph[`r${index}`] = { class_type: "Reroute", inputs: { value: link } };
|
|
link = [`r${index}`, 0];
|
|
}
|
|
return link;
|
|
}
|
|
for (const [reroutes, known] of [[32, true], [33, false]]) {
|
|
const scalar = factGraph(); scalar.s.inputs.seed = chain(scalar, reroutes, 32);
|
|
assert.equal(inspect(scalar).seed.status, known ? "known" : "unresolved", `${reroutes}+32 scalar links`);
|
|
const model = factGraph(); model.s.inputs.model = chain(model, reroutes, 31, true);
|
|
assert.equal(inspect(model).model.status, known ? "known" : "unresolved", `${reroutes}+31 LoRA+loader links`);
|
|
const noise = customFactGraph();
|
|
noise.n.inputs.noise_seed = chain(noise, reroutes - 1, 31);
|
|
noise.noiseAlias = { class_type: "Reroute", inputs: { value: ["n", 0] } };
|
|
noise.s.inputs.noise = ["noiseAlias", 0];
|
|
assert.equal(inspect(noise).seed.status, known ? "known" : "unresolved", `noise alias+provider+${reroutes - 1}+31 links`);
|
|
const guider = customFactGraph();
|
|
guider.model = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } };
|
|
guider.g.inputs.model = chain(guider, reroutes - 1, 30, true);
|
|
guider.guiderAlias = { class_type: "Reroute", inputs: { value: ["g", 0] } };
|
|
guider.s.inputs.guider = ["guiderAlias", 0];
|
|
assert.equal(inspect(guider).model.status, known ? "known" : "unresolved", `guider alias+provider+${reroutes - 1}+30 LoRA+loader links`);
|
|
}
|
|
const cycle = factGraph();
|
|
cycle.s.inputs.seed = ["r", 0]; cycle.r = { class_type: "Reroute", inputs: { value: ["p", 0] } }; cycle.p = { class_type: "PrimitiveInt", inputs: { value: ["r", 0] } };
|
|
assert.equal(inspect(cycle).seed.status, "unresolved");
|
|
cycle.s.inputs.model = ["r", 0]; cycle.p = { class_type: "LoraLoader", inputs: { model: ["r", 0] } };
|
|
assert.equal(inspect(cycle).model.status, "unresolved");
|
|
for (const [reroutes, known] of [[32, true], [33, false]]) {
|
|
const graph = factGraph(), value = chain(graph, reroutes, 31);
|
|
const workflow = factWorkflowFromGraph(graph);
|
|
const sourceLink = workflow.links.length + 1;
|
|
workflow.nodes.push({ id: "set", type: "SetNode", widgets_values: ["seed"], inputs: [{ name: "value", link: sourceLink }] }, { id: "get", type: "GetNode", widgets_values: ["seed"], inputs: [] });
|
|
workflow.links.push([sourceLink, value[0], 0, "set", 0, "INT"], [sourceLink + 1, "get", 0, "s", 0, "INT"]);
|
|
workflow.nodes.find(node => node.id === "s").inputs.push({ name: "seed", link: sourceLink + 1 });
|
|
const fact = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) }).seed;
|
|
assert.equal(fact.status, known ? "known" : "unresolved", `Get+${reroutes}+31 links, no extra setter expansion`);
|
|
}
|
|
});
|
|
|
|
test("workflow sampler selection retains prototype-named node identities", () => {
|
|
const workflow = { nodes: [
|
|
{ id: "__proto__", type: "KSampler", widgets_values: [1, "fixed", 20, "7", "euler", "normal"] },
|
|
{ id: "other", type: "KSampler", widgets_values: [2, "fixed", 20, "7", "euler", "normal"] },
|
|
], links: [] };
|
|
assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "ambiguous"));
|
|
});
|
|
|
|
test("workflow-only facts are configuration without creating sendable roles", () => {
|
|
const workflow = { nodes: [
|
|
{ id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"] },
|
|
{ id: 2, type: "SetNode", widgets_values: ["model"], inputs: [{ name: "MODEL", link: 1 }] },
|
|
{ id: 3, type: "GetNode", widgets_values: ["model"] },
|
|
{ id: 4, type: "KSamplerAdvanced", widgets_values: ["enable", "9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal", 0, 28, "disable"], inputs: [{ name: "model", link: 2 }] },
|
|
], links: [[1, 1, 0, 2, 0, "MODEL"], [2, 3, 0, 4, 0, "MODEL"]] };
|
|
const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) });
|
|
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "flux.safetensors"]);
|
|
assert.ok(Object.values(facts).every(fact => fact.source === "workflow" && fact.evidence === "configuration"));
|
|
assert.equal(facts.positive, undefined);
|
|
workflow.nodes.push({ ...workflow.nodes[3] });
|
|
assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "unresolved"));
|
|
});
|
|
|
|
test("limited projection stays unresolved", () => {
|
|
for (const extra of [
|
|
{ prompt_graph: { status: "limited", value: null } },
|
|
{ prompt_graph: absentFactGraph(), workflow_graph: { status: "limited", value: null } },
|
|
{ prompt_graph: absentFactGraph(), metadataStatus: "limited", parameters: null },
|
|
]) {
|
|
const facts = factsFor(null, extra);
|
|
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null && fact.reason === "projection-limited"));
|
|
}
|
|
const unreadable = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "unreadable" });
|
|
assert.equal(unreadable.seed.reason, "metadata-unreadable");
|
|
const absent = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "absent" });
|
|
assert.equal(absent.seed.status, "not-recorded");
|
|
});
|
|
|
|
test("facts preserve exact video-projected decimals without reading reserialized raw", () => {
|
|
const graph = factGraph();
|
|
const facts = factsFor(graph, { recordFormats: { prompt: "reserialized-json" }, raw: { prompt: '{"s":{"inputs":{"seed":9007199254740992,"cfg":6.5}}}' } });
|
|
assert.equal(facts.seed.value, "9007199254740993");
|
|
assert.equal(facts.cfg.value, "6.5000000000000000001");
|
|
assert.equal(factsFor(factGraph({ cfg: "1E+1000000000" })).cfg.value, "1E+1000000000");
|
|
});
|
|
|
|
test("nontext parameters remain unresolved", () => {
|
|
const parameters = "A portrait\nNegative prompt: blur\nSteps: 28, Sampler: Euler, Schedule type: Karras, CFG scale: 6.5000000000000000001, Seed: 9007199254740993, Model: model";
|
|
const base = { prompt_graph: absentFactGraph(), parameters };
|
|
for (const format of [undefined, "reserialized-json", "invalid"]) {
|
|
const facts = factsFor(null, { ...base, recordFormats: format ? { parameters: format } : {} });
|
|
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null));
|
|
}
|
|
const facts = factsFor(null, { ...base, recordFormats: { parameters: "original-text" } });
|
|
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "Euler", "Karras", "model"]);
|
|
assert.ok(Object.values(facts).every(fact => fact.source === "embedded-parameters" && fact.evidence === "embedded-parameters"));
|
|
for (const invalid of [parameters + "\nSteps: 5, Seed: 2", parameters + ", Seed: 1", parameters + "\ntrailing", parameters.replace("Seed: 9007199254740993", "Seed: -1"), parameters.replace("Karras", "invented"), parameters.replace("Negative prompt: blur", "Negative prompt: blur\nNegative prompt: again")]) {
|
|
assert.ok(Object.values(factsFor(null, { ...base, parameters: invalid, recordFormats: { parameters: "original-text" } })).some(fact => fact.status === "unresolved"));
|
|
}
|
|
});
|
|
|
|
test("malformed or unsupported API evidence blocks plausible workflow and parameter fallback", () => {
|
|
const workflow = { nodes: [{ id: 1, type: "KSampler", widgets_values: [42, "fixed", 20, 7, "euler", "normal"] }], links: [] };
|
|
const extra = { workflow_graph: parsedFactGraph(workflow), parameters: "portrait\nSteps: 20, Sampler: Euler, CFG scale: 7, Seed: 42", recordFormats: { parameters: "original-text" } };
|
|
for (const prompt_graph of [{ status: "malformed", value: null }, parsedFactGraph({ s: { class_type: "UnknownSampler", inputs: { seed: 99 } } }), parsedFactGraph({ s: { class_type: "KSampler", inputs: [] } })]) {
|
|
const facts = factsFor(null, { ...extra, prompt_graph });
|
|
assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null));
|
|
assert.ok(Object.values(facts).every(fact => fact.source !== "workflow" && fact.source !== "embedded-parameters"));
|
|
}
|
|
});
|
|
|
|
test("conservative facts bound cycles fan-out visits and link depth", () => {
|
|
const graph = factGraph({ seed: ["loop", 0], model: ["loop", 0] });
|
|
graph.loop = { class_type: "Unknown", inputs: { seed: ["loop", 0], model: ["loop", 0], a: ["loop", 0], b: ["loop", 0] } };
|
|
assert.equal(factsFor(graph).seed.status, "unresolved");
|
|
assert.equal(factsFor(graph).model.status, "unresolved");
|
|
graph.loop = { class_type: "PrimitiveInt", inputs: { value: ["loop", 0] } };
|
|
assert.equal(factsFor(graph).seed.status, "unresolved", "registered scalar cycle is bounded");
|
|
graph.loop = { class_type: "LoraLoader", inputs: { model: ["loop", 0] } };
|
|
assert.equal(factsFor(graph).model.status, "unresolved", "registered model cycle is bounded");
|
|
const chain = depth => {
|
|
const value = factGraph({ seed: ["p0", 0] });
|
|
for (let index = 0; index < depth; index++) value[`p${index}`] = { class_type: "PrimitiveInt", inputs: { value: index === depth - 1 ? "42" : [`p${index + 1}`, 0] } };
|
|
return value;
|
|
};
|
|
assert.equal(factsFor(chain(64)).seed.value, "42");
|
|
assert.equal(factsFor(chain(65)).seed.status, "unresolved");
|
|
const custom = chain(64);
|
|
custom.s = { class_type: "SamplerCustomAdvanced", inputs: { noise: ["noise", 0] } };
|
|
custom.noise = { class_type: "RandomNoise", inputs: { noise_seed: ["p0", 0] } };
|
|
assert.equal(factsFor(custom).seed.status, "unresolved", "the provider itself consumes one of the 64 allowed links");
|
|
});
|
|
|
|
// Spec section 10: graph size is not a facts bound. Only the per-lookup visit
|
|
// set (MAX_VISITS) and the projection's 20,000-node ceiling (`limited`) bound work.
|
|
test("facts resolve on a parsed 600-node graph with one KSampler", () => {
|
|
const large = factGraph();
|
|
for (let index = 0; index < 598; index++) large[`unused${index}`] = { class_type: "PrimitiveInt", inputs: { value: index } };
|
|
assert.equal(Object.keys(large).length, 600);
|
|
const facts = factsFor(large);
|
|
for (const [field, value] of [["seed", "9007199254740993"], ["steps", "28"], ["cfg", "6.5000000000000000001"]]) {
|
|
assert.equal(facts[field].status, "known", field);
|
|
assert.equal(facts[field].value, value);
|
|
assert.equal(facts[field].reason, null);
|
|
}
|
|
});
|
|
|
|
test("workflow facts are not gated by UI node count", () => {
|
|
const nodes = [
|
|
{ id: 1, type: "CheckpointLoaderSimple", widgets_values: ["wide.safetensors"] },
|
|
{ id: 2, type: "KSampler", widgets_values: [42, "fixed", 20, "7", "euler", "normal"], inputs: [{ name: "model", link: 1 }] },
|
|
];
|
|
for (let index = 0; index < 598; index++) nodes.push({ id: `note${index}`, type: "Note", widgets_values: [`note ${index}`] });
|
|
const workflow = { nodes, links: [[1, 1, 0, 2, 0, "MODEL"]] };
|
|
const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) });
|
|
assert.deepEqual(factFields.map(field => facts[field].status), Array(6).fill("known"));
|
|
assert.deepEqual(factFields.map(field => facts[field].value), ["42", "20", "7", "euler", "normal", "wide.safetensors"]);
|
|
assert.ok(Object.values(facts).every(fact => fact.source === "workflow" && fact.reason === null));
|
|
});
|
|
|
|
// Same shape as the workflow attached to #75: PromptManager nodes are saved as
|
|
// CLIPTextEncode with extra inputs, and the checkpoint loader is pysssss'.
|
|
const issue75Prompt = {
|
|
1: {
|
|
class_type: "KSampler",
|
|
inputs: {
|
|
seed: 480655185510613, steps: 25, cfg: 5.5,
|
|
sampler_name: "euler_ancestral", scheduler: "normal", denoise: 0.8,
|
|
model: ["20", 0], positive: ["39", 0], negative: ["40", 0], latent_image: ["36", 0],
|
|
},
|
|
},
|
|
8: { class_type: "CLIPSetLastLayer", inputs: { stop_at_clip_layer: -2, clip: ["20", 1] } },
|
|
20: {
|
|
class_type: "CheckpointLoader|pysssss",
|
|
inputs: { ckpt_name: "Anime\\Model-XL.safetensors", prompt: "[none]", example: "[none]" },
|
|
},
|
|
39: {
|
|
class_type: "CLIPTextEncode",
|
|
inputs: {
|
|
text: "1girl, cat_ears, smile", category: "", tags: "", search_text: "",
|
|
prepend_text: "masterpiece, best quality, ", append_text: "", clip: ["8", 0],
|
|
},
|
|
},
|
|
40: {
|
|
class_type: "CLIPTextEncode",
|
|
inputs: {
|
|
text: "3d, crowd", category: "", tags: "", search_text: "",
|
|
prepend_text: "bad quality, worst quality, ", append_text: "", clip: ["8", 0],
|
|
},
|
|
},
|
|
};
|
|
|
|
test("#75: traces PromptManager prompts and pysssss checkpoint from the sampler", () => {
|
|
const r = extractGenerationParams({ prompt: issue75Prompt });
|
|
assert.equal(r.positivePrompt, "masterpiece, best quality, 1girl, cat_ears, smile");
|
|
assert.equal(r.negativePrompt, "bad quality, worst quality, 3d, crowd");
|
|
assert.equal(r.checkpoint, "Anime\\Model-XL.safetensors");
|
|
assert.deepEqual(
|
|
[r.seed, r.steps, r.cfgScale, r.sampler],
|
|
[480655185510613, 25, 5.5, "euler_ancestral"],
|
|
);
|
|
});
|
|
|
|
test("#75: workflow blob does not override values from the prompt graph", () => {
|
|
const workflow = { nodes: [{ type: "KSampler", widgets_values: [1, "randomize", 99, 7, "ddim"] }] };
|
|
const r = extractGenerationParams({ prompt: issue75Prompt, workflow });
|
|
assert.equal(r.steps, 25);
|
|
assert.equal(r.cfgScale, 5.5);
|
|
});
|
|
|
|
test("#75: recovers per-node text when SaveImage patch overwrote every PromptManager node", () => {
|
|
// Pre-fix images: both nodes carry the final positive text in the prompt graph,
|
|
// but the workflow graph still has each node's real widget values.
|
|
const positiveFinal = "masterpiece, best quality, 1girl, smile";
|
|
const prompt = {
|
|
1: { class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 7, sampler_name: "euler", positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } },
|
|
20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
|
|
39: { class_type: "PromptManager", inputs: { text: positiveFinal, prepend_text: "masterpiece, best quality,", append_text: "" } },
|
|
40: { class_type: "PromptManager", inputs: { text: positiveFinal, prepend_text: "bad quality,", append_text: "" } },
|
|
};
|
|
const workflow = {
|
|
nodes: [
|
|
{ id: 39, type: "PromptManager", widgets_values: ["1girl, smile", "", "", "", "masterpiece, best quality,", ""] },
|
|
{ id: 40, type: "PromptManager", widgets_values: ["3d, crowd", "", "", "", "bad quality,", ""] },
|
|
],
|
|
};
|
|
const r = extractGenerationParams({ prompt, workflow });
|
|
assert.equal(r.positivePrompt, "masterpiece, best quality, 1girl, smile");
|
|
assert.equal(r.negativePrompt, "bad quality, 3d, crowd");
|
|
});
|
|
|
|
test("current metadata: API text wins over a stale or unexpanded workflow widget", () => {
|
|
// Dynamic prompt expanded at queue time: API holds what ran, workflow holds the template
|
|
const prompt = {
|
|
1: { class_type: "KSampler", inputs: { positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } },
|
|
20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
|
|
39: { class_type: "PromptManager", inputs: { text: "a red cat", prepend_text: "", append_text: "" } },
|
|
40: { class_type: "PromptManager", inputs: { text: "blurry", prepend_text: "", append_text: "" } },
|
|
};
|
|
const workflow = {
|
|
nodes: [
|
|
{ id: 39, type: "PromptManager", widgets_values: ["a {red|blue} cat", "", "", "", "", ""] },
|
|
{ id: 40, type: "PromptManager", widgets_values: ["blurry", "", "", "", "", ""] },
|
|
],
|
|
};
|
|
const r = extractGenerationParams({ prompt, workflow });
|
|
assert.equal(r.positivePrompt, "a red cat");
|
|
assert.equal(r.negativePrompt, "blurry");
|
|
});
|
|
|
|
test("current metadata: linked text is followed, not replaced by the widget's stale value", () => {
|
|
const prompt = {
|
|
1: { class_type: "KSampler", inputs: { positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } },
|
|
20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
|
|
39: { class_type: "PromptManager", inputs: { text: ["50", 0], prepend_text: "", append_text: "" } },
|
|
40: { class_type: "PromptManager", inputs: { text: ["50", 0], prepend_text: "bad,", append_text: "" } },
|
|
50: { class_type: "PrimitiveString", inputs: { value: "shared text" } },
|
|
};
|
|
const workflow = {
|
|
nodes: [
|
|
{ id: 39, type: "PromptManager", widgets_values: ["old value A", "", "", "", "", ""] },
|
|
{ id: 40, type: "PromptManager", widgets_values: ["old value B", "", "", "", "bad,", ""] },
|
|
],
|
|
};
|
|
const r = extractGenerationParams({ prompt, workflow });
|
|
assert.equal(r.positivePrompt, "shared text");
|
|
});
|
|
|
|
test("prepend/append are not doubled when text is already the combined prompt", () => {
|
|
const prompt = {
|
|
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
|
|
2: { class_type: "PromptManager", inputs: { text: "best quality, a cat, 8k", prepend_text: "best quality,", append_text: "8k" } },
|
|
3: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
|
|
};
|
|
assert.equal(extractGenerationParams({ prompt }).positivePrompt, "best quality, a cat, 8k");
|
|
});
|
|
|
|
test("linked prepend/append inputs are resolved (no longer baked into text since 3.2.3)", () => {
|
|
const prompt = {
|
|
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
|
|
2: { class_type: "PromptManager", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: ["6", 0] } },
|
|
3: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
|
|
5: { class_type: "PrimitiveString", inputs: { value: "masterpiece," } },
|
|
6: { class_type: "PrimitiveString", inputs: { value: "8k" } },
|
|
};
|
|
assert.equal(extractGenerationParams({ prompt }).positivePrompt, "masterpiece, a fox 8k");
|
|
});
|
|
|
|
test("negative prompt is identified by link, not by keywords", () => {
|
|
const prompt = {
|
|
3: { class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 7, sampler_name: "euler", positive: ["6", 0], negative: ["7", 0], model: ["4", 0] } },
|
|
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "sd15.safetensors" } },
|
|
6: { class_type: "CLIPTextEncode", inputs: { text: "embedding:goodstyle, a lighthouse" } },
|
|
7: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
|
|
};
|
|
const r = extractGenerationParams({ prompt });
|
|
assert.equal(r.positivePrompt, "embedding:goodstyle, a lighthouse");
|
|
assert.equal(r.negativePrompt, "blurry");
|
|
});
|
|
|
|
test("follows LoRA chains, ControlNet pass-through, concatenated text and primitive inputs", () => {
|
|
const prompt = {
|
|
1: { class_type: "KSamplerAdvanced", inputs: { noise_seed: 42, steps: ["9", 0], cfg: 6, sampler_name: "dpmpp_2m", positive: ["5", 0], negative: ["5", 1], model: ["3", 0] } },
|
|
2: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "base.safetensors" } },
|
|
3: { class_type: "LoraLoader", inputs: { lora_name: "style.safetensors", model: ["2", 0], clip: ["2", 1] } },
|
|
5: { class_type: "ControlNetApplyAdvanced", inputs: { positive: ["6", 0], negative: ["7", 0], strength: 1 } },
|
|
6: { class_type: "CLIPTextEncode", inputs: { text: ["8", 0] } },
|
|
7: { class_type: "CLIPTextEncode", inputs: { text: "lowres" } },
|
|
8: { class_type: "Text Concatenate", inputs: { delimiter: ", ", text_a: "a castle", text_b: "at dusk" } },
|
|
9: { class_type: "PrimitiveInt", inputs: { value: 30 } },
|
|
};
|
|
const r = extractGenerationParams({ prompt });
|
|
assert.equal(r.positivePrompt, "a castle, at dusk");
|
|
assert.equal(r.negativePrompt, "lowres");
|
|
assert.equal(r.checkpoint, "base.safetensors");
|
|
assert.equal(r.seed, 42);
|
|
assert.equal(r.steps, 30);
|
|
});
|
|
|
|
test("custom sampler graphs (Flux: SamplerCustomAdvanced + CFGGuider) resolve every field", () => {
|
|
const prompt = {
|
|
85: { class_type: "UNETLoader", inputs: { unet_name: "flux2-klein.safetensors" } },
|
|
135: { class_type: "Lora Loader (LoraManager)", inputs: { model: ["85", 0], clip: ["90", 0] } },
|
|
149: { class_type: "PromptManager", inputs: { text: "a red fox", prepend_text: ["133", 0], append_text: "" } },
|
|
152: { class_type: "SeedHistory", inputs: { seed: 1725922648 } },
|
|
// ControlNet-style node with positive/negative but no model: must not be taken as the sampler
|
|
200: { class_type: "ControlNetApplyAdvanced", inputs: { positive: ["149", 0], negative: ["274", 0], strength: 1 } },
|
|
265: { class_type: "SamplerCustomAdvanced", inputs: { noise: ["267", 0], guider: ["268", 0], sampler: ["269", 0], sigmas: ["266", 0] } },
|
|
266: { class_type: "Flux2Scheduler", inputs: { steps: 50, width: ["153", 1] } },
|
|
267: { class_type: "RandomNoise", inputs: { noise_seed: ["152", 0] } },
|
|
268: { class_type: "CFGGuider", inputs: { cfg: 5.0, model: ["135", 0], positive: ["149", 0], negative: ["274", 0] } },
|
|
269: { class_type: "KSamplerSelect", inputs: { sampler_name: "uni_pc" } },
|
|
274: { class_type: "CLIPTextEncode", inputs: { text: "watermark" } },
|
|
};
|
|
const r = extractGenerationParams({ prompt });
|
|
assert.deepEqual(
|
|
[r.positivePrompt, r.negativePrompt, r.checkpoint, r.cfgScale, r.seed, r.steps, r.sampler],
|
|
["a red fox", "watermark", "flux2-klein.safetensors", 5.0, 1725922648, 50, "uni_pc"],
|
|
);
|
|
});
|
|
|
|
test("multi-output settings nodes (SamplerCombo) map each field to its own value", () => {
|
|
const prompt = {
|
|
5: {
|
|
class_type: "KSampler",
|
|
inputs: {
|
|
seed: ["162", 0], steps: ["165", 2], cfg: ["165", 3], sampler_name: ["165", 0],
|
|
positive: ["6", 0], negative: ["7", 0], model: ["4", 0],
|
|
},
|
|
},
|
|
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } },
|
|
6: { class_type: "CLIPTextEncode", inputs: { text: "a cat" } },
|
|
7: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } },
|
|
162: { class_type: "SeedHistory", inputs: { seed: 1495646836, seed_history_ui: "" } },
|
|
165: { class_type: "SamplerCombo", inputs: { sampler_name: "euler_ancestral", scheduler: "normal", steps: 50, cfg: 7 } },
|
|
};
|
|
const r = extractGenerationParams({ prompt });
|
|
assert.deepEqual([r.seed, r.steps, r.cfgScale, r.sampler], [1495646836, 50, 7, "euler_ancestral"]);
|
|
});
|
|
|
|
test("workflow-only images account for the hidden control_after_generate widget", () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{ id: 1, type: "CheckpointLoader|pysssss", widgets_values: ["model.safetensors", "[none]"] },
|
|
{ id: 2, type: "KSampler", widgets_values: [123, "randomize", 25, 5.5, "euler", "normal", 1] },
|
|
],
|
|
};
|
|
const r = extractGenerationParams({ workflow });
|
|
assert.deepEqual(
|
|
[r.checkpoint, r.seed, r.steps, r.cfgScale, r.sampler],
|
|
["model.safetensors", 123, 25, 5.5, "euler"],
|
|
);
|
|
});
|
|
|
|
test("cyclic, fan-out graphs from crafted PNGs resolve quickly", () => {
|
|
// Every node links to itself and its neighbour through several inputs and has
|
|
// no scalars: without a visited set this explores ~inputs^MAX_DEPTH paths.
|
|
const loop = (id, next) => ({
|
|
class_type: "Evil",
|
|
inputs: {
|
|
text_a: [id, 0], text_b: [next, 0], text_c: [id, 0], text_d: [next, 0],
|
|
value_a: [id, 0], value_b: [next, 0], model: [next, 0],
|
|
},
|
|
});
|
|
const prompt = {
|
|
1: { class_type: "KSampler", inputs: { seed: ["2", 0], steps: ["3", 0], positive: ["2", 0], negative: ["3", 0], model: ["2", 0] } },
|
|
2: loop("2", "3"),
|
|
3: loop("3", "2"),
|
|
};
|
|
const started = Date.now();
|
|
const r = extractGenerationParams({ prompt });
|
|
assert.ok(Date.now() - started < 500, `took ${Date.now() - started}ms`);
|
|
assert.equal(r.positivePrompt, "No prompt found");
|
|
assert.equal(r.seed, "Unknown");
|
|
});
|
|
|
|
test("chained encoders whose prepend and append both link onward stay linear", () => {
|
|
const prompt = { 1: { class_type: "KSampler", inputs: { positive: ["n0", 0], negative: ["n0", 0], model: ["n0", 0] } } };
|
|
for (let i = 0; i < 60; i++) {
|
|
prompt[`n${i}`] = {
|
|
class_type: "PromptManager",
|
|
inputs: { text: `t${i}`, prepend_text: [`n${i + 1}`, 0], append_text: [`n${i + 1}`, 0] },
|
|
};
|
|
}
|
|
const started = Date.now();
|
|
extractGenerationParams({ prompt });
|
|
assert.ok(Date.now() - started < 500, `took ${Date.now() - started}ms`);
|
|
});
|
|
|
|
// UI-format link: [id, from_node, from_slot, to_node, to_slot, type]
|
|
test("workflow-only: traces prompts, model and KSamplerAdvanced settings through Set/Get and Reroute", () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{ id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"], outputs: [{ links: [1] }] },
|
|
{ id: 2, type: "SetNode", widgets_values: ["model"], inputs: [{ name: "MODEL", link: 1 }] },
|
|
{ id: 3, type: "GetNode", widgets_values: ["model"], outputs: [{ links: [2] }] },
|
|
{ id: 4, type: "PromptManager", widgets_values: ["a castle", "", "", "", "masterpiece,", ""], inputs: [], outputs: [{ links: [3] }] },
|
|
{ id: 5, type: "Reroute", inputs: [{ name: "", link: 3 }], outputs: [{ links: [4] }] },
|
|
{ id: 6, type: "CLIPTextEncode", widgets_values: ["lowres"], outputs: [{ links: [5] }] },
|
|
{
|
|
id: 7, type: "KSamplerAdvanced",
|
|
inputs: [{ name: "model", link: 2 }, { name: "positive", link: 4 }, { name: "negative", link: 5 }],
|
|
widgets_values: ["enable", 77, "randomize", 30, 6.5, "dpmpp_2m", "karras", 0, 10000, "disable"],
|
|
},
|
|
],
|
|
links: [
|
|
[1, 1, 0, 2, 0, "MODEL"], [2, 3, 0, 7, 0, "MODEL"], [3, 4, 0, 5, 0, "CONDITIONING"],
|
|
[4, 5, 0, 7, 1, "CONDITIONING"], [5, 6, 0, 7, 2, "CONDITIONING"],
|
|
],
|
|
};
|
|
const r = extractGenerationParams({ workflow });
|
|
assert.deepEqual(
|
|
[r.positivePrompt, r.negativePrompt, r.checkpoint, r.seed, r.steps, r.cfgScale, r.sampler],
|
|
["masterpiece, a castle", "lowres", "flux.safetensors", 77, 30, 6.5, "dpmpp_2m"],
|
|
);
|
|
});
|
|
|
|
test("workflow-only: widgets_values_named and widget-backed inputs take precedence over positions", () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{ id: 1, type: "CheckpointLoaderSimple", widgets_values: ["m.safetensors"], outputs: [{ links: [1] }] },
|
|
{ id: 2, type: "CLIPTextEncode", widgets_values_named: { text: "named text" }, widgets_values: ["ignored"], outputs: [{ links: [2] }] },
|
|
{ id: 3, type: "CLIPTextEncode", widgets_values: ["neg"], outputs: [{ links: [3] }] },
|
|
{
|
|
id: 4, type: "KSampler",
|
|
inputs: [
|
|
{ name: "model", link: 1 }, { name: "positive", link: 2 }, { name: "negative", link: 3 },
|
|
{ name: "seed", widget: { name: "seed" }, link: null },
|
|
{ name: "steps", widget: { name: "steps" }, link: null },
|
|
{ name: "cfg", widget: { name: "cfg" }, link: null },
|
|
{ name: "sampler_name", widget: { name: "sampler_name" }, link: null },
|
|
],
|
|
widgets_values: [5, "fixed", 12, 4, "euler", "normal", 1],
|
|
},
|
|
],
|
|
links: [[1, 1, 0, 4, 0, "MODEL"], [2, 2, 0, 4, 1, "CONDITIONING"], [3, 3, 0, 4, 2, "CONDITIONING"]],
|
|
};
|
|
const r = extractGenerationParams({ workflow });
|
|
assert.deepEqual([r.positivePrompt, r.seed, r.steps, r.cfgScale, r.sampler], ["named text", 5, 12, 4, "euler"]);
|
|
});
|
|
|
|
test("workflow-only: bypassed nodes pass through, muted nodes are ignored", () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{ id: 1, type: "CheckpointLoaderSimple", widgets_values: ["m.safetensors"], outputs: [{ links: [1] }] },
|
|
{ id: 2, type: "LoraLoader", mode: 4, inputs: [{ name: "model", type: "MODEL", link: 1 }], outputs: [{ type: "MODEL", links: [2] }] },
|
|
{ id: 3, type: "CLIPTextEncode", widgets_values: ["pos"], outputs: [{ links: [3] }] },
|
|
{ id: 4, type: "CLIPTextEncode", widgets_values: ["neg"], outputs: [{ links: [4] }] },
|
|
{ id: 5, type: "KSampler", mode: 2, inputs: [{ name: "model", link: 2 }, { name: "positive", link: 3 }, { name: "negative", link: 4 }], widgets_values: [9, "fixed", 99, 9, "ddim"] },
|
|
{ id: 6, type: "KSampler", inputs: [{ name: "model", link: 2 }, { name: "positive", link: 3 }, { name: "negative", link: 4 }], widgets_values: [1, "fixed", 20, 7, "euler"] },
|
|
],
|
|
links: [[1, 1, 0, 2, 0, "MODEL"], [2, 2, 0, 6, 0, "MODEL"], [3, 3, 0, 6, 1, "CONDITIONING"], [4, 4, 0, 6, 2, "CONDITIONING"]],
|
|
};
|
|
const r = extractGenerationParams({ workflow });
|
|
assert.deepEqual([r.checkpoint, r.positivePrompt, r.steps], ["m.safetensors", "pos", 20]);
|
|
});
|
|
|
|
test("missing or empty metadata yields placeholders", () => {
|
|
const r = extractGenerationParams({});
|
|
assert.equal(r.positivePrompt, "No prompt found");
|
|
assert.equal(r.negativePrompt, "No negative prompt found");
|
|
assert.equal(r.checkpoint, "Unknown");
|
|
assert.equal(extractGenerationParams(null).seed, "Unknown");
|
|
});
|
|
|
|
|
|
test("Focused API metadata composes linked positive text and preserves negative role", () => {
|
|
const prompt = {
|
|
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
|
|
2: { class_type: "PromptManagerFocused", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
|
|
3: { class_type: "PromptManagerFocused", inputs: { text: "blurry", prepend_text: "avoid" } },
|
|
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
|
|
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
|
|
};
|
|
const result = extractGenerationParams({ prompt });
|
|
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
|
assert.equal(result.negativePrompt, "avoid blurry");
|
|
});
|
|
|
|
test("Focused workflow-only metadata recovers serialized fields and connected composition", () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
|
|
{ id: 2, type: "PromptManagerFocused", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
|
|
{ id: 3, type: "PromptManagerFocused", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
|
|
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
|
|
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
|
|
],
|
|
links: [
|
|
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
|
|
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
|
|
],
|
|
};
|
|
const result = extractGenerationParams({ workflow });
|
|
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
|
assert.equal(result.negativePrompt, "avoid blurry watermark");
|
|
assert.equal(result.checkpoint, "model.safetensors");
|
|
});
|
|
|
|
test("Split API metadata composes linked positive text and preserves negative role", () => {
|
|
const prompt = {
|
|
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
|
|
2: { class_type: "PromptManagerSplit", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
|
|
3: { class_type: "PromptManagerSplit", inputs: { text: "blurry", prepend_text: "avoid" } },
|
|
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
|
|
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
|
|
};
|
|
const result = extractGenerationParams({ prompt });
|
|
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
|
assert.equal(result.negativePrompt, "avoid blurry");
|
|
});
|
|
|
|
test("Split workflow-only metadata recovers serialized fields and connected composition", () => {
|
|
const workflow = {
|
|
nodes: [
|
|
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
|
|
{ id: 2, type: "PromptManagerSplit", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
|
|
{ id: 3, type: "PromptManagerSplit", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
|
|
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
|
|
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
|
|
],
|
|
links: [
|
|
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
|
|
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
|
|
],
|
|
};
|
|
const result = extractGenerationParams({ workflow });
|
|
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
|
assert.equal(result.negativePrompt, "avoid blurry watermark");
|
|
assert.equal(result.checkpoint, "model.safetensors");
|
|
});
|