"""Contract tests for the V3 TimelineAnimation node. Pin the schema-derived contract (input ids, output ids/types/list- expansion, display name, category) so a refactor that drops or renames the JS-bound fields is caught here rather than via "the editor stopped binding to the widget". """ import importlib.util from pathlib import Path import pytest def _load_node_module(): """Load the node module in isolation. Requires ``comfy_api`` on sys.path (handled by conftest.py); skips otherwise so this file collects even on a stripped-down system Python. """ pytest.importorskip("comfy_api") repo_src = Path(__file__).resolve().parent.parent / "src" target = repo_src / "timelineanim_nodes" / "node_timeline_animation.py" assert target.is_file(), f"node_timeline_animation.py missing at {target}" spec = importlib.util.spec_from_file_location( "timelineanim_node_under_test", target, ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def _schema(): mod = _load_node_module() return mod.TimelineAnimation, mod.TimelineAnimation.define_schema() def test_node_id_display_name_category(): """node_id is the workflow compatibility contract — must stay stable. display_name / category are user-facing and may evolve. """ _, schema = _schema() assert schema.node_id == "TimelineAnimation", ( f"node_id drift: got {schema.node_id!r}. This is a workflow " "compatibility contract — changing it breaks saved workflows." ) assert schema.display_name == "Timeline Animation" assert schema.category == "Animation/Timeline" def test_outputs_pin(): """Five outputs in fixed order: list-expanded float, single-list float, integer count, then the rounded INT mirror (list-expanded int, single-list int). The list-expanded slots are what make shift-style downstream nodes run once per frame; without them they pass only frame 0 silently. The INT slots were APPENDED after count so pre-existing workflows' slot indices stay stable — never reorder. """ _, schema = _schema() output_ids = [o.id for o in schema.outputs] output_types = [o.io_type for o in schema.outputs] output_is_list = [o.is_output_list for o in schema.outputs] assert output_ids == ["float", "float_list", "count", "int", "int_list"], ( "Output IDs drifted — JS extension and downstream wiring " "assume these exact names/positions." ) assert output_types == ["FLOAT", "FLOAT", "INT", "INT", "INT"], ( f"Output types drift — got {output_types}." ) assert output_is_list == [True, False, False, True, False], ( f"is_output_list drift — got {output_is_list}. Slots 0 and 3 " "must be list-expanded for per-frame downstream execution." ) def test_input_ids_carry_widget_slots(): """The JS DOM widget binds to specific Python widget names. If any of these are renamed or dropped, the JS silently writes into nothing and the editor appears non-interactive. """ _, schema = _schema() input_ids = {i.id for i in schema.inputs} expected = { "keys_store", "start_frame", "end_frame", "min_value", "max_value", } missing = expected - input_ids assert not missing, ( f"Schema missing widget-bound inputs: {sorted(missing)}." ) # The playhead must NOT be an input — every declared input is part # of ComfyUI's cache key, so a current_frame widget would bust the # execute cache (and re-run the whole downstream chain) on every # scrub+queue. The JS reads it from window.ComfyTimeline.model. assert "current_frame" not in input_ids, ( "current_frame crept back into the schema — it busts the " "execute cache on every scrub." ) def test_keys_store_is_string_single_line(): """``keys_store`` carries a single-line JSON blob round-tripped by the JS editor. Must not switch to multiline (the JS treats it as one string value, not a textarea). """ _, schema = _schema() keys_store = next(i for i in schema.inputs if i.id == "keys_store") assert keys_store.io_type == "STRING" assert keys_store.multiline is False, ( "keys_store must be single-line — the JS editor stores a " "JSON blob there, not multi-line user text." ) def test_frame_inputs_are_effectively_unbounded(): """Frame numbers must accept any practical value — no max cap. Timecode-derived frame numbers (e.g. 1010001 for shot 1010 frame 1), edit-decision-list ranges, and very long sequences would otherwise hit a low ceiling. Pin that the bound is "no practical limit" (>= 2**31, i.e. way beyond any real frame count) for all three timeline Int inputs. """ _, schema = _schema() threshold = 2 ** 31 for input_id in ("start_frame", "end_frame"): spec = next(i for i in schema.inputs if i.id == input_id) assert spec.max is None or spec.max >= threshold, ( f"{input_id}.max = {spec.max!r} caps frame numbers " f"below {threshold}. Use 0xffffffffffffffff (the " "ComfyUI-core convention for unbounded INT inputs)." ) assert spec.min is None or spec.min <= -threshold, ( f"{input_id}.min = {spec.min!r} blocks negative frame " "numbers (handle frames before the start of a shot)." ) def test_bg_image_is_optional(): """``bg_image`` is reserved for a future viewport overlay; should be optional so the node loads cleanly without an upstream image. """ _, schema = _schema() bg_image = next((i for i in schema.inputs if i.id == "bg_image"), None) assert bg_image is not None, "bg_image input missing" assert bg_image.optional is True, ( "bg_image must be optional — the node has no required " "upstream image dependency." ) def test_baked_keys_is_optional_input_only(): """``baked_keys`` seeds the curve from an upstream per-frame list via the JS ``Build from Input`` button. Must be optional (most workflows author the curve in the JS editor) and ``force_input`` so it shows as a slot rather than a numeric widget. """ _, schema = _schema() baked = next((i for i in schema.inputs if i.id == "baked_keys"), None) assert baked is not None, "baked_keys input missing" assert baked.optional is True, ( "baked_keys must be optional — the JS editor remains usable " "without it wired." ) assert getattr(baked, "force_input", False) is True, ( "baked_keys must be force_input so it renders as an input " "slot, not a scalar number widget." ) def test_execute_echoes_baked_keys_to_ui(): """When ``baked_keys`` is provided, the execute()'s NodeOutput must carry ``ui={'baked_keys': [...], 'baked_start_frame': [N]}`` so the JS button can rebuild the curve without re-running. """ mod = _load_node_module() out = mod.TimelineAnimation.execute( keys_store="", start_frame=10, end_frame=15, min_value=0.0, max_value=1.0, bg_image=None, baked_keys=[0.1, 0.5, 0.9, 0.2, 0.0, 1.0], ) ui = getattr(out, "ui", None) assert isinstance(ui, dict), "execute must return io.NodeOutput with ui dict" # ui may be wrapped (e.g. {"ui": {...}}) by NodeOutput — drill in. payload = ui.get("ui", ui) if "ui" in ui and isinstance(ui["ui"], dict) else ui assert payload.get("baked_keys") == [0.1, 0.5, 0.9, 0.2, 0.0, 1.0] assert payload.get("baked_start_frame") == [10] def test_execute_no_baked_keys_no_ui(): """When ``baked_keys`` is unwired (None), the UI dict must NOT include a stale baked_keys payload — the JS button must remain disabled rather than rebuild from previous run's data. """ mod = _load_node_module() out = mod.TimelineAnimation.execute( keys_store="", start_frame=0, end_frame=10, min_value=0.0, max_value=1.0, bg_image=None, baked_keys=None, ) ui = getattr(out, "ui", None) or {} payload = ui.get("ui", ui) if isinstance(ui, dict) and "ui" in ui and isinstance(ui["ui"], dict) else ui if isinstance(payload, dict): assert "baked_keys" not in payload, ( "baked_keys must be absent from the ui payload when the " "input is unwired (got %r)" % payload ) def test_execute_is_classmethod(): """V3 contract: ``execute`` is a classmethod returning io.NodeOutput. Pin the shape so a refactor doesn't accidentally make it an instance method (V1 pattern). """ cls, _ = _schema() assert hasattr(cls, "execute") # classmethod descriptor: cls.execute.__self__ is the class itself. assert getattr(cls.execute, "__self__", None) is cls, ( "execute must be a classmethod; got %r" % type(cls.execute) ) def test_node_class_inherits_io_comfy_node(): """Pin V3 inheritance — without this, ComfyUI's V3 loader skips the class and the node never registers. """ pytest.importorskip("comfy_api") from comfy_api.latest import io cls, _ = _schema() assert issubclass(cls, io.ComfyNode), ( f"TimelineAnimation must subclass io.ComfyNode; got " f"{cls.__mro__}" ) def test_extension_entrypoint_is_async(): """``comfy_entrypoint`` must be an async callable returning a ComfyExtension instance — this is the V3 loader contract. """ pytest.importorskip("comfy_api") import asyncio import importlib mod = importlib.import_module("timelineanim_nodes") assert hasattr(mod, "comfy_entrypoint") ext = asyncio.run(mod.comfy_entrypoint()) from comfy_api.latest import ComfyExtension assert isinstance(ext, ComfyExtension) def test_extension_returns_timelineanimation_node(): """The extension's ``get_node_list`` must include TimelineAnimation (also includes TimelineImageViewer — viewer-specific assertion lives in test_image_viewer_contract.py). """ pytest.importorskip("comfy_api") import asyncio import importlib mod = importlib.import_module("timelineanim_nodes") async def get_nodes(): ext = await mod.comfy_entrypoint() return await ext.get_node_list() nodes = asyncio.run(get_nodes()) node_ids = [n.define_schema().node_id for n in nodes] assert "TimelineAnimation" in node_ids, ( f"Extension didn't return TimelineAnimation; got {node_ids}" )