Files

168 lines
6.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
});