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>
This commit is contained in:
@@ -172,6 +172,25 @@ class TestSequences:
|
||||
db.delete_sequences_for_file(df_id)
|
||||
assert db.list_sequences(df_id) == []
|
||||
|
||||
def test_count_sequences(self, db):
|
||||
pid = db.create_project("p1", "/p1")
|
||||
df_id = db.create_data_file(pid, "batch", "generic")
|
||||
assert db.count_sequences(df_id) == 0
|
||||
db.upsert_sequence(df_id, 1, {"a": 1})
|
||||
db.upsert_sequence(df_id, 2, {"b": 2})
|
||||
db.upsert_sequence(df_id, 3, {"c": 3})
|
||||
assert db.count_sequences(df_id) == 3
|
||||
|
||||
def test_query_total_sequences(self, db):
|
||||
pid = db.create_project("p1", "/p1")
|
||||
df_id = db.create_data_file(pid, "batch", "generic")
|
||||
db.upsert_sequence(df_id, 1, {"a": 1})
|
||||
db.upsert_sequence(df_id, 2, {"b": 2})
|
||||
assert db.query_total_sequences("p1", "batch") == 2
|
||||
|
||||
def test_query_total_sequences_nonexistent(self, db):
|
||||
assert db.query_total_sequences("nope", "nope") == 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# History trees
|
||||
|
||||
@@ -6,9 +6,6 @@ import pytest
|
||||
|
||||
from project_loader import (
|
||||
ProjectLoaderDynamic,
|
||||
ProjectLoaderStandard,
|
||||
ProjectLoaderVACE,
|
||||
ProjectLoaderLoRA,
|
||||
_fetch_json,
|
||||
_fetch_data,
|
||||
_fetch_keys,
|
||||
@@ -32,11 +29,23 @@ class TestFetchHelpers:
|
||||
result = _fetch_json("http://example.com/api")
|
||||
assert result == data
|
||||
|
||||
def test_fetch_json_failure(self):
|
||||
import urllib.error
|
||||
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 == {}
|
||||
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"}
|
||||
@@ -73,18 +82,23 @@ class TestFetchHelpers:
|
||||
|
||||
|
||||
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_data", return_value=data):
|
||||
result = node.load_dynamic(
|
||||
"http://localhost:8080", "proj1", "batch_i2v", 1,
|
||||
output_keys="prompt,seed,cfg"
|
||||
)
|
||||
assert result[0] == "hello"
|
||||
assert result[1] == 42
|
||||
assert result[2] == 1.5
|
||||
assert len(result) == MAX_DYNAMIC_OUTPUTS
|
||||
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."""
|
||||
@@ -92,13 +106,14 @@ class TestProjectLoaderDynamic:
|
||||
data = {"my,key": "comma_val", "normal": "ok"}
|
||||
node = ProjectLoaderDynamic()
|
||||
keys_json = _json.dumps(["my,key", "normal"])
|
||||
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[0] == "comma_val"
|
||||
assert result[1] == "ok"
|
||||
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."""
|
||||
@@ -107,41 +122,75 @@ class TestProjectLoaderDynamic:
|
||||
node = ProjectLoaderDynamic()
|
||||
keys_json = _json.dumps(["seed", "cfg", "prompt"])
|
||||
types_json = _json.dumps(["INT", "FLOAT", "STRING"])
|
||||
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[0] == 42 # string "42" coerced to int
|
||||
assert result[1] == 1.5 # string "1.5" coerced to float
|
||||
assert result[2] == "hello" # string stays 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_data", return_value={"prompt": "hello"}):
|
||||
result = node.load_dynamic(
|
||||
"http://localhost:8080", "proj1", "batch_i2v", 1,
|
||||
output_keys=""
|
||||
)
|
||||
assert all(v == "" for v in result)
|
||||
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_data", return_value={"prompt": "hello"}):
|
||||
result = node.load_dynamic(
|
||||
"http://localhost:8080", "proj1", "batch_i2v", 1,
|
||||
output_keys="nonexistent"
|
||||
)
|
||||
assert result[0] == ""
|
||||
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_data", return_value={"flag": True}):
|
||||
result = node.load_dynamic(
|
||||
"http://localhost:8080", "proj1", "batch_i2v", 1,
|
||||
output_keys="flag"
|
||||
)
|
||||
assert result[0] == "true"
|
||||
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()
|
||||
@@ -154,88 +203,9 @@ class TestProjectLoaderDynamic:
|
||||
assert ProjectLoaderDynamic.CATEGORY == "utils/json/project"
|
||||
|
||||
|
||||
class TestProjectLoaderStandard:
|
||||
def test_load_standard(self):
|
||||
data = {
|
||||
"general_prompt": "hello",
|
||||
"general_negative": "bad",
|
||||
"current_prompt": "specific",
|
||||
"negative": "neg",
|
||||
"camera": "pan",
|
||||
"flf": 0.5,
|
||||
"seed": 42,
|
||||
"video file path": "/v.mp4",
|
||||
"reference image path": "/r.png",
|
||||
"flf image path": "/f.png",
|
||||
}
|
||||
node = ProjectLoaderStandard()
|
||||
with patch("project_loader._fetch_data", return_value=data):
|
||||
result = node.load_standard("http://localhost:8080", "proj1", "batch", 1)
|
||||
assert result == ("hello", "bad", "specific", "neg", "pan", 0.5, 42, "/v.mp4", "/r.png", "/f.png")
|
||||
|
||||
def test_load_standard_defaults(self):
|
||||
node = ProjectLoaderStandard()
|
||||
with patch("project_loader._fetch_data", return_value={}):
|
||||
result = node.load_standard("http://localhost:8080", "proj1", "batch", 1)
|
||||
assert result[0] == "" # general_prompt
|
||||
assert result[5] == 0.0 # flf
|
||||
assert result[6] == 0 # seed
|
||||
|
||||
|
||||
class TestProjectLoaderVACE:
|
||||
def test_load_vace(self):
|
||||
data = {
|
||||
"general_prompt": "hello",
|
||||
"general_negative": "bad",
|
||||
"current_prompt": "specific",
|
||||
"negative": "neg",
|
||||
"camera": "pan",
|
||||
"flf": 0.5,
|
||||
"seed": 42,
|
||||
"frame_to_skip": 81,
|
||||
"input_a_frames": 16,
|
||||
"input_b_frames": 16,
|
||||
"reference path": "/ref",
|
||||
"reference switch": 1,
|
||||
"vace schedule": 2,
|
||||
"video file path": "/v.mp4",
|
||||
"reference image path": "/r.png",
|
||||
}
|
||||
node = ProjectLoaderVACE()
|
||||
with patch("project_loader._fetch_data", return_value=data):
|
||||
result = node.load_vace("http://localhost:8080", "proj1", "batch", 1)
|
||||
assert result[7] == 81 # frame_to_skip
|
||||
assert result[12] == 2 # vace_schedule
|
||||
|
||||
|
||||
class TestProjectLoaderLoRA:
|
||||
def test_load_loras(self):
|
||||
data = {
|
||||
"lora 1 high": "<lora:model1:1.0>",
|
||||
"lora 1 low": "<lora:model1:0.5>",
|
||||
"lora 2 high": "",
|
||||
"lora 2 low": "",
|
||||
"lora 3 high": "",
|
||||
"lora 3 low": "",
|
||||
}
|
||||
node = ProjectLoaderLoRA()
|
||||
with patch("project_loader._fetch_data", return_value=data):
|
||||
result = node.load_loras("http://localhost:8080", "proj1", "batch", 1)
|
||||
assert result[0] == "<lora:model1:1.0>"
|
||||
assert result[1] == "<lora:model1:0.5>"
|
||||
|
||||
def test_load_loras_empty(self):
|
||||
node = ProjectLoaderLoRA()
|
||||
with patch("project_loader._fetch_data", return_value={}):
|
||||
result = node.load_loras("http://localhost:8080", "proj1", "batch", 1)
|
||||
assert all(v == "" for v in result)
|
||||
|
||||
|
||||
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 "ProjectLoaderStandard" in PROJECT_NODE_CLASS_MAPPINGS
|
||||
assert "ProjectLoaderVACE" in PROJECT_NODE_CLASS_MAPPINGS
|
||||
assert "ProjectLoaderLoRA" in PROJECT_NODE_CLASS_MAPPINGS
|
||||
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 4
|
||||
assert len(PROJECT_NODE_CLASS_MAPPINGS) == 1
|
||||
assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 1
|
||||
|
||||
Reference in New Issue
Block a user