first commmit
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
DESIGN_WIDTH,
|
||||
DESIGN_HEIGHT,
|
||||
TARGET_FPS,
|
||||
MOVE_SPEED,
|
||||
PlayerColorState,
|
||||
JUMP_HEIGHT_STANDARD,
|
||||
JUMP_HEIGHT_CHARGED,
|
||||
JUMP_HEIGHT_YELLOW,
|
||||
JUMP_PREPARE_DELAY_MS,
|
||||
PARABOLIC_ANGLE_RIGHT,
|
||||
PARABOLIC_ANGLE_LEFT,
|
||||
SHURIKEN_INTERVAL_BASE,
|
||||
SHURIKEN_INTERVAL_UPGRADED,
|
||||
SWORD_INTERVAL,
|
||||
PERF_TOUCH_RESPONSE_MAX_MS,
|
||||
PERF_COMBO_RECOGNITION_MAX_MS,
|
||||
STORAGE_KEY,
|
||||
} from '@common/Constants';
|
||||
|
||||
describe('Constants — requirement baseline values', () => {
|
||||
it('uses 960x540 landscape baseline (req tech-stack)', () => {
|
||||
expect(DESIGN_WIDTH).toBe(960);
|
||||
expect(DESIGN_HEIGHT).toBe(540);
|
||||
expect(DESIGN_WIDTH / DESIGN_HEIGHT).toBeCloseTo(16 / 9, 3);
|
||||
});
|
||||
|
||||
it('locks target frame rate at 30 fps (req 18.1-18.3)', () => {
|
||||
expect(TARGET_FPS).toBe(30);
|
||||
});
|
||||
|
||||
it('assigns 100/100/150 px/s move speeds per color state (req 5.1-5.2)', () => {
|
||||
expect(MOVE_SPEED[PlayerColorState.Red]).toBe(100);
|
||||
expect(MOVE_SPEED[PlayerColorState.Green]).toBe(100);
|
||||
expect(MOVE_SPEED[PlayerColorState.Yellow]).toBe(150);
|
||||
});
|
||||
|
||||
it('uses 250/375/300 jump heights (req 2.2-2.3)', () => {
|
||||
expect(JUMP_HEIGHT_STANDARD).toBe(250);
|
||||
expect(JUMP_HEIGHT_CHARGED).toBe(375);
|
||||
expect(JUMP_HEIGHT_YELLOW).toBe(300);
|
||||
});
|
||||
|
||||
it('reserves ~150ms crouch delay before leaving ground (req 2.8)', () => {
|
||||
expect(JUMP_PREPARE_DELAY_MS).toBe(150);
|
||||
});
|
||||
|
||||
it('parabolic angles anchored at 45° and 135° (req 2.5)', () => {
|
||||
expect(PARABOLIC_ANGLE_RIGHT).toBe(45);
|
||||
expect(PARABOLIC_ANGLE_LEFT).toBe(135);
|
||||
});
|
||||
|
||||
it('weapon intervals conform to 0.3/0.25/0.5s (req 3.4/3.6)', () => {
|
||||
expect(SHURIKEN_INTERVAL_BASE).toBe(0.3);
|
||||
expect(SHURIKEN_INTERVAL_UPGRADED).toBe(0.25);
|
||||
expect(SWORD_INTERVAL).toBe(0.5);
|
||||
});
|
||||
|
||||
it('enforces <50ms touch and <100ms combo KPI thresholds (req 20.1/20.4)', () => {
|
||||
expect(PERF_TOUCH_RESPONSE_MAX_MS).toBe(50);
|
||||
expect(PERF_COMBO_RECOGNITION_MAX_MS).toBe(100);
|
||||
});
|
||||
|
||||
it('defines all storage keys required by persistence (req 17 & 19.5)', () => {
|
||||
expect(STORAGE_KEY.LevelUnlock).toBeDefined();
|
||||
expect(STORAGE_KEY.ControlLayout).toBeDefined();
|
||||
expect(STORAGE_KEY.AudioVolume).toBeDefined();
|
||||
expect(STORAGE_KEY.TutorialDone).toBeDefined();
|
||||
expect(STORAGE_KEY.StoryIntroSeen).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { EventBus } from '@common/EventBus';
|
||||
|
||||
describe('EventBus', () => {
|
||||
let bus: EventBus;
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new EventBus();
|
||||
});
|
||||
|
||||
it('delivers emitted payload to on() subscribers', () => {
|
||||
const spy = jest.fn();
|
||||
bus.on<number>('hit', spy);
|
||||
bus.emit('hit', 42);
|
||||
expect(spy).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('ignores duplicate subscriptions of the same handler', () => {
|
||||
const spy = jest.fn();
|
||||
bus.on('hit', spy);
|
||||
bus.on('hit', spy);
|
||||
bus.emit('hit', 1);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(bus.listenerCount('hit')).toBe(1);
|
||||
});
|
||||
|
||||
it('fires once() handlers exactly one time', () => {
|
||||
const spy = jest.fn();
|
||||
bus.once('hit', spy);
|
||||
bus.emit('hit', 1);
|
||||
bus.emit('hit', 2);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(bus.listenerCount('hit')).toBe(0);
|
||||
});
|
||||
|
||||
it('off(event, fn) removes only that handler', () => {
|
||||
const a = jest.fn();
|
||||
const b = jest.fn();
|
||||
bus.on('hit', a);
|
||||
bus.on('hit', b);
|
||||
bus.off('hit', a);
|
||||
bus.emit('hit', 1);
|
||||
expect(a).not.toHaveBeenCalled();
|
||||
expect(b).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('off(event) clears all handlers of that event', () => {
|
||||
const a = jest.fn();
|
||||
const b = jest.fn();
|
||||
bus.on('hit', a);
|
||||
bus.on('hit', b);
|
||||
bus.off('hit');
|
||||
bus.emit('hit', 1);
|
||||
expect(a).not.toHaveBeenCalled();
|
||||
expect(b).not.toHaveBeenCalled();
|
||||
expect(bus.listenerCount('hit')).toBe(0);
|
||||
});
|
||||
|
||||
it('isolates exceptions — one bad listener does not break fan-out', () => {
|
||||
const err = jest.fn();
|
||||
bus.setErrorHook(err);
|
||||
const good = jest.fn();
|
||||
bus.on('hit', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
bus.on('hit', good);
|
||||
bus.emit('hit', 1);
|
||||
expect(good).toHaveBeenCalledTimes(1);
|
||||
expect(err).toHaveBeenCalledTimes(1);
|
||||
expect(err.mock.calls[0][0]).toBe('hit');
|
||||
});
|
||||
|
||||
it('emit on an unknown event is a no-op', () => {
|
||||
expect(() => bus.emit('ghost', 1)).not.toThrow();
|
||||
});
|
||||
|
||||
it('clear() drops every handler of every event', () => {
|
||||
bus.on('a', () => {});
|
||||
bus.on('b', () => {});
|
||||
bus.clear();
|
||||
expect(bus.listenerCount('a')).toBe(0);
|
||||
expect(bus.listenerCount('b')).toBe(0);
|
||||
});
|
||||
|
||||
it('supports off for a non-existent handler without error', () => {
|
||||
const spy = jest.fn();
|
||||
bus.off('hit', spy);
|
||||
expect(bus.listenerCount('hit')).toBe(0);
|
||||
});
|
||||
|
||||
it('handles a once-handler unsubscribing itself during emit', () => {
|
||||
const calls: number[] = [];
|
||||
bus.once<number>('tick', (n) => calls.push(n));
|
||||
bus.on<number>('tick', (n) => calls.push(n * 10));
|
||||
bus.emit('tick', 1);
|
||||
bus.emit('tick', 2);
|
||||
expect(calls).toEqual([1, 10, 20]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Logger, LogLevel } from '@common/Logger';
|
||||
|
||||
describe('Logger — leveled logging', () => {
|
||||
let logger: Logger;
|
||||
let sink: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new Logger();
|
||||
sink = jest.fn();
|
||||
logger.setSink(sink);
|
||||
});
|
||||
|
||||
it('suppresses messages below the threshold', () => {
|
||||
logger.setLevel(LogLevel.Warn);
|
||||
logger.debug('mod', 'hidden');
|
||||
logger.info('mod', 'hidden');
|
||||
logger.warn('mod', 'shown');
|
||||
logger.error('mod', 'shown');
|
||||
expect(sink).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('forwards module + message + rest args to the sink', () => {
|
||||
logger.setLevel(LogLevel.Debug);
|
||||
logger.info('Pool', 'size', 5);
|
||||
expect(sink).toHaveBeenCalledWith(LogLevel.Info, 'Pool', 'size', 5);
|
||||
});
|
||||
|
||||
it('Silent level disables all output', () => {
|
||||
logger.setLevel(LogLevel.Silent);
|
||||
logger.error('mod', 'ignored');
|
||||
expect(sink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logger — performance metrics', () => {
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new Logger();
|
||||
});
|
||||
|
||||
it('records samples under a metric name', () => {
|
||||
for (const v of [10, 20, 30, 40, 50]) {
|
||||
logger.metric({ name: 'touch_ms', value: v });
|
||||
}
|
||||
const agg = logger.aggregate('touch_ms');
|
||||
expect(agg).toBeDefined();
|
||||
expect(agg!.count).toBe(5);
|
||||
expect(agg!.min).toBe(10);
|
||||
expect(agg!.max).toBe(50);
|
||||
expect(agg!.avg).toBeCloseTo(30);
|
||||
expect(agg!.p50).toBe(30);
|
||||
expect(agg!.p95).toBe(50);
|
||||
});
|
||||
|
||||
it('returns undefined aggregate for unknown metric', () => {
|
||||
expect(logger.aggregate('ghost')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('timerStart/timerEnd records elapsed time', () => {
|
||||
logger.timerStart('frame', 1000);
|
||||
const elapsed = logger.timerEnd('frame', 1016);
|
||||
expect(elapsed).toBe(16);
|
||||
expect(logger.aggregate('frame')!.avg).toBe(16);
|
||||
});
|
||||
|
||||
it('timerEnd without start returns undefined', () => {
|
||||
expect(logger.timerEnd('never', 100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resetMetrics clears all samples and running timers', () => {
|
||||
logger.metric({ name: 'a', value: 1 });
|
||||
logger.timerStart('b', 0);
|
||||
logger.resetMetrics();
|
||||
expect(logger.aggregate('a')).toBeUndefined();
|
||||
expect(logger.timerEnd('b', 10)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ObjectPool } from '@common/ObjectPool';
|
||||
|
||||
interface Bullet {
|
||||
x: number;
|
||||
alive: boolean;
|
||||
}
|
||||
|
||||
const makeBullet = (): Bullet => ({ x: 0, alive: false });
|
||||
const resetBullet = (b: Bullet): void => {
|
||||
b.x = 0;
|
||||
b.alive = false;
|
||||
};
|
||||
|
||||
describe('ObjectPool', () => {
|
||||
it('creates new instances when the free list is empty', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet });
|
||||
const a = pool.acquire();
|
||||
const b = pool.acquire();
|
||||
expect(a).not.toBe(b);
|
||||
expect(pool.stats().created).toBe(2);
|
||||
expect(pool.borrowedCount).toBe(2);
|
||||
expect(pool.freeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('reuses instances after release', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet, resetter: resetBullet });
|
||||
const a = pool.acquire();
|
||||
a.x = 100;
|
||||
a.alive = true;
|
||||
pool.release(a);
|
||||
expect(a.x).toBe(0);
|
||||
expect(a.alive).toBe(false);
|
||||
const b = pool.acquire();
|
||||
expect(b).toBe(a);
|
||||
expect(pool.stats().recycled).toBe(1);
|
||||
});
|
||||
|
||||
it('pre-allocates instances when preAlloc is set', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet, preAlloc: 5 });
|
||||
expect(pool.freeCount).toBe(5);
|
||||
expect(pool.stats().created).toBe(5);
|
||||
});
|
||||
|
||||
it('discards excess releases when over maxSize', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet, maxSize: 2 });
|
||||
const a = pool.acquire();
|
||||
const b = pool.acquire();
|
||||
const c = pool.acquire();
|
||||
pool.release(a);
|
||||
pool.release(b);
|
||||
pool.release(c); // should be dropped
|
||||
expect(pool.freeCount).toBe(2);
|
||||
});
|
||||
|
||||
it('fires onDoubleRelease and ignores double-release', () => {
|
||||
const spy = jest.fn();
|
||||
const pool = new ObjectPool<Bullet>({
|
||||
factory: makeBullet,
|
||||
onDoubleRelease: spy,
|
||||
});
|
||||
const a = pool.acquire();
|
||||
pool.release(a);
|
||||
pool.release(a);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(pool.freeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('drain() clears both free and borrowed', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet, preAlloc: 3 });
|
||||
pool.acquire();
|
||||
pool.drain();
|
||||
expect(pool.freeCount).toBe(0);
|
||||
expect(pool.borrowedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('stats track acquired / recycled totals', () => {
|
||||
const pool = new ObjectPool<Bullet>({ factory: makeBullet });
|
||||
const a = pool.acquire();
|
||||
const b = pool.acquire();
|
||||
pool.release(a);
|
||||
pool.release(b);
|
||||
pool.acquire();
|
||||
const s = pool.stats();
|
||||
expect(s.acquired).toBe(3);
|
||||
expect(s.recycled).toBe(2);
|
||||
expect(s.created).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Logger } from '@common/Logger';
|
||||
import { CORE_PERF_THRESHOLDS, PerfMonitor } from '@common/PerfMonitor';
|
||||
import {
|
||||
PERF_TOUCH_RESPONSE_MAX_MS,
|
||||
PERF_COMBO_RECOGNITION_MAX_MS,
|
||||
MAX_FIRST_PACKAGE_BYTES,
|
||||
MAX_AUDIO_BUNDLE_BYTES,
|
||||
MAX_MEMORY_PEAK_BYTES,
|
||||
} from '@common/Constants';
|
||||
|
||||
describe('PerfMonitor — threshold catalog (req 18 & 20)', () => {
|
||||
it('includes every KPI threshold called out in requirements 20.1-20.5', () => {
|
||||
const names = CORE_PERF_THRESHOLDS.map((t) => t.metric);
|
||||
expect(names).toContain('input/touchStart');
|
||||
expect(names).toContain('jump/state_toggle_ms');
|
||||
expect(names).toContain('input/combo_recognition_ms');
|
||||
expect(names).toContain('input/parabolic_accuracy');
|
||||
expect(names).toContain('jump/air_jump_block_rate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PerfMonitor — pass/fail evaluation', () => {
|
||||
function seedPassing(logger: Logger): void {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
logger.metric({ name: 'input/touchStart', value: 20 });
|
||||
logger.metric({ name: 'jump/state_toggle_ms', value: 25 });
|
||||
logger.metric({ name: 'input/combo_recognition_ms', value: 40 });
|
||||
}
|
||||
logger.metric({ name: 'input/parabolic_accuracy', value: 0.97 });
|
||||
logger.metric({ name: 'jump/air_jump_block_rate', value: 0.995 });
|
||||
}
|
||||
|
||||
it('passes when all metrics are within budget', () => {
|
||||
const logger = new Logger();
|
||||
seedPassing(logger);
|
||||
const monitor = new PerfMonitor(logger);
|
||||
const report = monitor.collectReport();
|
||||
expect(report.allPassing).toBe(true);
|
||||
});
|
||||
|
||||
it('fails when no samples exist for a threshold', () => {
|
||||
const monitor = new PerfMonitor(new Logger());
|
||||
const report = monitor.collectReport();
|
||||
expect(report.allPassing).toBe(false);
|
||||
expect(report.checks[0].reason).toMatch(/no samples/);
|
||||
});
|
||||
|
||||
it('fails when p95 latency exceeds the limit', () => {
|
||||
const logger = new Logger();
|
||||
seedPassing(logger);
|
||||
// Push a huge batch of slow touches so p95 exceeds budget.
|
||||
for (let i = 0; i < 200; i++) {
|
||||
logger.metric({ name: 'input/touchStart', value: PERF_TOUCH_RESPONSE_MAX_MS + 50 });
|
||||
}
|
||||
const report = new PerfMonitor(logger).collectReport();
|
||||
const touchCheck = report.checks.find((c) => c.threshold.metric === 'input/touchStart')!;
|
||||
expect(touchCheck.passing).toBe(false);
|
||||
});
|
||||
|
||||
it('fails when parabolic accuracy drops below 95%', () => {
|
||||
const logger = new Logger();
|
||||
seedPassing(logger);
|
||||
logger.resetMetrics();
|
||||
logger.metric({ name: 'input/touchStart', value: 20 });
|
||||
logger.metric({ name: 'jump/state_toggle_ms', value: 20 });
|
||||
logger.metric({ name: 'input/combo_recognition_ms', value: 20 });
|
||||
logger.metric({ name: 'input/parabolic_accuracy', value: 0.8 });
|
||||
logger.metric({ name: 'jump/air_jump_block_rate', value: 0.999 });
|
||||
const report = new PerfMonitor(logger).collectReport();
|
||||
expect(report.allPassing).toBe(false);
|
||||
});
|
||||
|
||||
it('evaluates build-size budget when provided', () => {
|
||||
const logger = new Logger();
|
||||
logger.metric({ name: 'input/touchStart', value: 10 });
|
||||
logger.metric({ name: 'jump/state_toggle_ms', value: 10 });
|
||||
logger.metric({ name: 'input/combo_recognition_ms', value: 10 });
|
||||
logger.metric({ name: 'input/parabolic_accuracy', value: 1 });
|
||||
logger.metric({ name: 'jump/air_jump_block_rate', value: 1 });
|
||||
const monitor = new PerfMonitor(logger);
|
||||
|
||||
const okReport = monitor.collectReport({
|
||||
firstPackageBytes: MAX_FIRST_PACKAGE_BYTES - 1,
|
||||
audioBundleBytes: MAX_AUDIO_BUNDLE_BYTES - 1,
|
||||
memoryPeakBytes: MAX_MEMORY_PEAK_BYTES - 1,
|
||||
});
|
||||
expect(okReport.sizeBudgetPassing).toBe(true);
|
||||
expect(okReport.allPassing).toBe(true);
|
||||
|
||||
const badReport = monitor.collectReport({
|
||||
firstPackageBytes: MAX_FIRST_PACKAGE_BYTES + 1,
|
||||
audioBundleBytes: MAX_AUDIO_BUNDLE_BYTES + 1,
|
||||
memoryPeakBytes: MAX_MEMORY_PEAK_BYTES + 1,
|
||||
});
|
||||
expect(badReport.sizeBudgetPassing).toBe(false);
|
||||
expect(badReport.allPassing).toBe(false);
|
||||
});
|
||||
|
||||
it('honours the <= comparator at exactly the boundary', () => {
|
||||
const logger = new Logger();
|
||||
logger.metric({ name: 'input/touchStart', value: PERF_TOUCH_RESPONSE_MAX_MS });
|
||||
logger.metric({ name: 'jump/state_toggle_ms', value: 10 });
|
||||
logger.metric({ name: 'input/combo_recognition_ms', value: PERF_COMBO_RECOGNITION_MAX_MS });
|
||||
logger.metric({ name: 'input/parabolic_accuracy', value: 1 });
|
||||
logger.metric({ name: 'jump/air_jump_block_rate', value: 1 });
|
||||
const report = new PerfMonitor(logger).collectReport();
|
||||
const touch = report.checks.find((c) => c.threshold.metric === 'input/touchStart')!;
|
||||
expect(touch.passing).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { StorageMgr, IStorageDriver } from '@common/StorageMgr';
|
||||
|
||||
function makeMemoryDriver(): IStorageDriver {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => (m.has(k) ? (m.get(k) as string) : null),
|
||||
setItem: (k, v) => {
|
||||
m.set(k, v);
|
||||
},
|
||||
removeItem: (k) => {
|
||||
m.delete(k);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('StorageMgr', () => {
|
||||
it('returns default when key is missing', () => {
|
||||
const sm = new StorageMgr(makeMemoryDriver());
|
||||
expect(sm.get('nope', { v: 1 })).toEqual({ v: 1 });
|
||||
});
|
||||
|
||||
it('round-trips structured values via JSON', () => {
|
||||
const sm = new StorageMgr(makeMemoryDriver());
|
||||
const layout = { jump: { x: 100, y: 40 }, shuriken: { x: 820, y: 40 } };
|
||||
sm.set('layout', layout);
|
||||
expect(sm.get('layout', null)).toEqual(layout);
|
||||
});
|
||||
|
||||
it('returns default when value is malformed JSON (req 17.6)', () => {
|
||||
const broken: IStorageDriver = {
|
||||
getItem: () => '{not json',
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const sm = new StorageMgr(broken);
|
||||
expect(sm.get('x', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('does not throw when the driver throws (req 17.6)', () => {
|
||||
const exploding: IStorageDriver = {
|
||||
getItem: () => {
|
||||
throw new Error('I/O error');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('I/O error');
|
||||
},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const sm = new StorageMgr(exploding);
|
||||
expect(() => sm.get('x', 'ok')).not.toThrow();
|
||||
expect(sm.get('x', 'ok')).toBe('ok');
|
||||
expect(() => sm.set('x', 'value')).not.toThrow();
|
||||
});
|
||||
|
||||
it('remove() deletes the key', () => {
|
||||
const sm = new StorageMgr(makeMemoryDriver());
|
||||
sm.set('k', 123);
|
||||
sm.remove('k');
|
||||
expect(sm.get('k', -1)).toBe(-1);
|
||||
});
|
||||
|
||||
it('setDriver swaps the underlying driver', () => {
|
||||
const sm = new StorageMgr(makeMemoryDriver());
|
||||
sm.set('k', 1);
|
||||
const fresh = makeMemoryDriver();
|
||||
sm.setDriver(fresh);
|
||||
expect(sm.get('k', 0)).toBe(0);
|
||||
sm.set('k', 2);
|
||||
expect(sm.get('k', 0)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { TimeMgr } from '@common/TimeMgr';
|
||||
|
||||
describe('TimeMgr', () => {
|
||||
let tm: TimeMgr;
|
||||
beforeEach(() => {
|
||||
tm = new TimeMgr();
|
||||
});
|
||||
|
||||
it('advances both clocks at timeScale 1', () => {
|
||||
tm.update(1);
|
||||
tm.update(0.5);
|
||||
expect(tm.gameTime).toBeCloseTo(1.5);
|
||||
expect(tm.realTime).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it('freezes gameTime when paused but keeps realTime ticking', () => {
|
||||
tm.pause();
|
||||
tm.update(1);
|
||||
expect(tm.gameTime).toBe(0);
|
||||
expect(tm.realTime).toBe(1);
|
||||
tm.resume();
|
||||
tm.update(0.5);
|
||||
expect(tm.gameTime).toBeCloseTo(0.5);
|
||||
expect(tm.realTime).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it('honors timeScale for slow-mo', () => {
|
||||
tm.setTimeScale(0.5);
|
||||
tm.update(2);
|
||||
expect(tm.gameTime).toBeCloseTo(1);
|
||||
expect(tm.realTime).toBe(2);
|
||||
});
|
||||
|
||||
it('clamps negative timeScale to 0', () => {
|
||||
tm.setTimeScale(-3);
|
||||
expect(tm.timeScale).toBe(0);
|
||||
tm.update(10);
|
||||
expect(tm.gameTime).toBe(0);
|
||||
});
|
||||
|
||||
it('scaledDelta() reflects pause and timeScale', () => {
|
||||
tm.setTimeScale(2);
|
||||
expect(tm.scaledDelta(0.1)).toBeCloseTo(0.2);
|
||||
tm.pause();
|
||||
expect(tm.scaledDelta(0.1)).toBe(0);
|
||||
});
|
||||
|
||||
it('reset() zeros everything', () => {
|
||||
tm.update(1);
|
||||
tm.pause();
|
||||
tm.setTimeScale(0.2);
|
||||
tm.reset();
|
||||
expect(tm.gameTime).toBe(0);
|
||||
expect(tm.realTime).toBe(0);
|
||||
expect(tm.timeScale).toBe(1);
|
||||
expect(tm.paused).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user