279 lines
10 KiB
Python
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}"
|
|
)
|