Files
KOFiblto-ComfyUI-SaturnNodes/tests/test_batch_queue.py
T

338 lines
13 KiB
Python

import os
import sys
import unittest
import json
import tempfile
import threading
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.queue_control import PersistentQueueManager, PauseQueueManager
import nodes.queue_control as queue_control
# 36 Colors matching web/batch_queue.js
BATCH_COLORS = [
"#10b981", "#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#06b6d4",
"#ef4444", "#14b8a6", "#6366f1", "#f97316", "#84cc16", "#d946ef",
"#0ea5e9", "#a855f7", "#e11d48", "#22c55e", "#eab308", "#64748b",
"#2dd4bf", "#fb7185", "#38bdf8", "#c084fc", "#f43f5e", "#4ade80",
"#fbbf24", "#818cf8", "#fb923c", "#a3e635", "#f472b6", "#22d3ee",
"#e879f9", "#a7f3d0", "#fde047", "#fda4af", "#93c5fd", "#c4b5fd"
]
def calculate_segment_types(ordered_pids, registry):
"""
Python mirror of the segment calculation logic in web/batch_queue.js
"""
segments = {}
for pid in ordered_pids:
info = registry.get(pid)
if not info or not info.get("batchId"):
segments[pid] = None
continue
batch_id = info["batchId"]
occurrences = [p for p in ordered_pids if registry.get(p, {}).get("batchId") == batch_id]
if len(occurrences) <= 1:
batch_count = info.get("batchCount", 1)
if batch_count == 1:
segments[pid] = "single"
else:
item_index = info.get("itemIndex", 0)
if item_index == 0:
segments[pid] = "first"
elif item_index == (batch_count - 1):
segments[pid] = "last"
else:
segments[pid] = "middle"
continue
first_pid = occurrences[0]
last_pid = occurrences[-1]
if pid == first_pid:
segments[pid] = "first"
elif pid == last_pid:
segments[pid] = "last"
else:
segments[pid] = "middle"
return segments
class TestBatchQueueLogic(unittest.TestCase):
def test_36_color_cycling(self):
self.assertEqual(len(BATCH_COLORS), 36)
# Batch 1 -> color index 0
self.assertEqual((1 - 1) % 36, 0)
self.assertEqual(BATCH_COLORS[(1 - 1) % 36], "#10b981")
# Batch 36 -> color index 35
self.assertEqual((36 - 1) % 36, 35)
self.assertEqual(BATCH_COLORS[(36 - 1) % 36], "#c4b5fd")
# Batch 37 -> cycles back to color index 0
self.assertEqual((37 - 1) % 36, 0)
self.assertEqual(BATCH_COLORS[(37 - 1) % 36], "#10b981")
# Batch 73 -> cycles back to color index 0
self.assertEqual((73 - 1) % 36, 0)
def test_single_item_batch(self):
registry = {
"p1": {"batchId": "b1", "batchCount": 1, "itemIndex": 0}
}
segments = calculate_segment_types(["p1"], registry)
self.assertEqual(segments["p1"], "single")
def test_intact_contiguous_batch(self):
registry = {
"p1": {"batchId": "b1", "batchCount": 3, "itemIndex": 0},
"p2": {"batchId": "b1", "batchCount": 3, "itemIndex": 1},
"p3": {"batchId": "b1", "batchCount": 3, "itemIndex": 2},
}
segments = calculate_segment_types(["p1", "p2", "p3"], registry)
self.assertEqual(segments["p1"], "first")
self.assertEqual(segments["p2"], "middle")
self.assertEqual(segments["p3"], "last")
def test_interrupted_batch_inserted_in_between(self):
# Batch A was [pA1, pA2], but prompt B was inserted in-between: [pA1, pB, pA2]
registry = {
"pA1": {"batchId": "bA", "batchCount": 2, "itemIndex": 0},
"pB": {"batchId": "bB", "batchCount": 1, "itemIndex": 0},
"pA2": {"batchId": "bA", "batchCount": 2, "itemIndex": 1},
}
segments = calculate_segment_types(["pA1", "pB", "pA2"], registry)
# pA1 is the start of Batch A (curves top, open bottom)
self.assertEqual(segments["pA1"], "first")
# pB is a standalone single item (curves top and bottom)
self.assertEqual(segments["pB"], "single")
# pA2 is the end of Batch A (open top, curves bottom)
self.assertEqual(segments["pA2"], "last")
def test_multiple_interruptions(self):
# [pA1, pB, pA2, pC, pA3]
registry = {
"pA1": {"batchId": "bA", "batchCount": 3, "itemIndex": 0},
"pB": {"batchId": "bB", "batchCount": 1, "itemIndex": 0},
"pA2": {"batchId": "bA", "batchCount": 3, "itemIndex": 1},
"pC": {"batchId": "bC", "batchCount": 1, "itemIndex": 0},
"pA3": {"batchId": "bA", "batchCount": 3, "itemIndex": 2},
}
segments = calculate_segment_types(["pA1", "pB", "pA2", "pC", "pA3"], registry)
self.assertEqual(segments["pA1"], "first")
self.assertEqual(segments["pB"], "single")
self.assertEqual(segments["pA2"], "middle")
self.assertEqual(segments["pC"], "single")
self.assertEqual(segments["pA3"], "last")
class TestPersistentQueueBatchMetadata(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.temp_file = os.path.join(self.temp_dir.name, "test_persistent_queue.json")
self.patcher = patch("nodes.queue_control.PERSISTENT_FILE", self.temp_file)
self.patcher.start()
self.enabled_patcher = patch("nodes.queue_control.is_persistent_queue_enabled", return_value=True)
self.enabled_patcher.start()
def tearDown(self):
self.enabled_patcher.stop()
self.patcher.stop()
self.temp_dir.cleanup()
def test_save_and_restore_batch_info(self):
mgr = PersistentQueueManager()
# Set batch info for a prompt
batch_data = {
"batchId": "b_12345",
"batchNumber": 5,
"batchCount": 3,
"itemIndex": 0,
"color": "#10b981",
"colorIndex": 4
}
mgr.set_batch_info("prompt_99", batch_data)
self.assertIn("prompt_99", mgr.batch_meta)
# Add item to persistent queue
item_tuple = (0, "prompt_99", {"prompt": {}}, {}, {})
mgr.add_item(item_tuple)
mgr.flush()
# Verify batch_info is attached inside persistent_items
saved_entry = [x for x in mgr.persistent_items if x["prompt_id"] == "prompt_99"][0]
self.assertEqual(saved_entry.get("batch_info"), batch_data)
# Simulate reload from disk
new_mgr = PersistentQueueManager()
self.assertIn("prompt_99", new_mgr.batch_meta)
self.assertEqual(new_mgr.batch_meta["prompt_99"]["batchId"], "b_12345")
def test_remove_item_cleans_batch_meta(self):
mgr = PersistentQueueManager()
mgr.set_batch_info("p1", {"batchId": "b1"})
mgr.add_item((0, "p1", {}, {}, {}))
self.assertIn("p1", mgr.batch_meta)
mgr.remove_item("p1")
mgr.flush()
self.assertNotIn("p1", mgr.batch_meta)
self.assertEqual(len(mgr.persistent_items), 0)
def test_wipe_all_cleans_batch_meta(self):
mgr = PersistentQueueManager()
mgr.set_batch_info("p1", {"batchId": "b1"})
mgr.set_batch_info("p2", {"batchId": "b2"})
mgr.wipe_all()
self.assertEqual(len(mgr.batch_meta), 0)
self.assertEqual(len(mgr.persistent_items), 0)
class TestPersistentQueueHardening(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.temp_file = os.path.join(self.temp_dir.name, "test_persistent_queue.json")
self.patch_file = patch("nodes.queue_control.PERSISTENT_FILE", self.temp_file)
self.patch_user = patch("nodes.queue_control.USER_DIR", self.temp_dir.name)
self.patch_enabled = patch("nodes.queue_control.is_persistent_queue_enabled", return_value=True)
self.patch_file.start()
self.patch_user.start()
self.patch_enabled.start()
def tearDown(self):
self.patch_enabled.stop()
self.patch_user.stop()
self.patch_file.stop()
self.temp_dir.cleanup()
def test_finished_prompt_cannot_be_resynced(self):
mgr = PersistentQueueManager()
mgr.set_batch_info("p1", {"batchId": "b1"})
mgr.add_item((0, "p1", {}, {}, {}))
mgr.remove_item("p1")
# A late /saturnnodes/batch_queue/sync from another client must not
# resurrect metadata for a prompt that already finished.
mgr.set_batch_info("p1", {"batchId": "b1"})
self.assertNotIn("p1", mgr.batch_meta)
# Flush so the debounce timer cannot fire after tearDown restores the
# real PERSISTENT_FILE (it would write into the live user directory).
mgr.flush()
def test_batch_meta_is_capped_keeping_recent_entries(self):
mgr = PersistentQueueManager()
mgr.MAX_BATCH_META = 3
with patch("nodes.queue_control.time.time", side_effect=[1, 2, 3, 4, 5]):
for i in range(5):
mgr.set_batch_info(f"p{i}", {"batchId": f"b{i}"})
self.assertEqual(len(mgr.batch_meta), 3)
self.assertNotIn("p0", mgr.batch_meta)
self.assertNotIn("p1", mgr.batch_meta)
self.assertIn("p2", mgr.batch_meta)
self.assertIn("p4", mgr.batch_meta)
def test_saves_are_debounced_compact_and_flushable(self):
mgr = PersistentQueueManager()
mgr.add_item((0, "p1", {"prompt": {}}, {}, {}))
# The first write is deliberately deferred; memory is authoritative.
self.assertTrue(mgr._save_dirty)
mgr.flush()
self.assertFalse(mgr._save_dirty)
self.assertTrue(os.path.exists(self.temp_file))
with open(self.temp_file, "r", encoding="utf-8") as f:
raw = f.read()
# Compact JSON (no indentation/newlines) keeps the file small.
self.assertNotIn("\n", raw)
payload = json.loads(raw)
self.assertEqual(payload["schema_version"], queue_control.PERSISTENT_QUEUE_SCHEMA_VERSION)
self.assertEqual(payload["items"][0]["prompt_id"], "p1")
self.assertFalse(os.path.exists(self.temp_file + ".tmp"))
def test_restore_retry_setting_keeps_then_drops_failed_prompts(self):
mgr = PersistentQueueManager()
mgr.persistent_items = [{
"prompt_id": "p1",
"item": [0, "p1", {"prompt": {}}, {}],
"retries": 0,
}]
server = MagicMock()
server.prompt_queue.get_history.return_value = {}
server.prompt_queue.put.side_effect = RuntimeError("queue unavailable")
server.number = 0
def fake_setting(key, default):
return "1" if key == "PERSISTENT_QUEUE_MAX_RETRIES" else default
with patch("nodes.queue_control.PromptServer.instance", server), \
patch("nodes.queue_control.get_env_setting", side_effect=fake_setting):
mgr.restore_queue("test-client")
self.assertEqual(len(mgr.persistent_items), 1)
self.assertEqual(mgr.persistent_items[0]["retries"], 1)
mgr.has_claimed_once = False
mgr.restore_queue("test-client")
self.assertEqual(len(mgr.persistent_items), 0)
self.assertIn("p1", mgr.recently_finished)
def test_pause_state_persists_when_enabled(self):
def fake_setting(key, default):
if key == "PERSIST_PAUSE_STATE":
return "true"
return default
with patch("nodes.queue_control.get_env_setting", side_effect=fake_setting):
first = PauseQueueManager()
first.set_pause(True, "instantly")
second = PauseQueueManager()
self.assertTrue(second.paused)
self.assertEqual(second.mode, "instantly")
self.assertEqual(second.is_waiting, True)
class TestPauseQueuePatchedGet(unittest.TestCase):
def test_finite_timeout_returns_instead_of_busy_spinning(self):
class FakeQueue:
def __init__(self):
self.currently_running = {}
self.mutex = threading.RLock()
self.calls = 0
self.last_timeout = object()
def get(self, timeout=None):
self.calls += 1
self.last_timeout = timeout
return None
queue = FakeQueue()
server = MagicMock()
server.prompt_queue = queue
def fake_setting(key, default):
return default
with patch("nodes.queue_control.get_env_setting", side_effect=fake_setting), \
patch.object(queue_control.PromptServer, "instance", server):
manager = PauseQueueManager()
manager.patch_all()
# ComfyUI main.py calls get(timeout=0.0) when a GC pass is due.
# The wrapper must return immediately (not spin forever).
self.assertIsNone(queue.get(timeout=0.0))
self.assertEqual(queue.calls, 1)
self.assertEqual(queue.last_timeout, 0.0)
if __name__ == "__main__":
unittest.main()