Files

279 lines
10 KiB
Python

"""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}"
)