Files
Comfyui-JSON-Manager/tests/test_project_loader.py
Ethanfel 4b5fff5c6e Improve ProjectLoaderDynamic UX: single node, error feedback, auto-refresh
Remove 3 redundant hardcoded nodes (Standard/VACE/LoRA), keeping only the
Dynamic node. Add total_sequences INT output (slot 0) for loop counting.
Add structured error handling: _fetch_json returns typed error dicts,
load_dynamic raises RuntimeError with descriptive messages, JS shows
red border/title on errors. Add 500ms debounced auto-refresh on widget
changes. Add 404s for missing project/file in API endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:16:08 +01:00

212 lines
9.5 KiB
Python

import json
from unittest.mock import patch, MagicMock
from io import BytesIO
import pytest
from project_loader import (
ProjectLoaderDynamic,
_fetch_json,
_fetch_data,
_fetch_keys,
MAX_DYNAMIC_OUTPUTS,
)
def _mock_urlopen(data: dict):
"""Create a mock context manager for urllib.request.urlopen."""
response = MagicMock()
response.read.return_value = json.dumps(data).encode()
response.__enter__ = lambda s: s
response.__exit__ = MagicMock(return_value=False)
return response
class TestFetchHelpers:
def test_fetch_json_success(self):
data = {"key": "value"}
with patch("project_loader.urllib.request.urlopen", return_value=_mock_urlopen(data)):
result = _fetch_json("http://example.com/api")
assert result == data
def test_fetch_json_network_error(self):
with patch("project_loader.urllib.request.urlopen", side_effect=OSError("connection refused")):
result = _fetch_json("http://example.com/api")
assert result["error"] == "network_error"
assert "connection refused" in result["message"]
def test_fetch_json_http_error(self):
import urllib.error
err = urllib.error.HTTPError(
"http://example.com/api", 404, "Not Found", {},
BytesIO(json.dumps({"detail": "Project 'x' not found"}).encode())
)
with patch("project_loader.urllib.request.urlopen", side_effect=err):
result = _fetch_json("http://example.com/api")
assert result["error"] == "http_error"
assert result["status"] == 404
assert "not found" in result["message"].lower()
def test_fetch_data_builds_url(self):
data = {"prompt": "hello"}
with patch("project_loader.urllib.request.urlopen", return_value=_mock_urlopen(data)) as mock:
result = _fetch_data("http://localhost:8080", "proj1", "batch_i2v", 1)
assert result == data
called_url = mock.call_args[0][0]
assert "/api/projects/proj1/files/batch_i2v/data?seq=1" in called_url
def test_fetch_keys_builds_url(self):
data = {"keys": ["prompt"], "types": ["STRING"]}
with patch("project_loader.urllib.request.urlopen", return_value=_mock_urlopen(data)) as mock:
result = _fetch_keys("http://localhost:8080", "proj1", "batch_i2v", 1)
assert result == data
called_url = mock.call_args[0][0]
assert "/api/projects/proj1/files/batch_i2v/keys?seq=1" in called_url
def test_fetch_data_strips_trailing_slash(self):
data = {"prompt": "hello"}
with patch("project_loader.urllib.request.urlopen", return_value=_mock_urlopen(data)) as mock:
_fetch_data("http://localhost:8080/", "proj1", "file1", 1)
called_url = mock.call_args[0][0]
assert "//api" not in called_url
def test_fetch_data_encodes_special_chars(self):
"""Project/file names with spaces or special chars should be percent-encoded."""
data = {"prompt": "hello"}
with patch("project_loader.urllib.request.urlopen", return_value=_mock_urlopen(data)) as mock:
_fetch_data("http://localhost:8080", "my project", "batch file", 1)
called_url = mock.call_args[0][0]
assert "my%20project" in called_url
assert "batch%20file" in called_url
assert " " not in called_url.split("?")[0] # no raw spaces in path
class TestProjectLoaderDynamic:
def _keys_meta(self, total=5):
return {"keys": [], "types": [], "total_sequences": total}
def test_load_dynamic_with_keys(self):
data = {"prompt": "hello", "seed": 42, "cfg": 1.5}
node = ProjectLoaderDynamic()
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value=data):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys="prompt,seed,cfg"
)
assert result[0] == 5 # total_sequences
assert result[1] == "hello"
assert result[2] == 42
assert result[3] == 1.5
assert len(result) == MAX_DYNAMIC_OUTPUTS + 1
def test_load_dynamic_with_json_encoded_keys(self):
"""JSON-encoded output_keys should be parsed correctly."""
import json as _json
data = {"my,key": "comma_val", "normal": "ok"}
node = ProjectLoaderDynamic()
keys_json = _json.dumps(["my,key", "normal"])
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value=data):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys=keys_json
)
assert result[1] == "comma_val"
assert result[2] == "ok"
def test_load_dynamic_type_coercion(self):
"""output_types should coerce values to declared types."""
import json as _json
data = {"seed": "42", "cfg": "1.5", "prompt": "hello"}
node = ProjectLoaderDynamic()
keys_json = _json.dumps(["seed", "cfg", "prompt"])
types_json = _json.dumps(["INT", "FLOAT", "STRING"])
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value=data):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys=keys_json, output_types=types_json
)
assert result[1] == 42 # string "42" coerced to int
assert result[2] == 1.5 # string "1.5" coerced to float
assert result[3] == "hello" # string stays string
def test_load_dynamic_empty_keys(self):
node = ProjectLoaderDynamic()
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value={"prompt": "hello"}):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys=""
)
# Slot 0 is total_sequences (INT), rest are empty strings
assert result[0] == 5
assert all(v == "" for v in result[1:])
def test_load_dynamic_missing_key(self):
node = ProjectLoaderDynamic()
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value={"prompt": "hello"}):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys="nonexistent"
)
assert result[1] == ""
def test_load_dynamic_bool_becomes_string(self):
node = ProjectLoaderDynamic()
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value={"flag": True}):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys="flag"
)
assert result[1] == "true"
def test_load_dynamic_returns_total_sequences(self):
"""total_sequences should be the first output from keys metadata."""
node = ProjectLoaderDynamic()
with patch("project_loader._fetch_keys", return_value={"keys": [], "types": [], "total_sequences": 42}):
with patch("project_loader._fetch_data", return_value={}):
result = node.load_dynamic(
"http://localhost:8080", "proj1", "batch_i2v", 1,
output_keys=""
)
assert result[0] == 42
def test_load_dynamic_raises_on_network_error(self):
"""Network errors from _fetch_keys should raise RuntimeError."""
node = ProjectLoaderDynamic()
error_resp = {"error": "network_error", "message": "Connection refused"}
with patch("project_loader._fetch_keys", return_value=error_resp):
with pytest.raises(RuntimeError, match="Failed to fetch project keys"):
node.load_dynamic("http://localhost:8080", "proj1", "batch", 1)
def test_load_dynamic_raises_on_data_fetch_error(self):
"""Network errors from _fetch_data should raise RuntimeError."""
node = ProjectLoaderDynamic()
error_resp = {"error": "http_error", "status": 404, "message": "Sequence not found"}
with patch("project_loader._fetch_keys", return_value=self._keys_meta()):
with patch("project_loader._fetch_data", return_value=error_resp):
with pytest.raises(RuntimeError, match="Failed to fetch sequence data"):
node.load_dynamic("http://localhost:8080", "proj1", "batch", 1)
def test_input_types_has_manager_url(self):
inputs = ProjectLoaderDynamic.INPUT_TYPES()
assert "manager_url" in inputs["required"]
assert "project_name" in inputs["required"]
assert "file_name" in inputs["required"]
assert "sequence_number" in inputs["required"]
def test_category(self):
assert ProjectLoaderDynamic.CATEGORY == "utils/json/project"
class TestNodeMappings:
def test_mappings_exist(self):
from project_loader import PROJECT_NODE_CLASS_MAPPINGS, PROJECT_NODE_DISPLAY_NAME_MAPPINGS
assert "ProjectLoaderDynamic" in PROJECT_NODE_CLASS_MAPPINGS
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 1
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 1