Files
ComfyAssets-ComfyUI_PromptM…/tests/test_validators.py
T
Vito Sansevero b98c716b48 feat: worker-thread setting speeds up the output scan and thumbnail generation
Both jobs processed one file at a time on a single core. Pillow decodes the
whole image to reach trailing PNG text chunks and releases the GIL while it
does, so a bounded thread pool scales almost linearly: on a 1 GB sample of
400 PNGs the metadata pass dropped from 18 s to 2.5 s at 8 threads, and 120
thumbnails from 4.6 s to 0.6 s.

- utils/parallel.py: map_parallel(func, items, workers) keeps order, runs on
  the caller's thread when workers is 1, and caps the pool at the item count
- PromptManagerConfig.WORKER_THREADS defaults to half the detected cores,
  is clamped to max_worker_threads() when loaded from config.json, and is
  persisted under "performance"
- settings API returns worker_threads and cpu_count and validates saves;
  the settings modal gets a "Worker threads" slider capped at the core count
- the scan reads each metadata batch on the configured threads; thumbnail
  generation processes batches of files per executor job, still emitting
  one progress event per file

Threads rather than processes: this runs inside ComfyUI's server, where
forking a CUDA-initialised process is unsafe and the spawn start method
would re-import ComfyUI's main module in every child.
2026-10-08 18:01:34 -07:00

318 lines
10 KiB
Python

