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.
318 lines
10 KiB
Python
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()
|