test(01-02): add failing tests for GameLoop class

- Tests for start(), stop(), isRunning(), getRafId()
- Tests for update callback with deltaTime
- Tests for delta time accumulation
- Tests for loop lifecycle (start/stop multiple times)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 23:46:34 +07:00
parent 1589b341e4
commit e46bc1db7a
2 changed files with 280 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
// src/__tests__/GameLoop.test.ts - Unit tests for GameLoop class
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GameLoop } from '../game/GameLoop';
describe('GameLoop', () => {
let gameLoop: GameLoop;
let updateCallback: ReturnType<typeof vi.fn>;
let originalRaf: typeof globalThis.requestAnimationFrame;
let originalCancelRaf: typeof globalThis.cancelAnimationFrame;
beforeEach(() => {
updateCallback = vi.fn();
originalRaf = globalThis.requestAnimationFrame;
originalCancelRaf = globalThis.cancelAnimationFrame;
// Mock requestAnimationFrame and cancelAnimationFrame on global
let rafId = 0;
const rafCallbacks: Map<number, FrameRequestCallback> = new Map();
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => {
rafId++;
rafCallbacks.set(rafId, cb);
return rafId;
});
globalThis.cancelAnimationFrame = vi.fn((id: number) => {
rafCallbacks.delete(id);
});
});
afterEach(() => {
gameLoop.stop();
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
vi.restoreAllMocks();
});
describe('start()', () => {
it('should set running to true', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
expect(gameLoop.isRunning()).toBe(true);
});
it('should call requestAnimationFrame', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
expect(globalThis.requestAnimationFrame).toHaveBeenCalled();
});
it('should not start again if already running', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
const callCount = (globalThis.requestAnimationFrame as ReturnType<typeof vi.fn>).mock.calls.length;
gameLoop.start(); // Should be ignored
expect((globalThis.requestAnimationFrame as ReturnType<typeof vi.fn>).mock.calls.length).toBe(callCount);
});
});
describe('stop()', () => {
it('should set running to false', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
gameLoop.stop();
expect(gameLoop.isRunning()).toBe(false);
});
it('should cancel the animation frame', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
const rafId = gameLoop.getRafId();
gameLoop.stop();
expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(rafId);
});
it('should be safe to call stop without start', () => {
gameLoop = new GameLoop(updateCallback);
expect(() => gameLoop.stop()).not.toThrow();
expect(gameLoop.isRunning()).toBe(false);
});
it('should be safe to call stop multiple times', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
gameLoop.stop();
gameLoop.stop();
expect(gameLoop.isRunning()).toBe(false);
});
});
describe('update callback', () => {
it('should call update with deltaTime when time has passed', () => {
vi.useFakeTimers();
let capturedCallback: FrameRequestCallback | null = null;
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => {
capturedCallback = cb;
return 1;
});
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
// Simulate a frame with enough time for one tick (~16.67ms)
if (capturedCallback) {
capturedCallback(20); // 20ms passed
}
// One tick should have been called
expect(updateCallback).toHaveBeenCalledWith(1000 / 60);
vi.useRealTimers();
});
it('should not call update when no time has passed', () => {
vi.useFakeTimers();
let capturedCallback: FrameRequestCallback | null = null;
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => {
capturedCallback = cb;
return 1;
});
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
// Simulate a frame with no time passed
if (capturedCallback) {
capturedCallback(0); // No time passed
}
expect(updateCallback).not.toHaveBeenCalled();
vi.useRealTimers();
});
});
describe('loop lifecycle', () => {
it('should be able to start and stop multiple times', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
expect(gameLoop.isRunning()).toBe(true);
gameLoop.stop();
expect(gameLoop.isRunning()).toBe(false);
gameLoop.start();
expect(gameLoop.isRunning()).toBe(true);
gameLoop.stop();
expect(gameLoop.isRunning()).toBe(false);
});
});
describe('delta time accumulation', () => {
it('should accumulate multiple ticks correctly', () => {
vi.useFakeTimers();
let capturedCallback: FrameRequestCallback | null = null;
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => {
capturedCallback = cb;
return 1;
});
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
// Simulate a frame with enough time for 3 ticks (~50ms)
if (capturedCallback) {
capturedCallback(50);
}
// Three ticks should have been called
expect(updateCallback).toHaveBeenCalledTimes(3);
expect(updateCallback).toHaveBeenCalledWith(1000 / 60);
vi.useRealTimers();
});
});
describe('tickLength', () => {
it('should target 60fps (16.67ms per tick)', () => {
gameLoop = new GameLoop(updateCallback);
expect(gameLoop.getTickLength()).toBeCloseTo(1000 / 60, 2);
});
});
});
+92
View File
@@ -0,0 +1,92 @@
// src/game/GameLoop.ts - requestAnimationFrame-based game loop
/**
* GameLoop provides a fixed-timestep game loop using requestAnimationFrame.
* It accumulates delta time and calls the update callback at 60fps.
*/
export class GameLoop {
private readonly tickLength: number = 1000 / 60; // 60 FPS target
private lastTick: number = 0;
private rafId: number = 0;
private running: boolean = false;
/**
* Creates a new GameLoop
* @param update - Callback function called with delta time in milliseconds
*/
constructor(private update: (deltaTime: number) => void) {}
/**
* Starts the game loop
*/
start(): void {
if (this.running) return;
this.running = true;
this.lastTick = performance.now();
this.rafId = requestAnimationFrame(this.main);
}
/**
* Stops the game loop
*/
stop(): void {
if (!this.running) return;
this.running = false;
cancelAnimationFrame(this.rafId);
this.rafId = 0;
}
/**
* Checks if the loop is currently running
*/
isRunning(): boolean {
return this.running;
}
/**
* Gets the current requestAnimationFrame ID
*/
getRafId(): number {
return this.rafId;
}
/**
* Gets the tick length in milliseconds
*/
getTickLength(): number {
return this.tickLength;
}
/**
* Main loop callback - called by requestAnimationFrame
*/
private main = (timestamp: number): void => {
if (!this.running) return;
this.rafId = requestAnimationFrame(this.main);
// Calculate how many ticks have passed
const nextTick = this.lastTick + this.tickLength;
let numTicks = 0;
if (timestamp > nextTick) {
const timeSinceTick = timestamp - this.lastTick;
numTicks = Math.floor(timeSinceTick / this.tickLength);
}
// Process accumulated ticks
this.queueUpdates(numTicks);
};
/**
* Calls update for each accumulated tick
*/
private queueUpdates(numTicks: number): void {
for (let i = 0; i < numTicks; i++) {
this.lastTick += this.tickLength;
this.update(this.tickLength);
}
}
}