Keep tests local only
The test suites stay on disk for local runs but are excluded from the repository and registry publishes.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.tracking
|
||||
tests/
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
// Minimal litegraph stand-in used to exercise web/asset_filter.js without a
|
||||
// browser. Run with: node tests/asset_filter_js.test.mjs
|
||||
import assert from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(path.join(here, "..", "web", "asset_filter.js"), "utf8");
|
||||
|
||||
// ─── Fake litegraph graph/node ───
|
||||
class FakeGraph {
|
||||
constructor() {
|
||||
this.links = new Map();
|
||||
this.nodes = new Map();
|
||||
this.nextLinkId = 1;
|
||||
}
|
||||
getNodeById(id) {
|
||||
return this.nodes.get(id) ?? null;
|
||||
}
|
||||
add(node) {
|
||||
node.graph = this;
|
||||
this.nodes.set(node.id, node);
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeNode {
|
||||
constructor(id, graph) {
|
||||
this.id = id;
|
||||
this.inputs = [];
|
||||
this.outputs = [{ name: "out", links: [] }];
|
||||
this.graph = graph;
|
||||
graph?.add(this);
|
||||
}
|
||||
addInput(name, type) {
|
||||
this.inputs.push({ name, type, link: null });
|
||||
this.onConnectionsChange?.(1, this.inputs.length - 1, false, null, null);
|
||||
}
|
||||
removeInput(index) {
|
||||
const input = this.inputs[index];
|
||||
if (input?.link != null) this.graph.links.delete(input.link);
|
||||
this.inputs.splice(index, 1);
|
||||
this.onConnectionsChange?.(1, index, false, null, null);
|
||||
}
|
||||
// origin.connect(originSlot, targetNode, targetSlot)
|
||||
connect(originSlot, target, targetSlot) {
|
||||
const input = target.inputs[targetSlot];
|
||||
if (!input) return null;
|
||||
if (input.link != null) this.graph.links.delete(input.link);
|
||||
const id = this.graph.nextLinkId++;
|
||||
this.graph.links.set(id, {
|
||||
id,
|
||||
origin_id: this.id,
|
||||
origin_slot: originSlot,
|
||||
target_id: target.id,
|
||||
target_slot: targetSlot,
|
||||
});
|
||||
input.link = id;
|
||||
target.onConnectionsChange?.(1, targetSlot, true, this.graph.links.get(id), input);
|
||||
return id;
|
||||
}
|
||||
setDirtyCanvas() {}
|
||||
}
|
||||
|
||||
// ─── Load the extension with a stubbed `app` ───
|
||||
let registered = null;
|
||||
const app = {
|
||||
registerExtension(ext) {
|
||||
registered = ext;
|
||||
},
|
||||
graph: { setDirtyCanvas() {} },
|
||||
};
|
||||
|
||||
const rafQueue = [];
|
||||
globalThis.requestAnimationFrame = (fn) => {
|
||||
rafQueue.push(fn);
|
||||
return rafQueue.length;
|
||||
};
|
||||
function flush() {
|
||||
// Drain queued callbacks, including ones scheduled while draining.
|
||||
let guard = 0;
|
||||
while (rafQueue.length) {
|
||||
if (++guard > 1000) throw new Error("requestAnimationFrame loop did not settle");
|
||||
rafQueue.shift()();
|
||||
}
|
||||
}
|
||||
|
||||
const moduleSource = source.replace(
|
||||
/^import\s+\{\s*app\s*\}\s+from\s+["'][^"']+["'];?\s*$/m,
|
||||
""
|
||||
);
|
||||
const factory = new Function("app", `${moduleSource}\nreturn null;`);
|
||||
factory(app);
|
||||
assert.ok(registered, "extension should register itself");
|
||||
|
||||
// Apply the extension's prototype patches to our fake node class. The real
|
||||
// backend declares 9 image + 3 video outputs, 0-based; the input caps are
|
||||
// derived from these names.
|
||||
const MAX_IMAGE = 9;
|
||||
const MAX_VIDEO = 3;
|
||||
const OUTPUT_NAMES = [
|
||||
...Array.from({ length: MAX_IMAGE }, (_, i) => `image_${i}`),
|
||||
...Array.from({ length: MAX_VIDEO }, (_, i) => `video_${i}`),
|
||||
"image_count",
|
||||
"video_count",
|
||||
];
|
||||
await registered.beforeRegisterNodeDef(FakeNode, {
|
||||
name: "AssetFilter",
|
||||
output_name: OUTPUT_NAMES,
|
||||
});
|
||||
|
||||
function names(node) {
|
||||
return node.inputs.map((i) => i.name);
|
||||
}
|
||||
|
||||
function makeFilterNode(graph) {
|
||||
const node = new FakeNode(`f${graph.nextLinkId}_${graph.nodes.size}`, graph);
|
||||
node.onAdded();
|
||||
flush();
|
||||
return node;
|
||||
}
|
||||
|
||||
// ─── Tests ───
|
||||
const tests = [];
|
||||
function test(name, fn) {
|
||||
tests.push([name, fn]);
|
||||
}
|
||||
|
||||
test("seeds one free image and one free video slot", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = makeFilterNode(graph);
|
||||
assert.deepStrictEqual(names(node), ["image_0", "video_0"]);
|
||||
});
|
||||
|
||||
test("connecting the free image slot appends exactly one new image slot", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = makeFilterNode(graph);
|
||||
const src = new FakeNode("src", graph);
|
||||
src.connect(0, node, 0);
|
||||
flush();
|
||||
assert.deepStrictEqual(names(node), ["image_0", "image_1", "video_0"]);
|
||||
});
|
||||
|
||||
test("repeated stabilize passes do not grow the input list", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = makeFilterNode(graph);
|
||||
const src = new FakeNode("src", graph);
|
||||
src.connect(0, node, 0);
|
||||
flush();
|
||||
const before = names(node);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
node.onConnectionsChange(1, 0, true, null, null);
|
||||
flush();
|
||||
}
|
||||
assert.deepStrictEqual(names(node), before, "layout must be stable");
|
||||
});
|
||||
|
||||
test("indices stay compact and never drift upward", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = makeFilterNode(graph);
|
||||
const a = new FakeNode("a", graph);
|
||||
const b = new FakeNode("b", graph);
|
||||
a.connect(0, node, 0);
|
||||
flush();
|
||||
b.connect(0, node, names(node).indexOf("image_1"));
|
||||
flush();
|
||||
assert.deepStrictEqual(names(node), [
|
||||
"image_0",
|
||||
"image_1",
|
||||
"image_2",
|
||||
"video_0",
|
||||
]);
|
||||
// Disconnect the first image; the other is renumbered back to image_0.
|
||||
const link = node.inputs[0].link;
|
||||
graph.links.delete(link);
|
||||
node.inputs[0].link = null;
|
||||
node.onConnectionsChange(1, 0, false, null, null);
|
||||
flush();
|
||||
assert.deepStrictEqual(names(node), ["image_0", "image_1", "video_0"]);
|
||||
});
|
||||
|
||||
test("image slots stay grouped above video slots", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = makeFilterNode(graph);
|
||||
const v = new FakeNode("v", graph);
|
||||
v.connect(0, node, names(node).indexOf("video_0"));
|
||||
flush();
|
||||
const i = new FakeNode("i", graph);
|
||||
i.connect(0, node, names(node).indexOf("image_0"));
|
||||
flush();
|
||||
assert.deepStrictEqual(names(node), [
|
||||
"image_0",
|
||||
"image_1",
|
||||
"video_0",
|
||||
"video_1",
|
||||
]);
|
||||
const order = names(node);
|
||||
const lastImage = order.lastIndexOf("image_1");
|
||||
const firstVideo = order.indexOf("video_0");
|
||||
assert.ok(lastImage < firstVideo, "all image slots precede video slots");
|
||||
});
|
||||
|
||||
test("existing links survive a rebuild and point at the right origins", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = makeFilterNode(graph);
|
||||
const img = new FakeNode("img", graph);
|
||||
const vid = new FakeNode("vid", graph);
|
||||
img.connect(0, node, names(node).indexOf("image_0"));
|
||||
flush();
|
||||
vid.connect(0, node, names(node).indexOf("video_0"));
|
||||
flush();
|
||||
const byName = Object.fromEntries(
|
||||
node.inputs.map((input) => [input.name, input])
|
||||
);
|
||||
const imgLink = graph.links.get(byName["image_0"].link);
|
||||
const vidLink = graph.links.get(byName["video_0"].link);
|
||||
assert.strictEqual(imgLink?.origin_id, "img");
|
||||
assert.strictEqual(vidLink?.origin_id, "vid");
|
||||
});
|
||||
|
||||
test("multiple videos keep their own origins after renumbering", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = makeFilterNode(graph);
|
||||
const v1 = new FakeNode("v1", graph);
|
||||
const v2 = new FakeNode("v2", graph);
|
||||
v1.connect(0, node, names(node).indexOf("video_0"));
|
||||
flush();
|
||||
v2.connect(0, node, names(node).indexOf("video_1"));
|
||||
flush();
|
||||
const byName = Object.fromEntries(
|
||||
node.inputs.map((input) => [input.name, input])
|
||||
);
|
||||
assert.strictEqual(graph.links.get(byName["video_0"].link)?.origin_id, "v1");
|
||||
assert.strictEqual(graph.links.get(byName["video_1"].link)?.origin_id, "v2");
|
||||
assert.deepStrictEqual(names(node), [
|
||||
"image_0",
|
||||
"video_0",
|
||||
"video_1",
|
||||
"video_2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("loading a legacy 1-based workflow normalizes to 0-based", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = new FakeNode("loaded", graph);
|
||||
// Simulate a workflow saved before the rename: drifted 1-based names and
|
||||
// interleaving. Everything is renumbered from image_0 / video_0.
|
||||
node.addInput("image_6116", "IMAGE");
|
||||
node.addInput("video_3", "*");
|
||||
node.addInput("image_2", "IMAGE");
|
||||
const src = new FakeNode("s", graph);
|
||||
src.connect(0, node, 0);
|
||||
src.connect(0, node, 1);
|
||||
node.configure({});
|
||||
flush();
|
||||
assert.deepStrictEqual(names(node), [
|
||||
"image_0",
|
||||
"image_1",
|
||||
"video_0",
|
||||
"video_1",
|
||||
]);
|
||||
});
|
||||
|
||||
// Fill a zone to its cap by connecting the trailing free slot repeatedly.
|
||||
function fillZone(graph, node, group, count) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const free = names(node).indexOf(`${group}_${i}`);
|
||||
if (free < 0) break;
|
||||
new FakeNode(`${group}_src${i}`, graph).connect(0, node, free);
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
test("image zone stops growing at the output count", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = makeFilterNode(graph);
|
||||
fillZone(graph, node, "image", MAX_IMAGE);
|
||||
const images = names(node).filter((n) => n.startsWith("image_"));
|
||||
assert.strictEqual(images.length, MAX_IMAGE, "no slot past the last output");
|
||||
assert.strictEqual(images.at(-1), `image_${MAX_IMAGE - 1}`);
|
||||
// Every slot is connected: a full zone keeps no trailing free slot.
|
||||
const unconnected = node.inputs.filter(
|
||||
(i) => i.name.startsWith("image_") && i.link == null
|
||||
);
|
||||
assert.strictEqual(unconnected.length, 0);
|
||||
});
|
||||
|
||||
test("video zone stops growing at its own smaller output count", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = makeFilterNode(graph);
|
||||
fillZone(graph, node, "video", MAX_VIDEO);
|
||||
const videos = names(node).filter((n) => n.startsWith("video_"));
|
||||
assert.strictEqual(videos.length, MAX_VIDEO);
|
||||
assert.strictEqual(videos.at(-1), `video_${MAX_VIDEO - 1}`);
|
||||
});
|
||||
|
||||
test("a full zone stays stable under repeated stabilize passes", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = makeFilterNode(graph);
|
||||
fillZone(graph, node, "image", MAX_IMAGE);
|
||||
fillZone(graph, node, "video", MAX_VIDEO);
|
||||
const before = names(node);
|
||||
assert.strictEqual(before.length, MAX_IMAGE + MAX_VIDEO);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
node.onConnectionsChange(1, 0, true, null, null);
|
||||
flush();
|
||||
}
|
||||
assert.deepStrictEqual(names(node), before);
|
||||
});
|
||||
|
||||
test("freeing a slot in a full zone leaves the gap in place", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = makeFilterNode(graph);
|
||||
fillZone(graph, node, "image", MAX_IMAGE);
|
||||
// Disconnect image_0. The zone is already at cap, so it must not renumber:
|
||||
// outputs mirror inputs positionally, and shifting image_1 down to image_0
|
||||
// would silently move every remaining asset to a different output.
|
||||
const link = node.inputs[0].link;
|
||||
graph.links.delete(link);
|
||||
node.inputs[0].link = null;
|
||||
node.onConnectionsChange(1, 0, false, null, null);
|
||||
flush();
|
||||
const images = node.inputs.filter((i) => i.name.startsWith("image_"));
|
||||
assert.strictEqual(images.length, MAX_IMAGE, "zone stays at cap");
|
||||
const free = images.filter((i) => i.link == null);
|
||||
assert.strictEqual(free.length, 1, "exactly one free slot");
|
||||
assert.strictEqual(free[0].name, "image_0", "the freed slot itself is free");
|
||||
// Every other slot keeps the origin it had before.
|
||||
for (let i = 1; i < MAX_IMAGE; i++) {
|
||||
const input = images[i];
|
||||
assert.strictEqual(
|
||||
graph.links.get(input.link)?.origin_id,
|
||||
`image_src${i}`,
|
||||
`image_${i} kept its origin`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("an over-cap layout from a stale workflow is trimmed to the caps", () => {
|
||||
const graph = new FakeGraph();
|
||||
const node = new FakeNode("stale", graph);
|
||||
// A workflow saved before the caps existed: 12 image inputs, all connected.
|
||||
const src = new FakeNode("s", graph);
|
||||
for (let i = 1; i <= 12; i++) node.addInput(`image_${i}`, "IMAGE");
|
||||
for (let i = 0; i < 12; i++) src.connect(0, node, i);
|
||||
node.configure({});
|
||||
flush();
|
||||
const images = names(node).filter((n) => n.startsWith("image_"));
|
||||
assert.strictEqual(images.length, MAX_IMAGE);
|
||||
assert.deepStrictEqual(
|
||||
images,
|
||||
Array.from({ length: MAX_IMAGE }, (_, i) => `image_${i}`)
|
||||
);
|
||||
});
|
||||
|
||||
test("caps fall back to 9/3 when output names are unavailable", async () => {
|
||||
// A defensive path: if nodeData carries no output names the extension must
|
||||
// still cap rather than grow without bound.
|
||||
class Fallback extends FakeNode {}
|
||||
await registered.beforeRegisterNodeDef(Fallback, { name: "AssetFilter" });
|
||||
const graph = new FakeGraph();
|
||||
const node = new Fallback("fb", graph);
|
||||
node.onAdded();
|
||||
flush();
|
||||
fillZone(graph, node, "video", 10);
|
||||
const videos = names(node).filter((n) => n.startsWith("video_"));
|
||||
assert.strictEqual(videos.length, 3);
|
||||
});
|
||||
|
||||
let failed = 0;
|
||||
for (const [name, fn] of tests) {
|
||||
rafQueue.length = 0;
|
||||
try {
|
||||
await fn();
|
||||
console.log(`ok ${name}`);
|
||||
} catch (error) {
|
||||
failed++;
|
||||
console.log(`FAIL ${name}\n ${error.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n${tests.length - failed}/${tests.length} passed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -1,377 +0,0 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "nodes" / "any_batch_process.py"
|
||||
|
||||
|
||||
def load_module():
|
||||
spec = importlib.util.spec_from_file_location("any_batch_process_test", MODULE_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def make_asset(path, age_hours):
|
||||
Path(path).write_bytes(b"x")
|
||||
if age_hours:
|
||||
mtime = time.time() - age_hours * 3600.0
|
||||
os.utime(path, (mtime, mtime))
|
||||
|
||||
|
||||
class AssetFilterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.module = load_module()
|
||||
# Force the non-Comfy code path so tests never touch the real user
|
||||
# directory state file.
|
||||
self.module.folder_paths = None
|
||||
self.module._memory_state.clear()
|
||||
self.node = self.module.AssetFilter()
|
||||
self.tmp = tempfile.mkdtemp(prefix="asset_filter_test_")
|
||||
self.fresh_image = os.path.join(self.tmp, "fresh.png")
|
||||
self.stale_image = os.path.join(self.tmp, "stale.png")
|
||||
self.new_image = os.path.join(self.tmp, "new.png")
|
||||
self.fresh_video = os.path.join(self.tmp, "fresh.mp4")
|
||||
self.stale_video = os.path.join(self.tmp, "stale.mp4")
|
||||
make_asset(self.fresh_image, 0)
|
||||
make_asset(self.stale_image, 48)
|
||||
make_asset(self.new_image, 0)
|
||||
make_asset(self.fresh_video, 0)
|
||||
make_asset(self.stale_video, 48)
|
||||
self.image_a = torch.rand(1, 64, 64, 3)
|
||||
self.image_b = torch.rand(1, 32, 32, 3)
|
||||
|
||||
def filter(self, inputs, prompt, node_id="20", workflow_id="wf-test", **payloads):
|
||||
prompt[node_id] = {
|
||||
"class_type": "AssetFilter",
|
||||
"inputs": {name: [source, 0] for name, source in inputs.items()},
|
||||
}
|
||||
return self.node.filter_assets(
|
||||
freshness_hours=24.0,
|
||||
prompt=prompt,
|
||||
node_id=node_id,
|
||||
extra_pnginfo={"workflow": {"id": workflow_id}},
|
||||
**payloads,
|
||||
)
|
||||
|
||||
def split(self, result):
|
||||
"""Unpack the flat output tuple into (images, videos, counts)."""
|
||||
n_img = self.module._MAX_IMAGE_OUTPUTS
|
||||
n_vid = self.module._MAX_VIDEO_OUTPUTS
|
||||
self.assertEqual(len(result), n_img + n_vid + 2)
|
||||
return (
|
||||
list(result[:n_img]),
|
||||
list(result[n_img:n_img + n_vid]),
|
||||
result[-2],
|
||||
result[-1],
|
||||
)
|
||||
|
||||
def kept(self, slots):
|
||||
"""Survivors in slot order, ignoring the None gaps between them."""
|
||||
return [value for value in slots if value is not None]
|
||||
|
||||
def at(self, slots, index):
|
||||
"""The value on output <kind>_<index> (0-based, as shown in the UI)."""
|
||||
return slots[index]
|
||||
|
||||
def image_nodes(self, mapping):
|
||||
return {
|
||||
source: {"class_type": "LoadImage", "inputs": {"image": path}}
|
||||
for source, path in mapping.items()
|
||||
}
|
||||
|
||||
def test_bootstrap_uses_mtime_and_positional_outputs(self):
|
||||
prompt = self.image_nodes({"10": self.fresh_image, "11": self.stale_image})
|
||||
images, videos, image_count, video_count = self.split(
|
||||
self.filter(
|
||||
{"image_0": "10", "image_1": "11"},
|
||||
prompt,
|
||||
image_0=self.image_a,
|
||||
image_1=self.image_b,
|
||||
)
|
||||
)
|
||||
self.assertEqual(image_count, 1)
|
||||
self.assertEqual(video_count, 0)
|
||||
kept = self.kept(images)
|
||||
self.assertEqual(len(kept), 1)
|
||||
self.assertTrue(torch.equal(kept[0], self.image_a))
|
||||
# No videos connected: every video output stays None so a reference
|
||||
# consumer skips those slots instead of being pruned.
|
||||
self.assertEqual(self.kept(videos), [])
|
||||
|
||||
def test_bootstrap_all_stale_falls_back_to_pass_through(self):
|
||||
prompt = self.image_nodes({"10": self.stale_image, "11": self.stale_image})
|
||||
images, _, image_count, _ = self.split(
|
||||
self.filter(
|
||||
{"image_0": "10", "image_1": "11"},
|
||||
prompt,
|
||||
image_0=self.image_a,
|
||||
image_1=self.image_b,
|
||||
)
|
||||
)
|
||||
self.assertEqual(image_count, 2)
|
||||
self.assertEqual(len(self.kept(images)), 2)
|
||||
|
||||
def test_unchanged_name_is_filtered_on_next_run(self):
|
||||
prompt = self.image_nodes({"10": self.fresh_image, "11": self.fresh_image})
|
||||
self.filter(
|
||||
{"image_0": "10", "image_1": "11"},
|
||||
prompt,
|
||||
image_0=self.image_a,
|
||||
image_1=self.image_b,
|
||||
)
|
||||
# Second run: slot 0 gets a new filename, slot 1 is untouched even
|
||||
# though its file mtime is fresh.
|
||||
prompt = self.image_nodes({"10": self.new_image, "11": self.fresh_image})
|
||||
images, _, image_count, _ = self.split(
|
||||
self.filter(
|
||||
{"image_0": "10", "image_1": "11"},
|
||||
prompt,
|
||||
image_0=self.image_a,
|
||||
image_1=self.image_b,
|
||||
)
|
||||
)
|
||||
self.assertEqual(image_count, 1)
|
||||
self.assertTrue(torch.equal(self.at(images, 0), self.image_a))
|
||||
self.assertIsNone(self.at(images, 1))
|
||||
|
||||
def test_rerun_without_changes_reuses_previous_selection(self):
|
||||
prompt = self.image_nodes({"10": self.fresh_image, "11": self.stale_image})
|
||||
first = self.split(
|
||||
self.filter(
|
||||
{"image_0": "10", "image_1": "11"},
|
||||
prompt,
|
||||
image_0=self.image_a,
|
||||
image_1=self.image_b,
|
||||
)
|
||||
)
|
||||
second = self.split(
|
||||
self.filter(
|
||||
{"image_0": "10", "image_1": "11"},
|
||||
prompt,
|
||||
image_0=self.image_a,
|
||||
image_1=self.image_b,
|
||||
)
|
||||
)
|
||||
self.assertEqual(first[2], 1)
|
||||
self.assertEqual(second[2], 1)
|
||||
self.assertTrue(torch.equal(second[0][0], self.image_a))
|
||||
|
||||
def test_runtime_assets_without_source_file_are_kept(self):
|
||||
prompt = {
|
||||
"10": {"class_type": "EmptyImage", "inputs": {"width": 64, "height": 64}},
|
||||
}
|
||||
images, _, image_count, _ = self.split(
|
||||
self.filter({"image_0": "10"}, prompt, image_0=self.image_a)
|
||||
)
|
||||
self.assertEqual(image_count, 1)
|
||||
self.assertEqual(len(self.kept(images)), 1)
|
||||
|
||||
def test_traces_through_intermediate_nodes(self):
|
||||
prompt = {
|
||||
"10": {"class_type": "VHS_LoadVideo", "inputs": {"video": self.fresh_video}},
|
||||
"11": {"class_type": "ImageScale", "inputs": {"image": ["10", 0]}},
|
||||
"12": {"class_type": "VHS_LoadVideo", "inputs": {"video": self.stale_video}},
|
||||
}
|
||||
video_payload = object()
|
||||
stale_payload = object()
|
||||
_, videos, _, video_count = self.split(
|
||||
self.filter(
|
||||
{"video_0": "11", "video_1": "12"},
|
||||
prompt,
|
||||
video_0=video_payload,
|
||||
video_1=stale_payload,
|
||||
)
|
||||
)
|
||||
self.assertEqual(video_count, 1)
|
||||
self.assertIs(videos[0], video_payload)
|
||||
|
||||
def test_non_media_widgets_are_not_mistaken_for_sources(self):
|
||||
prompt = {
|
||||
"10": {"class_type": "CLIPTextEncode", "inputs": {"text": "stale.png"}},
|
||||
}
|
||||
_, videos, _, video_count = self.split(
|
||||
self.filter({"video_0": "10"}, prompt, video_0=self.image_a)
|
||||
)
|
||||
self.assertEqual(video_count, 1)
|
||||
self.assertEqual(len(self.kept(videos)), 1)
|
||||
|
||||
def test_original_sizes_are_preserved(self):
|
||||
prompt = self.image_nodes({"10": self.fresh_image, "11": self.fresh_image})
|
||||
images, _, image_count, _ = self.split(
|
||||
self.filter(
|
||||
{"image_0": "10", "image_1": "11"},
|
||||
prompt,
|
||||
image_0=self.image_a,
|
||||
image_1=self.image_b,
|
||||
)
|
||||
)
|
||||
self.assertEqual(image_count, 2)
|
||||
# Each survivor keeps its own resolution: separate outputs mean no
|
||||
# torch.cat, so no black-border padding.
|
||||
self.assertEqual(tuple(images[0].shape), (1, 64, 64, 3))
|
||||
self.assertEqual(tuple(images[1].shape), (1, 32, 32, 3))
|
||||
|
||||
def test_flexible_inputs_accept_image_and_video_names(self):
|
||||
inputs = self.module.AssetFilter.INPUT_TYPES()["optional"]
|
||||
self.assertIn("image_0", inputs)
|
||||
self.assertIn("image_3", inputs)
|
||||
self.assertIn("video_12", inputs)
|
||||
self.assertNotIn("anything_else", inputs)
|
||||
self.assertEqual(inputs["image_0"][0], "IMAGE")
|
||||
self.assertEqual(inputs["image_3"][0], "IMAGE")
|
||||
self.assertEqual(inputs["video_12"][0], "*")
|
||||
|
||||
def test_output_signature_matches_consumer_slot_counts(self):
|
||||
cls = self.module.AssetFilter
|
||||
n_img = self.module._MAX_IMAGE_OUTPUTS
|
||||
n_vid = self.module._MAX_VIDEO_OUTPUTS
|
||||
self.assertEqual(len(cls.RETURN_TYPES), n_img + n_vid + 2)
|
||||
self.assertEqual(len(cls.RETURN_NAMES), len(cls.RETURN_TYPES))
|
||||
# 0-based, matching MiniMax H3's ref_image_0..8 / ref_video_0..2.
|
||||
self.assertEqual(cls.RETURN_NAMES[0], "image_0")
|
||||
self.assertEqual(cls.RETURN_NAMES[n_img - 1], f"image_{n_img - 1}")
|
||||
self.assertEqual(cls.RETURN_NAMES[n_img], "video_0")
|
||||
self.assertEqual(cls.RETURN_NAMES[-2:], ("image_count", "video_count"))
|
||||
self.assertEqual(cls.RETURN_TYPES[:n_img], tuple(["IMAGE"] * n_img))
|
||||
self.assertEqual(cls.RETURN_TYPES[-2:], ("INT", "INT"))
|
||||
# Each output carries one asset, so the engine must not treat them as
|
||||
# lists to expand into separate executions.
|
||||
self.assertFalse(hasattr(cls, "OUTPUT_IS_LIST"))
|
||||
|
||||
def test_survivors_beyond_the_last_output_are_dropped(self):
|
||||
n_img = self.module._MAX_IMAGE_OUTPUTS
|
||||
count = n_img + 2
|
||||
paths = {}
|
||||
payloads = {}
|
||||
inputs = {}
|
||||
for i in range(count):
|
||||
path = os.path.join(self.tmp, f"surplus_{i}.png")
|
||||
make_asset(path, 0)
|
||||
paths[str(i)] = path
|
||||
payloads[f"image_{i}"] = torch.rand(1, 8, 8, 3)
|
||||
inputs[f"image_{i}"] = str(i)
|
||||
images, _, image_count, _ = self.split(
|
||||
self.filter(inputs, self.image_nodes(paths), **payloads)
|
||||
)
|
||||
self.assertEqual(image_count, count)
|
||||
# The tuple keeps its declared width; slots past the last output have
|
||||
# nowhere to go, so those inputs are dropped.
|
||||
self.assertEqual(len(images), n_img)
|
||||
self.assertEqual(len(self.kept(images)), n_img)
|
||||
for i in range(n_img):
|
||||
self.assertTrue(torch.equal(self.at(images, i), payloads[f"image_{i}"]))
|
||||
|
||||
def test_survivor_stays_on_its_own_output_slot(self):
|
||||
# image_0 keeps its filename (dropped), image_1 changes (kept). The
|
||||
# survivor must stay on output image_1 — compacting it onto image_0
|
||||
# would make a downstream preview show a different image than the slot
|
||||
# it is wired to.
|
||||
prompt = self.image_nodes({"10": self.fresh_image, "11": self.stale_image})
|
||||
self.filter(
|
||||
{"image_0": "10", "image_1": "11"},
|
||||
prompt,
|
||||
image_0=self.image_a,
|
||||
image_1=self.image_b,
|
||||
)
|
||||
prompt = self.image_nodes({"10": self.fresh_image, "11": self.new_image})
|
||||
images, _, image_count, _ = self.split(
|
||||
self.filter(
|
||||
{"image_0": "10", "image_1": "11"},
|
||||
prompt,
|
||||
image_0=self.image_a,
|
||||
image_1=self.image_b,
|
||||
)
|
||||
)
|
||||
self.assertEqual(image_count, 1)
|
||||
self.assertIsNone(self.at(images, 0))
|
||||
self.assertTrue(torch.equal(self.at(images, 1), self.image_b))
|
||||
|
||||
def test_holes_are_preserved_across_the_whole_zone(self):
|
||||
# Survivors on slots 1 and 3 must appear on outputs 1 and 3, leaving
|
||||
# 0 and 2 empty. Reference consumers skip the gaps.
|
||||
paths = {}
|
||||
payloads = {}
|
||||
inputs = {}
|
||||
for i in range(4):
|
||||
path = os.path.join(self.tmp, f"hole_{i}.png")
|
||||
make_asset(path, 0 if i % 2 == 1 else 48)
|
||||
paths[str(i)] = path
|
||||
payloads[f"image_{i}"] = torch.rand(1, 8 * (i + 1), 8 * (i + 1), 3)
|
||||
inputs[f"image_{i}"] = str(i)
|
||||
images, _, image_count, _ = self.split(
|
||||
self.filter(inputs, self.image_nodes(paths), **payloads)
|
||||
)
|
||||
self.assertEqual(image_count, 2)
|
||||
self.assertIsNone(self.at(images, 0))
|
||||
self.assertIsNone(self.at(images, 2))
|
||||
self.assertTrue(torch.equal(self.at(images, 1), payloads["image_1"]))
|
||||
self.assertTrue(torch.equal(self.at(images, 3), payloads["image_3"]))
|
||||
|
||||
def test_legacy_1_based_inputs_still_execute(self):
|
||||
# A workflow saved before the rename to 0-based names feeds image_1 /
|
||||
# image_2; the backend must still accept them (the frontend renumbers
|
||||
# on load, but raw execution must not break either).
|
||||
prompt = self.image_nodes({"10": self.fresh_image, "11": self.fresh_image})
|
||||
images, _, image_count, _ = self.split(
|
||||
self.filter(
|
||||
{"image_1": "10", "image_2": "11"},
|
||||
prompt,
|
||||
image_1=self.image_a,
|
||||
image_2=self.image_b,
|
||||
)
|
||||
)
|
||||
self.assertEqual(image_count, 2)
|
||||
self.assertTrue(torch.equal(self.at(images, 1), self.image_a))
|
||||
self.assertTrue(torch.equal(self.at(images, 2), self.image_b))
|
||||
|
||||
def test_state_is_scoped_per_workflow_and_node(self):
|
||||
prompt = self.image_nodes({"10": self.fresh_image})
|
||||
self.filter({"image_0": "10"}, prompt, image_0=self.image_a)
|
||||
state = self.module._load_state()
|
||||
self.assertIn("wf-test:20", state)
|
||||
# A different node in a different workflow must not collide.
|
||||
prompt_b = self.image_nodes({"10": self.fresh_image})
|
||||
self.filter(
|
||||
{"image_0": "10"},
|
||||
prompt_b,
|
||||
node_id="21",
|
||||
workflow_id="wf-other",
|
||||
image_0=self.image_a,
|
||||
)
|
||||
state = self.module._load_state()
|
||||
self.assertIn("wf-test:20", state)
|
||||
self.assertIn("wf-other:21", state)
|
||||
|
||||
def test_state_key_reads_nested_workflow_id(self):
|
||||
self.assertEqual(
|
||||
self.module._state_key({"workflow": {"id": "abc"}}, "7"), "abc:7"
|
||||
)
|
||||
# Tolerate a flat shape and a missing id without raising.
|
||||
self.assertEqual(self.module._state_key({"id": "flat"}, "7"), "flat:7")
|
||||
self.assertEqual(self.module._state_key(None, "7"), "None:7")
|
||||
|
||||
def test_unused_outputs_are_none_not_blockers(self):
|
||||
# Only a video is connected, so every image output has nothing to emit.
|
||||
# None is required here: a reference consumer skips None slots, whereas
|
||||
# an ExecutionBlocker on any one slot prunes the whole consumer node.
|
||||
prompt = {
|
||||
"10": {"class_type": "VHS_LoadVideo", "inputs": {"video": self.fresh_video}},
|
||||
}
|
||||
images, videos, image_count, video_count = self.split(
|
||||
self.filter({"video_0": "10"}, prompt, video_0=self.image_a)
|
||||
)
|
||||
self.assertEqual(image_count, 0)
|
||||
self.assertEqual(video_count, 1)
|
||||
self.assertTrue(all(value is None for value in images))
|
||||
self.assertEqual(len(self.kept(videos)), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user