"""
Comprehensive tests for input validation utilities.
Tests all 7 validator functions in utils/validators.py including
edge cases, boundary conditions, and security-related inputs.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.validators import (
validate_prompt_text,
validate_rating,
validate_tags,
validate_category,
sanitize_input,
parse_tags_string,
validate_result_timeout,
validate_worker_threads,
)
class TestValidateCategory(unittest.TestCase):
"""Test validate_category function."""
def test_none_is_valid(self):
self.assertTrue(validate_category(None))
def test_empty_string_is_valid(self):
self.assertTrue(validate_category(""))
def test_whitespace_only_is_valid(self):
self.assertTrue(validate_category(" "))
def test_normal_category(self):
self.assertTrue(validate_category("landscape"))
def test_category_with_spaces(self):
self.assertTrue(validate_category("nature photos"))
def test_category_with_hyphens_underscores(self):
self.assertTrue(validate_category("sci-fi_art"))
def test_unicode_category(self):
self.assertTrue(validate_category("日本語カテゴリ"))
def test_max_length_boundary(self):
self.assertTrue(validate_category("x" * 100))
def test_exceeds_max_length(self):
with self.assertRaises(ValueError) as ctx:
validate_category("x" * 101)
self.assertIn("100", str(ctx.exception))
def test_non_string_raises(self):
with self.assertRaises(ValueError):
validate_category(123)
def test_list_raises(self):
with self.assertRaises(ValueError):
validate_category(["category"])
def test_control_characters_rejected(self):
with self.assertRaises(ValueError) as ctx:
validate_category("bad\x00category")
self.assertIn("control characters", str(ctx.exception))
def test_tab_character_rejected(self):
with self.assertRaises(ValueError):
validate_category("bad\tcategory")
def test_newline_rejected(self):
with self.assertRaises(ValueError):
validate_category("bad\ncategory")
class TestSanitizeInput(unittest.TestCase):
"""Test sanitize_input function."""
def test_normal_text_unchanged(self):
self.assertEqual(sanitize_input("hello world"), "hello world")
def test_strips_outer_whitespace(self):
self.assertEqual(sanitize_input(" hello "), "hello")
def test_removes_null_bytes(self):
self.assertEqual(sanitize_input("hello\x00world"), "helloworld")
def test_normalizes_windows_line_endings(self):
self.assertEqual(sanitize_input("line1\r\nline2"), "line1\nline2")
def test_normalizes_old_mac_line_endings(self):
self.assertEqual(sanitize_input("line1\rline2"), "line1\nline2")
def test_strips_whitespace_per_line(self):
result = sanitize_input(" line1 \n line2 ")
self.assertEqual(result, "line1\nline2")
def test_limits_consecutive_empty_lines(self):
text = "a\n\n\n\n\nb"
result = sanitize_input(text)
# Max 2 consecutive empty lines = 3 newlines between content
# 4+ newlines (3+ empty lines) should never appear
self.assertNotIn("\n\n\n\n", result)
self.assertIn("a", result)
self.assertIn("b", result)
def test_non_string_returns_empty(self):
self.assertEqual(sanitize_input(None), "")
self.assertEqual(sanitize_input(123), "")
self.assertEqual(sanitize_input([]), "")
def test_empty_string(self):
self.assertEqual(sanitize_input(""), "")
def test_preserves_single_newlines(self):
result = sanitize_input("line1\nline2\nline3")
self.assertEqual(result, "line1\nline2\nline3")
def test_mixed_line_endings(self):
result = sanitize_input("a\r\nb\rc\nd")
self.assertEqual(result, "a\nb\nc\nd")
class TestParseTagsString(unittest.TestCase):
"""Test parse_tags_string function."""
def test_simple_comma_separated(self):
result = parse_tags_string("tag1, tag2, tag3")
self.assertEqual(result, ["tag1", "tag2", "tag3"])
def test_extra_whitespace(self):
result = parse_tags_string(" tag1 , tag2 ")
self.assertEqual(result, ["tag1", "tag2"])
def test_empty_string_returns_empty_list(self):
self.assertEqual(parse_tags_string(""), [])
def test_none_returns_empty_list(self):
self.assertEqual(parse_tags_string(None), [])
def test_non_string_returns_empty_list(self):
self.assertEqual(parse_tags_string(123), [])
def test_single_tag(self):
self.assertEqual(parse_tags_string("solo"), ["solo"])
def test_deduplication(self):
result = parse_tags_string("tag1, tag1, tag2, tag1")
self.assertEqual(result, ["tag1", "tag2"])
def test_empty_segments_skipped(self):
result = parse_tags_string("tag1,,tag2,,,tag3")
self.assertEqual(result, ["tag1", "tag2", "tag3"])
def test_max_20_tags(self):
tags = ", ".join(f"tag{i}" for i in range(25))
result = parse_tags_string(tags)
self.assertEqual(len(result), 20)
def test_strips_null_bytes_from_tags(self):
result = parse_tags_string("clean\x00tag, normal")
self.assertIn("cleantag", result)
self.assertIn("normal", result)
class TestValidatePromptTextEdgeCases(unittest.TestCase):
"""Additional edge cases beyond test_basic.py coverage."""
def test_exactly_max_length(self):
self.assertTrue(validate_prompt_text("x" * 10000))
def test_one_over_max_length(self):
with self.assertRaises(ValueError):
validate_prompt_text("x" * 10001)
def test_single_character(self):
self.assertTrue(validate_prompt_text("a"))
def test_unicode_text(self):
self.assertTrue(validate_prompt_text("日本語プロンプト"))
def test_multiline_text(self):
self.assertTrue(validate_prompt_text("line1\nline2\nline3"))
def test_boolean_raises(self):
with self.assertRaises(ValueError):
validate_prompt_text(True)
def test_none_raises(self):
with self.assertRaises(ValueError):
validate_prompt_text(None)
class TestValidateRatingEdgeCases(unittest.TestCase):
"""Additional edge cases beyond test_basic.py coverage."""
def test_float_raises(self):
with self.assertRaises(ValueError):
validate_rating(3.5)
def test_negative_raises(self):
with self.assertRaises(ValueError):
validate_rating(-1)
def test_zero_raises(self):
with self.assertRaises(ValueError):
validate_rating(0)
def test_boolean_true_is_int(self):
# In Python, bool is a subclass of int: True == 1
# This tests the actual behavior
self.assertTrue(validate_rating(True))
class TestValidateTagsEdgeCases(unittest.TestCase):
"""Additional edge cases beyond test_basic.py coverage."""
def test_empty_list_is_valid(self):
self.assertTrue(validate_tags([]))
def test_comma_string_parsed(self):
self.assertTrue(validate_tags("tag1, tag2"))
def test_control_char_in_tag_rejected(self):
with self.assertRaises(ValueError) as ctx:
validate_tags(["bad\x00tag"])
self.assertIn("control characters", str(ctx.exception))
def test_tab_in_tag_rejected(self):
with self.assertRaises(ValueError):
validate_tags(["bad\ttag"])
def test_non_string_tag_in_list_rejected(self):
with self.assertRaises(ValueError):
validate_tags([123])
def test_dict_input_rejected(self):
with self.assertRaises(ValueError):
validate_tags({"tag": "value"})
def test_exactly_20_tags(self):
self.assertTrue(validate_tags([f"tag{i}" for i in range(20)]))
def test_exactly_50_char_tag(self):
self.assertTrue(validate_tags(["x" * 50]))
def test_whitespace_only_tag_rejected(self):
with self.assertRaises(ValueError):
validate_tags([" "])
class TestValidateResultTimeout(unittest.TestCase):
"""Test validate_result_timeout function (#149)."""
def test_zero_disables_auto_hide(self):
self.assertEqual(validate_result_timeout(0), 0)
def test_typical_value(self):
self.assertEqual(validate_result_timeout(30), 30)
def test_max_boundary(self):
self.assertEqual(validate_result_timeout(300), 300)
def test_returns_int(self):
self.assertIsInstance(validate_result_timeout(5), int)
def test_none_rejected(self):
with self.assertRaises(ValueError):
validate_result_timeout(None)
def test_garbage_string_rejected(self):
with self.assertRaises(ValueError):
validate_result_timeout("soon")
def test_numeric_string_coerced(self):
self.assertEqual(validate_result_timeout(" 10 "), 10)
def test_fractional_values_rejected(self):
for value in (1.5, "1.5"):
with self.assertRaises(ValueError):
validate_result_timeout(value)
def test_out_of_range_rejected_not_clamped(self):
for value in (-5, 301, 9999):
with self.assertRaises(ValueError):
validate_result_timeout(value)
def test_bool_rejected(self):
with self.assertRaises(ValueError):
validate_result_timeout(True)
class TestValidateWorkerThreads(unittest.TestCase):
"""validate_worker_threads(value, max_workers) -> int in [1, max_workers]."""
def test_accepts_ints_and_digit_strings_within_range(self):
self.assertEqual(validate_worker_threads(1, 8), 1)
self.assertEqual(validate_worker_threads(8, 8), 8)
self.assertEqual(validate_worker_threads("4", 8), 4)
def test_rejects_out_of_range_bool_and_junk(self):
for value in (0, -1, 9, True, False, "x", None, 2.5, [2]):
with self.assertRaises(ValueError, msg=repr(value)):
validate_worker_threads(value, 8)
def test_max_of_one_only_accepts_one(self):
self.assertEqual(validate_worker_threads(1, 1), 1)
with self.assertRaises(ValueError):
validate_worker_threads(2, 1)
if __name__ == "__main__":
unittest.main()