Files

496 lines
17 KiB
Python

"""Global Timeline Refactor — Phase 1 (model layer) JS-asset pins.
``timeline_model.js`` is the PUBLIC standard API any ComfyUI node or
extension binds to (``window.ComfyTimeline``). These are pure file/text
checks (no JS runner — ``node`` is not on PATH); they pin the API
contract so a refactor that drops a mutator, an event, the public
namespace, or the persistence hook surfaces here instead of silently
breaking every downstream consumer in the browser.
The sampling math itself stays covered by the Python<->JS parity tests
(``test_math_parity.py``); ``Curve.sample`` only delegates to the same
``sampleAt`` those tests exercise.
"""
from pathlib import Path
_REPO = Path(__file__).resolve().parent.parent
_JS_DIR = _REPO / "src" / "timelineanim_nodes" / "web" / "js"
_MODEL = _JS_DIR / "timeline_model.js"
def _model_js() -> str:
return _MODEL.read_text(encoding="utf-8")
# ---- file + module shape -------------------------------------------
def test_timeline_model_js_exists():
assert _MODEL.is_file(), (
f"timeline_model.js missing at {_MODEL}. Phase 1 of the global "
"timeline refactor introduces the model layer here."
)
def test_model_exports_the_three_classes():
js = _model_js()
for marker in [
"export class Curve",
"export class CurveStore",
"export class TimelineModel",
]:
assert marker in js, (
f"timeline_model.js missing export: {marker!r} — consumers "
"import these by name."
)
def test_model_classes_are_event_driven():
"""CurveStore and TimelineModel must extend EventTarget so any
extension can subscribe via addEventListener. Pin both bases.
"""
js = _model_js()
assert "class CurveStore extends EventTarget" in js, (
"CurveStore must extend EventTarget to emit 'change' events."
)
assert "class TimelineModel extends EventTarget" in js, (
"TimelineModel must extend EventTarget to emit frame/range/"
"fps/play events."
)
def test_model_reuses_sampler_math():
"""Curve.sample must delegate to sampler.js sampleAt — NOT
re-implement curve math — so the model, the renderer, and the
Python sampler stay byte-for-byte in agreement.
"""
js = _model_js()
assert "from './sampler.js'" in js, (
"timeline_model.js must import from sampler.js."
)
assert "sampleAt" in js, (
"Curve.sample must call sampleAt from sampler.js rather than "
"duplicating the Hermite/linear/stepped math."
)
# ---- public singleton / namespace ----------------------------------
def test_public_namespace_is_comfytimeline():
"""The standard global is ``window.ComfyTimeline``. Pin the
idempotent guard so a re-import can't replace the model out from
under live subscribers.
"""
js = _model_js()
assert "window.ComfyTimeline" in js, (
"timeline_model.js must publish the singleton on "
"window.ComfyTimeline."
)
assert "if (window.ComfyTimeline) return" in js, (
"Bootstrap must be idempotent — reuse the existing singleton "
"on re-import instead of constructing a second model."
)
def test_singleton_exposes_model_and_curves():
js = _model_js()
# The published object must carry both sub-objects + the classes.
for marker in ["model,", "curves,", "Curve,", "CurveStore,", "TimelineModel,"]:
assert marker in js, (
f"Public singleton object missing field: {marker!r}"
)
def test_es_module_accessor_present():
js = _model_js()
assert "export function getTimeline" in js, (
"Provide getTimeline() so ES-module consumers can import the "
"singleton instead of reaching for the window global."
)
def test_does_not_resurrect_legacy_registry_name():
"""The new standard is ComfyTimeline. The model file must not
define the legacy window.timelineAnim registry (that lives in
shared.js during the migration and is removed as nodes migrate).
"""
js = _model_js()
assert "window.timelineAnim" not in js, (
"timeline_model.js should not reference the legacy "
"window.timelineAnim registry — the new namespace is "
"window.ComfyTimeline."
)
# ---- TimelineModel API surface -------------------------------------
def test_model_has_full_mutator_surface():
"""Maya-parity transport + range + fps + frame. Pin every mutator
so a consumer can rely on the contract.
"""
js = _model_js()
for marker in [
"setCurrentFrame(frame, source)",
"setAnimRange(start, end, source)",
"setPlayRange(start, end, source)",
"setFps(fps, source)",
"setLoop(loop, source)",
"play(source)",
"pause(source)",
"togglePlay(source)",
]:
assert marker in js, (
f"TimelineModel missing mutator: {marker!r}"
)
def test_model_tracks_two_ranges_plus_fps_and_transport():
"""The model fields any view reads. Anim range + play range +
fps + transport state, Maya-style.
"""
js = _model_js()
for field in [
"this.currentFrame",
"this.animStart",
"this.animEnd",
"this.playStart",
"this.playEnd",
"this.fps",
"this.playing",
"this.loop",
]:
assert field in js, (
f"TimelineModel must track {field} as a public field."
)
def test_model_emits_the_documented_events():
"""Subscribers bind to these event names. Pin the emit sites so a
rename doesn't silently break every listener.
"""
js = _model_js()
for ev in ["'frame'", "'range'", "'fps'", "'play'", "'loop'"]:
assert ev in js, (
f"TimelineModel must emit a {ev} event."
)
assert "new CustomEvent" in js, (
"Events must be CustomEvents so detail (frame/range/source) "
"rides along."
)
def test_mutators_carry_a_source_token_for_echo_suppression():
"""Every mutator threads an optional `source` into the event detail
so the originating view can ignore its own echo (no feedback loop).
Pin the exact emit shapes (not just the word 'source', which appears
dozens of times) so echo-suppression can't be silently dropped.
"""
js = _model_js()
assert "this._emit('frame', { frame: f, source })" in js, (
"setCurrentFrame must emit frame with the source token."
)
assert "this._emit('fps', { fps: n, source })" in js, (
"setFps must emit fps with the source token."
)
assert "source," in js, "Range emit detail must carry source."
def test_model_runs_a_raf_playback_loop():
"""Live playback advances the playhead by elapsed*fps on a
requestAnimationFrame loop, wrapping at the play range when loop is
on. Pin the loop primitives.
"""
js = _model_js()
assert "requestAnimationFrame" in js, (
"Playback must drive the playhead on a requestAnimationFrame "
"loop."
)
assert "cancelAnimationFrame" in js, (
"pause() must cancel the rAF loop."
)
assert "this.fps" in js and "_playFrame" in js, (
"The loop must advance a fractional playhead by elapsed*fps."
)
# ---- CurveStore API surface ----------------------------------------
def test_curvestore_registry_surface():
js = _model_js()
for marker in [
"register(curveId, curve)",
"unregister(curveId)",
"get(curveId)",
"touch(curveId)",
]:
assert marker in js, (
f"CurveStore missing registry method: {marker!r}"
)
assert "new CustomEvent('change'" in js, (
"CurveStore must emit a 'change' CustomEvent with the curveId "
"so the bar knows which ticks to repaint."
)
# ---- persistence: app.graph.extra round-trip -----------------------
def test_persistence_round_trips_via_graph_extra():
"""State rides the workflow JSON via app.graph.extra.timeline:
synchronous small-state write on change, rehydrate on graph load.
"""
js = _model_js()
assert "serialize()" in js, "Model needs serialize() for persistence."
assert "load(data)" in js, "Model needs load(data) to rehydrate."
assert "app.graph" in js and "extra" in js and "timeline" in js, (
"Persistence must read/write app.graph.extra.timeline."
)
def test_persistence_rehydrates_on_after_configure_graph():
"""Rehydrate hook must be afterConfigureGraph (verified against the
current frontend extension lifecycle).
"""
js = _model_js()
assert "afterConfigureGraph" in js, (
"Rehydrate must hook the afterConfigureGraph extension "
"lifecycle so a loaded workflow restores its timeline state."
)
assert "registerExtension" in js, (
"Persistence must register an extension to receive "
"afterConfigureGraph."
)
def test_persistence_write_is_synchronous():
"""An immediate workflow save must see the latest timeline state."""
js = _model_js()
assert "model.addEventListener(ev, persist)" in js
assert "g.extra.timeline = model.serialize()" in js
persistence = js[js.find("function _wirePersistence"):]
assert "setTimeout" not in persistence, (
"Timeline persistence must not lag behind an immediate workflow save."
)
def test_load_does_not_immediately_rewrite():
"""Rehydrate tags its events source:'load' and the persistence
scheduler skips that source — otherwise loading a workflow would
instantly mark it dirty by writing identical data back.
"""
js = _model_js()
assert "'load'" in js, (
"load() must tag its emitted events with source 'load'."
)
assert "source === 'load'" in js, (
"The persistence scheduler must skip events whose source is "
"'load' to avoid a rehydrate->rewrite loop."
)
# ---- studio-grade hardening (audit follow-ups) ---------------------
def test_api_version_separate_from_schema_version():
"""apiVersion (semver of the public surface) must be distinct from
schemaVersion (the app.graph.extra serialization integer), so
consumers can feature-detect without false breakage on persistence
shape changes.
"""
js = _model_js()
assert "apiVersion" in js and "API_VERSION" in js
assert "schemaVersion" in js and "SCHEMA_VERSION" in js
def test_playback_loop_clamps_dt():
"""rAF pauses in background tabs → huge dt on resume. The loop must
clamp dt so the playhead doesn't teleport. Pin the EXACT formula so a
regression (dropped clamp, wrong bound) is caught — the math itself is
untestable here without a JS runtime, so the formula pin is the lock.
"""
js = _model_js()
assert "MAX_TICK_DT" in js, "Playback must cap dt (MAX_TICK_DT)."
assert "Math.min(Math.max(0, (ts - this._lastTs) / 1000), MAX_TICK_DT)" in js, (
"dt must be clamped to [0, MAX_TICK_DT]."
)
def test_playback_loop_wrap_formula():
"""Loop wrap must preserve the fractional overshoot across the
boundary (constant playback speed through the loop point)."""
js = _model_js()
assert "this._playFrame = lo + ((this._playFrame - lo) % span)" in js, (
"Loop wrap formula changed — playback speed will jitter at the "
"loop boundary if this drifts."
)
def test_step_wraps_within_play_range():
"""step(±N) must wrap within the play range, handling negative delta
(the +span)%span idiom)."""
js = _model_js()
assert "((f - this.playStart) % span + span) % span" in js, (
"step() wrap formula changed — negative steps won't wrap correctly."
)
def test_playback_reschedules_before_emitting():
"""A throwing subscriber must not freeze transport. The tick must
reschedule the next rAF BEFORE emitting the frame event.
"""
js = _model_js()
tick = js.find("_tick(ts)")
assert tick > 0
body = js[tick:tick + 1800]
sched = body.find("requestAnimationFrame(this._tick)")
emit = body.find("setCurrentFrame(")
assert sched > 0 and emit > 0 and sched < emit, (
"_tick must schedule the next frame before the first "
"setCurrentFrame emit so a throwing listener can't stall it."
)
def test_playback_guards_inverted_range():
js = _model_js()
assert "span <= 0" in js, (
"_tick must guard span<=0 (inverted/zero range) and pause "
"rather than spin."
)
def test_model_has_derived_helpers():
js = _model_js()
for marker in [
"get duration", "isFrameInPlayRange", "clampToPlayRange",
"frameAtTime", "timeAtFrame", "goToStart", "goToEnd",
"snapshot()", "reset(",
]:
assert marker in js, f"Model missing derived/helper: {marker!r}"
def test_model_has_subscribe_convenience():
js = _model_js()
assert "subscribe(events, cb)" in js, (
"Model must offer subscribe(events, cb) -> unsubscribe."
)
def test_model_has_playback_speed():
js = _model_js()
assert "playbackSpeed" in js and "setPlaybackSpeed" in js
def test_model_has_every_frame_playback_mode():
"""Maya 'play every frame' — advance exactly one frame per tick
instead of elapsed*fps. Pin the field, setter, tick branch, and that
it's persisted."""
js = _model_js()
assert "playbackMode" in js and "setPlaybackMode" in js
assert "'everyframe'" in js
assert "this.playbackMode === 'everyframe'" in js, (
"tick must branch on every-frame mode."
)
assert "playbackMode: this.playbackMode" in js, "playbackMode must serialize."
def test_scrub_rebases_play_frame_except_during_playback():
"""An external scrub must take effect mid-playback (rebase the
fractional playhead), while the loop's own 'playback' updates keep
sub-frame accumulation."""
js = _model_js()
assert "source !== 'playback'" in js, (
"setCurrentFrame must rebase _playFrame unless source is "
"'playback'."
)
def test_curvestore_has_merged_frames_and_prune():
js = _model_js()
assert "mergedFrames(" in js, "CurveStore must expose mergedFrames()."
assert "prune(" in js, "CurveStore must expose prune(validIds)."
def test_curve_dedupes_by_frame():
js = _model_js()
# normalize must collapse duplicate rounded frames via a Map keyed on
# the rounded frame (last wins). Pin the specific construct so the
# dedupe can't silently revert to a plain sort.
assert "byFrame" in js and "byFrame.set(Math.round(out.frame)" in js, (
"Curve.normalize must dedupe keys into a Map keyed by rounded "
"frame (last-wins)."
)
def test_load_clamps_current_frame_into_range():
"""A stale/hostile saved currentFrame must be clamped into the anim
range by load(), not applied verbatim."""
js = _model_js()
assert "clampToAnimRange(Math.round(num(data.currentFrame" in js, (
"load() must clamp the loaded playhead via clampToAnimRange."
)
def test_serialize_omits_playing():
"""serialize() must NOT include `playing` — else a saved workflow
would auto-play on load. snapshot() includes it; serialize() omits."""
js = _model_js()
start = js.find("serialize() {")
assert start > 0, "serialize() not found"
body = js[start:start + 500]
assert "playing" not in body, (
"serialize() must omit `playing` (no auto-play on workflow load)."
)
snap = js.find("snapshot() {")
assert snap > 0 and "playing" in js[snap:snap + 400], (
"snapshot() SHOULD include playing (it's the live read-side state)."
)
def test_named_timeline_registry_exists():
js = _model_js()
assert "class TimelineRegistry" in js
assert "getOrCreate(name)" in js
assert "timelines," in js, "Singleton must expose the timelines registry."
def test_singleton_exposes_api_and_ready():
js = _model_js()
assert "apiVersion:" in js and "schemaVersion:" in js
assert "ready" in js, "Singleton must expose a ready Promise."
def test_rehydrate_resets_when_graph_has_no_timeline_data():
"""The model is a singleton that outlives graphs. afterConfigureGraph
must reset() to defaults when the loaded graph carries NO
extra.timeline (File→New, or a workflow authored without the pack) —
otherwise the previous workflow's frame/range leaks into new
consumer nodes and bakes into their queued prompts."""
js = _model_js()
idx = js.find("afterConfigureGraph()")
assert idx > 0, "persistence extension must hook afterConfigureGraph"
body = js[idx:idx + 900]
assert "model.load(data)" in body
assert "model.reset('load')" in body, (
"afterConfigureGraph must model.reset('load') when the graph has "
"no saved timeline — stale singleton state must not leak across "
"workflows."
)
def test_load_emits_mode_for_mode_only_change():
"""load() must emit 'mode' when playbackMode changed — the range/fps
emits only fire when THOSE axes changed, so a mode-only difference
would otherwise leave the bar's EF button stale. (Behaviorally
covered in tests/js/model.test.mjs; this pins the source.)"""
js = _model_js()
idx = js.find("load(data) {")
assert idx > 0
body = js[idx:idx + 2600]
assert "_emit('mode'" in body, (
"load() must emit 'mode' on a playbackMode change."
)