first commmit

This commit is contained in:
jakciehan
2026-05-06 08:17:32 +08:00
commit 427a33c55b
269 changed files with 20906 additions and 0 deletions
+71
View File
@@ -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();
});
});
+98
View File
@@ -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]);
});
});
+78
View File
@@ -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();
});
});
+88
View File
@@ -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);
});
});
+110
View File
@@ -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);
});
});
+71
View File
@@ -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);
});
});
+58
View File
@@ -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);
});
});