Files
ComfyAssets-ComfyUI_PromptM…/tests/test_config.py
T
Vito 00284d92da feat: LoraManager integration — trigger words & example import (#52) (#136)
* feat: add LoRA utility module for LoraManager integration (#52)

Detection, metadata reading, trigger word cache, and prompt injection
for ComfyUI-Lora-Manager. All functions return empty results when
LoraManager is not installed.

* feat: add IntegrationConfig for opt-in third-party integrations (#52)

* feat(api): add LoraManager integration endpoints (#52)

Detection, enable/disable, scan/import, trigger word lookup,
and cache refresh endpoints under /prompt_manager/lora/*.

* feat: inject LoRA trigger words into prompts at encoding time (#52)

When enabled, scans for <lora:NAME:WEIGHT> tags in prompt text and
appends trigger words from LoraManager metadata. Behind config toggle.

* feat(ui): add LoraManager integration settings and import UI (#52)

Integrations section in settings with auto-detection badge, enable
toggle, trigger word toggle, and Import LoRA Data button.

* fix: detect LoraManager with case-insensitive directory scan (#52)

* fix: simplify LoraManager heuristic to match real install structure (#52)

* fix: find ComfyUI root via folder_paths and handle symlinked installs (#52)

* fix(ui): add pointer-events-none to toggle switch overlays (#52)

The styled div was intercepting clicks meant for the sr-only checkbox
input, preventing toggle switches from being clickable.

* fix: use lora-manager category and tag for imported LoRA data (#52)

* fix: use rglob to find metadata in lora subdirectories (#52)

* feat: discover LoRA dirs from extra_model_paths.yaml and folder_paths (#52)

Only models/loras under the ComfyUI root was checked. Now also parses
extra_model_paths.yaml and uses folder_paths.get_folder_paths('loras')
at runtime to find all configured LoRA directories.

* fix: use example prompts instead of trigger words, serve LoRA preview images (#52)

- Prompt text now uses civitai example prompts (images[].meta.prompt)
  when available, falling back to model name instead of trigger words
- Image serving now allows paths within LoRA directories when the
  integration is enabled, fixing 403 errors on preview images

* fix: handle null civitai field, link all preview images (#52)

- Guard against civitai: null in metadata (was crashing the scan)
- Link all local preview images per LoRA, not just the first
- Add get_civitai_image_urls() for future remote image support

* feat: reimport clears previous lora-manager data first (#52)

Clicking Import LoRA Data now deletes all existing lora-manager
category prompts before scanning, ensuring a clean reimport.

* feat: download civitai example images during LoRA import (#52)

Images from civitai.images[] are downloaded to data/lora_images/ cache
and linked to prompts alongside local preview files. Cached files are
reused on reimport. Image serving allows the cache directory.

* feat: add CivitAI API key setting for authenticated image downloads (#52)

Most civitai example images (especially NSFW) require authentication.
Adds API key field to the Integrations settings panel, passed as
Bearer token when downloading example images.

* feat(ui): add progress modal for LoRA import (#52)

Closes settings modal and shows a dedicated progress modal with
progress bar, status text, and processed/imported counts during
LoRA import. Auto-closes after completion.

* fix: reduce download timeout to 5s, update progress per LoRA (#52)

5K images at 15s timeout was painfully slow. Reduced to 5s fail-fast.
Progress now updates for every LoRA with image count, not every 5th.

* perf: use 512px thumbnails and parallel downloads for LoRA images (#52)

Full-size civitai images averaged 5.6MB each (27GB total for 5K images).
Now requests /width=512/ thumbnails (~50-100KB) and downloads 8 in
parallel per LoRA. Expected speedup: ~100x smaller + 8x parallel.

* fix: resize downloaded images to 512px thumbnails locally (#52)

Civitai CDN returns 401 for /width=N/ thumbnail URLs with API key auth.
Instead, download the original and resize to 512px via PIL before saving.
Reduces disk usage from ~5MB to ~30-50KB per image.

* docs: update README for v3.2.1 LoRA Manager integration (#52)

- Add LoRA Manager integration section with setup guide and CivitAI key docs
- Add folder filter section with rescan note for existing libraries
- Add v3.2.1 changelog entry, split from v3.2.0
- Add WIP notice for LoRA Manager feature
- Update AutoTag section to include WD14 models
- Fix stale references (KikoTextEncode, outdated file structure)
- Remove dated v2 development note
- Add screenshots for settings, integration, and filtered results
- Fix code review items: remove unused constant, add comments to empty excepts
- Bump version to 3.2.1

* test: add unit tests for LoRA integration and folder filter (#52)

- test_lora_utils.py: 32 tests covering trigger word extraction, example
  prompts, image URLs, metadata parsing, cache dir, TriggerWordCache
- test_lora_database.py: 17 tests covering delete_prompts_by_category,
  folder filter search, get_prompt_subfolders, LoRA import workflow
- test_config.py: 7 new IntegrationConfig tests for structure, enable,
  partial update, reset, and roundtrip

* fix: remove unused MagicMock import in test_lora_utils (#52)
2026-04-03 18:26:23 -07:00

296 lines
11 KiB
Python

"""
Tests for GalleryConfig and PromptManagerConfig.
The config module imports `from server import PromptServer` at module level,
which is ComfyUI's server. We mock it before import to run tests standalone.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock
# Mock ComfyUI's server module before importing config
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules["server"] = _mock_server
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.config import GalleryConfig, IntegrationConfig, PromptManagerConfig
class TestGalleryConfig(unittest.TestCase):
"""Test GalleryConfig class."""
def setUp(self):
"""Save original values to restore after each test."""
self._orig = GalleryConfig.get_config()
def tearDown(self):
"""Restore original config values."""
GalleryConfig.update_config(self._orig)
def test_get_config_structure(self):
config = GalleryConfig.get_config()
self.assertIn("monitoring", config)
self.assertIn("tracking", config)
self.assertIn("database", config)
self.assertIn("web_interface", config)
self.assertIn("performance", config)
def test_default_values(self):
config = GalleryConfig.get_config()
self.assertTrue(config["monitoring"]["enabled"])
self.assertEqual(
config["monitoring"]["extensions"],
[".png", ".jpg", ".jpeg", ".webp", ".gif"],
)
self.assertEqual(config["tracking"]["prompt_timeout"], 600)
self.assertEqual(config["web_interface"]["images_per_page"], 20)
def test_update_monitoring(self):
GalleryConfig.update_config(
{"monitoring": {"enabled": False, "processing_delay": 5.0}}
)
self.assertFalse(GalleryConfig.MONITORING_ENABLED)
self.assertEqual(GalleryConfig.PROCESSING_DELAY, 5.0)
def test_update_tracking(self):
GalleryConfig.update_config({"tracking": {"prompt_timeout": 300}})
self.assertEqual(GalleryConfig.PROMPT_TIMEOUT, 300)
def test_update_database(self):
GalleryConfig.update_config(
{"database": {"auto_cleanup": False, "max_image_age_days": 30}}
)
self.assertFalse(GalleryConfig.AUTO_CLEANUP_MISSING_FILES)
self.assertEqual(GalleryConfig.MAX_IMAGE_AGE_DAYS, 30)
def test_update_web_interface(self):
GalleryConfig.update_config(
{"web_interface": {"images_per_page": 50, "thumbnail_size": 512}}
)
self.assertEqual(GalleryConfig.IMAGES_PER_PAGE, 50)
self.assertEqual(GalleryConfig.THUMBNAIL_SIZE, 512)
def test_update_performance(self):
GalleryConfig.update_config({"performance": {"max_concurrent_processing": 5}})
self.assertEqual(GalleryConfig.MAX_CONCURRENT_PROCESSING, 5)
def test_partial_update_preserves_other_values(self):
original_timeout = GalleryConfig.PROMPT_TIMEOUT
GalleryConfig.update_config({"monitoring": {"enabled": False}})
self.assertEqual(GalleryConfig.PROMPT_TIMEOUT, original_timeout)
def test_empty_update_changes_nothing(self):
before = GalleryConfig.get_config()
GalleryConfig.update_config({})
after = GalleryConfig.get_config()
self.assertEqual(before, after)
class TestPromptManagerConfig(unittest.TestCase):
"""Test PromptManagerConfig class."""
def setUp(self):
self._orig = PromptManagerConfig.get_config()
def tearDown(self):
PromptManagerConfig.update_config(self._orig)
def test_get_config_structure(self):
config = PromptManagerConfig.get_config()
self.assertIn("database", config)
self.assertIn("web_ui", config)
self.assertIn("performance", config)
self.assertIn("gallery", config)
def test_gallery_config_nested(self):
config = PromptManagerConfig.get_config()
# Gallery section should match GalleryConfig output
self.assertIn("monitoring", config["gallery"])
self.assertIn("tracking", config["gallery"])
def test_default_database_values(self):
config = PromptManagerConfig.get_config()
self.assertEqual(config["database"]["default_path"], "prompts.db")
self.assertTrue(config["database"]["enable_duplicate_detection"])
self.assertTrue(config["database"]["enable_auto_save"])
def test_default_web_ui_values(self):
config = PromptManagerConfig.get_config()
self.assertEqual(config["web_ui"]["webui_display_mode"], "newtab")
def test_update_database(self):
PromptManagerConfig.update_config({"database": {"default_path": "custom.db"}})
self.assertEqual(PromptManagerConfig.DEFAULT_DB_PATH, "custom.db")
def test_update_web_ui(self):
PromptManagerConfig.update_config({"web_ui": {"webui_display_mode": "popup"}})
self.assertEqual(PromptManagerConfig.WEBUI_DISPLAY_MODE, "popup")
def test_update_performance(self):
PromptManagerConfig.update_config({"performance": {"max_search_results": 50}})
self.assertEqual(PromptManagerConfig.MAX_SEARCH_RESULTS, 50)
def test_update_propagates_to_gallery(self):
PromptManagerConfig.update_config(
{"gallery": {"monitoring": {"enabled": False}}}
)
self.assertFalse(GalleryConfig.MONITORING_ENABLED)
# Restore
GalleryConfig.MONITORING_ENABLED = True
def test_save_and_load_file(self):
tmp = tempfile.NamedTemporaryFile(
delete=False, suffix=".json", dir=tempfile.gettempdir()
)
tmp.close()
try:
# Modify a value
PromptManagerConfig.MAX_SEARCH_RESULTS = 42
PromptManagerConfig.save_to_file(tmp.name)
# Reset and reload
PromptManagerConfig.MAX_SEARCH_RESULTS = 100
PromptManagerConfig.load_from_file(tmp.name)
self.assertEqual(PromptManagerConfig.MAX_SEARCH_RESULTS, 42)
finally:
os.unlink(tmp.name)
def test_load_nonexistent_file_uses_defaults(self):
# Should not raise, just log and continue
original = PromptManagerConfig.MAX_SEARCH_RESULTS
PromptManagerConfig.load_from_file("/nonexistent/path/config.json")
self.assertEqual(PromptManagerConfig.MAX_SEARCH_RESULTS, original)
def test_load_invalid_json_file(self):
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w")
tmp.write("not valid json {{")
tmp.close()
try:
original = PromptManagerConfig.MAX_SEARCH_RESULTS
PromptManagerConfig.load_from_file(tmp.name)
# Should not crash, keeps existing values
self.assertEqual(PromptManagerConfig.MAX_SEARCH_RESULTS, original)
finally:
os.unlink(tmp.name)
def test_save_creates_directory(self):
tmp_dir = tempfile.mkdtemp()
nested_path = os.path.join(tmp_dir, "subdir", "config.json")
try:
PromptManagerConfig.save_to_file(nested_path)
self.assertTrue(os.path.exists(nested_path))
with open(nested_path) as f:
data = json.load(f)
self.assertIn("database", data)
finally:
import shutil
shutil.rmtree(tmp_dir)
def test_saved_file_is_valid_json(self):
tmp = tempfile.NamedTemporaryFile(
delete=False, suffix=".json", dir=tempfile.gettempdir()
)
tmp.close()
try:
PromptManagerConfig.save_to_file(tmp.name)
with open(tmp.name) as f:
data = json.load(f)
self.assertIsInstance(data, dict)
finally:
os.unlink(tmp.name)
class TestIntegrationConfig(unittest.TestCase):
"""Test IntegrationConfig for LoRA Manager settings."""
def setUp(self):
"""Save original values to restore after each test."""
self._orig = IntegrationConfig.get_config()
def tearDown(self):
"""Restore original config values."""
IntegrationConfig.update_config(self._orig)
def test_reset_to_disabled(self):
"""Integrations can be fully disabled via update_config."""
IntegrationConfig.update_config(
{
"lora_manager": {
"enabled": False,
"path": "",
"trigger_words_enabled": False,
"civitai_api_key": "",
}
}
)
config = IntegrationConfig.get_config()
lora = config["lora_manager"]
self.assertFalse(lora["enabled"])
self.assertEqual(lora["path"], "")
self.assertFalse(lora["trigger_words_enabled"])
self.assertEqual(lora["civitai_api_key"], "")
def test_get_config_structure(self):
config = IntegrationConfig.get_config()
self.assertIn("lora_manager", config)
lora = config["lora_manager"]
self.assertIn("enabled", lora)
self.assertIn("path", lora)
self.assertIn("trigger_words_enabled", lora)
self.assertIn("civitai_api_key", lora)
def test_update_config_enables(self):
IntegrationConfig.update_config(
{
"lora_manager": {
"enabled": True,
"path": "/some/path",
"trigger_words_enabled": True,
"civitai_api_key": "test-key-123",
}
}
)
self.assertTrue(IntegrationConfig.LORA_MANAGER_ENABLED)
self.assertEqual(IntegrationConfig.LORA_MANAGER_PATH, "/some/path")
self.assertTrue(IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED)
self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, "test-key-123")
def test_update_partial(self):
"""Updating one field shouldn't affect others."""
# Reset to known state first
IntegrationConfig.update_config(
{"lora_manager": {"enabled": False, "path": "/known"}}
)
# Now update only enabled
IntegrationConfig.update_config({"lora_manager": {"enabled": True}})
self.assertTrue(IntegrationConfig.LORA_MANAGER_ENABLED)
self.assertEqual(IntegrationConfig.LORA_MANAGER_PATH, "/known")
def test_update_empty_dict_noop(self):
"""Updating with empty dict preserves current state."""
before = IntegrationConfig.get_config()
IntegrationConfig.update_config({})
after = IntegrationConfig.get_config()
self.assertEqual(before, after)
def test_roundtrip(self):
"""get_config → update_config → get_config should be stable."""
IntegrationConfig.update_config(
{"lora_manager": {"enabled": True, "path": "/test"}}
)
config1 = IntegrationConfig.get_config()
IntegrationConfig.update_config(config1)
config2 = IntegrationConfig.get_config()
self.assertEqual(config1, config2)
if __name__ == "__main__":
unittest.main()