From b31faa42746f6fadd01a29202bfcea52c638e913 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 3 Apr 2026 00:25:30 +0200 Subject: [PATCH] feat: add ProjectResolution node Implements ProjectResolution with TDD: fetches a [width, height] pair from a resolution series by loop index, clamping out-of-bounds indices to the last entry and returning (512, 512) defaults on error or missing key. Also registers the node in mappings and updates TestNodeMappings count to 4. Co-Authored-By: Claude Sonnet 4.6 --- project_loader.py | 57 ++++++++++++++++++++++ tests/test_project_loader.py | 95 +++++++++++++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/project_loader.py b/project_loader.py index 8827191..591cb21 100644 --- a/project_loader.py +++ b/project_loader.py @@ -293,15 +293,72 @@ class ProjectKey: return (str(val),) +class ProjectResolution: + """Fetches a (width, height) pair from a resolution series by loop index.""" + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "source_label": ("STRING", {"default": "", "multiline": False}), + "key_name": ("STRING", {"default": "resolutions", "multiline": False}), + "index": ("INT", {"default": 0, "min": 0, "max": 9999}), + }, + "optional": { + "manager_url": ("STRING", {"default": "http://localhost:8080", "multiline": False}), + "project_name": ("STRING", {"default": "", "multiline": False}), + "file_name": ("STRING", {"default": "", "multiline": False}), + "sequence_number": ("INT", {"default": 1, "min": 1, "max": 9999}), + }, + } + + RETURN_TYPES = ("INT", "INT") + RETURN_NAMES = ("width", "height") + FUNCTION = "fetch_resolution" + CATEGORY = "utils/json/project" + OUTPUT_NODE = False + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("nan") + + def fetch_resolution(self, source_label, key_name, index, + manager_url="http://localhost:8080", project_name="", + file_name="", sequence_number=1): + sequence_number = int(sequence_number) + logger.info("ProjectResolution.fetch_resolution: source=%s key=%s url=%s project=%s file=%s seq=%s index=%s", + source_label, key_name, manager_url, project_name, file_name, sequence_number, index) + # source_label is used by JS to identify which ProjectSource to sync + # config from. The actual config arrives via the optional widgets below. + data = _fetch_data(manager_url, project_name, file_name, sequence_number) + if data.get("error") in ("http_error", "network_error", "parse_error"): + logger.warning("ProjectResolution.fetch_resolution failed: %s", data.get("message")) + return (512, 512) + + series = data.get(key_name) + if not isinstance(series, list) or len(series) == 0: + logger.warning("ProjectResolution: key '%s' is not a resolution series", key_name) + return (512, 512) + + clamped = max(0, min(index, len(series) - 1)) + entry = series[clamped] + if not isinstance(entry, (list, tuple)) or len(entry) < 2: + logger.warning("ProjectResolution: entry at index %d is malformed: %r", clamped, entry) + return (512, 512) + + return (to_int(entry[0]), to_int(entry[1])) + + # --- Mappings --- PROJECT_NODE_CLASS_MAPPINGS = { "ProjectLoaderDynamic": ProjectLoaderDynamic, "ProjectSource": ProjectSource, "ProjectKey": ProjectKey, + "ProjectResolution": ProjectResolution, } PROJECT_NODE_DISPLAY_NAME_MAPPINGS = { "ProjectLoaderDynamic": "Project Loader (Dynamic)", "ProjectSource": "Project Source", "ProjectKey": "Project Key", + "ProjectResolution": "Project Resolution", } diff --git a/tests/test_project_loader.py b/tests/test_project_loader.py index 153b88a..01e4c2a 100644 --- a/tests/test_project_loader.py +++ b/tests/test_project_loader.py @@ -344,11 +344,102 @@ class TestProjectKey: assert ProjectKey.CATEGORY == "utils/json/project" +class TestProjectResolution: + def test_input_types(self): + from project_loader import ProjectResolution + inputs = ProjectResolution.INPUT_TYPES() + assert "source_label" in inputs["required"] + assert "key_name" in inputs["required"] + assert "index" in inputs["required"] + assert inputs["required"]["index"][0] == "INT" + + def test_two_outputs(self): + from project_loader import ProjectResolution + assert ProjectResolution.RETURN_TYPES == ("INT", "INT") + assert ProjectResolution.RETURN_NAMES == ("width", "height") + + def test_fetch_resolution_basic(self): + from project_loader import ProjectResolution + node = ProjectResolution() + data = {"resolutions": [[512, 512], [768, 1344], [1344, 768]]} + with patch("project_loader._fetch_data", return_value=data): + result = node.fetch_resolution( + source_label="src", key_name="resolutions", index=1, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (768, 1344) + + def test_fetch_resolution_index_zero(self): + from project_loader import ProjectResolution + node = ProjectResolution() + data = {"resolutions": [[512, 512], [1024, 1024]]} + with patch("project_loader._fetch_data", return_value=data): + result = node.fetch_resolution( + source_label="src", key_name="resolutions", index=0, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (512, 512) + + def test_fetch_resolution_clamps_on_out_of_bounds(self): + from project_loader import ProjectResolution + node = ProjectResolution() + data = {"resolutions": [[512, 512], [1024, 1024]]} + with patch("project_loader._fetch_data", return_value=data): + result = node.fetch_resolution( + source_label="src", key_name="resolutions", index=99, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (1024, 1024) # last entry + + def test_fetch_resolution_missing_key_returns_defaults(self): + from project_loader import ProjectResolution + node = ProjectResolution() + with patch("project_loader._fetch_data", return_value={}): + result = node.fetch_resolution( + source_label="src", key_name="nonexistent", index=0, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (512, 512) + + def test_fetch_resolution_network_error_returns_defaults(self): + from project_loader import ProjectResolution + node = ProjectResolution() + error_resp = {"error": "network_error", "message": "Connection refused"} + with patch("project_loader._fetch_data", return_value=error_resp): + result = node.fetch_resolution( + source_label="src", key_name="resolutions", index=0, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (512, 512) + + def test_fetch_resolution_malformed_entry_returns_defaults(self): + from project_loader import ProjectResolution + node = ProjectResolution() + data = {"resolutions": [[512]]} # single-element, not a valid pair + with patch("project_loader._fetch_data", return_value=data): + result = node.fetch_resolution( + source_label="src", key_name="resolutions", index=0, + manager_url="http://localhost:8080", project_name="p", + file_name="f", sequence_number=1, + ) + assert result == (512, 512) + + def test_category(self): + from project_loader import ProjectResolution + assert ProjectResolution.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 "ProjectSource" in PROJECT_NODE_CLASS_MAPPINGS assert "ProjectKey" in PROJECT_NODE_CLASS_MAPPINGS - assert len(PROJECT_NODE_CLASS_MAPPINGS) == 3 - assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 3 + assert "ProjectResolution" in PROJECT_NODE_CLASS_MAPPINGS + assert len(PROJECT_NODE_CLASS_MAPPINGS) == 4 + assert len(PROJECT_NODE_DISPLAY_NAME_MAPPINGS) == 4