@spearwolf/twopoint5d
Version:
Create 2.5D realtime graphics and pixelart with WebGL and three.js
176 lines • 6.65 kB
JavaScript
import { describe, expect, it } from 'vitest';
import { FrameLoop } from './FrameLoop.js';
function makeFakeRenderer() {
return {
callback: null,
setAnimationLoop(cb) {
this.callback = cb;
},
tick(now) {
this.callback(now);
},
};
}
function subscribe(loop) {
const events = [];
const target = {
[FrameLoop.OnFrame](props) {
events.push(props);
},
};
loop.start(target);
return { events, target };
}
describe('FrameLoop', () => {
it('first emitted frame has deltaTime === 0, not NaN', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(0, renderer);
const { events } = subscribe(loop);
renderer.tick(1000);
expect(events).toHaveLength(1);
expect(Number.isNaN(events[0].deltaTime)).toBe(false);
expect(events[0].deltaTime).toBe(0);
});
it('emits a single OnFrame per rAF tick with monotonically increasing frameNo', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(0, renderer);
const { events } = subscribe(loop);
renderer.tick(1000);
renderer.tick(1016);
renderer.tick(1032);
expect(events.map((e) => e.frameNo)).toEqual([1, 2, 3]);
});
it('lastNow in emitted props reflects the previous frame timestamp (not the current one)', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(0, renderer);
const { events } = subscribe(loop);
renderer.tick(1000);
renderer.tick(1016);
expect(events[0].now).toBe(1);
expect(events[0].lastNow).toBe(1);
expect(events[1].now).toBeCloseTo(1.016);
expect(events[1].lastNow).toBe(1);
expect(events[1].deltaTime).toBeCloseTo(0.016);
});
it('measuredFps is 0 until the first measurement window completes', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(0, renderer);
const { events } = subscribe(loop);
for (let i = 0; i < 10; i++) {
renderer.tick(1000 + i * 16);
}
for (const evt of events) {
expect(evt.measuredFps).toBe(0);
}
});
it('measuredFps produces a plausible value once the first window completes', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(0, renderer);
const { events } = subscribe(loop);
for (let i = 0; i < 31; i++) {
renderer.tick(1000 + i * (1000 / 60));
}
expect(events).toHaveLength(31);
expect(events[30].measuredFps).toBe(60);
});
it('maxFps throttles emissions to the target rate', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(30, renderer);
const { events } = subscribe(loop);
renderer.tick(0);
renderer.tick(16);
renderer.tick(33);
renderer.tick(50);
renderer.tick(66);
expect(events).toHaveLength(3);
});
it('maxFps grid stays stable across many frames (no drift)', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(60, renderer);
const { events } = subscribe(loop);
const VSYNC = 1000 / 240;
for (let i = 0; i < 41; i++) {
renderer.tick(i * VSYNC);
}
expect(events.length).toBeGreaterThanOrEqual(10);
expect(events.length).toBeLessThanOrEqual(12);
const targetDelta = 1 / 60;
for (let i = 1; i < events.length; i++) {
expect(events[i].deltaTime).toBeGreaterThan(targetDelta * 0.95);
expect(events[i].deltaTime).toBeLessThan(targetDelta * 1.1);
}
});
it('maxFps tolerates rAF ticks arriving slightly early (jitter tolerance)', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(60, renderer);
const { events } = subscribe(loop);
renderer.tick(0);
renderer.tick(16.5);
renderer.tick(33.0);
expect(events).toHaveLength(3);
});
it('after a long pause the schedule snaps forward — no catch-up burst', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(30, renderer);
const { events } = subscribe(loop);
renderer.tick(0);
renderer.tick(1000);
expect(events).toHaveLength(2);
renderer.tick(1001);
expect(events).toHaveLength(2);
renderer.tick(1034);
expect(events).toHaveLength(3);
});
it('setFps() resets the schedule mid-loop', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(30, renderer);
const { events } = subscribe(loop);
renderer.tick(0);
renderer.tick(33);
expect(events).toHaveLength(2);
loop.setFps(120);
renderer.tick(40);
renderer.tick(45);
renderer.tick(49);
expect(events).toHaveLength(4);
});
it('subscriptionCount tracks start()/stop() idempotently', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(0, renderer);
const a = { [FrameLoop.OnFrame]() { } };
const b = { [FrameLoop.OnFrame]() { } };
expect(loop.subscriptionCount).toBe(0);
loop.start(a);
expect(loop.subscriptionCount).toBe(1);
loop.start(a);
expect(loop.subscriptionCount).toBe(1);
loop.start(b);
expect(loop.subscriptionCount).toBe(2);
loop.stop(a);
expect(loop.subscriptionCount).toBe(1);
loop.stop(a);
expect(loop.subscriptionCount).toBe(1);
loop.stop(b);
expect(loop.subscriptionCount).toBe(0);
});
it('start() returns an unsubscribe function', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(0, renderer);
const target = { [FrameLoop.OnFrame]() { } };
const unsubscribe = loop.start(target);
expect(loop.subscriptionCount).toBe(1);
expect(typeof unsubscribe).toBe('function');
unsubscribe();
expect(loop.subscriptionCount).toBe(0);
});
it('clear() removes all subscribers', () => {
const renderer = makeFakeRenderer();
const loop = new FrameLoop(0, renderer);
loop.start({ [FrameLoop.OnFrame]() { } });
loop.start({ [FrameLoop.OnFrame]() { } });
expect(loop.subscriptionCount).toBe(2);
loop.clear();
expect(loop.subscriptionCount).toBe(0);
});
});
//# sourceMappingURL=FrameLoop.spec.js.map