168 lines
6.4 KiB
JavaScript
168 lines
6.4 KiB
JavaScript
// Behavioral tests for the ComfyTimeline model (the state machine that
|
||
// the Python suite can only grep-pin). Run via the Python wrapper
|
||
// tests/test_js_model_runtime.py, or directly:
|
||
// node --import ./setup.mjs --test tests/js/model.test.mjs
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import {
|
||
TimelineModel, Curve, CurveStore,
|
||
} from '../../src/timelineanim_nodes/web/js/timeline_model.js';
|
||
|
||
test('dt is clamped — a long tab-resume gap does not teleport the playhead', () => {
|
||
const m = new TimelineModel();
|
||
m.setAnimRange(0, 100000); m.setPlayRange(0, 100000); m.setFps(24);
|
||
m.play();
|
||
m._tick(0); // seeds _lastTs
|
||
m._tick(100000); // 100s gap → must clamp, not advance ~2.4M frames
|
||
assert.ok(m.currentFrame <= 24, `dt-clamp failed: currentFrame=${m.currentFrame}`);
|
||
});
|
||
|
||
test('loop wraps within the play range and keeps playing', () => {
|
||
const m = new TimelineModel();
|
||
m.setAnimRange(0, 100); m.setPlayRange(0, 4); m.setFps(10); m.setLoop(true);
|
||
m.play();
|
||
m._tick(0);
|
||
m._tick(1000); // +10 frames over span 5 → wraps
|
||
assert.ok(m.currentFrame >= 0 && m.currentFrame <= 4,
|
||
`loop out of range: ${m.currentFrame}`);
|
||
assert.equal(m.playing, true);
|
||
});
|
||
|
||
test('one-shot stops at the play end', () => {
|
||
const m = new TimelineModel();
|
||
m.setAnimRange(0, 100); m.setPlayRange(0, 4); m.setFps(10); m.setLoop(false);
|
||
m.play();
|
||
// dt per tick is capped at MAX_TICK_DT (0.25s), so cross the 4-frame play
|
||
// range with several in-clamp ticks (each 0.2s × 10fps = 2 frames).
|
||
m._tick(0); m._tick(200); m._tick(400); m._tick(600);
|
||
assert.equal(m.currentFrame, 4);
|
||
assert.equal(m.playing, false);
|
||
});
|
||
|
||
test('every-frame mode advances exactly one frame per tick', () => {
|
||
const m = new TimelineModel();
|
||
m.setAnimRange(0, 100); m.setPlayRange(0, 100); m.setFps(1000);
|
||
m.setPlaybackMode('everyframe');
|
||
m.play();
|
||
m._tick(16); m._tick(32); // huge fps, but every-frame → +1 per tick
|
||
assert.equal(m.currentFrame, 2);
|
||
});
|
||
|
||
test('step wraps within the play range (handles negative delta)', () => {
|
||
const m = new TimelineModel();
|
||
m.setAnimRange(0, 100); m.setPlayRange(0, 9); m.setCurrentFrame(0);
|
||
m.step(-1); assert.equal(m.currentFrame, 9);
|
||
m.step(1); assert.equal(m.currentFrame, 0);
|
||
});
|
||
|
||
test('serialize/load round-trips (minus playing) and clamps a hostile frame', () => {
|
||
const m = new TimelineModel();
|
||
m.setAnimRange(0, 100); m.setPlayRange(10, 90); m.setFps(30);
|
||
m.setCurrentFrame(50); m.setLoop(false);
|
||
const snap = m.serialize();
|
||
assert.equal('playing' in snap, false, 'serialize must omit playing');
|
||
|
||
const m2 = new TimelineModel();
|
||
m2.load(snap);
|
||
assert.equal(m2.animStart, 0); assert.equal(m2.animEnd, 100);
|
||
assert.equal(m2.playStart, 10); assert.equal(m2.playEnd, 90);
|
||
assert.equal(m2.fps, 30); assert.equal(m2.currentFrame, 50);
|
||
assert.equal(m2.loop, false);
|
||
|
||
const m3 = new TimelineModel();
|
||
m3.load({ animStart: 0, animEnd: 100, currentFrame: 99999 });
|
||
assert.ok(m3.currentFrame <= 100, `load did not clamp: ${m3.currentFrame}`);
|
||
});
|
||
|
||
test('load canonicalizes hostile fractional ranges to integer frames', () => {
|
||
const m = new TimelineModel('fractional-load');
|
||
m.load({
|
||
animStart: 1.4,
|
||
animEnd: 9.6,
|
||
playStart: 2.5,
|
||
playEnd: 8.49,
|
||
currentFrame: 4.6,
|
||
});
|
||
assert.equal(m.animStart, 1);
|
||
assert.equal(m.animEnd, 10);
|
||
assert.equal(m.playStart, 3);
|
||
assert.equal(m.playEnd, 8);
|
||
assert.equal(m.currentFrame, 5);
|
||
});
|
||
|
||
test('load() emits mode/fps events for a mode- or speed-only change', () => {
|
||
// Regression: load() used to set playbackMode/playbackSpeed silently
|
||
// when the ranges were identical, leaving subscribers (the bar's EF
|
||
// button) showing stale transport state after a workflow load.
|
||
const m = new TimelineModel();
|
||
const events = [];
|
||
m.subscribe(['mode', 'fps'], (e) => events.push([e.type, e.detail.source]));
|
||
m.load({ ...m.serialize(), playbackMode: 'everyframe', playbackSpeed: 2.0 });
|
||
assert.equal(m.playbackMode, 'everyframe');
|
||
assert.equal(m.playbackSpeed, 2.0);
|
||
assert.ok(events.some(([t, s]) => t === 'mode' && s === 'load'),
|
||
`no 'mode' event emitted: ${JSON.stringify(events)}`);
|
||
assert.ok(events.some(([t, s]) => t === 'fps' && s === 'load'),
|
||
`no 'fps' (speed) event emitted: ${JSON.stringify(events)}`);
|
||
});
|
||
|
||
test('setPlayRange clamps into the anim range; setAnimRange pulls play in', () => {
|
||
const m = new TimelineModel();
|
||
m.setAnimRange(10, 20);
|
||
m.setPlayRange(0, 100); // clamp into [10,20]
|
||
assert.equal(m.playStart, 10); assert.equal(m.playEnd, 20);
|
||
m.setAnimRange(12, 18); // pulls play inward
|
||
assert.ok(m.playStart >= 12 && m.playEnd <= 18);
|
||
});
|
||
|
||
test('event detail carries the source token (echo suppression)', () => {
|
||
const m = new TimelineModel();
|
||
let got = null;
|
||
m.subscribe('frame', (e) => { got = e.detail; });
|
||
m.setCurrentFrame(7, 'my-token');
|
||
assert.equal(got.frame, 7);
|
||
assert.equal(got.source, 'my-token');
|
||
});
|
||
|
||
test('Curve dedupes by frame (last wins) and stays sorted', () => {
|
||
const c = new Curve([
|
||
{ frame: 5, value: 1 }, { frame: 5, value: 2 }, { frame: 0, value: 0 },
|
||
]);
|
||
assert.equal(c.keys.length, 2);
|
||
assert.deepEqual(c.keys.map((k) => k.frame), [0, 5]);
|
||
assert.equal(c.keys.find((k) => k.frame === 5).value, 2);
|
||
});
|
||
|
||
test('Curve canonicalizes fractional frames with Math.round last-wins', () => {
|
||
const c = new Curve([
|
||
{ frame: 0.49, value: 1 },
|
||
{ frame: 0.40, value: 2 },
|
||
{ frame: 1.50, value: 4 },
|
||
]);
|
||
assert.deepEqual(c.keys.map((k) => k.frame), [0, 2]);
|
||
assert.deepEqual(c.keys.map((k) => k.value), [2, 4]);
|
||
});
|
||
|
||
test('CurveStore mergedFrames / prune / change events', () => {
|
||
const s = new CurveStore();
|
||
let changes = 0;
|
||
s.addEventListener('change', () => { changes += 1; });
|
||
s.register(1, new Curve([{ frame: 0, value: 0 }, { frame: 10, value: 1 }]));
|
||
s.register(2, new Curve([{ frame: 5, value: 0 }, { frame: 10, value: 1 }]));
|
||
assert.deepEqual(s.mergedFrames(), [0, 5, 10]);
|
||
assert.equal(s.prune([1]), 1); // drops curve 2
|
||
assert.deepEqual(s.mergedFrames(), [0, 10]);
|
||
assert.ok(changes >= 3);
|
||
});
|
||
|
||
test('Curve.sample matches the shared sampler at the keys', () => {
|
||
const c = new Curve([
|
||
{ frame: 0, value: 0, in_type: 'linear', out_type: 'linear' },
|
||
{ frame: 10, value: 10, in_type: 'linear', out_type: 'linear' },
|
||
]);
|
||
assert.equal(c.sample(0), 0);
|
||
assert.equal(c.sample(10), 10);
|
||
assert.equal(c.sample(5), 5); // linear midpoint
|
||
});
|