tests/ (Python backend tests, .mjs frontend rule tests) is no longer ignored. tools/run_tests.mjs runs the structure check, every tests/*.mjs and the Python tests with $PYTHON, the portable python_embeded or python. docs/guides/testing.md says how to run and write them; README links it. Developer notes no longer point at 18 test files that were removed earlier (AGENTS.md table, backend, frontend and update-guide notes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
6.6 KiB
JavaScript
136 lines
6.6 KiB
JavaScript
// Where a LoRA goes and how it is wired (web/modules/graph_splice.js), on a small stand-in graph.
|
|
import assert from 'node:assert/strict';
|
|
import { chainEnd, loraInsertionCapabilities, mainModelLoaders, planLoraInsertion, spliceLora } from '../web/modules/graph_splice.js';
|
|
|
|
const SLOTS = {
|
|
CheckpointLoaderSimple: [[], ['MODEL', 'CLIP', 'VAE']],
|
|
UNETLoader: [[], ['MODEL']],
|
|
CLIPLoader: [[], ['CLIP']],
|
|
CLIPTextEncode: [['CLIP'], ['CONDITIONING']],
|
|
KSampler: [['MODEL', 'CONDITIONING', 'CONDITIONING', 'LATENT'], ['LATENT']],
|
|
LoraLoader: [['MODEL', 'CLIP'], ['MODEL', 'CLIP']],
|
|
LoraLoaderModelOnly: [['MODEL'], ['MODEL']],
|
|
};
|
|
|
|
class Graph {
|
|
constructor() { this._nodes = []; this.links = {}; this.nextLink = 1; this.nextNode = 1; }
|
|
getNodeById(id) { return this._nodes.find(node => node.id === id) || null; }
|
|
getLink(id) { return this.links[id] || null; }
|
|
add(node) { node.id ??= this.nextNode++; node.graph = this; this._nodes.push(node); }
|
|
remove(node) {
|
|
node.inputs.forEach((input, slot) => input.link != null && this.unlink(input.link));
|
|
node.outputs.forEach(output => [...output.links].forEach(id => this.unlink(id)));
|
|
this._nodes = this._nodes.filter(item => item !== node);
|
|
}
|
|
unlink(id) {
|
|
const link = this.links[id];
|
|
if (!link) return;
|
|
const origin = this.getNodeById(link.origin_id);
|
|
origin.outputs[link.origin_slot].links = origin.outputs[link.origin_slot].links.filter(item => item !== id);
|
|
this.getNodeById(link.target_id).inputs[link.target_slot].link = null;
|
|
delete this.links[id];
|
|
}
|
|
}
|
|
|
|
function node(graph, type, failConnect = false) {
|
|
const [inputs, outputs] = SLOTS[type];
|
|
const item = {
|
|
type, mode: 0,
|
|
inputs: inputs.map(t => ({ type: t, link: null })),
|
|
outputs: outputs.map(t => ({ type: t, links: [] })),
|
|
connect(slot, target, targetSlot) {
|
|
if (failConnect) return null;
|
|
const g = this.graph;
|
|
if (target.inputs[targetSlot].link != null) g.unlink(target.inputs[targetSlot].link);
|
|
const id = g.nextLink++;
|
|
g.links[id] = { id, origin_id: this.id, origin_slot: slot, target_id: target.id, target_slot: targetSlot };
|
|
this.outputs[slot].links.push(id);
|
|
target.inputs[targetSlot].link = id;
|
|
return g.links[id];
|
|
},
|
|
};
|
|
if (graph) graph.add(item);
|
|
return item;
|
|
}
|
|
|
|
const source = (graph, target, slot) => {
|
|
const link = graph.getLink(target.inputs[slot].link);
|
|
return link ? `${graph.getNodeById(link.origin_id).type}#${link.origin_id}` : null;
|
|
};
|
|
|
|
// A checkpoint whose CLIP feeds both prompts.
|
|
function checkpointWorkflow() {
|
|
const g = new Graph();
|
|
const ckpt = node(g, 'CheckpointLoaderSimple');
|
|
const pos = node(g, 'CLIPTextEncode');
|
|
const neg = node(g, 'CLIPTextEncode');
|
|
const sampler = node(g, 'KSampler');
|
|
ckpt.connect(1, pos, 0); ckpt.connect(1, neg, 0); ckpt.connect(0, sampler, 0);
|
|
pos.connect(0, sampler, 1); neg.connect(0, sampler, 2);
|
|
return { g, ckpt, pos, neg, sampler };
|
|
}
|
|
|
|
{
|
|
const { g, ckpt, pos, neg, sampler } = checkpointWorkflow();
|
|
const plan = planLoraInsertion(g, ckpt, 'after', { textEncoder: true });
|
|
assert.equal(plan.clip, true);
|
|
const lora = spliceLora({ graph: g, plan, anchorNode: ckpt, insertedNode: node(null, 'LoraLoader') });
|
|
assert.equal(source(g, lora, 0), `CheckpointLoaderSimple#${ckpt.id}`);
|
|
assert.equal(source(g, lora, 1), `CheckpointLoaderSimple#${ckpt.id}`);
|
|
for (const prompt of [pos, neg]) assert.equal(source(g, prompt, 0), `LoraLoader#${lora.id}`, 'both prompts move behind the LoRA');
|
|
assert.equal(source(g, sampler, 0), `LoraLoader#${lora.id}`);
|
|
// The next one goes after the LoRA already there; a model-only LoRA leaves CLIP alone.
|
|
assert.equal(chainEnd(g, mainModelLoaders(g)[0], 'MODEL'), lora);
|
|
const plan2 = planLoraInsertion(g, lora, 'after', { textEncoder: false });
|
|
const lora2 = spliceLora({ graph: g, plan: plan2, anchorNode: lora, insertedNode: node(null, 'LoraLoaderModelOnly') });
|
|
assert.equal(source(g, sampler, 0), `LoraLoaderModelOnly#${lora2.id}`);
|
|
assert.equal(source(g, pos, 0), `LoraLoader#${lora.id}`);
|
|
}
|
|
|
|
{
|
|
// A UNet with its own CLIP loader: the CLIP line comes from the CLIP loader.
|
|
const g = new Graph();
|
|
const unet = node(g, 'UNETLoader');
|
|
const clip = node(g, 'CLIPLoader');
|
|
const pos = node(g, 'CLIPTextEncode');
|
|
const neg = node(g, 'CLIPTextEncode');
|
|
const sampler = node(g, 'KSampler');
|
|
clip.connect(0, pos, 0); clip.connect(0, neg, 0); unet.connect(0, sampler, 0);
|
|
pos.connect(0, sampler, 1); neg.connect(0, sampler, 2);
|
|
const plan = planLoraInsertion(g, unet, 'after', { textEncoder: true });
|
|
const lora = spliceLora({ graph: g, plan, anchorNode: unet, insertedNode: node(null, 'LoraLoader') });
|
|
assert.equal(source(g, lora, 0), `UNETLoader#${unet.id}`);
|
|
assert.equal(source(g, lora, 1), `CLIPLoader#${clip.id}`);
|
|
assert.equal(source(g, neg, 0), `LoraLoader#${lora.id}`);
|
|
assert.equal(planLoraInsertion(g, unet, 'after', { textEncoder: false }).clip, false);
|
|
}
|
|
|
|
{
|
|
// Before a sampler: only its own model input moves; CLIP through the prompts wired into it.
|
|
const { g, ckpt, pos, neg, sampler } = checkpointWorkflow();
|
|
const caps = loraInsertionCapabilities(g, sampler);
|
|
assert.equal(caps.before.supported, true);
|
|
assert.equal(caps.after.supported, false); // a sampler has no MODEL output
|
|
const plan = planLoraInsertion(g, sampler, 'before', { textEncoder: true });
|
|
assert.equal(plan.clip, true);
|
|
const lora = spliceLora({ graph: g, plan, anchorNode: sampler, insertedNode: node(null, 'LoraLoader') });
|
|
assert.equal(source(g, sampler, 0), `LoraLoader#${lora.id}`);
|
|
assert.equal(source(g, pos, 0), `LoraLoader#${lora.id}`);
|
|
assert.equal(source(g, neg, 0), `LoraLoader#${lora.id}`);
|
|
assert.equal(source(g, lora, 1), `CheckpointLoaderSimple#${ckpt.id}`);
|
|
}
|
|
|
|
{
|
|
// A connection that fails leaves the graph as it was.
|
|
const { g, ckpt, pos, sampler } = checkpointWorkflow();
|
|
const plan = planLoraInsertion(g, ckpt, 'after', { textEncoder: true });
|
|
const broken = node(null, 'LoraLoader', true);
|
|
assert.throws(() => spliceLora({ graph: g, plan, anchorNode: ckpt, insertedNode: broken }), /Failed to connect/);
|
|
assert.equal(g._nodes.includes(broken), false);
|
|
assert.equal(source(g, pos, 0), `CheckpointLoaderSimple#${ckpt.id}`);
|
|
assert.equal(source(g, sampler, 0), `CheckpointLoaderSimple#${ckpt.id}`);
|
|
assert.equal(planLoraInsertion(g, pos, 'after').supported, false); // a prompt has no MODEL output
|
|
}
|
|
|
|
console.log('graph_splice: ok');
|